Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion products/tasks/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 thread
adboio marked this conversation as resolved.
Comment on lines +3010 to +3028

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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]:
Expand Down
24 changes: 24 additions & 0 deletions products/tasks/backend/presentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ class TaskRunArtifactResponseSerializer(serializers.Serializer):
)
storage_path = serializers.CharField(help_text="S3 object key for the artifact")
uploaded_at = serializers.CharField(help_text="Timestamp when the artifact was uploaded")
dismissed_at = serializers.CharField(
required=False,
help_text="Timestamp when a user dismissed the artifact. Absent while the artifact is shown.",
)
url = serializers.URLField(
required=False,
help_text=(
Expand Down Expand Up @@ -1391,6 +1395,26 @@ class TaskRunArtifactPresignResponseSerializer(serializers.Serializer):
expires_in = serializers.IntegerField(help_text="URL expiry in seconds")


class TaskRunArtifactsDismissRequestSerializer(serializers.Serializer):
artifact_ids = serializers.ListField(
child=serializers.CharField(max_length=128),
allow_empty=False,
max_length=100,
help_text=(
"Manifest ids of the artifacts to update. Pass every version of a file together so the "
"whole file is dismissed rather than a single upload of it."
),
)
dismissed = serializers.BooleanField(
default=True,
help_text="True to hide the artifacts from clients, false to show them again.",
)


class TaskRunArtifactsDismissResponseSerializer(serializers.Serializer):
artifacts = TaskRunArtifactResponseSerializer(many=True, help_text="Updated list of artifacts on the run")


TASK_SUMMARIES_MAX_IDS = 5000


Expand Down
42 changes: 42 additions & 0 deletions products/tasks/backend/presentation/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@
TaskRunAppendLogRequestSerializer,
TaskRunArtifactPresignRequestSerializer,
TaskRunArtifactPresignResponseSerializer,
TaskRunArtifactsDismissRequestSerializer,
TaskRunArtifactsDismissResponseSerializer,
TaskRunArtifactsFinalizeUploadRequestSerializer,
TaskRunArtifactsFinalizeUploadResponseSerializer,
TaskRunArtifactsPrepareUploadRequestSerializer,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 actions

should_fix best_practice

Why we think it's a valid issue
  • Checked: Compared how the three artifact sub-resource actions in this viewset handle the identical facade error == "not_found" sentinel — artifacts_presign, artifacts_download, and the new artifacts_dismiss — and surveyed where bare raise NotFound() vs TaskRunErrorResponseSerializer({"error": ...}) is used across the file.
  • Found: For the exact same condition, artifacts_presign returns Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}), 404) (views/api.py:1564-1568) and artifacts_download does the identical thing (1607-1611). The new artifacts_dismiss (PR diff) does raise NotFound("Artifact not found on this run"), which DRF serializes as {"detail": ...}. Same facade sentinel, same verbatim message string, different JSON key — a real, verified divergence, not a subjective preference.
  • Found: The convention is genuinely nuanced but consistent: bare raise NotFound() is the baseline for a missing/invisible run (used throughout, e.g. 1563, 1606), while the differentiated artifact-not-found-on-run error goes through TaskRunErrorResponseSerializer in both siblings. Dismiss is the sole action that routes that specific sub-resource error through the run-missing shape. The copied message string signals the author intended to mirror the siblings, so this reads as an accidental mechanism mismatch.
  • Found: The 404 OpenAPI response is declared with only a bare description (no response= schema), so pnpm typescript:check/generated types cannot catch the shape drift — it surfaces only at runtime.
  • Impact: A client with a shared {"error"} parser across artifact actions (the stacked desktop client is the intended consumer) reads undefined for this one action's 404 and cannot render its message; the status is still a correct 404, so it is a contract wart, not a functional break. Worth fixing before this new endpoint (also scaffolded as an MCP tool) ships and locks the inconsistent shape in — the fix is a one-line change to match the two siblings. Not overengineering (it aligns with existing code, adds no abstraction) and not pure style (JSON body key is an observable contract).
Issue description

In artifacts_dismiss, when set_task_run_artifacts_dismissed reports error == "not_found", the view does raise NotFound("Artifact not found on this run"), which DRF serializes as {"detail": "Artifact not found on this run"}. Its two closest siblings in the very same viewset, artifacts_presign and artifacts_download, handle the identical "artifact not found on this run" condition by returning Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}).data, status=status.HTTP_404_NOT_FOUND) — an {"error": ...} body built from the shared TaskRunErrorResponseSerializer that this same file already uses consistently for every other artifact-related 400/404. The new endpoint reuses the exact same English message string, showing the intent was to mirror that existing convention, but the implementation mechanism (raising a bare DRF exception vs. building an explicit Response from TaskRunErrorResponseSerializer) produces a different JSON key (detail vs error) for what is, from a caller's perspective, the same logical error on the same sub-resource. Any client code (including the stacked desktop client in #78644) that centralizes error handling for this artifact API around the {"error": ...} shape — which is what every other artifact action in this file returns — will get undefined/a missing field for this one action's 404, and has to special-case it. Because the OpenAPI 404 response for this action is declared with only a bare description (no response= schema, matching the pattern used for artifacts_presign/artifacts_download), this mismatch is invisible to generated-type checking on the frontend, so it won't be caught by pnpm typescript:check — it will only surface as an actual runtime shape difference when a client tries to read .error off a 404 response from this endpoint.

