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
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@
)
}

function ArtifactPreview({ taskId, mode }: { taskId: string; mode: PreviewMode }): JSX.Element | null {

Check warning on line 396 in products/posthog_ai/frontend/scenes/TaskTracker/components/TaskRunArtifacts.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`ArtifactPreview` has cyclomatic complexity 25 (warn >10)
const { selectedArtifact, selectedKind, selectedText, selectedRun, currentProjectId, artifactTextLoading } =
useValues(taskRunArtifactsLogic({ taskId }))
const { ensureSelectedText, loadArtifactText } = useActions(taskRunArtifactsLogic({ taskId }))
Expand Down Expand Up @@ -722,7 +722,7 @@
)
}

function ArtifactToolbar({

Check warning on line 725 in products/posthog_ai/frontend/scenes/TaskTracker/components/TaskRunArtifacts.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`ArtifactToolbar` has cyclomatic complexity 33 (warn >10)
taskId,
artifact,
mode,
Expand Down Expand Up @@ -757,7 +757,7 @@
objectRef && currentProjectId !== null
? objectKindLink(objectRef.objectKind, objectRef.objectId, `/project/${currentProjectId}`)
: null
const downloadUrl = artifactDownloadUrl(currentProjectId, taskId, artifact)
const downloadUrl = artifactDownloadUrl(currentProjectId, taskId, artifact, { forDownload: true })
const single = files.length < 2
const versioned = !!selectedFile && selectedFile.versions.length > 1
// Plain text already shows its source, so only these kinds get a view switch.
Expand Down Expand Up @@ -927,7 +927,7 @@
)
}

