diff --git a/codex_procedures/content.generate/PROMPT.md b/codex_procedures/content.generate/PROMPT.md index 7c03e281..b0a8124c 100644 --- a/codex_procedures/content.generate/PROMPT.md +++ b/codex_procedures/content.generate/PROMPT.md @@ -71,7 +71,9 @@ Write exactly two UTF-8 Markdown files at the declared `output.path` and `output.companion_path`. For `draft`, the primary file is public copy only: start with a `# Title`, then the complete article/page draft with factual sources beside the supported claims. Shape it with the search-and-answer-engines skill: answer first, question headings, a table -when comparing options, an FAQ where it fits and `## Sources` last. +when comparing options, an FAQ where it fits and `## Sources` last. For an article, use the +page-figures skill for any figure, diagram, interactive piece, video or callout it needs; an +answer page or a page refresh has none. No Tin frontmatter, checklists, generation notes or approval commentary belongs in this file. For any no-draft outcome, write a short assessment instead of an article. Its exact format is `# Content assessment`, a blank line, the exact `rationale` string from the judgment below, diff --git a/codex_procedures/content.generate/skills/page-figures/SKILL.md b/codex_procedures/content.generate/skills/page-figures/SKILL.md new file mode 100644 index 00000000..ffd62c45 --- /dev/null +++ b/codex_procedures/content.generate/skills/page-figures/SKILL.md @@ -0,0 +1,77 @@ +--- +name: page-figures +description: Add figures, diagrams, interactive pieces, videos and callouts to an article draft only where they show what the words can't, as files in its assets folder that the founder approves with the article. +--- + +# Figures, diagrams, interactive pieces and videos + +Add one only where it shows something the words can't carry as well: a process, a comparison, +a structure, a measured result. Most articles need none, one or two. Never decorate. + +Your output instructions name the article's assets folder. Write each file directly in it and +refer to it from the article with a path that starts `.//`. Tin keeps only the files +the article refers to, and the founder approves them with the words. + +## Diagram + +A `mermaid` block for a flow, a sequence, a hierarchy or a timeline. Tin draws it in the +project's brand colours, so don't set colours or styles in it. Keep it to the few nodes the point +needs. + +## Figure + +An SVG file in the assets folder: + +```markdown +![What the figure shows](.//.svg "What the reader should notice") +``` + +The alt text says what the figure shows; the caption says what to notice. Draw it plainly, with +real labels and the article's own numbers. The SVG must have no script, no event handlers, no +`foreignObject` and no links or images from elsewhere; Tin leaves out an SVG that has any. + +## Interactive piece + +One self-contained HTML file in the assets folder, only when interaction teaches something a still +figure can't, such as a simulation or a calculator: + +````markdown +```tin-embed +src: .//.html +height: 420 +title: What the reader can do with it +``` +```` + +Inline its CSS and JavaScript. It runs with no network access: no requests, no outside fonts, +libraries or images. Keep it small, give it a sensible default state, and make it work with a +mouse, a keyboard and a touch screen. + +## Video + +A video that already exists at a public address, on the founder's site or on YouTube, Vimeo, Loom +or Mux: + +````markdown +```tin-video +url: https://www.youtube.com/watch?v=... +title: What it shows +``` +```` + +Never invent a video or an address. A long video file is linked, never copied into the folder. + +## Callout + +`> [!NOTE]`, `> [!TIP]` or `> [!WARNING]` on the first line of a quote, for a short aside the +reader shouldn't miss. Use one or two at most. + +## Existing pages + +When the brief points to an existing page that has figures and you can read its source, keep +them: copy an inline SVG into the folder as its own file, or rebuild an interactive piece as an +embed with its script and data. Don't describe a figure in words instead of keeping it. If you +can't carry one over, say which and why in Generation notes. + +List every figure, diagram, interactive piece and video in Generation notes with one line on what +each shows. diff --git a/codex_procedures/content.public_article/PROMPT.md b/codex_procedures/content.public_article/PROMPT.md index 45301e77..2bf16356 100644 --- a/codex_procedures/content.public_article/PROMPT.md +++ b/codex_procedures/content.public_article/PROMPT.md @@ -22,10 +22,12 @@ Plan before drafting, but publish only the finished article in the declared Mark article should open in the reader's world, land one salient idea, explain the mechanism, use concrete evidence, acknowledge material assumptions and limitations, and leave the reader with a useful contribution. Shape it with the search-and-answer-engines skill: its search-listing frontmatter -comes first, then the `# ` title and an answer-first opening. Apply the public-article-edit pass -before finishing. +comes first, then the `# ` title and an answer-first opening. Use the page-figures skill for any +figure, diagram, interactive piece, video or callout the article needs. Apply the public-article-edit +pass before finishing. -This is a reviewable draft, not a publishing action. Do not change another project file, publish to +This is a reviewable draft, not a publishing action. Do not change another project file (the +article's assets folder is part of the draft), publish to a website, send the article, contact anyone, or ask an interactive question. Put unresolved choices or missing evidence into the separate Generation notes document, never the public copy. diff --git a/codex_procedures/content.public_article/skills/page-figures/SKILL.md b/codex_procedures/content.public_article/skills/page-figures/SKILL.md new file mode 100644 index 00000000..ffd62c45 --- /dev/null +++ b/codex_procedures/content.public_article/skills/page-figures/SKILL.md @@ -0,0 +1,77 @@ +--- +name: page-figures +description: Add figures, diagrams, interactive pieces, videos and callouts to an article draft only where they show what the words can't, as files in its assets folder that the founder approves with the article. +--- + +# Figures, diagrams, interactive pieces and videos + +Add one only where it shows something the words can't carry as well: a process, a comparison, +a structure, a measured result. Most articles need none, one or two. Never decorate. + +Your output instructions name the article's assets folder. Write each file directly in it and +refer to it from the article with a path that starts `.//`. Tin keeps only the files +the article refers to, and the founder approves them with the words. + +## Diagram + +A `mermaid` block for a flow, a sequence, a hierarchy or a timeline. Tin draws it in the +project's brand colours, so don't set colours or styles in it. Keep it to the few nodes the point +needs. + +## Figure + +An SVG file in the assets folder: + +```markdown +![What the figure shows](.//.svg "What the reader should notice") +``` + +The alt text says what the figure shows; the caption says what to notice. Draw it plainly, with +real labels and the article's own numbers. The SVG must have no script, no event handlers, no +`foreignObject` and no links or images from elsewhere; Tin leaves out an SVG that has any. + +## Interactive piece + +One self-contained HTML file in the assets folder, only when interaction teaches something a still +figure can't, such as a simulation or a calculator: + +````markdown +```tin-embed +src: .//.html +height: 420 +title: What the reader can do with it +``` +```` + +Inline its CSS and JavaScript. It runs with no network access: no requests, no outside fonts, +libraries or images. Keep it small, give it a sensible default state, and make it work with a +mouse, a keyboard and a touch screen. + +## Video + +A video that already exists at a public address, on the founder's site or on YouTube, Vimeo, Loom +or Mux: + +````markdown +```tin-video +url: https://www.youtube.com/watch?v=... +title: What it shows +``` +```` + +Never invent a video or an address. A long video file is linked, never copied into the folder. + +## Callout + +`> [!NOTE]`, `> [!TIP]` or `> [!WARNING]` on the first line of a quote, for a short aside the +reader shouldn't miss. Use one or two at most. + +## Existing pages + +When the brief points to an existing page that has figures and you can read its source, keep +them: copy an inline SVG into the folder as its own file, or rebuild an interactive piece as an +embed with its script and data. Don't describe a figure in words instead of keeping it. If you +can't carry one over, say which and why in Generation notes. + +List every figure, diagram, interactive piece and video in Generation notes with one line on what +each shows. diff --git a/docs/content-review.md b/docs/content-review.md index 918302c1..1d9d5941 100644 --- a/docs/content-review.md +++ b/docs/content-review.md @@ -15,6 +15,12 @@ the next roadmap item. separate artifact and viewer action, never text or a link embedded in public copy. - Approval is bound to the current reviewed revision. An old approval cannot approve a newer draft. Only the final approved copy is eligible for configured delivery. +- An article (content.public_article 1.9.0, content.generate 1.10.0 articles) may carry + figures, diagrams, interactive pieces, videos and callouts. The reader shows them as + they'll ship: SVG figures as images, embeds in a sandboxed frame with no network or + access to Tin, videos from YouTube, Vimeo, Loom or Mux (a video file loads only on + play), and a note under the title for any file Tin left out. The Decisions card says + what the draft carries, for example "With 2 figures and 1 interactive piece." - A failed revision needs explicit retry. Feedback is not discarded by generic retry, and a stopped revision closes its waiting review ancestry. - Discard turns down anything waiting in Decisions: the run ends as declined, nothing it @@ -40,6 +46,14 @@ outbox bridges dispatch; Temporal carries command/run identifiers, not feedback article contents. Preserve historical adapter/output contracts and exact-version guards. Do not infer approval from a generated no-draft assessment. +An article draft whose definition declares `output.assets` may carry figures and embeds in +its assets folder (`
.assets/`, `src/tin_lite/page_assets.py`). The article declares +them: Tin keeps only the files it refers to, leaves out a missing, oversized or unsafe one +with its reason (never a failed run), and records each kept file's checksum with the +checkpoint. The review token binds that list with the article, approved-page consumers +re-check it, and a revision copies the files it keeps into its own folder. Drafts without +assets keep their exact token and contract. + The shared implementation lives in `src/tin_lite/workflow_reviews.py`, `workflow_review_store.py`, `workflow_review_dispatch.py` and `article_review.py`. The dashboard composer is `src/tin_lite/static/workflow-review.js`. diff --git a/package.json b/package.json index b1ed8d0c..d2332162 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test:browser": "node --test web/raw-file-security.browser.test.js web/fonts.browser.test.js web/diagram-renderer.browser.test.js web/diagram-viewport.browser.test.js scripts/check_diagram.test.mjs web/json-viewer.test.js web/json-viewer.browser.test.js web/output-comparison.browser.test.js web/content-plan.browser.test.js web/content-plan-page.browser.test.js web/billing.browser.test.js web/run-payment-card.browser.test.js web/style-capture.browser.test.js web/content-draft.browser.test.js web/content-delivery.browser.test.js web/workflow-review.browser.test.js web/auth.browser.test.js web/mcp-consent.browser.test.js web/app-domain.browser.test.js web/lock-page.browser.test.js web/project-menu.browser.test.js web/chat.browser.test.js web/project-connections.browser.test.js web/code-workflow.browser.test.js web/page-url.browser.test.js web/files-page.browser.test.js web/narrow-layout.browser.test.js web/decisions.browser.test.js web/website-changes.browser.test.js web/integration-resource.browser.test.js web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js", + "test:browser": "node --test web/raw-file-security.browser.test.js web/fonts.browser.test.js web/diagram-renderer.browser.test.js web/diagram-viewport.browser.test.js scripts/check_diagram.test.mjs web/json-viewer.test.js web/json-viewer.browser.test.js web/output-comparison.browser.test.js web/content-plan.browser.test.js web/content-plan-page.browser.test.js web/billing.browser.test.js web/run-payment-card.browser.test.js web/style-capture.browser.test.js web/content-draft.browser.test.js web/content-delivery.browser.test.js web/workflow-review.browser.test.js web/auth.browser.test.js web/mcp-consent.browser.test.js web/app-domain.browser.test.js web/lock-page.browser.test.js web/project-menu.browser.test.js web/chat.browser.test.js web/project-connections.browser.test.js web/code-workflow.browser.test.js web/page-url.browser.test.js web/files-page.browser.test.js web/narrow-layout.browser.test.js web/decisions.browser.test.js web/website-changes.browser.test.js web/integration-resource.browser.test.js web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js web/page-bundles.browser.test.js", "test:x-browser": "node --test web/x-posts.browser.test.js web/x-feedback.browser.test.js web/x-reader-page.browser.test.js web/x-integration.browser.test.js web/x-workflow-setup.browser.test.js web/monthly-schedule.browser.test.js", "test:files-browser": "node --test web/files-page.browser.test.js", "test:narrow-layout-browser": "node --test web/narrow-layout.browser.test.js", diff --git a/sandbox/procedure_app_server.py b/sandbox/procedure_app_server.py index ce9a811b..346d2f32 100644 --- a/sandbox/procedure_app_server.py +++ b/sandbox/procedure_app_server.py @@ -344,6 +344,22 @@ def _identity_instruction(context: dict) -> str: ) +def _assets_instruction(output: dict[str, Any]) -> str: + """The one exception to "modify no other project file": the article's assets folder.""" + assets = output.get("assets") + if not isinstance(assets, dict): + return "" + folder = str(assets["folder"]) + name = folder.rsplit("/", 1)[-1] + return ( + f" The article may also use figures and embeds: write them as files directly in " + f"`{STATE_DIR / folder}` (SVG images, or self-contained HTML files for interactive " + f"pieces), at most {assets['max_files']} files and {assets['max_bytes']} bytes " + f"together, and refer to each one from the article as `./{name}/`. Tin " + "keeps only the files the article refers to." + ) + + def _result_instruction(output: dict[str, Any], output_kind: str, output_path: object) -> str: if output_kind != "project.artifact": text = ( @@ -382,12 +398,14 @@ def _result_instruction(output: dict[str, Any], output_kind: str, output_path: o f"the structured judgment only to `{companion}` " f"(at most {output['companion_max_bytes']} bytes). Modify no other project file. " "Do not force an article when current coverage already satisfies the brief." + + _assets_instruction(output) ) return ( f"Write the public article only to `{absolute}`. Write internal generation notes " f"only to `{companion}` (at most {output['companion_max_bytes']} bytes). " "Modify no other project file. Do not put notes or source frontmatter in the article. " "Do not merely describe the artifacts in your final response." + + _assets_instruction(output) ) workspace_note = "" if STATE_DIR != WORKSPACE: diff --git a/sandbox/run_procedure.sh b/sandbox/run_procedure.sh index a7d6f014..95ef96bc 100644 --- a/sandbox/run_procedure.sh +++ b/sandbox/run_procedure.sh @@ -16,6 +16,10 @@ if [[ "${1:-}" == "--check-companion" ]]; then printf 'TIN_PROCEDURE_COMPANION_V1\n' exit 0 fi +if [[ "${1:-}" == "--check-assets" ]]; then + printf 'TIN_PROCEDURE_ASSETS_V1\n' + exit 0 +fi if [[ -n "${OPENAI_API_KEY:-}" || -n "${CODEX_API_KEY:-}" || \ -n "${TIN_LITE_LUNA_API_KEY:-}" || -n "${ANTHROPIC_API_KEY:-}" || \ @@ -93,6 +97,22 @@ if [[ -n "${TIN_PROCEDURE_COMPANION_PATH:-}" ]]; then fi output_paths+=("${TIN_PROCEDURE_COMPANION_PATH}") fi +# An article draft may also write figures and embeds into its assets folder, next to it. +assets_folder="${TIN_PROCEDURE_ASSETS_FOLDER:-}" +if [[ -n "${assets_folder}" ]]; then + if [[ "${result_kind}" != "project.artifact" || + "${assets_folder}" != "${TIN_PROCEDURE_OUTPUT_PATH%.md}.assets" || + ! "${TIN_PROCEDURE_ASSETS_MAX_FILES:-}" =~ ^[1-9][0-9]*$ || + ! "${TIN_PROCEDURE_ASSETS_MAX_BYTES:-}" =~ ^[1-9][0-9]*$ ]]; then + echo "invalid procedure assets folder" >&2 + exit 64 + fi +fi +# Changed paths outside the declared outputs, ignoring files directly in the assets folder. +outside_outputs() { + grep -Fvx -f <(printf '%s\n' "${output_paths[@]}") | + { if [[ -n "${assets_folder}" ]]; then grep -Ev "^${assets_folder//./\\.}/[^/]+$"; else cat; fi; } || true +} if [[ -n "${TIN_RUN_TOOLS_URL:-}" || -n "${TIN_RUN_TOOLS_GRANT:-}" ]]; then if [[ -z "${TIN_RUN_TOOLS_URL:-}" || -z "${TIN_RUN_TOOLS_GRANT:-}" ]]; then echo "incomplete run-tool configuration" >&2 @@ -236,7 +256,7 @@ if [[ "${remote_status}" -eq 0 ]]; then GIT_CONFIG_VALUE_0="${TIN_EPHEMERAL_AUTH_HEADER}" \ git -C "${state_workspace}" fetch --quiet ephemeral "${commit_sha}" changed="$(git -C "${state_workspace}" diff-tree --no-commit-id --name-only -r "${commit_sha}")" - unexpected="$(printf '%s\n' "${changed}" | grep -Fvx -f <(printf '%s\n' "${output_paths[@]}") || true)" + unexpected="$(printf '%s\n' "${changed}" | outside_outputs)" if [[ -n "${unexpected}" ]]; then echo "ephemeral recovery branch is not a valid procedure result" >&2 exit 67 @@ -420,16 +440,42 @@ else exit 65 fi done - unexpected="$({ + if [[ -n "${assets_folder}" && -d "${state_workspace}/${assets_folder}" ]]; then + # Only regular files directly in the folder can be referenced; anything else is dropped. + while IFS= read -r -d '' extra; do + echo "Discarded ${extra#"${state_workspace}/"}: assets are files directly in the folder" >&2 + rm -rf -- "${extra}" + done < <(find "${state_workspace}/${assets_folder}" -mindepth 1 -maxdepth 1 \ + \( -type l -o ! -type f \) -print0) + fi + unexpected="$( git -C "${state_workspace}" status --porcelain --untracked-files=all | cut -c4- | - grep -Fvx -f <(printf '%s\n' "${output_paths[@]}") - } || true)" + outside_outputs + )" if [[ -n "${unexpected}" ]]; then echo "Codex changed files outside the procedure output contract:" >&2 printf '%s\n' "${unexpected}" | head -n 20 >&2 exit 66 fi - if ! python3 - "${state_workspace}/${TIN_PROCEDURE_OUTPUT_PATH}" <<'PY' + if [[ -n "${assets_folder}" && -d "${state_workspace}/${assets_folder}" ]]; then + # The host keeps only the files the article refers to; these are runaway guards. + asset_count=0 + asset_bytes=0 + while IFS= read -r -d '' asset; do + asset_count=$((asset_count + 1)) + asset_bytes=$((asset_bytes + $(wc -c < "${asset}"))) + done < <(find "${state_workspace}/${assets_folder}" -mindepth 1 -maxdepth 1 -type f -print0) + if (( asset_count > TIN_PROCEDURE_ASSETS_MAX_FILES || asset_bytes > TIN_PROCEDURE_ASSETS_MAX_BYTES )); then + echo "Codex procedure assets exceed their declared limits" >&2 + exit 65 + fi + fi + scanned=("${state_workspace}/${TIN_PROCEDURE_OUTPUT_PATH}") + if [[ -n "${assets_folder}" && -d "${state_workspace}/${assets_folder}" ]]; then + while IFS= read -r -d '' asset; do scanned+=("${asset}"); done < \ + <(find "${state_workspace}/${assets_folder}" -mindepth 1 -maxdepth 1 -type f -print0) + fi + if ! python3 - "${scanned[@]}" <<'PY' import base64, json, os, sys context_path = os.environ.get("TIN_PROCEDURE_CONTEXT_PATH") if context_path: @@ -437,11 +483,13 @@ if context_path: else: context = json.loads(base64.b64decode(os.environ["TIN_PROCEDURE_CONTEXT_B64"], validate=True)) secret = (context.get("identity") or {}).get("password") -if secret and secret.encode("utf-8") in open(sys.argv[1], "rb").read(): - raise SystemExit("procedure output contains the test identity secret") sys.path.insert(0, "/opt/tin-lite") from payment_card_guard import reject_card_leak -reject_card_leak(open(sys.argv[1], "rb").read(), context.get("payment_card")) +for path in sys.argv[1:]: + content = open(path, "rb").read() + if secret and secret.encode("utf-8") in content: + raise SystemExit("procedure output contains the test identity secret") + reject_card_leak(content, context.get("payment_card")) PY then exit 65 @@ -450,6 +498,9 @@ fi if [[ -n "$(git -C "${state_workspace}" status --porcelain)" ]]; then git -C "${state_workspace}" add -- "${output_paths[@]}" + if [[ -n "${assets_folder}" && -d "${state_workspace}/${assets_folder}" ]]; then + git -C "${state_workspace}" add -- "${assets_folder}" + fi git -C "${state_workspace}" commit -m "Save codex.procedure result" >/dev/null fi commit_sha="$(git -C "${state_workspace}" rev-parse HEAD)" diff --git a/src/tin_lite/activities.py b/src/tin_lite/activities.py index bee0a259..7a247130 100644 --- a/src/tin_lite/activities.py +++ b/src/tin_lite/activities.py @@ -3278,6 +3278,14 @@ async def persist_codex_procedure_artifact(self, run_id_text: str) -> None: await self._reject_card_leak( run_id=run_id, procedure=procedure, content=recovered ) + assets, dropped_assets = ( + await self._procedure_assets( + run, project, procedure, recovered_revision, recovered + ) + if recovered_revision is not None + and procedure.result_kind == PROJECT_ARTIFACT_RESULT + else ((), []) + ) await self._complete_procedure_persist( conn=conn, run_id=run_id, @@ -3285,6 +3293,7 @@ async def persist_codex_procedure_artifact(self, run_id_text: str) -> None: ephemeral_branch=run.ephemeral_branch, sandbox_id=None, result={ + **({"dropped_assets": dropped_assets} if dropped_assets else {}), "checkpoint_path": checkpoint_path, "summary": f"{workflow_definition.title} produced its result.", "message": "The procedure result is ready.", @@ -3307,6 +3316,7 @@ async def persist_codex_procedure_artifact(self, run_id_text: str) -> None: companions=await self._procedure_companions( run, project, procedure, recovered_revision ), + assets=assets, ).to_dict() } if recovered_revision is not None @@ -3712,6 +3722,13 @@ async def retain_interrupted(content=None): await self._reject_card_leak( run_id=run_id, procedure=procedure, content=checkpoint ) + assets, dropped_assets = ( + await self._procedure_assets( + run, project, procedure, checkpoint_revision, checkpoint + ) + if procedure.result_kind == PROJECT_ARTIFACT_RESULT + else ((), []) + ) await self._complete_procedure_persist( conn=conn, run_id=run_id, @@ -3719,6 +3736,7 @@ async def retain_interrupted(content=None): ephemeral_branch=run.ephemeral_branch, sandbox_id=sandbox_id, result={ + **({"dropped_assets": dropped_assets} if dropped_assets else {}), "checkpoint_path": checkpoint_path, "result_kind": procedure.result_kind, "ephemeral_commit_sha": checkpoint_revision, @@ -3734,6 +3752,7 @@ async def retain_interrupted(content=None): companions=await self._procedure_companions( run, project, procedure, checkpoint_revision ), + assets=assets, ).to_dict() } if procedure.result_kind == PROJECT_ARTIFACT_RESULT @@ -4110,6 +4129,50 @@ async def _validate_procedure_publication_lease(self, run: WorkflowRun) -> None: ): raise StaleGenerationError("procedure lease no longer owns the session") + async def _procedure_assets(self, run, project, procedure, revision, content): + """The article's figures and embeds: the files it refers to that Tin can keep. + + A missing, oversized or unsafe file is left out with its reason; it never fails a run. + """ + from tin_lite import page_assets + + policy = procedure.output_assets + if policy is None or procedure.assets_folder is None: + return (), [] + try: + article = content.decode("utf-8") + except UnicodeDecodeError: + return (), [] + kept, dropped, total = [], [], 0 + for path in page_assets.referenced(article, procedure.output_path): + if len(kept) == policy.max_files: + dropped.append({"path": path, "reason": "the article refers to too many files"}) + continue + try: + raw = await self._storage.read_procedure_checkpoint( + repo_id=project.state_repo_id, revision=revision, path=path + ) + except (ValueError, OutputConflictError): + dropped.append({"path": path, "reason": "the file is missing"}) + continue + reason = page_assets.problem(path, raw) + if reason is None and total + len(raw) > policy.max_bytes: + reason = "the files together are larger than their limit" + if reason is not None: + dropped.append({"path": path, "reason": reason}) + continue + total += len(raw) + kept.append( + OutputCheckpoint.create( + run=run, + revision=revision, + path=path, + media_type=page_assets.media_type(path), + content=raw, + ) + ) + return tuple(kept), dropped + async def _procedure_companions(self, run, project, procedure, revision): from tin_lite import content_draft @@ -4219,6 +4282,11 @@ async def _publish_project_procedure( run, project, procedure, revision ): raise ValueError("Saved companions differ from the pinned output contract.") + if ( + checkpoint.assets + != (await self._procedure_assets(run, project, procedure, revision, content))[0] + ): + raise ValueError("Saved assets differ from the article they belong to.") from tin_lite import content_draft from tin_lite.content_editorial_judgment import ( LABELS, @@ -4379,6 +4447,13 @@ async def request_codex_procedure_review(self, run_id_text: str) -> bool: repo_id=project.state_repo_id, commit_sha=sha, path=path ) artifact_title, lede = display_title(raw), review_line(raw) + from tin_lite.page_assets import attachments_line + + attachments = attachments_line( + canonical.result.get("checkpoint"), raw.decode("utf-8", errors="replace") + ) + else: + attachments = None destination = ( f"Approval opens an unmerged GitHub PR in {delivery['repository']}" + ("." if delivery.get("system_run_id") else f" at {delivery['path']}.") @@ -4401,7 +4476,7 @@ async def request_codex_procedure_review(self, run_id_text: str) -> bool: artifact_path=path, artifact_title=artifact_title, summary=summary, - explanation=" ".join(part for part in (lede, destination) if part), + explanation=" ".join(part for part in (lede, attachments, destination) if part), ) if required: from tin_lite.organic_content import project_review_progress diff --git a/src/tin_lite/api.py b/src/tin_lite/api.py index a08c67b2..de84b76a 100644 --- a/src/tin_lite/api.py +++ b/src/tin_lite/api.py @@ -1121,6 +1121,13 @@ class MarkdownHeadingView(BaseModel): title: str +class MarkdownAssetView(BaseModel): + """A figure or embed the reader loads from the project at the document's revision.""" + + path: str + media_type: str + + class MarkdownDocumentView(BaseModel): markdown: str html: str @@ -1135,6 +1142,57 @@ class MarkdownDocumentView(BaseModel): size_bytes: int | None = None related_documents: list[dict[str, str]] = Field(default_factory=list) sha256: str | None = None + assets: list[MarkdownAssetView] = Field(default_factory=list) + # Files the draft referred to that Tin left out, each with its reason. + asset_notes: list[str] = Field(default_factory=list) + + +async def _run_document_assets(database, run, path, source): + """A draft's kept figures and embeds, from its published checkpoint, and what was left out.""" + from tin_lite import page_assets + + if source != "canonical" or not path.endswith(".md") or run.executor != "codex.procedure": + return [], [], None + published = await database.get_effect(f"{run.id}:procedure_canonical_commit") + checkpoint = ( + (published.result or {}).get("checkpoint") + if published is not None and published.status == "completed" + else None + ) + persisted = await database.get_effect(f"{run.id}:procedure_artifact_persist") + dropped = ((persisted.result or {}) if persisted is not None else {}).get("dropped_assets") + notes = [ + f"{item['path'].rsplit('/', 1)[-1]} was left out: {item['reason']}." + for item in dropped or [] + ] + assets = [ + MarkdownAssetView(path=item["artifact_path"], media_type=item["media_type"]) + for item in (checkpoint or {}).get("assets") or [] + ] + return assets, notes, page_assets.folder(path) if assets or notes else None + + +async def _file_document_assets(storage, project, path, revision, markdown): + """The figures and embeds a Markdown file in the project refers to that exist there.""" + from tin_lite import page_assets + + if not path.endswith(".md"): + return [], None + referenced = page_assets.referenced(markdown, path) + if not referenced: + return [], None + try: + present = set( + await storage.list_canonical_files_at(repo_id=project.state_repo_id, revision=revision) + ) + except LookupError: + return [], None + assets = [ + MarkdownAssetView(path=item, media_type=page_assets.media_type(item)) + for item in referenced + if item in present + ] + return assets, page_assets.folder(path) class ProjectFileView(BaseModel): @@ -2721,7 +2779,7 @@ async def get_project_file_document( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="project file is not Markdown", ) - _, content = await _read_project_file( + project, content = await _read_project_file( project_id=project_id, path=path, revision=revision, @@ -2735,7 +2793,10 @@ async def get_project_file_document( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Markdown project file is not UTF-8", ) from exc - document = render_markdown(markdown) + assets, folder = await _file_document_assets( + request.app.state.runtime.storage, project, path, revision, markdown + ) + document = render_markdown(markdown, asset_folder=folder) source_query = f"path={quote(path, safe='')}&revision={revision}" response.headers["X-Tin-File-Source"] = "code.storage" response.headers["X-Tin-File-Revision"] = revision @@ -2753,6 +2814,7 @@ async def get_project_file_document( path=path, revision=revision, size_bytes=len(content), + assets=assets, ) @@ -4469,7 +4531,10 @@ async def get_artifact_document( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Markdown artifact is not UTF-8", ) from exc - document = render_markdown(markdown) + assets, asset_notes, folder = await _run_document_assets( + request.app.state.runtime.database, run, output.path, source + ) + document = render_markdown(markdown, asset_folder=folder) response.headers["X-Tin-Artifact-Source"] = "code.storage" response.headers["X-Tin-Output-Kind"] = source response.headers["X-Tin-Output-Revision"] = output.revision @@ -4491,6 +4556,8 @@ async def get_artifact_document( headings=[ MarkdownHeadingView(id=heading.id, title=heading.title) for heading in document.headings ], + assets=assets, + asset_notes=asset_notes, ) diff --git a/src/tin_lite/approved_article.py b/src/tin_lite/approved_article.py index 9ae792c7..1c5429c3 100644 --- a/src/tin_lite/approved_article.py +++ b/src/tin_lite/approved_article.py @@ -10,7 +10,7 @@ import re from uuid import UUID -from tin_lite import content_draft, content_editorial_judgment +from tin_lite import content_draft, content_editorial_judgment, page_assets from tin_lite.content_delivery import DRAFT_WORKFLOW_ID, article_body from tin_lite.domain import RunStatus from tin_lite.workflow_review_store import digest @@ -44,12 +44,14 @@ def _review_artifact(run, publication): not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256) ): raise ValueError("The approved article's publication digest is invalid.") + assets = page_assets.review_binding(checkpoint) return { "run_id": str(run.id), "path": run.artifact_path, "revision": run.canonical_commit_sha, "sha256": sha256, "assessment": content_editorial_judgment.no_draft(publication), + **({"assets": assets} if assets else {}), } @@ -137,6 +139,18 @@ async def select(*, database, storage, project_id, source_run_id, include_style= "due_date": prepared.result["due_date"], # A content.generate answer page is adapted like content.answer_page's pages. **({"source_kind": "answer_page", "page_metadata": metadata} if metadata else {}), + **( + { + "assets": await page_assets.verified( + storage, + repo_id=project.state_repo_id, + revision=run.canonical_commit_sha, + binding=artifact["assets"], + ) + } + if artifact.get("assets") + else {} + ), } style = prepared.result.get("style") if include_style and style is not None and not isinstance(style, dict): @@ -198,6 +212,7 @@ async def guard(conn, *, project_id, source): "revision": source["source_revision"], "sha256": source["publication_sha256"], "assessment": False, + **page_assets.source_binding(source), } token = digest( { diff --git a/src/tin_lite/approved_document.py b/src/tin_lite/approved_document.py index 13583524..92b74b40 100644 --- a/src/tin_lite/approved_document.py +++ b/src/tin_lite/approved_document.py @@ -12,7 +12,7 @@ import re from uuid import UUID -from tin_lite import approved_article +from tin_lite import approved_article, page_assets from tin_lite.content_delivery import ( ANSWER_PAGE_WORKFLOW_ID, PUBLIC_ARTICLE_WORKFLOW_ID, @@ -87,15 +87,16 @@ async def select(*, database, storage, project_id, source_run_id): ): raise ValueError("The approved page's publication proof is unavailable.") publication_sha256 = None + approved_assets: list[dict] = [] if kind == "public_article": # Public articles are approved through the review command; its token binds the # exact version that was read. An approval without one proves nothing. command = await _review_command(database.pool, run.id) if command is None: raise ValueError("The approved article's review proof is unavailable.") - publication_sha256 = approved_article._verify_review(run, published.result, command)[ - "sha256" - ] + reviewed = approved_article._verify_review(run, published.result, command) + publication_sha256 = reviewed["sha256"] + approved_assets = reviewed.get("assets", []) project = await database.get_project(project_id) raw = await storage.read_canonical_artifact( repo_id=project.state_repo_id, commit_sha=run.canonical_commit_sha, path=run.artifact_path @@ -121,6 +122,18 @@ async def select(*, database, storage, project_id, source_run_id): "article_sha256": hashlib.sha256(article.encode()).hexdigest(), "title": title, "page_metadata": _metadata(listing), + **( + { + "assets": await page_assets.verified( + storage, + repo_id=project.state_repo_id, + revision=run.canonical_commit_sha, + binding=approved_assets, + ) + } + if approved_assets + else {} + ), } @@ -152,6 +165,7 @@ async def guard(conn, *, project_id, source): "revision": source["source_revision"], "sha256": source["publication_sha256"], "assessment": False, + **page_assets.source_binding(source), } token = digest( { diff --git a/src/tin_lite/article_review.py b/src/tin_lite/article_review.py index 816d0089..0051830f 100644 --- a/src/tin_lite/article_review.py +++ b/src/tin_lite/article_review.py @@ -23,6 +23,10 @@ in Generation notes instead of inventing its contents or claiming you read it. Recheck factual corrections against evidence. You may retain an assessment if another article still adds no value. Never claim a requested change was made when it was not. +When `source.assets` lists figures or embeds, they are the saved copy's files in the project +checkout. Copy the ones you keep into this run's own assets folder, change them as the +feedback asks, and point the article's references at your folder; files you don't copy are +left out of the revision. Put a short `## What changed` section and `## Feedback not followed` section in the separate Generation notes file. Use 'None.' in the latter only when appropriate. Neither section, process notes, nor a link to them belongs in the public copy. The existing output contract, diff --git a/src/tin_lite/catalog.py b/src/tin_lite/catalog.py index 72ccbf17..124b6656 100644 --- a/src/tin_lite/catalog.py +++ b/src/tin_lite/catalog.py @@ -89,6 +89,7 @@ from tin_lite.model_providers import ModelCapability, ModelRoute, ProviderName from tin_lite.organic_audit import AUDIT_KEY, AUDIT_POLICY, MARKETS from tin_lite.organic_audit_ai import AI_CONTRACT, AI_SCHEMAS +from tin_lite.page_assets import AssetPolicy from tin_lite.procedure_documents import AGENT_REVISION from tin_lite.procedures import ( BROWSER_SANDBOX_PROFILE, @@ -852,7 +853,7 @@ def definition_and_files_with_wiki( "route you chose, and a refresh changes exactly the approved lines. The roadmap stays " "unchanged.", executor=CODEX_PROCEDURE_EXECUTOR, - version_label="1.9.0", + version_label="1.10.0", system=ORGANIC_TRAFFIC_SYSTEM, # A weekly occurrence drafts the next article in plan order and holds while an # earlier draft from the same program still waits for review. @@ -925,6 +926,8 @@ def definition_and_files_with_wiki( output_path_template=content_draft.PATH_TEMPLATE, output_validator=content_draft.EDITORIAL_VALIDATOR, output_max_bytes=80_000, + # An article's figures and embeds (page_assets), approved with its words. + output_assets=AssetPolicy(max_files=12, max_bytes=2_000_000), project_skills=( ProjectSkillDependency(name="writing-style", path=STYLE_PATH, required=False), ), @@ -1776,7 +1779,7 @@ def definition_and_files_with_wiki( "public article." ), executor=CODEX_PROCEDURE_EXECUTOR, - version_label="1.8.0", + version_label="1.9.0", system=ORGANIC_TRAFFIC_SYSTEM, prerequisites=( WorkflowPrerequisite( @@ -1849,6 +1852,8 @@ def definition_and_files_with_wiki( output_path_template=article_review.PATH_TEMPLATE, output_validator="public-article.v2", output_max_bytes=300_000, + # Its figures and embeds (page_assets), approved with its words. + output_assets=AssetPolicy(max_files=12, max_bytes=2_000_000), project_skills=( ProjectSkillDependency( name="writing-style", diff --git a/src/tin_lite/code_storage.py b/src/tin_lite/code_storage.py index bdd2cd1e..e4a597a8 100644 --- a/src/tin_lite/code_storage.py +++ b/src/tin_lite/code_storage.py @@ -630,7 +630,7 @@ async def stage_native_output( async def _checkpoint_contents(self, repo_id, checkpoint, content): checkpoint.validate_content(content) files = {checkpoint.artifact_path: content} - for item in checkpoint.companions: + for item in (*checkpoint.companions, *checkpoint.assets): raw = await self.read_procedure_checkpoint( repo_id=repo_id, revision=item.ephemeral_commit_sha, path=item.artifact_path ) diff --git a/src/tin_lite/content_repository_delivery.py b/src/tin_lite/content_repository_delivery.py index e2a181f4..27c3c16d 100644 --- a/src/tin_lite/content_repository_delivery.py +++ b/src/tin_lite/content_repository_delivery.py @@ -1067,7 +1067,9 @@ def validate_copy(manifest, source): or manifest["head_sha"] != source["binding"]["head_sha"] ): raise ValueError("The repository differs from the pinned delivery source.") - paragraphs = [words for block in re.split(r"\n\s*\n", article) if (words := _words(block))] + # Embed, video and diagram blocks are figures, not prose; a figure line has no words. + prose = FIGURE_FENCE.sub("\n\n", article) + paragraphs = [words for block in re.split(r"\n\s*\n", prose) if (words := _words(block))] exact, worded, closest = [], [], (0, None) for item in manifest["files"]: path, text = item["path"], item["content"] @@ -1098,6 +1100,11 @@ def validate_copy(manifest, source): } +FIGURE_FENCE = re.compile( + r"^(`{3,}|~{3,})[ \t]*(?:tin-embed|tin-video|mermaid)\b.*?^\1[ \t]*$", re.M | re.S +) + + def _stores_exactly(path, text, article): """The article byte for byte: a Markdown body, or one complete JSON string token.""" if path.endswith((".md", ".mdx")): diff --git a/src/tin_lite/documents.py b/src/tin_lite/documents.py index 5f4dbd49..ae548372 100644 --- a/src/tin_lite/documents.py +++ b/src/tin_lite/documents.py @@ -34,13 +34,31 @@ class RenderedMarkdown: reading_minutes: int +_CALLOUT = re.compile(r"\s*