Suggested fix

Return the same shape as artifacts_presign/artifacts_download for this case: return Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}).data, status=status.HTTP_404_NOT_FOUND) instead of raise NotFound("Artifact not found on this run"). This keeps a single, predictable error contract ({"error": string}) across all artifact-not-found-on-run responses in the viewset, and lets a shared client-side error parser work for every artifact action, including this new one.

Prompt to fix with AI (copy-paste)
## Context
@products/tasks/backend/presentation/views/api.py#L1601-1614

<issue_description>
In `artifacts_dismiss`, when `set_task_run_artifacts_dismissed` reports `error == "not_found"`, the view does `raise NotFound("Artifact not found on this run")`, which DRF serializes as `{"detail": "Artifact not found on this run"}`. Its two closest siblings in the very same viewset, `artifacts_presign` and `artifacts_download`, handle the identical "artifact not found on this run" condition by returning `Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}).data, status=status.HTTP_404_NOT_FOUND)` — an `{"error": ...}` body built from the shared `TaskRunErrorResponseSerializer` that this same file already uses consistently for every other artifact-related 400/404. The new endpoint reuses the exact same English message string, showing the intent was to mirror that existing convention, but the implementation mechanism (raising a bare DRF exception vs. building an explicit `Response` from `TaskRunErrorResponseSerializer`) produces a different JSON key (`detail` vs `error`) for what is, from a caller's perspective, the same logical error on the same sub-resource. Any client code (including the stacked desktop client in #78644) that centralizes error handling for this artifact API around the `{"error": ...}` shape — which is what every other artifact action in this file returns — will get `undefined`/a missing field for this one action's 404, and has to special-case it. Because the OpenAPI `404` response for this action is declared with only a bare `description` (no `response=` schema, matching the pattern used for `artifacts_presign`/`artifacts_download`), this mismatch is invisible to generated-type checking on the frontend, so it won't be caught by `pnpm typescript:check` — it will only surface as an actual runtime shape difference when a client tries to read `.error` off a 404 response from this endpoint.
</issue_description>

<issue_validation>
- **Checked:** Compared how the three artifact sub-resource actions in this viewset handle the identical facade `error == "not_found"` sentinel — `artifacts_presign`, `artifacts_download`, and the new `artifacts_dismiss` — and surveyed where bare `raise NotFound()` vs `TaskRunErrorResponseSerializer({"error": ...})` is used across the file.
- **Found:** For the exact same condition, `artifacts_presign` returns `Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}), 404)` (views/api.py:1564-1568) and `artifacts_download` does the identical thing (1607-1611). The new `artifacts_dismiss` (PR diff) does `raise NotFound("Artifact not found on this run")`, which DRF serializes as `{"detail": ...}`. Same facade sentinel, same verbatim message string, different JSON key — a real, verified divergence, not a subjective preference.
- **Found:** The convention is genuinely nuanced but consistent: bare `raise NotFound()` is the baseline for a missing/invisible *run* (used throughout, e.g. 1563, 1606), while the differentiated *artifact-not-found-on-run* error goes through `TaskRunErrorResponseSerializer` in both siblings. Dismiss is the sole action that routes that specific sub-resource error through the run-missing shape. The copied message string signals the author intended to mirror the siblings, so this reads as an accidental mechanism mismatch.
- **Found:** The 404 OpenAPI response is declared with only a bare `description` (no `response=` schema), so `pnpm typescript:check`/generated types cannot catch the shape drift — it surfaces only at runtime.
- **Impact:** A client with a shared `{"error"}` parser across artifact actions (the stacked desktop client is the intended consumer) reads `undefined` for this one action's 404 and cannot render its message; the status is still a correct 404, so it is a contract wart, not a functional break. Worth fixing before this new endpoint (also scaffolded as an MCP tool) ships and locks the inconsistent shape in — the fix is a one-line change to match the two siblings. Not overengineering (it aligns with existing code, adds no abstraction) and not pure style (JSON body key is an observable contract).
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Return the same shape as `artifacts_presign`/`artifacts_download` for this case: `return Response(TaskRunErrorResponseSerializer({"error": "Artifact not found on this run"}).data, status=status.HTTP_404_NOT_FOUND)` instead of `raise NotFound("Artifact not found on this run")`. This keeps a single, predictable error contract (`{"error": string}`) across all artifact-not-found-on-run responses in the viewset, and lets a shared client-side error parser work for every artifact action, including this new one.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid — fixed in a5c0775. Now returns TaskRunErrorResponseSerializer({"error": ...}) with a 404, matching artifacts_presign and artifacts_download.

Your note that generated types could not catch this turned out to matter more than the shape itself: chasing it surfaced that dismissed_at was declared allow_null=True, and DRF checks allow_null before required in get_attribute, so a missing key serialized as null on every artifact — including staged user attachments that have no dismissal concept. That was breaking two existing tests. Restoring a file now drops the key instead of nulling it, so the field is only ever a string or absent, and the generated types were regenerated to match.


