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
16 changes: 15 additions & 1 deletion codex_procedures/content.deliver/PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Answer pages and public articles carry their search listing apart from the copy
`page_metadata` (`meta_title`, `meta_description`, and the approved `slug` when the draft
chose one). Keep the Markdown in Tin unchanged.
Inspect this repository's instructions and existing pages, and prepare one coherent site
change (at most 10 files) as an unmerged PR.
change (at most 30 files) as an unmerged PR.

Build the page the way this site builds its pages: a Markdown or MDX file, a component, a
typed page registry, plain HTML or whatever else the repository uses. Convert the format
Expand All @@ -24,6 +24,20 @@ back into the site (for example to the product), byline and dates, and structure
JSON-LD) that they carry. Use only the fonts and files the site already loads; do not add web
fonts or other outside resources.

The approved page may carry figures and interactive pieces, listed in `assets` with each
file's project path, and diagram, video and callout blocks in `article`. Your project-state
checkout is at the approved revision: copy each asset from `/home/user/state/<path>` into the
place this site keeps such files, and show it the way the site shows media. An SVG becomes an
image or the site's figure component, with its alt text and caption. An interactive piece
becomes an iframe or an inline component, with its script and data unchanged. Turn each
`mermaid` block into SVG with
`node /opt/tin-lite/diagram/scripts/check_diagram.mjs check <file>.mmd --out <empty absolute directory> --no-previews`,
which writes `light.svg` and `dark.svg`; use the site's own diagram support instead when it
has one. Show each `tin-video` block with the site's video component or the provider's embed
code, linking a video file rather than copying it, and each `> [!NOTE]`-style callout with
the site's own note style, or a plain aside. Every approved asset, diagram and video has to
appear on the page.

If the site does not serve the page's route yet, add the smallest route that fits how its
other pages are served. Change what the page needs (the page, its route, and any index or
sitemap the site keeps) and nothing else.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ description: Put one approved article, answer page or public article on an exist
the site already loads; never add web fonts or other outside resources.
5. If the site does not serve the page's route yet, add the smallest route that fits how
its other pages are served (a static file in its public folder, a route file, a registry
entry), within the 10-file limit. Prefer tools the site already has; add a dependency
entry), within the 30-file limit. Place the page's figures, embeds, diagrams and videos as
the prompt describes. Prefer tools the site already has; add a dependency
only when the page cannot be built without one, and say why in the PR body.
6. Add only necessary page/index metadata, following existing dates, slugs, canonical
links and components. Do not invent author identity, product claims or new sales copy.
Expand Down
14 changes: 14 additions & 0 deletions codex_procedures/website.change/PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ back into the site (for example to the product), byline and dates, and structure
JSON-LD) that they carry. Use only the fonts and files the site already loads; do not add web
fonts or other outside resources.

The approved page may carry figures and interactive pieces, listed in `assets` with each
file's project path, and diagram, video and callout blocks in `article`. Your project-state
checkout is at the approved revision: copy each asset from `/home/user/state/<path>` into the
place this site keeps such files, and show it the way the site shows media. An SVG becomes an
image or the site's figure component, with its alt text and caption. An interactive piece
becomes an iframe or an inline component, with its script and data unchanged. Turn each
`mermaid` block into SVG with
`node /opt/tin-lite/diagram/scripts/check_diagram.mjs check <file>.mmd --out <empty absolute directory> --no-previews`,
which writes `light.svg` and `dark.svg`; use the site's own diagram support instead when it
has one. Show each `tin-video` block with the site's video component or the provider's embed
code, linking a video file rather than copying it, and each `> [!NOTE]`-style callout with
the site's own note style, or a plain aside. Every approved asset, diagram and video has to
appear on the page.

When `route` is set, the founder chose where these pages live: publish the page at exactly
that route, wherever the site keeps such pages. Never put a
page under `content/answers/`, which is Tin's draft folder and not a page on the site.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ instead: the change is the audit's technical fixes or planned URL changes, not a
5. If the site does not serve the chosen path yet, add the smallest route that fits how its
other pages are served (a static file in its public folder, a route file, a registry
entry), keeping its files inside the route's own folder where the framework allows, so
Tin can merge it as part of the page. Stay within the 20-file limit.
Tin can merge it as part of the page. Stay within the 20-file limit. Place the page's
figures, embeds, diagrams and videos as the prompt describes.
6. Stay clear of protected paths: never change a file that serves a path in
`protected_paths` (the shared sign-in, sign-up and auth-return pages, plus any the
founder listed). Leave root layouts, middleware, redirects, host and build settings and
Expand Down
16 changes: 16 additions & 0 deletions docs/content-delivery-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,19 @@ Record exactly which deployment and tests passed below before claiming completio
packaged application with synthetic HTTP; the signed-in in-app browser was unavailable.
- Live article-PR acceptance remains pending a suitable explicit repository/content path.
Never assume the connected repository hosts a particular website; verify ownership and paths.

