From a6a8b0f5eb743df8ea18421297f1e9efc4da97dd Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:05:38 +0000 Subject: [PATCH 1/4] feat(tasks): return presigned download URL on artifact finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifacts/finalize_upload response now includes a presigned download URL for each finalized artifact, so callers (e.g. the upload_artifact agent tool) can link to the uploaded file directly. The URL is minted per response via object_storage.get_presigned_url (mirroring the existing log_url presign pattern) and is deliberately not written back to the run manifest, since presigned URLs are time-limited. The new serializer field is optional, so responses that reuse TaskRunArtifactResponseSerializer without a URL are unaffected. Note: products/tasks/frontend/generated/* must be regenerated with `hogli build:openapi` to pick up the new field. I could not run codegen in this sandbox — schema generation requires a running Postgres/dev stack that wasn't provisioned. Generated-By: PostHog Code Task-Id: d3757c3d-15f3-485a-b4f3-d0b760f78963 --- products/tasks/backend/facade/api.py | 10 +++++++++- products/tasks/backend/presentation/serializers.py | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index a7199e03d551..6d4de63332d0 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -2480,7 +2480,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 2d8b56bfbc05..f8f413864575 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): From 703c3d392e8ad9cb9da59de58f3f16d5fde6814c Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:05:41 +0000 Subject: [PATCH 2/4] chore: update OpenAPI generated types --- products/tasks/frontend/generated/api.schemas.ts | 2 ++ services/mcp/src/api/generated.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index f818c6f1dc2d..b8a75054dfc6 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 305e9b5577a9..1fd63286d6d7 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -43962,6 +43962,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; } /** From 5478b91ec6a2b5ab7a19762b44c3aa9692c43007 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:05:45 +0000 Subject: [PATCH 3/4] chore(agents): default to running and verifying in the dev environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the AGENTS.md environment guidance (previously PR #74021) into this PR: treat the sandbox as a full dev environment — install missing deps and run tests/Storybook/the app to verify — worded as a method so it holds on a constrained sandbox too. Generated-By: PostHog Code Task-Id: d3757c3d-15f3-485a-b4f3-d0b760f78963 --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8ac6974ef541..5496121bc28f 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: From 59e6b5f3f9a4685db225e8b1580a6e5b2b23def9 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:24:09 +0000 Subject: [PATCH 4/4] chore(tasks): update finalize idempotent test for artifact url field finalize_task_run_artifact_uploads now returns a presigned url on each response entry (not persisted to the manifest), so the idempotent-path test can no longer assert the response equals the stored manifest verbatim. Compare the stored fields separately from the URL and assert a URL is present, preserving the idempotency guarantee (existing entry returned unchanged, no re-head/re-tag). Generated-By: PostHog Code Task-Id: d3757c3d-15f3-485a-b4f3-d0b760f78963 --- products/tasks/backend/tests/test_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index fb5fa4ab19ec..944f2603f985 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -6037,7 +6037,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()