-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(tasks): let users dismiss files uploaded by a run #78654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4e3a2e0
e1f6ebb
a85e0d0
8119bd7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2795,6 +2795,7 @@ def finalize_task_run_artifact_uploads( | |
| manifest = list(run.artifacts or []) | ||
| artifact_prefix = f"{run.get_artifact_s3_prefix()}/" | ||
| finalized_entries: list[dict] = [] | ||
| new_entries: list[dict] = [] | ||
| new_storage_paths: list[str] = [] | ||
|
|
||
| for artifact in artifacts: | ||
|
|
@@ -2835,10 +2836,21 @@ def finalize_task_run_artifact_uploads( | |
| metadata=artifact.get("metadata"), | ||
| ) | ||
| manifest.append(entry) | ||
| new_entries.append(entry) | ||
| finalized_entries.append(entry) | ||
| new_storage_paths.append(storage_path) | ||
|
|
||
| _save_artifact_manifest(run, manifest) | ||
| if new_entries: | ||
| # Re-read the manifest under the row lock rather than writing back the snapshot taken | ||
| # above: verifying the uploads does S3 I/O, and a dismissal that commits in that window | ||
| # would be silently reverted by a blind whole-array write. | ||
| with transaction.atomic(): | ||
| locked_run = TaskRun.objects.select_for_update().get(pk=run.pk) | ||
| new_ids = {entry["id"] for entry in new_entries} | ||
| merged = [entry for entry in (locked_run.artifacts or []) if entry.get("id") not in new_ids] | ||
| merged.extend(new_entries) | ||
| _save_artifact_manifest(locked_run, merged) | ||
|
|
||
| for storage_path in new_storage_paths: | ||
| _tag_artifact_object(run, storage_path) | ||
|
|
||
|
|
@@ -2976,6 +2988,48 @@ def presign_task_run_artifact( | |
| return url, None | ||
|
|
||
|
|
||
| def _without_dismissal(entry: dict) -> dict: | ||
| return {key: value for key, value in entry.items() if key != "dismissed_at"} | ||
|
|
||
|
|
||
| def set_task_run_artifacts_dismissed( | ||
| run_id: str | UUID, task_id: str | UUID, team_id: int, *, artifact_ids: list[str], dismissed: bool | ||
| ) -> tuple[list[dict] | None, str | None]: | ||
| """Mark run artifacts as dismissed, or bring them back. | ||
|
|
||
| Dismissal is a ``dismissed_at`` stamp on the manifest entry rather than a delete: the object | ||
| stays in storage until its TTL expires, so a file dismissed by mistake can be restored. | ||
|
|
||
| Returns ``(manifest, error)``: ``(None, None)`` when the run isn't found, ``(None, "not_found")`` | ||
| when an id isn't on the run, else ``(updated_manifest, None)``. | ||
| """ | ||
| run = _get_visible_run(run_id, task_id, team_id) | ||
| if run is None: | ||
| return None, None | ||
|
|
||
| with transaction.atomic(): | ||
| locked_run = TaskRun.objects.select_for_update().get(pk=run.pk) | ||
| manifest = list(locked_run.artifacts or []) | ||
| requested = set(artifact_ids) | ||
| if not requested.issubset({entry.get("id") for entry in manifest}): | ||
| return None, "not_found" | ||
|
|
||
| # Restoring drops the key rather than nulling it, so a manifest entry only ever carries | ||
| # ``dismissed_at`` while it is dismissed and the response shape stays a plain optional. | ||
| dismissed_at = django_timezone.now().isoformat() | ||
| manifest = [ | ||
| ( | ||
| ({**entry, "dismissed_at": dismissed_at} if dismissed else _without_dismissal(entry)) | ||
| if entry.get("id") in requested | ||
| else entry | ||
| ) | ||
| for entry in manifest | ||
| ] | ||
| _save_artifact_manifest(locked_run, manifest) | ||
|
Comment on lines
+3010
to
+3028
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If loop-run artifact seeding or warm-run staging overlaps a dismissal, those writers can save a stale whole-manifest snapshot without participating in this row-locking protocol, removing the newly committed Prompt To Fix With AIThis is a comment left during a code review.
Path: products/tasks/backend/facade/api.py
Line: 3003-3021
Comment:
**Unlocked writers erase dismissals**
If loop-run artifact seeding or warm-run staging overlaps a dismissal, those writers can save a stale whole-manifest snapshot without participating in this row-locking protocol, removing the newly committed `dismissed_at` value and making the artifact visible again.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
| return manifest, None | ||
|
|
||
|
|
||
| def read_task_run_artifact( | ||
| run_id: str | UUID, task_id: str | UUID, team_id: int, *, storage_path: str | ||
| ) -> tuple[bytes | None, dict | None, str | None]: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,6 +110,8 @@ | |
| TaskRunAppendLogRequestSerializer, | ||
| TaskRunArtifactPresignRequestSerializer, | ||
| TaskRunArtifactPresignResponseSerializer, | ||
| TaskRunArtifactsDismissRequestSerializer, | ||
| TaskRunArtifactsDismissResponseSerializer, | ||
| TaskRunArtifactsFinalizeUploadRequestSerializer, | ||
| TaskRunArtifactsFinalizeUploadResponseSerializer, | ||
| TaskRunArtifactsPrepareUploadRequestSerializer, | ||
|
|
@@ -1663,6 +1665,46 @@ def artifacts_presign(self, request, pk=None, **kwargs): | |
| serializer = TaskRunArtifactPresignResponseSerializer({"url": url, "expires_in": 3600}) | ||
| return Response(serializer.data) | ||
|
|
||
| @validated_request( | ||
| request_serializer=TaskRunArtifactsDismissRequestSerializer, | ||
| responses={ | ||
| 200: OpenApiResponse( | ||
| response=TaskRunArtifactsDismissResponseSerializer, | ||
| description="Run with updated artifact manifest", | ||
| ), | ||
| 404: OpenApiResponse(description="Artifact not found"), | ||
| }, | ||
| summary="Dismiss or restore task run artifacts", | ||
| description=( | ||
| "Hides artifacts from clients without deleting them from storage, so a file dismissed " | ||
| "by mistake can be restored." | ||
| ), | ||
| strict_request_validation=True, | ||
| ) | ||
| @action( | ||
| detail=True, | ||
| methods=["post"], | ||
| url_path="artifacts/dismiss", | ||
| required_scopes=["task:write"], | ||
| ) | ||
| def artifacts_dismiss(self, request, pk=None, **kwargs): | ||
| task_id = self._ensure_task_accessible() | ||
| manifest, error = tasks_facade.set_task_run_artifacts_dismissed( | ||
| pk, | ||
| task_id, | ||
| self.team_id, | ||
| artifact_ids=request.validated_data["artifact_ids"], | ||
| dismissed=request.validated_data["dismissed"], | ||
| ) | ||
| if error == "not_found": | ||
| return Response( | ||
| TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}).data, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
| if manifest is None: | ||
| raise NotFound() | ||
| return Response(TaskRunArtifactsDismissResponseSerializer({"artifacts": manifest}).data) | ||
|
Comment on lines
+1690
to
+1706
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New dismiss endpoint's "artifact not found on this run" 404 uses a different response shape than its sibling artifact actionsWhy we think it's a valid issue
Issue descriptionIn Suggested fixReturn the same shape as Prompt to fix with AI (copy-paste)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. valid — fixed in a5c0775. Now returns Your note that generated types could not catch this turned out to matter more than the shape itself: chasing it surfaced that |
||
|
|
||
| @validated_request( | ||
| request_serializer=TaskRunArtifactPresignRequestSerializer, | ||
| responses={ | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.