## Page bundles

An approved article may carry figures, interactive pieces, diagrams, videos and callouts
([review](content-review.md)). content.deliver 1.7.0 and website.change 1.6.0 put them on the
site in its own form:

- The approved source lists each asset's project path and checksum; a delivery run with assets
checks out the approved revision (`procedure_project_revision`) and copies the files from
`/home/user/state`. Mermaid blocks become SVG with the sandbox's offline renderer
(`check_diagram.mjs check … --no-previews`), unless the site has its own diagram support.
- `figure_check` sits beside `copy_check`: each approved file must arrive byte for byte (same
checksum), each video by its address or ID, each diagram as a new SVG or the site's Mermaid
support. `not_confirmed` only holds Tin's own merge; the pull request still opens.
- One pull request or commit may hold up to 30 files and 2 MB (`PULL_REQUEST_MAX_*`), and a
saved patch is read back up to 4 MB. Procedures that declare nothing keep 512 KB.
25 changes: 15 additions & 10 deletions src/tin_lite/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@ def transient_failure(message: str | None, *, restarted: bool = False) -> bool:
return _TRANSIENT_FAILURE_PATTERN.search(message) is not None


def procedure_project_revision(run, procedure, content_source):
"""The project revision a procedure's checkout pins, or None for the canonical head."""
if procedure.output_validator in REVIEWED_DIAGRAM_VALIDATORS or (
procedure.review_revision_context or {}
).get("project_revision"):
return run.expected_head_sha
if content_source is not None and content_source.get("assets"):
# A page's figures and embeds are read where they were approved.
return content_source["source_revision"]
return None


class TinActivities:
async def _run_accounted_procedure(self, *, conn, run, sandbox_id, run_input):
if (
Expand Down Expand Up @@ -3504,12 +3516,12 @@ async def retain_interrupted(content=None):
f"{self._settings.switchboard_public_url.rstrip('/')}"
"/internal/run-tools/mcp"
)
content_source = None
if procedure.result_kind == GITHUB_PULL_REQUEST_RESULT:
technical = None
expected_binding = None
from tin_lite import content_repository_delivery