@validated_request(
request_serializer=TaskRunArtifactPresignRequestSerializer,
responses={
Expand Down
99 changes: 99 additions & 0 deletions products/tasks/backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7614,6 +7614,105 @@ def test_cancel_unknown_run_returns_404(self):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)


class TestTaskRunArtifactDismissAPI(BaseTaskAPITest):
def _create_run_with_artifacts(self) -> tuple[Task, TaskRun]:
task = self.create_task()
run = task.create_run(environment=TaskRun.Environment.CLOUD)
run.artifacts = [
build_task_artifact_entry(
artifact_id=artifact_id,
name=name,
artifact_type="output",
source="agent_output",
size=1024,
content_type="text/markdown",
storage_path=f"tasks/artifacts/team_{self.team.id}/task_{task.id}/run_{run.id}/{artifact_id}_{name}",
)
for artifact_id, name in (
("artifact-1", "report.md"),
("artifact-2", "report.md"),
("artifact-3", "chart.png"),
)
]
run.save(update_fields=["artifacts", "updated_at"])
return task, run

def _dismiss(self, task: Task, run: TaskRun, artifact_ids: list[str], dismissed: bool):
return self.client.post(
f"/api/projects/@current/tasks/{task.id}/runs/{run.id}/artifacts/dismiss/",
{"artifact_ids": artifact_ids, "dismissed": dismissed},
format="json",
)

def test_dismiss_flags_only_the_requested_artifacts_and_can_be_undone(self):
task, run = self._create_run_with_artifacts()

response = self._dismiss(task, run, ["artifact-1", "artifact-2"], True)

self.assertEqual(response.status_code, status.HTTP_200_OK)
run.refresh_from_db()
dismissed_at = {artifact["id"]: artifact.get("dismissed_at") for artifact in run.artifacts}
self.assertIsNotNone(dismissed_at["artifact-1"])
self.assertIsNotNone(dismissed_at["artifact-2"])
self.assertIsNone(dismissed_at.get("artifact-3"))
self.assertEqual([artifact["id"] for artifact in response.json()["artifacts"]], list(dismissed_at))

self.assertEqual(self._dismiss(task, run, ["artifact-1", "artifact-2"], False).status_code, status.HTTP_200_OK)
run.refresh_from_db()
self.assertTrue(all("dismissed_at" not in artifact for artifact in run.artifacts))

def test_dismiss_unknown_artifact_leaves_the_manifest_alone(self):
task, run = self._create_run_with_artifacts()

response = self._dismiss(task, run, ["artifact-1", "artifact-missing"], True)

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.json()["error"], "Artifact not found on this run")
run.refresh_from_db()
self.assertTrue(all(artifact.get("dismissed_at") is None for artifact in run.artifacts))

@patch("posthog.storage.object_storage.get_presigned_url")
@patch("posthog.storage.object_storage.tag")
@patch("posthog.storage.object_storage.head_object")
def test_dismissal_survives_a_finalize_upload_that_overlaps_it(self, mock_head_object, mock_tag, mock_presign):
task, run = self._create_run_with_artifacts()
new_artifact_id = uuid.uuid4().hex
storage_path = f"{run.get_artifact_s3_prefix()}/{new_artifact_id[:8]}_summary.pdf"
mock_presign.return_value = None

# Dismissing from inside head_object lands the dismissal in the window where finalize has
# read the manifest but not yet written it — the overlap when an agent revises a file the
# user is dismissing.
def dismiss_mid_flight(_storage_path):
self._dismiss(task, run, ["artifact-1"], True)
return {"ContentLength": 4096, "ContentType": "application/pdf"}

mock_head_object.side_effect = dismiss_mid_flight

response = self.client.post(
f"/api/projects/@current/tasks/{task.id}/runs/{run.id}/artifacts/finalize_upload/",
{
"artifacts": [
{
"id": new_artifact_id,
"name": "summary.pdf",
"type": "output",
"source": "agent_output",
"storage_path": storage_path,
"content_type": "application/pdf",
}
]
},
format="json",
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
run.refresh_from_db()
artifacts_by_id = {artifact["id"]: artifact for artifact in run.artifacts}
self.assertIsNotNone(artifacts_by_id["artifact-1"].get("dismissed_at"))
self.assertIn(new_artifact_id, artifacts_by_id)


class TestTaskRunSessionLogsAPI(BaseTaskAPITest):
"""Tests for the GET .../session_logs/ endpoint that returns filtered log entries."""

Expand Down
18 changes: 18 additions & 0 deletions products/tasks/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions products/tasks/frontend/generated/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions products/tasks/frontend/generated/api.zod.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions products/tasks/mcp/tools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,9 @@ tools:
tasks-runs-artifacts-create:
operation: tasks_runs_artifacts_create
enabled: false
tasks-runs-artifacts-dismiss-create:
operation: tasks_runs_artifacts_dismiss_create
enabled: false
tasks-runs-artifacts-download-create:
operation: tasks_runs_artifacts_download_create
enabled: false
Expand Down
Loading
Loading