\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:
)?\n?") +_ASSET_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.(?:svg|html)") +_FIELD = re.compile(r"([a-z]+)[ \t]*:[ \t]*(.+)") + + class _TinHTMLRenderer(HTMLRenderer): - def __init__(self) -> None: + def __init__(self, asset_folder: str | None = None) -> None: # Raw HTML is handled explicitly below. This lets existing reports keep their harmless #
/ formatting while every other tag is shown as text instead of executed. super().__init__(escape=False) self.headings: list[MarkdownHeading] = [] self._slug_counts: dict[str, int] = {} + # An article's assets folder (page_assets): figures and embeds the reader loads itself. + self.asset_folder = asset_folder + + def _asset(self, url: str) -> str | None: + """The project path of a file in the article's assets folder, or None.""" + if not self.asset_folder: + return None + name = self.asset_folder.rsplit("/", 1)[-1] + relative = url.strip().removeprefix("./") + if not relative.startswith(f"{name}/"): + return None + file = relative[len(name) + 1 :] + return f"{self.asset_folder}/{file}" if _ASSET_NAME.fullmatch(file) else None def heading(self, text: str, level: int, **attrs: object) -> str: title = unescape(striptags(text)).strip() @@ -61,6 +79,15 @@ def link(self, text: str, url: str, title: str | None = None) -> str: def image(self, text: str, url: str, title: str | None = None) -> str: alt = striptags(text) filename = PurePosixPath(urlparse(url).path).name or "image" + if asset := self._asset(url): + # No src: the reader loads the file with the member's session, never by URL. + rendered = ( + f'" rendered = ( f'{safe_entity(alt)} str: language = "" if info: language = info.strip().split(None, 1)[0] + if language in {"tin-embed", "tin-video"} and self.asset_folder: + fields = { + match[1]: match[2].strip() + for line in code.splitlines() + if (match := _FIELD.fullmatch(line.strip())) + } + title = safe_entity(fields.get("title", "")) + if language == "tin-embed" and (asset := self._asset(fields.get("src", ""))): + try: + height = min(max(int(fields.get("height", "420")), 120), 1600) + except ValueError: + height = 420 + return ( + f'