function ArtifactsWorkspace({ taskId }: { taskId: string }): JSX.Element {

Check warning on line 930 in products/posthog_ai/frontend/scenes/TaskTracker/components/TaskRunArtifacts.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`ArtifactsWorkspace` has cyclomatic complexity 14 (warn >10)
const { files, selectedArtifact, isEditing } = useValues(taskRunArtifactsLogic({ taskId }))
const { setActiveTab, reportFullPageOpened } = useActions(taskRunArtifactsLogic({ taskId }))
const [mode, setMode] = useState<PreviewMode>('rendered')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ describe('taskRunArtifacts', () => {
function slackFile(
location: Record<string, unknown>,
name = 'signups.png',
contentType = 'image/png'
contentType = 'image/png',
size = 2048
): TaskRunLivingArtifactResponseApi {
return livingArtifact({
id: 'doc-2',
Expand All @@ -265,7 +266,7 @@ describe('taskRunArtifacts', () => {
{
version: 1,
run_id: 'run-1',
size: 2048,
size,
content_type: contentType,
location,
created_at: '2026-09-30T16:00:00Z',
Expand Down Expand Up @@ -325,8 +326,16 @@ describe('taskRunArtifacts', () => {
'none',
],
['a Slack file with no stored copy has no preview', {}, 'image.png', 'image/png', 'none'],
])('%s', (_, location, name, contentType, expected) => {
const [file] = livingArtifactFiles([slackFile(location, name, contentType)])
[
'a stored Slack file video over the preview limit only downloads',
{ storage_path: 'tasks/doc.v1.mp4' },
'demo.mp4',
'video/mp4',
'none',
30 * 1024 * 1024,
],
])('%s', (_, location, name, contentType, expected, size = 2048) => {
const [file] = livingArtifactFiles([slackFile(location, name, contentType, size)])
expect(artifactPreviewKind(file.latest)).toBe(expected)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,19 @@
return { objectKind: metadata.object_kind, objectId: metadata.object_id }
}

// The app streams a living version preview through a web worker, so a larger file only downloads.
// Keep in step with LIVING_VERSION_PREVIEW_MAX_BYTES in the tasks backend.
export const LIVING_PREVIEW_MAX_BYTES = 25 * 1024 * 1024

export function artifactPreviewKind(
artifact: TaskRunArtifactResponseApi & { living?: LivingVersion }
): ArtifactPreviewKind {
if (artifact.living && artifact.living.text === null) {
if (!artifact.living.stored || (artifact.size ?? 0) > LIVING_PREVIEW_MAX_BYTES) {
return 'none'
}
// A stored file plays in an `img` or a `video` from its URL. Text needs a read of the body, so it downloads.
const kind = artifact.living.stored ? fileKind(artifact) : 'none'
const kind = fileKind(artifact)
return kind === 'image' || kind === 'video' ? kind : 'none'
}
if (artifact.type === 'reference') {
Expand All @@ -79,7 +86,7 @@
return fileKind(artifact)
}

function fileKind(artifact: TaskRunArtifactResponseApi): ArtifactPreviewKind {

Check warning on line 89 in products/posthog_ai/frontend/scenes/TaskTracker/taskRunArtifacts.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`fileKind` has cyclomatic complexity 17 (warn >10)
const contentType = (artifact.content_type ?? '').split(';')[0].trim().toLowerCase()
const ext = extension(artifact.name)
if (contentType === 'text/html' || ext === 'html' || ext === 'htm') {
Expand Down Expand Up @@ -146,7 +153,7 @@
}

/** RFC 4180 CSV: quoted fields may hold commas, newlines and doubled quotes. */
export function parseCsv(text: string): string[][] {

Check warning on line 156 in products/posthog_ai/frontend/scenes/TaskTracker/taskRunArtifacts.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`parseCsv` has cyclomatic complexity 14 (warn >10)
const rows: string[][] = []
let row: string[] = []
let field = ''
Expand Down Expand Up @@ -376,7 +383,7 @@
'plain-text': 'plaintext',
}

export function editableArtifactKind(artifact: RunArtifact): EditableArtifactKind | null {

Check warning on line 386 in products/posthog_ai/frontend/scenes/TaskTracker/taskRunArtifacts.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`editableArtifactKind` has cyclomatic complexity 13 (warn >10)
// A living document and a cited object have no uploaded file to replace.
if (artifact.living || artifact.type === 'reference' || !artifact.storage_path || !artifact.id) {
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,17 @@ const MAX_MEDIA_PREVIEW_BYTES = 200 * 1024 * 1024
* A URL that an `img`, a `video` or an `a` can use directly. The download-by-id URL redirects to a fresh
* presigned link. A stored living version streams from the app origin. Other living versions have no URL.
*/
export function artifactDownloadUrl(projectId: number | null, taskId: string, artifact: RunArtifact): string | null {
/**
* The URL of an artifact's file. With `forDownload`, a stored living version redirects to object storage, so a
* large file does not pass through the app. A preview keeps the app URL, because the media-src policy allows
* video only from the app origin.
*/
export function artifactDownloadUrl(
projectId: number | null,
taskId: string,
artifact: RunArtifact,
{ forDownload = false }: { forDownload?: boolean } = {}
): string | null {
if (projectId === null || !artifact.id) {
return null
}
Expand All @@ -381,7 +391,8 @@ export function artifactDownloadUrl(projectId: number | null, taskId: string, ar
taskId,
artifact.runId,
artifact.living.artifactId,
artifact.living.version
artifact.living.version,
forDownload ? { download: true } : undefined
)
: null
}
Expand Down
52 changes: 50 additions & 2 deletions products/tasks/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime, timedelta
from pathlib import PurePosixPath
from typing import Any, Literal, TypeVar
from urllib.parse import urlparse
from uuid import UUID, uuid4
Expand Down Expand Up @@ -323,6 +324,7 @@
"prepare_task_staged_artifacts",
"presign_task_run_artifact",
"presign_task_run_artifact_download",
"presign_task_run_living_artifact_version_download",
"read_task_run_artifact",
"read_task_run_living_artifact_version",
"get_task_run_log_urls",
Expand Down Expand Up @@ -3180,7 +3182,7 @@
)


def update_task_run(

Check warning on line 3185 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`update_task_run` has cyclomatic complexity 38 (warn >10)

Check warning on line 3185 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`update_task_run` has cyclomatic complexity 38 (warn >10)
run_id: str | UUID,
task_id: str | UUID,
team_id: int,
Expand Down Expand Up @@ -4222,7 +4224,7 @@
return prepared, True


def finalize_task_run_artifact_uploads(

Check warning on line 4227 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`finalize_task_run_artifact_uploads` has cyclomatic complexity 13 (warn >10)

Check warning on line 4227 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`finalize_task_run_artifact_uploads` has cyclomatic complexity 13 (warn >10)
run_id: str | UUID,
task_id: str | UUID,
team_id: int,
Expand Down Expand Up @@ -4375,10 +4377,11 @@
"""Read the content of one living artifact version.

Returns ``(content, error)``: ``(None, None)`` if the run isn't found, ``(None, "not_found")`` if the
artifact or version isn't found or keeps no content, ``(None, "read_failed")`` if the storage read
raised, else ``(content, None)``.
artifact or version isn't found or keeps no content, ``(None, "too_large")`` if a stored version is
over the preview limit, ``(None, "read_failed")`` if the storage read raised, else ``(content, None)``.
"""
from products.tasks.backend.logic.services.living_artifacts import ( # noqa: PLC0415 — keep storage deps off the api import path
LivingArtifactVersionTooLarge,
get_task_artifact_for_run,
read_living_artifact_version,
)
Expand All @@ -4395,6 +4398,8 @@
return None, "not_found"
try:
content = read_living_artifact_version(artifact, version)
except LivingArtifactVersionTooLarge:
return None, "too_large"
except Exception:
logger.exception("Failed to read living artifact %s version %s for team %s", artifact_id, version, team_id)
return None, "read_failed"
Expand All @@ -4403,6 +4408,49 @@
return content, None


def presign_task_run_living_artifact_version_download(
run_id: str | UUID, task_id: str | UUID, team_id: int, *, artifact_id: str | UUID, version: int
) -> contracts.LivingArtifactVersionDownload:
"""Presign a download URL for one stored living artifact version.

The error is ``"not_found"`` if the artifact or version isn't found, ``"not_stored"`` if the version
keeps its content as text, and ``"unavailable"`` if presigning fails. Both fields are None if the run
isn't found.
"""
from posthog.storage import object_storage # noqa: PLC0415 — keep storage deps off the api import path

from products.tasks.backend.logic.services.living_artifacts import ( # noqa: PLC0415 — keep storage deps off the api import path
get_task_artifact_for_run,
resolve_living_artifact_version,

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,100p' products/tasks/backend/facade/api.py
sed -n '4370,4455p' products/tasks/backend/facade/api.py
sed -n '1,85p' products/tasks/backend/logic/services/living_artifacts.py

Repository: PostHog/posthog

Length of output: 10234


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- api.py imports and relevant references ---'
rg -n -C 3 'living_artifacts|object_storage|read_task_run_living_artifact_version|presign_task_run_living_artifact_version_download' products/tasks/backend/facade/api.py

printf '%s\n' '--- living_artifacts.py imports and relevant definitions ---'
sed -n '1,120p' products/tasks/backend/logic/services/living_artifacts.py
rg -n -C 3 'def (get_task_artifact_for_run|resolve_living_artifact_version|read_living_artifact_version)|from products.tasks.backend.facade|import products.tasks.backend.facade|from posthog.storage|object_storage' products/tasks/backend/logic/services/living_artifacts.py

printf '%s\n' '--- storage module location and imports ---'
fd -t f 'object_storage*' posthog products
rg -n -C 4 'from posthog.storage import object_storage|import object_storage|class .*Storage|def get_presigned_url|object_storage[[:space:]]*=' posthog/storage products/tasks/backend

Repository: PostHog/posthog

Length of output: 41990


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- storage package files ---'
fd -t f . posthog/storage | sort

printf '%s\n' '--- object_storage.py header and client initialization ---'
sed -n '1,180p' posthog/storage/object_storage.py
rg -n -C 3 '^(object_storage|class |def |[A-Z_]+ =)|boto|S3|Storage' posthog/storage/object_storage.py | head -160

printf '%s\n' '--- storage package initializer ---'
if [ -f posthog/storage/__init__.py ]; then sed -n '1,160p' posthog/storage/__init__.py; fi

printf '%s\n' '--- facade contracts imports and header ---'
sed -n '1,100p' products/tasks/backend/facade/contracts.py
rg -n 'products\.tasks\.backend\.facade\.api|from .*facade import api|import .*facade\.api' products/tasks/backend/logic/services/living_artifacts.py products/tasks/backend/facade/contracts.py

printf '%s\n' '--- direct api imports from living_artifacts service ---'
rg -n 'products\.tasks\.backend\.facade\.api|facade import api|from products\.tasks\.backend\.facade' products/tasks/backend/logic/services/living_artifacts.py

Repository: PostHog/posthog

Length of output: 16642


Move the resolver import to module scope.

The local import violates the backend import rule. living_artifacts.py imports object_storage, but this does not eagerly initialize a storage client: object_storage.py starts with UnavailableStorage, and imports boto3 only when object_storage_client() is called. Do not split the module solely to avoid eager storage initialization.

)

run = _get_visible_run(run_id, task_id, team_id)
if run is None:
return contracts.LivingArtifactVersionDownload(url=None, error=None)
try:
UUID(str(artifact_id))
except ValueError:
return contracts.LivingArtifactVersionDownload(url=None, error="not_found")
artifact = get_task_artifact_for_run(run, artifact_id)
resolved = resolve_living_artifact_version(artifact, version) if artifact is not None else None
if artifact is None or resolved is None:
return contracts.LivingArtifactVersionDownload(url=None, error="not_found")
if not resolved.storage_path:
return contracts.LivingArtifactVersionDownload(url=None, error="not_stored")
url = object_storage.get_presigned_url(
resolved.storage_path,
content_type=resolved.content_type or None,
# Agent-written HTML or SVG must not render as a page, so the browser always saves it.
content_disposition=content_disposition_header(
as_attachment=True, filename=PurePosixPath(artifact.name).name or "artifact"
)
or "attachment",
)
if not url:
return contracts.LivingArtifactVersionDownload(url=None, error="unavailable")
return contracts.LivingArtifactVersionDownload(url=url, error=None)


def create_task_run_living_artifact(
run_id: str | UUID,
task_id: str | UUID,
Expand Down Expand Up @@ -5013,7 +5061,7 @@
return missing_artifact_ids, True


def signal_task_run_user_message(

Check warning on line 5064 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`signal_task_run_user_message` has cyclomatic complexity 13 (warn >10)

Check warning on line 5064 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`signal_task_run_user_message` has cyclomatic complexity 13 (warn >10)
run_id: str | UUID,
task_id: str | UUID,
team_id: int,
Expand Down Expand Up @@ -5421,7 +5469,7 @@
return bool(sandbox_id) and preview.get("sandbox_id") == sandbox_id


def resolve_task_run_preview_redirect(

Check warning on line 5472 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`resolve_task_run_preview_redirect` has cyclomatic complexity 12 (warn >10)

Check warning on line 5472 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`resolve_task_run_preview_redirect` has cyclomatic complexity 12 (warn >10)
run_id: str | UUID, task_id: str | UUID, team_id: int, *, user_id: int
) -> TaskRunPreviewRedirect | None:
from products.tasks.backend.exceptions import (
Expand Down Expand Up @@ -5756,7 +5804,7 @@
}


def bootstrap_task_run(

Check warning on line 5807 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`bootstrap_task_run` has cyclomatic complexity 24 (warn >10)

Check warning on line 5807 in products/tasks/backend/facade/api.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`bootstrap_task_run` has cyclomatic complexity 24 (warn >10)
task_id: str | UUID, team_id: int, user_id: int | None, *, validated_data: dict
) -> contracts.TaskRunCreateResult | None:
"""Create a task run (without starting execution) from validated bootstrap data.
Expand Down
6 changes: 6 additions & 0 deletions products/tasks/backend/facade/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,3 +1028,9 @@ class LivingArtifactVersionContent:
name: str
content_type: str
content: bytes


@dataclass(frozen=True, kw_only=True)

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the repository dataclass decorator.

Replace @dataclass(frozen=True, kw_only=True) with @frozen and import it from posthog.dataclasses. As per coding guidelines, “Use @frozen from posthog.dataclasses.”

Proposed decorator change
-@dataclass(frozen=True, kw_only=True)
+@frozen

Source: Coding guidelines

class LivingArtifactVersionDownload:
url: str | None
error: Literal["not_found", "not_stored", "unavailable"] | None
69 changes: 55 additions & 14 deletions products/tasks/backend/logic/services/living_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import structlog
from slack_sdk.errors import SlackApiError

from posthog.dataclasses import frozen
from posthog.event_usage import groups
from posthog.ph_client import ph_scoped_capture
from posthog.slack.channels import MAX_BUTTON_URL_CHARS, SlackButton, section_block
Expand Down Expand Up @@ -377,12 +378,25 @@ def open_task_artifact(artifact: TaskArtifact) -> str | None:
return _adapter_for_existing_artifact(artifact).open(artifact)


def read_living_artifact_version(artifact: TaskArtifact, version: int) -> LivingArtifactVersionContent | None:
"""Return the content of one version, or None when the version is unknown or keeps no content.
# The app streams a preview through a web worker, so a larger stored version only downloads.
# Keep in step with LIVING_PREVIEW_MAX_BYTES in the TaskTracker frontend.
LIVING_VERSION_PREVIEW_MAX_BYTES = 25 * 1024 * 1024

A Slack file version keeps its bytes in object storage. A canvas or message version keeps its
text in the version record. Storage read errors propagate to the caller.
"""

class LivingArtifactVersionTooLarge(Exception):
pass


@frozen
class LivingVersionLocation:
record: dict[str, Any]
content_type: str
# Empty when the version keeps its content as text in the record.
storage_path: str


def resolve_living_artifact_version(artifact: TaskArtifact, version: int) -> LivingVersionLocation | None:
"""Find one version and where it keeps its content, or None when the version is unknown or its path is foreign."""
record = next(
(
candidate
Expand All @@ -398,20 +412,47 @@ def read_living_artifact_version(artifact: TaskArtifact, version: int) -> Living
content_type = str(record.get("content_type") or location.get("content_type") or "") or _guess_content_type(
artifact.name
)

storage_path = str(location.get("storage_path") or "")
if storage_path:
# Every living artifact object sits under its task's prefix. A path outside it is not this artifact's object.
if not storage_path.startswith(_task_artifact_s3_prefix(artifact)):
return None
payload = object_storage.read_bytes(storage_path, missing_ok=True)
# Every living artifact object sits under its task's prefix. A path outside it is not this artifact's object.
if storage_path and not storage_path.startswith(_task_artifact_s3_prefix(artifact)):
return None
return LivingVersionLocation(record=record, content_type=content_type, storage_path=storage_path)


def _stored_version_size(resolved: LivingVersionLocation) -> int | None:
size = resolved.record.get("size")
if isinstance(size, int):
return size
head = object_storage.head_object(resolved.storage_path)
length = head.get("ContentLength") if head else None
return length if isinstance(length, int) else None


def read_living_artifact_version(artifact: TaskArtifact, version: int) -> LivingArtifactVersionContent | None:
"""Return the content of one version, or None when the version is unknown or keeps no content.

A Slack file version keeps its bytes in object storage. A canvas or message version keeps its
text in the version record. A stored version above the preview limit raises
LivingArtifactVersionTooLarge. Storage read errors propagate to the caller.
"""
resolved = resolve_living_artifact_version(artifact, version)
if resolved is None:
return None

if resolved.storage_path:
size = _stored_version_size(resolved)
if size is not None and size > LIVING_VERSION_PREVIEW_MAX_BYTES:
raise LivingArtifactVersionTooLarge()
payload = object_storage.read_bytes(resolved.storage_path, missing_ok=True)
if payload is None:
return None
return LivingArtifactVersionContent(name=artifact.name, content_type=content_type, content=payload)
return LivingArtifactVersionContent(name=artifact.name, content_type=resolved.content_type, content=payload)
Comment on lines +431 to +449

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '388,465p' products/tasks/backend/logic/services/living_artifacts.py
rg -n 'def head_object|def read_bytes' posthog/storage/object_storage.py

Repository: PostHog/posthog

Length of output: 4464


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- object storage module slices ---'
sed -n '1,90p' posthog/storage/object_storage.py
sed -n '560,785p' posthog/storage/object_storage.py
printf '%s\n' '--- reader usages and endpoint context ---'
rg -n -C 5 'read_living_artifact_version|LivingArtifactVersionTooLarge|living_artifact_version_storage_path' products/tasks posthog | head -240
printf '%s\n' '--- relevant route/view names ---'
rg -n -C 4 'preview|living_artifact|artifact.*version|version.*artifact' products/tasks/backend products/tasks/frontend 2>/dev/null | head -260

Repository: PostHog/posthog

Length of output: 41511


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- object-storage read_bytes implementations ---'
sed -n '100,175p' posthog/storage/object_storage.py
sed -n '180,215p' posthog/storage/object_storage.py
sed -n '280,315p' posthog/storage/object_storage.py
printf '%s\n' '--- preview facade functions ---'
sed -n '4345,4465p' products/tasks/backend/facade/api.py
printf '%s\n' '--- callers and route registration ---'
rg -n -C 6 'get_living_artifact_version_content|get_living_artifact_version_url|living_artifact_version_content|living_artifact_version_url' products/tasks --glob '*.py'

Repository: PostHog/posthog

Length of output: 13071


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- exact facade callers and route bindings ---'
rg -n -C 5 'read_task_run_living_artifact_version' products/tasks --glob '*.py'
printf '%s\n' '--- preview endpoint test ---'
sed -n '8585,8705p' products/tasks/backend/tests/test_api.py

Repository: PostHog/posthog

Length of output: 9071


Enforce the preview limit when storage size is unknown.

When both the version record and head_object provide no usable size, the guard at living_artifacts.py:442-443 is skipped. The preview endpoint then calls read_bytes, whose S3 implementation uses Body.read() and loads the complete object. A stored object larger than 25 MiB can therefore consume unbounded worker memory. Reject unknown-size previews or read through a bounded storage operation before returning the content.

Suggested fix
         size = _stored_version_size(resolved)
-        if size is not None and size > LIVING_VERSION_PREVIEW_MAX_BYTES:
+        if size is None or size > LIVING_VERSION_PREVIEW_MAX_BYTES:
             raise LivingArtifactVersionTooLarge()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def read_living_artifact_version(artifact: TaskArtifact, version: int) -> LivingArtifactVersionContent | None:
"""Return the content of one version, or None when the version is unknown or keeps no content.
A Slack file version keeps its bytes in object storage. A canvas or message version keeps its
text in the version record. A stored version above the preview limit raises
LivingArtifactVersionTooLarge. Storage read errors propagate to the caller.
"""
resolved = _resolve_living_version(artifact, version)
if resolved is None:
return None
if resolved.storage_path:
size = _stored_version_size(resolved)
if size is not None and size > LIVING_VERSION_PREVIEW_MAX_BYTES:
raise LivingArtifactVersionTooLarge()
payload = object_storage.read_bytes(resolved.storage_path, missing_ok=True)
if payload is None:
return None
return LivingArtifactVersionContent(name=artifact.name, content_type=content_type, content=payload)
return LivingArtifactVersionContent(name=artifact.name, content_type=resolved.content_type, content=payload)
def read_living_artifact_version(artifact: TaskArtifact, version: int) -> LivingArtifactVersionContent | None:
"""Return the content of one version, or None when the version is unknown or keeps no content.
A Slack file version keeps its bytes in object storage. A canvas or message version keeps its
text in the version record. A stored version above the preview limit raises
LivingArtifactVersionTooLarge. Storage read errors propagate to the caller.
"""
resolved = _resolve_living_version(artifact, version)
if resolved is None:
return None
if resolved.storage_path:
size = _stored_version_size(resolved)
if size is None or size > LIVING_VERSION_PREVIEW_MAX_BYTES:
raise LivingArtifactVersionTooLarge()
payload = object_storage.read_bytes(resolved.storage_path, missing_ok=True)
if payload is None:
return None
return LivingArtifactVersionContent(name=artifact.name, content_type=resolved.content_type, content=payload)


text = record.get("content")
text = resolved.record.get("content")
if isinstance(text, str):
return LivingArtifactVersionContent(name=artifact.name, content_type=content_type, content=text.encode("utf-8"))
return LivingArtifactVersionContent(
name=artifact.name, content_type=resolved.content_type, content=text.encode("utf-8")
)
return None


Expand Down
47 changes: 43 additions & 4 deletions products/tasks/backend/presentation/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4519,20 +4519,36 @@ def edit(self, request, pk=None, **kwargs):
OpenApiTypes.INT,
OpenApiParameter.PATH,
description="Version number of the living artifact, as listed in its versions.",
)
),
OpenApiParameter(
"download",
OpenApiTypes.BOOL,
OpenApiParameter.QUERY,
required=False,
description=(
"Set to true to save the version. A stored file then redirects to a short-lived presigned "
"URL, so a large file never passes through the app. Leave unset for an inline preview."
),
),
],
responses={
(200, "application/octet-stream"): OpenApiResponse(
response=OpenApiTypes.BINARY,
description="Version content, with the content type the version was saved with",
),
302: OpenApiResponse(description="With download=true, a redirect to a presigned URL for the stored file"),
400: OpenApiResponse(response=TaskRunErrorResponseSerializer, description="Unable to read the version"),
404: OpenApiResponse(description="Living artifact or version not found, or the version keeps no content"),
413: OpenApiResponse(
response=TaskRunErrorResponseSerializer,
description="The stored file is too large to preview. Request it with download=true.",
),
},
summary="Download one version of a living artifact",
description=(
"Streams the content of one living artifact version from the app origin. Slack file versions return "
"their stored file. Slack canvas and message versions return their text."
"Returns the content of one living artifact version. Slack file versions return their stored file, "
"streamed from the app origin for a preview or redirected to a presigned URL with download=true. "
"Slack canvas and message versions return their text."
),
operation_id="tasks_runs_living_artifacts_version_content",
)
Expand All @@ -4544,9 +4560,32 @@ def edit(self, request, pk=None, **kwargs):
)
def version_content(self, request, pk=None, version=None, **kwargs):
task_id = self._ensure_task_accessible()
version_number = int(str(version))
if str(request.query_params.get("download", "")).lower() in ("1", "true"):
download = tasks_facade.presign_task_run_living_artifact_version_download(
self._run_id(), task_id, self.team_id, artifact_id=str(pk), version=version_number
)
if download.url:
redirect = HttpResponseRedirect(download.url)
redirect["Cache-Control"] = "no-store"
return redirect
if download.error == "unavailable":
return Response(
TaskRunErrorResponseSerializer({"error": "Unable to read this version"}).data,
status=status.HTTP_400_BAD_REQUEST,
)
if download.error != "not_stored":
raise NotFound()
content, error = tasks_facade.read_task_run_living_artifact_version(
self._run_id(), task_id, self.team_id, artifact_id=str(pk), version=int(str(version))
self._run_id(), task_id, self.team_id, artifact_id=str(pk), version=version_number
)
if error == "too_large":
return Response(
TaskRunErrorResponseSerializer(
{"error": "This file is too large to preview. Download it instead."}
).data,
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
if error == "read_failed":
return Response(
TaskRunErrorResponseSerializer({"error": "Unable to read this version"}).data,
Expand Down
Loading
Loading