diff --git a/AGENTS.md b/AGENTS.md index 1626806b59c5..c663ef9e9c41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ ## Commands - Environment: + - This is a full dev environment, not a restricted patch-editing sandbox — it has `node`, `pnpm`, a package mirror, and `apt`, so tools and dependencies that aren't present yet can be installed, and tests, Storybook, and the app can actually be run. A missing `node_modules`, browser binary, or flox usually just means setup hasn't run yet (`pnpm install`, `npx playwright install --with-deps chromium`, or building the nested `@posthog/quill` workspace that `global.scss` imports), rather than that running things is impossible. + - So the absence of a tool isn't evidence that a task can't be done — installing it is the first step. The honest signal that something genuinely can't run is an attempt that fails for a specific, nameable reason (no network access, `apt` unavailable, out of memory), which is worth reporting alongside whatever fallback you take. + - This matters most for visual and UX work, where reading the code isn't the same as seeing the result. Rendering the affected surface (for example in Storybook via a headless browser) and comparing before and after is what actually confirms such a change, and is usually worth the setup cost. - Use flox when available — prefer `flox activate -- bash -c ""` if commands fail - Never use `flox activate` in interactive sessions (it hangs if you try) - Tests: diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index 06946863cef6..42187751fe59 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -2728,7 +2728,15 @@ def finalize_task_run_artifact_uploads( for storage_path in new_storage_paths: _tag_artifact_object(run, storage_path) - return finalized_entries, None + # Mint a fresh presigned download URL per response entry so the caller (e.g. the + # upload_artifact tool) can surface a link to the file. Presigned URLs expire, so + # they are attached to the response only and never written back to the manifest. + response_entries: list[dict] = [] + for entry in finalized_entries: + presigned_url = object_storage.get_presigned_url(entry["storage_path"]) + response_entries.append({**entry, "url": presigned_url} if presigned_url else dict(entry)) + + return response_entries, None def list_task_run_living_artifacts(run_id: str | UUID, task_id: str | UUID, team_id: int) -> list[dict] | None: diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 398351a454b3..307f6774d54e 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -251,6 +251,13 @@ 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") + url = serializers.URLField( + required=False, + help_text=( + "Presigned download URL for the artifact. Populated on the finalize-upload response so " + "the caller can link to the file directly; it is time-limited and not persisted on the manifest." + ), + ) class TaskRunDetailSerializer(DataclassSerializer): diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index ece88a369fcd..2e3d7914ee1c 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -6394,7 +6394,11 @@ def test_finalize_artifact_uploads_is_idempotent_for_existing_entry(self, mock_t ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["artifacts"], run.artifacts) + returned_artifacts = response.json()["artifacts"] + # The finalize response augments each entry with a presigned download URL that is not + # persisted on the manifest, so compare the stored fields separately from the URL. + self.assertEqual([{k: v for k, v in a.items() if k != "url"} for a in returned_artifacts], run.artifacts) + self.assertTrue(all(a.get("url") for a in returned_artifacts)) mock_head_object.assert_not_called() mock_tag.assert_not_called() diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index 7e594b50d0a8..29eb970f258f 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -1311,6 +1311,8 @@ export interface TaskRunArtifactResponseApi { storage_path: string /** Timestamp when the artifact was uploaded */ uploaded_at: string + /** Presigned download URL for the artifact. Populated on the finalize-upload response so the caller can link to the file directly; it is time-limited and not persisted on the manifest. */ + url?: string } /** diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 91f78301fcfa..85396631d2a2 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -46526,6 +46526,8 @@ export namespace Schemas { storage_path: string; /** Timestamp when the artifact was uploaded */ uploaded_at: string; + /** Presigned download URL for the artifact. Populated on the finalize-upload response so the caller can link to the file directly; it is time-limited and not persisted on the manifest. */ + url?: string; } /**