\n' + ) + url = fields.get("url", "") + if language == "tin-video" and url.startswith(("https://", "http://")): + link = self.safe_url(url) + poster = fields.get("poster", "") + poster_attr = ( + f' data-poster="{self.safe_url(poster)}"' + if poster.startswith(("https://", "http://")) + else "" + ) + return ( + f'
' + f"{title or escape_text(url)}
\n" + ) language_attr = f' data-language="{safe_entity(language)}"' if language else "" code_class = f' class="language-{safe_entity(language)}"' if language else "" return ( @@ -80,6 +137,19 @@ def block_code(self, code: str, info: str | None = None) -> str: f"{escape_text(code)}\n" ) + def block_quote(self, text: str) -> str: + """GitHub's alert syntax (`> [!NOTE]`) as a callout; any other quote as before.""" + match = _CALLOUT.match(text) + if match is None: + return super().block_quote(text) + kind = match[1].lower() + rest = text[match.end() :].lstrip() + rest = rest[len("

") :] if rest.startswith("

") else f"

{rest}" + return ( + f'

\n' + ) + def inline_html(self, html: str) -> str: compact = re.sub(r"\s+", "", html).lower() if compact in _SAFE_INLINE_HTML: @@ -99,8 +169,10 @@ def handle_data(self, data: str) -> None: self.parts.append(data) -def render_markdown(markdown: str) -> RenderedMarkdown: - renderer = _TinHTMLRenderer() +def render_markdown(markdown: str, *, asset_folder: str | None = None) -> RenderedMarkdown: + """Safe HTML for the reader. With an article's `asset_folder`, its figures, embeds and + videos render as placeholders the reader fills in; otherwise as ordinary Markdown.""" + renderer = _TinHTMLRenderer(asset_folder) parser = mistune.create_markdown( renderer=renderer, plugins=["strikethrough", "table", "task_lists", "url"], diff --git a/src/tin_lite/e2b_runtime.py b/src/tin_lite/e2b_runtime.py index 758b571d..a31b34d9 100644 --- a/src/tin_lite/e2b_runtime.py +++ b/src/tin_lite/e2b_runtime.py @@ -591,6 +591,11 @@ async def _run_procedure_and_kill( envs["TIN_PROCEDURE_COMPANION_MAX_BYTES"] = str( run_input.context["output"]["companion_max_bytes"] ) + assets = run_input.context.get("output", {}).get("assets") + if assets: + envs["TIN_PROCEDURE_ASSETS_FOLDER"] = str(assets["folder"]) + envs["TIN_PROCEDURE_ASSETS_MAX_FILES"] = str(assets["max_files"]) + envs["TIN_PROCEDURE_ASSETS_MAX_BYTES"] = str(assets["max_bytes"]) envs["TIN_PROCEDURE_OUTPUT_MAX_BYTES"] = str(run_input.output_max_bytes) envs["TIN_PROCEDURE_RESULT_KIND"] = run_input.result_kind if run_input.project_revision is not None: @@ -656,6 +661,12 @@ async def receive_stdout(chunk: str) -> None: ) if ready.stdout.strip() != "TIN_PROCEDURE_COMPANION_V1": raise RuntimeError("Sandbox image lacks companion document support") + if assets: + ready = await sandbox.commands.run( + "/opt/tin-lite/run-procedure --check-assets", timeout=15 + ) + if ready.stdout.strip() != "TIN_PROCEDURE_ASSETS_V1": + raise RuntimeError("Sandbox image lacks article assets support") if run_input.isolated: if run_input.usage_sink is None: raise ValueError("isolated procedures require their own profile and usage sink") diff --git a/src/tin_lite/page_assets.py b/src/tin_lite/page_assets.py new file mode 100644 index 00000000..a4980230 --- /dev/null +++ b/src/tin_lite/page_assets.py @@ -0,0 +1,151 @@ +"""The files an article draft carries next to it: figures and embeds in its assets folder. + +A draft at `content/articles/2026-10-05-1a2b3c4d.md` may write files to +`content/articles/2026-10-05-1a2b3c4d.assets/` and refer to them from the Markdown, as +`./2026-10-05-1a2b3c4d.assets/flock-paths.svg` in a figure or as the `src:` of a `tin-embed` +block. The article declares its assets: Tin keeps exactly the files the Markdown refers to, +records their checksums with the checkpoint, and approval binds them with the article. Files +nothing refers to are never published. + +Projects are untrusted. An SVG that could run script is left out with a reason rather than +failing the run; the reader shows SVG only as an image and embeds only in a sandboxed frame. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass + +# Text assets for now; raster images come with binary checkpoint support. +MEDIA_TYPES = {".svg": "image/svg+xml", ".html": "text/html"} +FILE_MAX_BYTES = 512_000 +MAX_FILES_CEILING = 24 +MAX_BYTES_CEILING = 4_000_000 +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.(?:svg|html)") +# Script, event handlers, embedded HTML, and references that leave the file. +UNSAFE_SVG = re.compile( + r"<\s*script\b|<\s*foreignObject\b|\son[a-z]+\s*=|javascript:" + r"|(?:xlink:)?href\s*=\s*[\"']\s*(?!#|data:image/)[^\"'\s]", + re.I, +) + + +def folder(article_path: str) -> str: + """The assets folder next to an article: its path without `.md`, plus `.assets`.""" + if not article_path.endswith(".md"): + raise ValueError("Only a Markdown article has an assets folder.") + return article_path[: -len(".md")] + ".assets" + + +def referenced(article: str, article_path: str) -> list[str]: + """The assets the article refers to, as project paths, in the order they first appear.""" + name = folder(article_path).rsplit("/", 1)[-1] + pattern = re.compile(rf"(? str: + return MEDIA_TYPES[path[path.rindex(".") :]] + + +def problem(path: str, content: bytes) -> str | None: + """Why an asset can't be kept, or None. A problem drops the asset; it never fails a run.""" + if not content or len(content) > FILE_MAX_BYTES: + return f"it is empty or larger than {FILE_MAX_BYTES // 1000} KB" + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + return "it is not UTF-8 text" + if path.endswith(".svg") and UNSAFE_SVG.search(text): + return "the SVG contains script, event handlers or outside references" + return None + + +@dataclass(frozen=True) +class AssetPolicy: + """How many assets a draft may keep, and how many bytes they may use together.""" + + max_files: int + max_bytes: int + + def definition(self) -> dict[str, int]: + return {"max_files": self.max_files, "max_bytes": self.max_bytes} + + @classmethod + def load(cls, value: object) -> AssetPolicy: + if not isinstance(value, dict) or set(value) != {"max_files", "max_bytes"}: + raise ValueError("Codex procedure assets contract is invalid") + files, size = value["max_files"], value["max_bytes"] + if ( + type(files) is not int + or type(size) is not int + or not 1 <= files <= MAX_FILES_CEILING + or not 1 <= size <= MAX_BYTES_CEILING + ): + raise ValueError("Codex procedure assets limits are out of range") + return cls(max_files=files, max_bytes=size) + + +def review_binding(checkpoint: dict | None) -> list[dict[str, str]]: + """The assets an approval binds: each kept file's path and checksum, from the checkpoint.""" + return [ + {"path": item["artifact_path"], "sha256": item["sha256"]} + for item in (checkpoint or {}).get("assets") or [] + ] + + +def source_binding(source: dict) -> dict: + """The review record's assets, rebuilt from a pinned delivery source; empty without any.""" + assets = [ + {"path": item["path"], "sha256": item["sha256"]} for item in source.get("assets") or [] + ] + return {"assets": assets} if assets else {} + + +async def verified(storage, *, repo_id: str, revision: str, binding: list[dict]) -> list[dict]: + """The approved assets as delivery receives them, each re-read and checked at `revision`.""" + assets = [] + for item in binding: + raw = await storage.read_canonical_artifact( + repo_id=repo_id, commit_sha=revision, path=item["path"] + ) + if hashlib.sha256(raw).hexdigest() != item["sha256"]: + raise ValueError("An approved figure or embed differs from what was approved.") + assets.append( + { + "path": item["path"], + "sha256": item["sha256"], + "media_type": media_type(item["path"]), + "bytes": len(raw), + } + ) + return assets + + +def _count(number: int, noun: str) -> str: + return f"{number} {noun}" if number == 1 else f"{number} {noun}s" + + +def attachments_line(checkpoint: dict | None, article: str) -> str | None: + """What a reviewer will see besides the words, for the Decisions card, or None.""" + kept = [item["media_type"] for item in (checkpoint or {}).get("assets") or []] + figures = kept.count("image/svg+xml") + len( + re.findall(r"(?m)^(?:`{3,}|~{3,})\s*mermaid\b", article) + ) + embeds = kept.count("text/html") + videos = len(re.findall(r"(?m)^(?:`{3,}|~{3,})\s*tin-video\b", article)) + parts = [ + _count(number, noun) + for number, noun in ((figures, "figure"), (embeds, "interactive piece"), (videos, "video")) + if number + ] + if not parts: + return None + listed = parts[0] if len(parts) == 1 else f"{', '.join(parts[:-1])} and {parts[-1]}" + return f"With {listed}." diff --git a/src/tin_lite/procedures.py b/src/tin_lite/procedures.py index 54093769..dadfe4c6 100644 --- a/src/tin_lite/procedures.py +++ b/src/tin_lite/procedures.py @@ -16,6 +16,7 @@ from tin_lite.diagram_compositions import parse_diagram_v2 from tin_lite.domain import CODEX_PROCEDURE_EXECUTOR, MEMORY_INDEX_PATH from tin_lite.memory import MAX_MEMORY_BYTES, validate_memory_index +from tin_lite.page_assets import AssetPolicy from tin_lite.procedure_documents import ( DocumentPair, parse_document_pair, @@ -377,6 +378,7 @@ class CodexProcedureSpec: sandbox: SandboxProfile = SandboxProfile() identity: TestIdentityPolicy = TestIdentityPolicy() output_section: OutputSection | None = None + output_assets: AssetPolicy | None = None workspace_capabilities: tuple[str, ...] = () repair_policy: str | None = None allow_no_change: bool = False @@ -412,6 +414,8 @@ class PinnedCodexProcedure: sandbox: SandboxProfile = SandboxProfile() identity: TestIdentityPolicy = TestIdentityPolicy() output_section: OutputSection | None = None + # Article drafts that may write figures and embeds into their assets folder. + output_assets: AssetPolicy | None = None workspace_capabilities: tuple[str, ...] = () repair_policy: str | None = None allow_no_change: bool = False @@ -435,6 +439,14 @@ def repository_workspace(self) -> bool: def binary_output(self) -> bool: return self.output_media_type in BINARY_ARTIFACT_MEDIA_TYPES + @property + def assets_folder(self) -> str | None: + if self.output_assets is None or not self.output_path: + return None + from tin_lite.page_assets import folder + + return folder(self.output_path) + @property def companion_path(self) -> str | None: if self.documents: @@ -524,6 +536,8 @@ def sandbox_context( output["validator"] = self.output_validator if self.output_section is not None: output["section"] = self.output_section.definition() + if self.assets_folder is not None: + output["assets"] = {"folder": self.assets_folder, **self.output_assets.definition()} context: dict[str, Any] = { "workflow_key": self.workflow_key, "prompt": self.prompt, @@ -635,6 +649,7 @@ class CodexProcedureSource: github_pull_request: GitHubPullRequestProcedure | None = None github_workspace: GitHubRepositoryWorkspace | None = None output_section: OutputSection | None = None + output_assets: AssetPolicy | None = None sandbox: SandboxProfile = SandboxProfile() identity: bool | TestIdentityPolicy = False @@ -744,11 +759,18 @@ def materialize(self, workflow_key: str) -> tuple[dict[str, Any], dict[str, byte output["validator"] = self.output_validator if self.output_section is not None: output["section"] = self.output_section.definition() + if self.output_assets is not None: + output["assets"] = self.output_assets.definition() verification = {"commands": []} else: - if self.github_workspace is not None or self.output_section is not None: + if ( + self.github_workspace is not None + or self.output_section is not None + or self.output_assets is not None + ): raise ValueError( - "pull-request procedures cannot declare a read-only workspace or a section" + "pull-request procedures cannot declare a read-only workspace, a section " + "or assets" ) pull_request = self.github_pull_request workspace = { @@ -920,6 +942,9 @@ def validate_codex_procedure_definition(definition: dict[str, Any]) -> CodexProc output_max_files = 1 receipt_path_template: str | None = None output_section: OutputSection | None = None + output_assets: AssetPolicy | None = None + if result_kind != PROJECT_ARTIFACT_RESULT and output.get("assets") is not None: + raise ValueError("Only article drafts carry an assets folder") if result_kind == PROJECT_ARTIFACT_RESULT: if workspace_kind == GITHUB_REPOSITORY_WORKSPACE and workspace_capabilities != ( "contents.read", @@ -1040,6 +1065,13 @@ def validate_codex_procedure_definition(definition: dict[str, Any]) -> CodexProc PUBLIC_ARTICLE_VALIDATOR, }: output_max_files = 2 + if output.get("assets") is not None: + if output_validator not in { + *content_draft.CLEAN_VALIDATORS, + PUBLIC_ARTICLE_VALIDATOR, + } or not resolved_path.endswith(".md"): + raise ValueError("Only article drafts carry an assets folder") + output_assets = AssetPolicy.load(output["assets"]) if output_validator == CHARACTER_SVG_VALIDATOR and ( output_media_type != CHARACTER_SVG_MEDIA_TYPE or output_path_template is None @@ -1262,6 +1294,7 @@ def validate_codex_procedure_definition(definition: dict[str, Any]) -> CodexProc sandbox=sandbox, identity=identity, output_section=output_section, + output_assets=output_assets, workspace_capabilities=workspace_capabilities, repair_policy=repair_policy, allow_no_change=allow_no_change, @@ -1417,6 +1450,7 @@ async def load_pinned_codex_procedure( sandbox=spec.sandbox, identity=spec.identity, output_section=spec.output_section, + output_assets=spec.output_assets, workspace_capabilities=spec.workspace_capabilities, repair_policy=spec.repair_policy, allow_no_change=spec.allow_no_change, diff --git a/src/tin_lite/publication.py b/src/tin_lite/publication.py index d4ae90e4..d982be8e 100644 --- a/src/tin_lite/publication.py +++ b/src/tin_lite/publication.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urlencode +from tin_lite import page_assets from tin_lite.domain import WorkflowRun if TYPE_CHECKING: @@ -73,17 +74,22 @@ class OutputCheckpoint: byte_count: int version: int = 1 companions: tuple[OutputCheckpoint, ...] = () + # An article's figures and embeds (page_assets): only the files the article refers to. + assets: tuple[OutputCheckpoint, ...] = () def to_dict(self) -> dict[str, Any]: value = asdict(self) value.pop("companions") + value.pop("assets") if self.companions: value["companions"] = [item.to_dict() for item in self.companions] + if self.assets: + value["assets"] = [item.to_dict() for item in self.assets] return value @property def files(self) -> tuple[OutputCheckpoint, ...]: - return (self, *self.companions) + return (self, *self.companions, *self.assets) @classmethod def create( @@ -95,6 +101,7 @@ def create( media_type: str, content: bytes, companions: tuple[OutputCheckpoint, ...] = (), + assets: tuple[OutputCheckpoint, ...] = (), ) -> OutputCheckpoint: result = cls( run_id=str(run.id), @@ -109,6 +116,7 @@ def create( byte_count=len(content), version=2 if companions else 1, companions=companions, + assets=assets, ) return cls.load(result.to_dict(), run=run) @@ -122,9 +130,24 @@ def load(cls, value: dict[str, Any], *, run: WorkflowRun) -> OutputCheckpoint: or any(not isinstance(item, dict) or item.get("companions") for item in children) ): raise ValueError("saved output has invalid companions") + assets = value.get("assets", []) + if ( + not isinstance(assets, list) + or len(assets) > page_assets.MAX_FILES_CEILING + or any( + not isinstance(item, dict) or item.get("companions") or item.get("assets") + for item in assets + ) + ): + raise ValueError("saved output has invalid assets") result = cls( - **{key: value[key] for key in cls.__dataclass_fields__ if key != "companions"}, + **{ + key: value[key] + for key in cls.__dataclass_fields__ + if key not in {"companions", "assets"} + }, companions=tuple(cls.load(item, run=run) for item in children), + assets=tuple(cls.load(item, run=run) for item in assets), ) except (KeyError, TypeError) as exc: raise ValueError("saved output has no validated checkpoint identity") from exc @@ -155,6 +178,7 @@ def load(cls, value: dict[str, Any], *, run: WorkflowRun) -> OutputCheckpoint: "text/plain", "application/json", "image/svg+xml", + "text/html", "video/mp4", } or not isinstance(result.sha256, str) @@ -170,6 +194,22 @@ def load(cls, value: dict[str, Any], *, run: WorkflowRun) -> OutputCheckpoint: for item in result.companions ): raise ValueError("saved companion does not belong to the same output revision") + if result.assets: + folder = page_assets.folder(result.artifact_path) + "/" + paths = [item.artifact_path for item in result.assets] + if ( + len(set(paths)) != len(paths) + or sum(item.byte_count for item in result.assets) > page_assets.MAX_BYTES_CEILING + or any( + item.ephemeral_commit_sha != result.ephemeral_commit_sha + or not item.artifact_path.startswith(folder) + or not page_assets.NAME.fullmatch(item.artifact_path[len(folder) :]) + or item.media_type != page_assets.media_type(item.artifact_path) + or item.byte_count > page_assets.FILE_MAX_BYTES + for item in result.assets + ) + ): + raise ValueError("saved assets do not belong to the same article revision") return result @property diff --git a/src/tin_lite/static/app.css b/src/tin_lite/static/app.css index f4e66954..80fb9154 100644 --- a/src/tin_lite/static/app.css +++ b/src/tin_lite/static/app.css @@ -8384,6 +8384,97 @@ html[data-theme="dark"] .integration-badge.is-monochrome::before { border-radius: 4px; } +/* Page bundles: a draft's figures, embeds, videos and callouts. */ +.md-figure, +.md-embed, +.md-video { + margin: 4px 0 34px; +} + +.md-figure img { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} + +.md-figure figcaption, +.md-embed figcaption, +.md-video figcaption { + margin-top: 10px; + color: var(--ink-muted); + font-size: 13px; + line-height: 18px; + text-align: center; +} + +.md-embed-frame { + display: block; + width: 100%; + background: var(--paper-card); + border: 1px solid var(--paper-border); + border-radius: var(--radius-card); +} + +.md-video-frame, +.md-video-file { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + border: 0; + border-radius: var(--radius-card); +} + +.md-embed-missing, +.md-asset-notes { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 11px; + line-height: 16px; +} + +.md-embed-missing { + padding: 24px; + background: var(--paper-deep); + border-radius: 4px; + text-align: center; +} + +.md-asset-notes { + margin: 0 0 24px; +} + +.md-callout { + margin: 0 0 30px; + padding: 16px 20px; + background: var(--paper-well); + border-radius: var(--radius-card); +} + +.md-callout p { + margin: 0 0 8px; +} + +.md-callout p:last-child { + margin-bottom: 0; +} + +.md-callout .md-callout-label { + margin-bottom: 6px; + color: var(--ink-secondary); + font-family: var(--mono); + font-size: 11px; + line-height: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.md-callout[data-kind="warning"] .md-callout-label, +.md-callout[data-kind="caution"] .md-callout-label { + color: var(--danger); +} + .md-image-fallback { display: flex; min-height: 300px; diff --git a/src/tin_lite/static/app.js b/src/tin_lite/static/app.js index e3b4b401..42a1794a 100644 --- a/src/tin_lite/static/app.js +++ b/src/tin_lite/static/app.js @@ -3276,6 +3276,7 @@ function renderDocument() { ? cached.path || run?.artifact_path : null; state.documentCleanup = window.TinMarkdownViewer.mount(main, cached, { mode: "in-app", + loadAsset: route.source !== "retained" ? bundleAssetLoader(cached.revision) : undefined, contextLabel: route.source === "retained" && run?.retained_output?.reason === "execution_interrupted" ? `Partial result · ${cached.filename}` : undefined, pathElement: projectPath ? projectFilePathElement({ path: projectPath }, "markdown-filename") : undefined, @@ -5613,6 +5614,14 @@ function projectFileRawUrl(route, download = false, projectId = state.project.id return `/api/projects/${encodeURIComponent(projectId)}/files/raw?${query.toString()}`; } +// A draft's figures and embeds, read at the document's revision with the member's session. +function bundleAssetLoader(revision, projectId = state.project?.id) { + if (!revision || !projectId) return undefined; + return (path) => authorizedFetch(projectFileRawUrl({ path, revision }, false, projectId), { + headers: { Accept: "*/*" }, + }).then((response) => response.arrayBuffer()); +} + function formatFileSize(bytes) { if (bytes < 1024) return `${bytes} b`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} kb`; @@ -5962,6 +5971,7 @@ function renderFile() { if (cached?.kind === "markdown") { state.documentCleanup = window.TinMarkdownViewer.mount(main, cached.document, { mode: "in-app", + loadAsset: bundleAssetLoader(route.revision), contextLabel: route.path, pathElement: projectFilePathElement(route, "markdown-filename"), factsText: `markdown · ${comparisonFileLabel(route)} · ${shortRevision(route.revision)}`, diff --git a/src/tin_lite/static/markdown-viewer.js b/src/tin_lite/static/markdown-viewer.js index 24127cae..0e1f1b77 100644 --- a/src/tin_lite/static/markdown-viewer.js +++ b/src/tin_lite/static/markdown-viewer.js @@ -2,6 +2,11 @@ (function defineTinMarkdownViewer() { const NUMERIC_CELL = /^[-+]?[$€£]?\d[\d,]*(?:\.\d+)?%?(?:\s+of\s+\d+)?$/i; + // An embed is untrusted: it runs in an opaque-origin frame that can't reach the network. + const EMBED_POLICY = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data: blob:; font-src data:; media-src data: blob:"; + const VIDEO_FILE = /\.(?:mp4|webm|m4v|mov)(?:[?#]|$)/i; function makeElement(tagName, className, text) { const element = document.createElement(tagName); @@ -114,6 +119,7 @@ decorateTables(article); decorateImages(article); + decorateBundle(article, documentData, options.loadAsset); decorateDiagrams(article); const cleanupMap = buildSectionMap(article, map, mapInner); return () => cleanupMap(); @@ -133,28 +139,165 @@ }); } + function showImageFallback(image) { + if (!image.isConnected) return; + const frame = makeElement("div", "md-image-fallback"); + frame.setAttribute("role", "img"); + const alt = image.alt.trim(); + const filename = image.dataset.fallbackName || "image"; + frame.setAttribute("aria-label", alt || filename); + frame.innerHTML = + ''; + const label = makeElement( + "span", + "", + [alt, filename].filter((value, index, values) => value && values.indexOf(value) === index).join(" · "), + ); + frame.append(label); + image.replaceWith(frame); + } + function decorateImages(article) { - article.querySelectorAll("img").forEach((image) => { - const showFallback = () => { - if (!image.isConnected) return; - const frame = makeElement("div", "md-image-fallback"); - frame.setAttribute("role", "img"); - const alt = image.alt.trim(); - const filename = image.dataset.fallbackName || "image"; - frame.setAttribute("aria-label", alt || filename); - frame.innerHTML = - ''; - const label = makeElement( - "span", - "", - [alt, filename].filter((value, index, values) => value && values.indexOf(value) === index).join(" · "), - ); - frame.append(label); - image.replaceWith(frame); - }; - image.addEventListener("error", showFallback, { once: true }); - if (image.complete && image.naturalWidth === 0) showFallback(); + // Bundle figures have no src yet; decorateBundle loads them. + article.querySelectorAll("img:not(.md-asset)").forEach((image) => { + image.addEventListener("error", () => showImageFallback(image), { once: true }); + if (image.complete && image.naturalWidth === 0) showImageFallback(image); + }); + } + + // A draft's figures, embeds and videos (page bundles). Files load with the member's session + // through `loadAsset(path)`; nothing in the article is fetched by URL. + function decorateBundle(article, documentData, loadAsset) { + const assets = new Map((documentData.assets || []).map((item) => [item.path, item.media_type])); + if (documentData.asset_notes?.length) { + // Under the title: what the draft referred to that Tin left out, and why. + const notes = makeElement("p", "md-asset-notes", documentData.asset_notes.join(" ")); + const title = article.querySelector("h1"); + if (title) title.after(notes); + else article.prepend(notes); + } + article.querySelectorAll("img.md-asset").forEach((image) => { + wrapFigure(image); + const type = assets.get(image.dataset.asset); + if (!type || !loadAsset) { + showImageFallback(image); + return; + } + loadAsset(image.dataset.asset) + .then((bytes) => imageUrl(new Blob([bytes], { type }))) + .then((url) => { + image.addEventListener("error", () => showImageFallback(image), { once: true }); + image.src = url; + }) + .catch(() => showImageFallback(image)); + }); + article.querySelectorAll("figure.md-embed").forEach((figure) => { + const title = figure.dataset.title || ""; + if (!assets.has(figure.dataset.asset) || !loadAsset) { + figure.append(makeElement("div", "md-embed-missing", "This interactive piece isn't available.")); + return; + } + loadAsset(figure.dataset.asset) + .then((bytes) => { + const frame = document.createElement("iframe"); + frame.className = "md-embed-frame"; + // Scripts run, but in an opaque origin: no Tin cookies, storage, page or network. + frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("referrerpolicy", "no-referrer"); + frame.title = title || "Interactive piece"; + frame.style.height = `${Number(figure.dataset.height) || 420}px`; + frame.srcdoc = embedDocument(new TextDecoder().decode(bytes)); + figure.prepend(frame); + if (title) figure.append(makeElement("figcaption", "", title)); + }) + .catch(() => { + figure.append(makeElement("div", "md-embed-missing", "This interactive piece couldn't load.")); + }); }); + article.querySelectorAll("figure.md-video").forEach((figure) => { + const url = figure.dataset.url || ""; + const title = figure.dataset.title || ""; + const player = videoPlayer(url, figure.dataset.poster, title); + if (!player) return; // The link the server rendered stays. + figure.replaceChildren(player); + if (title) figure.append(makeElement("figcaption", "", title)); + }); + } + + function wrapFigure(image) { + const parent = image.parentElement; + if (parent?.tagName !== "P" || parent.childNodes.length !== 1) return; + const figure = makeElement("figure", "md-figure"); + parent.replaceWith(figure); + figure.append(image); + if (image.title) figure.append(makeElement("figcaption", "", image.title)); + } + + function imageUrl(blob) { + if (blob.type !== "image/svg+xml") return Promise.resolve(URL.createObjectURL(blob)); + // As in Files: an SVG becomes a data URL, inert in and opaque if opened directly. + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error("Tin could not load this figure.")); + reader.readAsDataURL(blob); + }); + } + + function embedDocument(html) { + const policy = ``; + const doctype = html.match(/^\s*]*>/i); + return doctype ? doctype[0] + policy + html.slice(doctype[0].length) : policy + html; + } + + // Players from a short list of hosts; a video file plays in place and loads only on play. + function videoPlayer(url, poster, title) { + let parsed; + try { + parsed = new URL(url); + } catch (_error) { + return null; + } + if (!["https:", "http:"].includes(parsed.protocol)) return null; + if (VIDEO_FILE.test(parsed.pathname)) { + const video = document.createElement("video"); + video.className = "md-video-file"; + video.controls = true; + video.preload = "none"; + video.playsInline = true; + video.src = parsed.href; + if (poster?.startsWith("https://")) video.poster = poster; + return video; + } + const src = providerEmbed(parsed); + if (!src) return null; + const frame = document.createElement("iframe"); + frame.className = "md-video-frame"; + frame.src = src; + frame.title = title || "Video"; + frame.loading = "lazy"; + frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups"); + frame.setAttribute("allow", "fullscreen; picture-in-picture; encrypted-media"); + frame.setAttribute("referrerpolicy", "strict-origin-when-cross-origin"); + return frame; + } + + function providerEmbed(url) { + const host = url.hostname.replace(/^www\./, ""); + const segments = url.pathname.split("/").filter(Boolean); + const id = (value) => (/^[A-Za-z0-9_-]{4,64}$/.test(value || "") ? value : null); + if (host === "youtube.com" || host === "m.youtube.com") { + const video = id(url.searchParams.get("v")) || (segments[0] === "shorts" ? id(segments[1]) : null); + return video && `https://www.youtube-nocookie.com/embed/${video}`; + } + if (host === "youtu.be") return id(segments[0]) && `https://www.youtube-nocookie.com/embed/${segments[0]}`; + if (host === "vimeo.com") return /^\d+$/.test(segments[0] || "") && `https://player.vimeo.com/video/${segments[0]}`; + if (host === "loom.com" && segments[0] === "share") return id(segments[1]) && `https://www.loom.com/embed/${segments[1]}`; + if (host === "stream.mux.com" || host === "player.mux.com") { + const playback = id((segments[0] || "").replace(/\.m3u8$/, "")); + return playback && `https://player.mux.com/${playback}`; + } + return null; } function decorateDiagrams(article) { diff --git a/src/tin_lite/workflow_reviews.py b/src/tin_lite/workflow_reviews.py index 34884814..36639dae 100644 --- a/src/tin_lite/workflow_reviews.py +++ b/src/tin_lite/workflow_reviews.py @@ -3,7 +3,7 @@ import hashlib from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 -from tin_lite import content_draft, content_editorial_judgment +from tin_lite import content_draft, content_editorial_judgment, page_assets from tin_lite.domain import RunStatus from tin_lite.project_files import safe_project_file_path from tin_lite.workflow_review_store import ReviewConflict, accept_approval, digest, unpack @@ -62,12 +62,15 @@ async def artifact(self, run): or receipt.result.get("artifact_path") != artifact_run.artifact_path ): raise ReviewConflict("The saved copy does not match its publication proof.") + assets = page_assets.review_binding(receipt.result.get("checkpoint")) return artifact_run, { "run_id": str(artifact_run.id), "path": artifact_run.artifact_path, "revision": artifact_run.canonical_commit_sha, "sha256": (receipt.result.get("checkpoint") or {}).get("sha256"), "assessment": content_editorial_judgment.no_draft(receipt.result), + # Only drafts with figures or embeds carry this, so older tokens stay the same. + **({"assets": assets} if assets else {}), } async def view(self, run_id, actor, post_id=""): @@ -328,11 +331,14 @@ async def compare(self, run_id, actor): raise ValueError( "These versions are too large for comparison. Read each full copy." ) + published = await self.db.get_effect(f"{version.id}:procedure_canonical_commit") + checkpoint = (published.result or {}).get("checkpoint") if published else None result[name] = { "run_id": str(version.id), "version": version.review_version, "path": version.artifact_path, "content": raw.decode("utf-8"), + "assets": page_assets.review_binding(checkpoint), } return result diff --git a/tests/js/procedure-output.cjs b/tests/js/procedure-output.cjs index ce983565..312a1bcd 100644 --- a/tests/js/procedure-output.cjs +++ b/tests/js/procedure-output.cjs @@ -35,7 +35,7 @@ const context = vm.createContext({ }); vm.runInContext([ "workflowForRun", "availableRunOutput", "outputReadUrl", "retainedOutputMessage", "isMarkdownPath", - "openRunOutputFile", "openRunArtifact", "hasOutputConflict", "runFingerprint", "renderDocument", + "openRunOutputFile", "openRunArtifact", "hasOutputConflict", "runFingerprint", "renderDocument", "bundleAssetLoader", ].map(extract).join("\n"), context); assert.equal(context.availableRunOutput(run).source, "retained"); context.openRunArtifact("run"); diff --git a/tests/test_article_search_structure.py b/tests/test_article_search_structure.py index 59a7bf64..c2f282a0 100644 --- a/tests/test_article_search_structure.py +++ b/tests/test_article_search_structure.py @@ -52,8 +52,8 @@ def test_listing_does_not_excuse_a_missing_title(): @pytest.mark.parametrize( ("key", "version", "skill"), [ - ("content.public_article", "1.8.0", "search-and-answer-engines"), - ("content.generate", "1.9.0", "search-and-answer-engines"), + ("content.public_article", "1.9.0", "search-and-answer-engines"), + ("content.generate", "1.10.0", "search-and-answer-engines"), ], ) def test_blog_workflows_pin_the_search_structure_guidance(key, version, skill): diff --git a/tests/test_content_draft.py b/tests/test_content_draft.py index 5b701ddc..b613c113 100644 --- a/tests/test_content_draft.py +++ b/tests/test_content_draft.py @@ -26,7 +26,7 @@ from tin_lite.writing_style import STYLE_PATH -async def fixture(db, monkeypatch, *, editorial=False, clean=False, judgment=False): +async def fixture(db, monkeypatch, *, editorial=False, clean=False, judgment=False, assets=False): db, storage, project, configured, planner, model, create = await setup( db, monkeypatch, editorial=editorial ) @@ -45,8 +45,15 @@ async def fixture(db, monkeypatch, *, editorial=False, clean=False, judgment=Fal output_validator=( content_draft.CLEAN_VALIDATOR if clean else content_draft.VALIDATOR ), + # Historical contracts had no assets folder. + output_assets=None, ), ) + if assets: + from tin_lite.page_assets import AssetPolicy + + policy = AssetPolicy(max_files=12, max_bytes=2_000_000) + spec = replace(spec, procedure=replace(spec.procedure, output_assets=policy)) definition, resources = spec.definition_and_resource_files() await db.upsert_registry_workflow( workflow_id=spec.id, diff --git a/tests/test_one_content_generate.py b/tests/test_one_content_generate.py index 2b60963d..a5fa672d 100644 --- a/tests/test_one_content_generate.py +++ b/tests/test_one_content_generate.py @@ -268,7 +268,7 @@ def connected(f, monkeypatch): def test_the_definition_drafts_three_kinds_with_a_review_policy_each(): definition = spec(content_draft.KEY).definition - assert definition["version"] == "1.9.0" + assert definition["version"] == "1.10.0" assert content_draft.supported_kinds(definition) == ("article", "answer", "refresh") assert definition["human_review"]["review_label"] == "Review article" kinds = definition["human_review_kinds"] @@ -652,7 +652,8 @@ def test_versions_on_main_are_unchanged(): # The versions #267 introduced, none of them on main yet. # content.plan 0.9.0 (content-editorial-v8) raised 0.8.0's output cap and changed nothing else. -RELEASED = {"content.generate": "1.9.0", "content.plan": "0.9.0", "content.refresh": "1.1.0"} +# content.generate 1.10.0 lets an article carry figures and embeds (page bundles). +RELEASED = {"content.generate": "1.10.0", "content.plan": "0.9.0", "content.refresh": "1.1.0"} async def test_retired_workflows_are_hidden_but_saved_configurations_still_run( diff --git a/tests/test_page_assets.py b/tests/test_page_assets.py new file mode 100644 index 00000000..fbf2614c --- /dev/null +++ b/tests/test_page_assets.py @@ -0,0 +1,429 @@ +"""An article draft's figures and embeds: kept, approved and published with the article.""" + +import hashlib +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from test_clean_content_draft import notes +from test_content_draft import fixture, start +from test_procedure_publication import publication_db as publication_db + +from tin_lite import approved_article, content_draft, page_assets +from tin_lite.activities import TinActivities +from tin_lite.catalog import BUILTIN_WORKFLOWS +from tin_lite.procedures import PinnedCodexProcedure, validate_codex_procedure_definition +from tin_lite.publication import OutputCheckpoint +from tin_lite.workflow_reviews import WorkflowReviews + +GOOD_SVG = b'' +EMBED = b"" +UNSAFE_SVG = b'' + + +def article(folder_name): + return ( + "# Documented messaging setup\n\n" + + ("Follow the published API reference for the supported request fields. " * 6).rstrip() + + f'\n\n![How a request flows](./{folder_name}/flow.svg "The request path")\n\n' + f"```tin-embed\nsrc: ./{folder_name}/field.html\nheight: 420\ntitle: Try it\n```\n\n" + f"![Unsafe](./{folder_name}/unsafe.svg)\n\n![Missing](./{folder_name}/missing.svg)\n" + ).encode() + + +def test_the_article_declares_its_assets(): + path = "content/articles/2026-10-05-1a2b.md" + text = ( + "![a](./2026-10-05-1a2b.assets/flow.svg)\n```tin-embed\n" + "src: ./2026-10-05-1a2b.assets/field.html\n```\n" + "again 2026-10-05-1a2b.assets/flow.svg, elsewhere other.assets/x.svg, " + "./2026-10-05-1a2b.assets/old.svg.bak" + ) + assert page_assets.referenced(text, path) == [ + "content/articles/2026-10-05-1a2b.assets/flow.svg", + "content/articles/2026-10-05-1a2b.assets/field.html", + ] + assert page_assets.problem("a.svg", GOOD_SVG) is None + assert page_assets.problem("a.html", EMBED) is None + for bad in ( + UNSAFE_SVG, + b'', + b'x', + b"", + b"", + ): + assert page_assets.problem("a.svg", bad) + assert page_assets.problem("a.html", b"\xff\xfe") + + +def test_only_article_drafts_declare_an_assets_folder(): + spec = next(w for w in BUILTIN_WORKFLOWS if w.key == content_draft.KEY) + policy = page_assets.AssetPolicy(max_files=12, max_bytes=2_000_000) + definition, _ = replace( + spec, procedure=replace(spec.procedure, output_assets=policy) + ).definition_and_resource_files() + assert definition["procedure"]["output"]["assets"] == {"max_files": 12, "max_bytes": 2_000_000} + assert validate_codex_procedure_definition(definition).output_assets == policy + for limits in ( + {"max_files": 0, "max_bytes": 1}, + {"max_files": 1}, + {"max_files": 99, "max_bytes": 1}, + ): + broken = {**definition, "procedure": {**definition["procedure"]}} + broken["procedure"]["output"] = {**definition["procedure"]["output"], "assets": limits} + with pytest.raises(ValueError, match="assets"): + validate_codex_procedure_definition(broken) + other = next(w for w in BUILTIN_WORKFLOWS if w.key == "content.diagram") + diagram, _ = other.definition_and_resource_files() + diagram["procedure"]["output"]["assets"] = {"max_files": 1, "max_bytes": 1} + with pytest.raises(ValueError, match="Only article drafts"): + validate_codex_procedure_definition(diagram) + # Definitions without assets are unchanged, so pinned runs keep their exact contract. + plain, _ = replace( + spec, procedure=replace(spec.procedure, output_assets=None) + ).definition_and_resource_files() + assert "assets" not in plain["procedure"]["output"] + # The sandbox learns where the article's files go, and the runner's limits. + pinned = PinnedCodexProcedure( + workflow_key=content_draft.KEY, + prompt="p", + entry_skill="s", + skill_files={}, + output_path="content/drafts/x.md", + output_validator=content_draft.CLEAN_VALIDATOR, + output_assets=policy, + ) + assert pinned.sandbox_context(inputs={})["output"]["assets"] == { + "folder": "content/drafts/x.assets", + "max_files": 12, + "max_bytes": 2_000_000, + } + assert "assets" not in replace(pinned, output_assets=None).sandbox_context(inputs={})["output"] + + +async def drafted(db, monkeypatch): + monkeypatch.setattr("tin_lite.activities.activity.heartbeat", lambda *args: None) + f = await fixture(db, monkeypatch, clean=True, assets=True) + run = await start(f) + sandboxes = SimpleNamespace(create=AsyncMock(return_value="sandbox-test"), kill=AsyncMock()) + f.activities = TinActivities( + database=f.db, storage=f.storage, settings=f.settings, sandboxes=sandboxes + ) + await f.activities.prepare_codex_procedure(str(run.id)) + ctx = await f.service.saved(run.id) + await f.activities.create_codex_procedure_sandbox(str(run.id)) + run = await f.db.get_run(run.id) + path = content_draft.PATH_TEMPLATE.format(run_id=run.id) + folder = page_assets.folder(path) + name = folder.rsplit("/", 1)[-1] + base = f.storage.repo.head + revision = f.storage.repo.edit( + { + path: article(name), + content_draft.notes_path(path): notes(ctx), + f"{folder}/flow.svg": GOOD_SVG, + f"{folder}/field.html": EMBED, + f"{folder}/unsafe.svg": UNSAFE_SVG, + f"{folder}/unused.svg": GOOD_SVG, + }, + parent=run.expected_head_sha, + ) + f.storage.branch_revision, f.storage.repo.head = revision, base + return f, run, sandboxes, path, folder + + +async def test_a_draft_keeps_approves_and_publishes_the_files_its_article_uses( + publication_db, monkeypatch +): + f, run, sandboxes, path, folder = await drafted(publication_db, monkeypatch) + await f.activities.persist_codex_procedure_artifact(str(run.id)) + receipt = await f.db.get_effect(f"{run.id}:procedure_artifact_persist") + checkpoint = OutputCheckpoint.load(receipt.result["checkpoint"], run=run) + assert [item.artifact_path for item in checkpoint.assets] == [ + f"{folder}/flow.svg", + f"{folder}/field.html", + ] + assert [item.media_type for item in checkpoint.assets] == ["image/svg+xml", "text/html"] + # Left out with a reason, never a failed run; a file nothing refers to isn't kept at all. + assert receipt.result["dropped_assets"] == [ + { + "path": f"{folder}/unsafe.svg", + "reason": "the SVG contains script, event handlers or outside references", + }, + {"path": f"{folder}/missing.svg", "reason": "the file is missing"}, + ] + + await f.activities.commit_codex_procedure_artifact(str(run.id)) + tree = f.storage.repo.trees[f.storage.repo.head] + assert tree[f"{folder}/flow.svg"][1] == GOOD_SVG and tree[f"{folder}/field.html"][1] == EMBED + assert f"{folder}/unsafe.svg" not in tree and f"{folder}/unused.svg" not in tree + + # Approval binds the assets with the article. + assert await f.activities.request_codex_procedure_review(str(run.id)) + run = await f.db.get_run(run.id) + reviews = WorkflowReviews(runtime=f.runtime, settings=f.settings) + _, artifact = await reviews.artifact(run) + bound = [ + {"path": f"{folder}/flow.svg", "sha256": hashlib.sha256(GOOD_SVG).hexdigest()}, + {"path": f"{folder}/field.html", "sha256": hashlib.sha256(EMBED).hexdigest()}, + ] + assert artifact["assets"] == bound + published = await f.db.get_effect(f"{run.id}:procedure_canonical_commit") + assert approved_article._review_artifact(run, published.result)["assets"] == bound + + # Delivery re-reads each approved file and refuses one that changed. + repo_id = (await f.db.get_project(run.project_id)).state_repo_id + assets = await page_assets.verified( + f.storage, repo_id=repo_id, revision=run.canonical_commit_sha, binding=bound + ) + assert [(a["media_type"], a["bytes"]) for a in assets] == [ + ("image/svg+xml", len(GOOD_SVG)), + ("text/html", len(EMBED)), + ] + assert page_assets.source_binding({"assets": assets}) == {"assets": bound} + changed = f.storage.repo.edit({f"{folder}/flow.svg": UNSAFE_SVG}) + with pytest.raises(ValueError, match="differs from what was approved"): + await page_assets.verified(f.storage, repo_id=repo_id, revision=changed, binding=bound) + + +def test_a_saved_checkpoint_keeps_assets_inside_the_articles_folder(): + run = SimpleNamespace( + id="00000000-0000-4000-8000-0000000000aa", + project_id="00000000-0000-4000-8000-0000000000bb", + generation=1, + definition_commit_sha="a" * 40, + expected_head_sha="b" * 40, + ) + path = "content/drafts/x.md" + + def item(asset_path, media_type="image/svg+xml", revision="c" * 40): + return OutputCheckpoint.create( + run=run, revision=revision, path=asset_path, media_type=media_type, content=GOOD_SVG + ) + + good = OutputCheckpoint.create( + run=run, + revision="c" * 40, + path=path, + media_type="text/markdown", + content=b"# A\n", + assets=(item("content/drafts/x.assets/flow.svg"),), + ) + assert good.files[-1].artifact_path == "content/drafts/x.assets/flow.svg" + assert OutputCheckpoint.load(good.to_dict(), run=run) == good + # A checkpoint without assets serializes exactly as before. + assert ( + "assets" + not in OutputCheckpoint.create( + run=run, revision="c" * 40, path=path, media_type="text/markdown", content=b"# A\n" + ).to_dict() + ) + for bad in ( + item("content/drafts/other.assets/flow.svg"), + item("content/drafts/x.assets/flow.svg", media_type="text/html"), + item("content/drafts/x.assets/flow.svg", revision="d" * 40), + ): + with pytest.raises(ValueError, match="assets"): + OutputCheckpoint.create( + run=run, + revision="c" * 40, + path=path, + media_type="text/markdown", + content=b"# A\n", + assets=(bad,), + ) + + +@pytest.mark.parametrize("ready", [True, False]) +async def test_the_sandbox_gets_the_folder_and_an_old_image_refuses_it_early(monkeypatch, ready): + import base64 + import json + + from tin_lite.e2b_runtime import E2BRuntime, SandboxProcedureInput + + payload = base64.b64encode(json.dumps({"summary": "Done.", "message": "Ok."}).encode()) + seen = {} + + class Handle: + async def wait(self): + return SimpleNamespace( + stdout=f"TIN_PROCEDURE_COMMIT_SHA={'c' * 40}\n" + f"TIN_PROCEDURE_RESULT={payload.decode()}\n" + ) + + class Commands: + async def run(self, *args, **kwargs): + command = args[0] + if command.endswith("isolated-procedure check"): + return SimpleNamespace(stdout="TIN_ISOLATION_READY_V1") + if command.endswith("codex_api_config.py --check"): + return SimpleNamespace(stdout="TIN_CODEX_API_READY_V1") + if command.endswith("--check-companion"): + return SimpleNamespace(stdout="TIN_PROCEDURE_COMPANION_V1") + if command.endswith("--check-assets"): + return SimpleNamespace(stdout="TIN_PROCEDURE_ASSETS_V1" if ready else "") + seen.update(kwargs["envs"]) + return Handle() + + class Sandbox: + commands = Commands() + + class files: # noqa: N801 + @staticmethod + async def write(path, data): + return None + + @staticmethod + async def kill(): + return None + + async def connect(*args, **kwargs): + return Sandbox() + + monkeypatch.setattr("tin_lite.e2b_runtime.AsyncSandbox.connect", connect) + runtime = E2BRuntime( + api_key="e2b-test", # noqa: S106 + template="tin-lite-codex", + timeout_seconds=60, + egress_allow_hosts=(), + ) + output = { + "kind": "project.artifact", + "path": "content/drafts/x.md", + "companion_path": "content/drafts/x.generation.md", + "companion_max_bytes": 24_000, + "assets": {"folder": "content/drafts/x.assets", "max_files": 12, "max_bytes": 2_000_000}, + } + run = runtime.run_procedure_and_kill( + sandbox_id="sandbox-1", + run_input=SandboxProcedureInput( + execution_key="run:procedure_artifact_persist", + isolated=True, + usage_sink=AsyncMock(), + api_url="https://tin.test/relay", + api_grant="synthetic-relay-grant", + canonical_url="https://storage.test/canonical.git", + canonical_auth_header="Authorization: Basic canonical", + canonical_branch="main", + ephemeral_url="https://storage.test/ephemeral.git", + ephemeral_auth_header="Authorization: Basic ephemeral", + ephemeral_branch="procedures/run/1", + proxy_url="http://proxy.test:3128", + no_proxy="tin.test", + context={"workflow_key": content_draft.KEY, "output": output}, + output_path="content/drafts/x.md", + output_max_bytes=80_000, + result_kind="project.artifact", + ), + ) + if not ready: + with pytest.raises(RuntimeError, match="lacks article assets support"): + await run + assert not seen + return + await run + assert seen["TIN_PROCEDURE_ASSETS_FOLDER"] == "content/drafts/x.assets" + assert seen["TIN_PROCEDURE_ASSETS_MAX_FILES"] == "12" + assert seen["TIN_PROCEDURE_ASSETS_MAX_BYTES"] == "2000000" + + +async def test_a_revision_receives_the_approved_assets(publication_db, monkeypatch): + from tin_lite.workflow_reviews import revision_context + + f, run, _, _, folder = await drafted(publication_db, monkeypatch) + await f.activities.persist_codex_procedure_artifact(str(run.id)) + await f.activities.commit_codex_procedure_artifact(str(run.id)) + run = await f.db.get_run(run.id) + _, artifact = await WorkflowReviews(runtime=f.runtime, settings=f.settings).artifact(run) + command = { + "artifact": artifact, + "reference_files": [], + "feedback": "Make the figure clearer.", + "root_run_id": run.id, + "source_run_id": run.id, + } + context = await revision_context(f.db, f.storage, command, run) + assert [a["path"] for a in context["source"]["assets"]] == [ + f"{folder}/flow.svg", + f"{folder}/field.html", + ] + from tin_lite.article_review import revision_prompt + + assert "this run's own assets folder" in revision_prompt(context) + + +def test_the_wording_check_skips_embed_video_and_diagram_blocks(): + from tin_lite.content_repository_delivery import FIGURE_FENCE + + text = ( + "Intro words here.\n\n```tin-embed\nsrc: ./a.assets/x.html\nheight: 420\n```\n\n" + "After the embed.\n\n~~~mermaid\ngraph LR\nA-->B\n~~~\n\n```python\nprint(1)\n```\n" + ) + kept = FIGURE_FENCE.sub("\n\n", text) + assert "height" not in kept and "graph LR" not in kept + assert "Intro words here." in kept and "print(1)" in kept + + +def test_the_reader_renders_figures_embeds_videos_and_callouts(): + from tin_lite.documents import render_markdown + + markdown = ( + '# T\n\n![Flow](./a.assets/flow.svg "What to notice")\n\n' + "```tin-embed\nsrc: ./a.assets/field.html\nheight: 9999\ntitle: Try it\n```\n\n" + "```tin-embed\nsrc: ../../secrets.html\n```\n\n" + "```tin-video\nurl: https://www.youtube.com/watch?v=dQw4w9WgXcQ\ntitle: Trailer\n```\n\n" + "```tin-video\nurl: javascript:alert(1)\n```\n\n" + "> [!WARNING]\n> Mind the gate.\n\n> A plain quote.\n" + ) + html = render_markdown(markdown, asset_folder="content/articles/a.assets").html + # A figure has no URL; the reader loads it with the member's session. + assert 'class="md-asset" data-asset="content/articles/a.assets/flow.svg"' in html + assert 'src="./a.assets' not in html + assert 'data-height="1600" data-title="Try <b>it</b>"' in html + # A path outside the folder and a script URL stay plain code. + assert "src: ../../secrets.html" in html and "javascript:alert" in html + assert html.count('class="md-embed"') == 1 and html.count('class="md-video"') == 1 + assert '