content_source = None
if content_repository_delivery.adapts(run):
content_source = await content_repository_delivery.saved_source(
self._db, run_id
Expand Down Expand Up @@ -3627,15 +3639,8 @@ async def retain_interrupted(content=None):
),
# Card runs keep no agent narration, as they keep no rollouts.
progress_sink=None if payment_card else self._progress_sink(run_id),
project_revision=(
run.expected_head_sha
if (
procedure.output_validator in REVIEWED_DIAGRAM_VALIDATORS
or (procedure.review_revision_context or {}).get(
"project_revision"
)
)
else None
project_revision=procedure_project_revision(
run, procedure, content_source
),
result_kind=procedure.result_kind,
run_tools_url=run_tools_url,
Expand Down
14 changes: 8 additions & 6 deletions src/tin_lite/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ def definition_and_files_with_wiki(
"wording, and the PR adds nothing but the page, or the page at the route you chose "
"for such pages.",
executor=CODEX_PROCEDURE_EXECUTOR,
version_label="1.6.0",
version_label="1.7.0",
system=ORGANIC_TRAFFIC_SYSTEM,
schedule_modes=("on_demand",),
input_schema={
Expand Down Expand Up @@ -697,10 +697,10 @@ def definition_and_files_with_wiki(
github_pull_request=GitHubPullRequestProcedure(
receipt_path_template="content/deliveries/{run_id}.md",
verification_commands=(content_repository_delivery.CHECK_COMMAND,),
# The procedure ceilings, as runaway guards: a page in any format, its route,
# Runaway guards: a page in any format with its figures and embeds, its route,
# index and sitemap.
max_files=10,
max_bytes=512_000,
max_files=30,
max_bytes=2_000_000,
),
),
),
Expand All @@ -720,7 +720,7 @@ def definition_and_files_with_wiki(
"protected pages, opens a pull request for you to merge. Deleting a page stays with "
"you.",
executor=CODEX_PROCEDURE_EXECUTOR,
version_label="1.5.0",
version_label="1.6.0",
system=ORGANIC_TRAFFIC_SYSTEM,
schedule_modes=("on_demand",),
# Agents start it for an approved change; the catalog has no picker for change rows.
Expand Down Expand Up @@ -833,8 +833,10 @@ def definition_and_files_with_wiki(
github_pull_request=GitHubPullRequestProcedure(
receipt_path_template="website/changes/{run_id}.md",
verification_commands=(content_repository_delivery.CHECK_COMMAND,),
# site-fix-v5's file cap, for an audit run and a page alike.
# site-fix-v5's file cap, for an audit run and a page alike; a page with its
# figures and embeds needs more bytes than a technical batch.
max_files=technical_fix.POLICY_MAX_FILES[technical_fix.BATCH_POLICY],
max_bytes=2_000_000,
site_repair_policy=technical_fix.BATCH_POLICY,
allow_no_change=True,
),
Expand Down
4 changes: 3 additions & 1 deletion src/tin_lite/code_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
# Text outputs stay under the comparison limit; a declared binary output (the demo video) is
# only ever read whole, never diffed.
_PUBLICATION_TEXT_MAX_BYTES = 1_000_000
# A pull-request patch saved as JSON may reach twice its procedure's 2 MB ceiling.
_CHECKPOINT_TEXT_MAX_BYTES = 4_000_000
_PUBLICATION_BINARY_MAX_BYTES = 16_000_000
_BINARY_MEDIA_TYPES = frozenset({"video/mp4"})
# Reads addressed by a full commit SHA are immutable, so one process keeps a bounded copy.
Expand Down Expand Up @@ -513,7 +515,7 @@ async def read_procedure_checkpoint(
) -> bytes:
if not _is_commit_sha(revision) or not _safe_repo_path(path):
raise ValueError("procedure checkpoint requires an immutable revision and safe path")
max_bytes = _PUBLICATION_BINARY_MAX_BYTES if binary else _PUBLICATION_TEXT_MAX_BYTES
max_bytes = _PUBLICATION_BINARY_MAX_BYTES if binary else _CHECKPOINT_TEXT_MAX_BYTES
key = ("checkpoint", repo_id, revision, path, max_bytes)
if (cached := self._pinned.get(key)) is not None:
return cached
Expand Down
61 changes: 60 additions & 1 deletion src/tin_lite/content_repository_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ def merge_rule(manifest, proof, route):
"""
from tin_lite.page_routes import matches

if proof.get("copy_check") == "not_confirmed":
if "not_confirmed" in {proof.get("copy_check"), proof.get("figure_check")}:
return None
if page_only(manifest, proof):
return "page_only"
Expand Down Expand Up @@ -823,6 +823,13 @@ async def publish_after_pull_request(
"reason": "Tin couldn't confirm the page keeps the approved wording word for "
"word, so it waits for your review.",
}
elif proof.get("figure_check") == "not_confirmed":
result = {
**base,
"status": "left_open",
"reason": "Tin couldn't confirm every approved figure, interactive piece, "
"diagram and video is on the page, so it waits for your review.",
}
elif rule is None:
result = {
**base,
Expand Down Expand Up @@ -1091,15 +1098,67 @@ def validate_copy(manifest, source):
from tin_lite.page_urls import public_route

route = public_route(manifest.get("body"))
figures = figure_check(manifest, source)
return {
"article_path": path,
"article_sha256": source["article_sha256"],
"copy_check": check,
**({"figure_check": figures} if figures else {}),
"build_check": "not_verified_by_tin",
**({"public_route": route} if route else {}),
}


VIDEO_BLOCK = re.compile(r"^(`{3,}|~{3,})[ \t]*tin-video\b(.*?)^\1[ \t]*$", re.M | re.S)
MERMAID_BLOCK = re.compile(r"^(?:`{3,}|~{3,})[ \t]*mermaid\b", re.M)


def figure_check(manifest, source):
"""Whether every approved figure, embed, diagram and video is on the page, or None.

An approved file must arrive byte for byte (the same checksum), a video by its address or
its last path segment (the provider's ID), and each diagram as an SVG or the site's own
Mermaid support. Like copy_check this decides only Tin's own merge, never the PR.
"""
article = source["article"]
assets = source.get("assets") or []
videos = [
match.group(1).strip()
for block in VIDEO_BLOCK.finditer(article)
if (match := re.search(r"(?m)^\s*url\s*:\s*(\S+)", block.group(2)))
]
diagrams = len(MERMAID_BLOCK.findall(article))
if not (assets or videos or diagrams):
return None
files = manifest.get("files") or []
digests = {hashlib.sha256(item["content"].encode()).hexdigest() for item in files}
text = "\n".join(item["content"] for item in files)
# Diagrams arrive as new SVG files (not the approved figures' copies) or inline SVG.
approved = {asset["sha256"] for asset in assets}
drawn = sum(
(hashlib.sha256(item["content"].encode()).hexdigest() not in approved)
if item["path"].endswith(".svg")
else item["content"].count("<svg")
for item in files
)
confirmed = (
all(asset["sha256"] in digests for asset in assets)
and all(_video_shown(url, text) for url in videos)
and (drawn >= diagrams or "mermaid" in text.casefold())
)
return "confirmed" if confirmed else "not_confirmed"


def _video_shown(url, text):
from urllib.parse import parse_qs, urlsplit

if url in text:
return True
parts = urlsplit(url)
ids = [parts.path.rstrip("/").rsplit("/", 1)[-1], *parse_qs(parts.query).get("v", [])]
return any(len(value) >= 4 and value in text for value in ids)


FIGURE_FENCE = re.compile(
r"^(`{3,}|~{3,})[ \t]*(?:tin-embed|tin-video|mermaid)\b.*?^\1[ \t]*$", re.M | re.S
)
Expand Down
31 changes: 21 additions & 10 deletions src/tin_lite/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@
GSC_MIN_ROW_BYTES = 70


# Runaway guards for one pull request or commit Tin writes: a technical batch (20 files) or a
# page with its figures, embeds, route and sitemap. Each procedure declares its own, smaller limit.
PULL_REQUEST_MAX_FILES = 30
PULL_REQUEST_MAX_BYTES = 2_000_000


class IntegrationError(RuntimeError):
"""A safe integration failure that may be shown to a project member."""

Expand Down Expand Up @@ -2081,8 +2087,9 @@ async def github_merge_pull_request(
raise IntegrationError("GitHub pull request to merge is invalid")
if not commit_title.strip() or len(commit_title) > 200 or not _safe_github_ref(branch):
raise IntegrationError("GitHub merge request is invalid")
# Up to 20: a website.change technical batch (site-fix-v5's file cap).
if not 1 <= len(files) <= 20 or not all(_safe_github_path(item.path) for item in files):
if not 1 <= len(files) <= PULL_REQUEST_MAX_FILES or not all(
_safe_github_path(item.path) for item in files
):
raise IntegrationError("GitHub merge must name the pull request's files")

def request_fingerprint(repository):
Expand Down Expand Up @@ -3276,10 +3283,12 @@ async def github_create_pull_request(
raise IntegrationError("GitHub execution key is invalid")
if not title.strip() or len(title) > 200 or len(body) > 20_000:
raise IntegrationError("GitHub pull-request title or body is invalid")
if not 1 <= len(files) <= 10:
raise IntegrationError("GitHub pull requests must contain between 1 and 10 files")
if sum(len(item.content.encode()) for item in files) > 512_000:
raise IntegrationError("GitHub pull-request files exceed the 512 KB limit")
if not 1 <= len(files) <= PULL_REQUEST_MAX_FILES:
raise IntegrationError(
f"GitHub pull requests must contain between 1 and {PULL_REQUEST_MAX_FILES} files"
)
if sum(len(item.content.encode()) for item in files) > PULL_REQUEST_MAX_BYTES:
raise IntegrationError("GitHub pull-request files exceed the 2 MB limit")
for item in files:
if not _safe_github_path(item.path):
raise IntegrationError("GitHub pull request contains an unsafe file path")
Expand Down Expand Up @@ -3466,10 +3475,12 @@ async def github_commit_files(
raise IntegrationError("GitHub execution key is invalid")
if not message.strip() or len(message) > 200:
raise IntegrationError("GitHub commit message is invalid")
if not 1 <= len(files) <= 10:
raise IntegrationError("GitHub commits must contain between 1 and 10 files")
if sum(len(item.content.encode()) for item in files) > 512_000:
raise IntegrationError("GitHub commit files exceed the 512 KB limit")
if not 1 <= len(files) <= PULL_REQUEST_MAX_FILES:
raise IntegrationError(
f"GitHub commits must contain between 1 and {PULL_REQUEST_MAX_FILES} files"
)
if sum(len(item.content.encode()) for item in files) > PULL_REQUEST_MAX_BYTES:
raise IntegrationError("GitHub commit files exceed the 2 MB limit")
for item in files:
if not _safe_github_path(item.path):
raise IntegrationError("GitHub commit contains an unsafe file path")
Expand Down
Loading
Loading