Skip to content

Persist custom-captureSpec screenshots under the full capture identity - #5893

Open
lukemelia wants to merge 2 commits into
cs-12629-capture-stage-telemetry-per-stage-timings-across-thefrom
cs-12635-persist-custom-capturespec-screenshots-extend-the-canonical
Open

Persist custom-captureSpec screenshots under the full capture identity#5893
lukemelia wants to merge 2 commits into
cs-12629-capture-stage-telemetry-per-stage-timings-across-thefrom
cs-12635-persist-custom-capturespec-screenshots-extend-the-canonical

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

What this does

A screenshot capture's canonical identity now covers its full geometry — viewport, deviceScaleFactor, fullPage, clip — not just format. A custom-spec capture therefore persists to the MediaCache under its own spec hash and serves on its own durable GET _screenshot/ URL, exactly like a format-only capture: ledger fast path, job coalescing, and captures[].url all apply.

  • Canonical identity (capture-spec.ts): CaptureSpec extends the geometry override fields; canonicalCaptureSpecString / captureSpecHash / canonicalCaptureSpecQuery represent them with keys sorted (nested objects included) and default-valued fields elided, so every spelling of one capture geometry is one cache key. The engine defaults (800×600 viewport, scale 1, fullPage false) are pinned in runtime-common and shared with the prerender pool's viewport-restore constant, so the elision rule and the engine cannot drift.
  • URL grammar: viewport=1280x800, dsf=2, fullPage=true, clip=0,0,400x300 are unreserved in the GET DSL (envelope and target stay reserved). The GET params translate into the POST body's shape and run through the one shared validator, so both surfaces accept and refuse identical geometry with identical wording; grammar-level failures name the param. clip's x/y parse as Number because the validator admits any non-negative finite value — the served URL must reparse every spelling String can emit.
  • Ledger + coalescing: the POST handler's canonical-only gate is gone — the entry key hashes the full spec — and the worker task's persist refusal for override-carrying jobs is gone with it. Same-spec concurrent requests coalesce through the persist identity; the coalesce predicate compares the specs field-by-field as a guard against a producer whose hash and spec disagree, and a spec mismatch never joins.
  • Honest Retry-After: a custom-spec POST that outruns the sync wait now lands its capture in the ledger, so the 503's Retry-After is a cheap-resume promise again — the retry answers from the ledger with no second render. The handler doc caveat describing the discarded-render behavior is deleted.
  • Cost posture: on an open realm every distinct spec is its own render and ledger entry; the allowArbitraryScreenshots opt-in remains the deliberate cost boundary (documented at the miss path) — the parse bounds each capture's pixel cost, the serialized lane bounds concurrency, and the on-demand TTL reclaims idle entries. No per-instance spec-cardinality cap.

Out of scope: envelope / target params and declared-screenshot name= addressing.

Tests

  • Canonicalization properties: explicit defaults ≡ bare URL, numeric spellings normalize, GET grammar ≡ POST body, and canonicalCaptureSpecQuery round-trips through parseCaptureSpecParams to the same canonical form (scientific-notation clip offsets included).
  • GET DSL 400s: malformed grammar names the param; out-of-range values carry the shared validator's wording.
  • Cross-surface: a POST custom capture serves on its GET URL as a pure ledger hit; a custom-geometry GET captures, persists under its spec hash, leaves the canonical identity untouched, and re-serves alternate spellings without rendering.
  • Coalescing: two concurrent same-spec GETs fold onto one render; unit coverage for same-spec join, spec-mismatch insert, and custom-vs-canonical insert.
  • Timeout contract: a timed-out custom-spec POST persists anyway and its retry answers from the ledger with zero re-renders.

media-cache-dsl-test (27), screenshot-card-test (36), and the serving/gc/adapter suites (35) all pass locally; ember-tsc and eslint clean in runtime-common and realm-server.

Stacked on #5871.

🤖 Generated with Claude Code

Extend the canonical capture identity (canonicalCaptureSpecString /
captureSpecHash / canonicalCaptureSpecQuery) to viewport,
deviceScaleFactor, fullPage, and clip, and unreserve those params in the
GET _screenshot/ URL DSL (viewport=1280x800, dsf=2, fullPage=true,
clip=0,0,400x300) with the same bounds validation and wording as the
POST body. A custom capture now persists to the MediaCache under its
spec hash and serves on its own durable GET URL: the POST handler's
canonical-only ledger gate is removed, the worker task's persist
refusal for override-carrying jobs is removed, and same-spec jobs
coalesce via the persist identity. This also makes the POST timeout
path's Retry-After honest for custom specs: the abandoned job's bytes
land in the ledger and the retry answers from it with no second render.

Engine defaults (800x600 viewport, scale 1, fullPage false) are pinned
in runtime-common and elided during canonicalization, so every spelling
of one capture geometry is one cache key; the prerender pool's restore
viewport now shares that constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 26m 59s ⏱️ - 13m 4s
4 581 tests +84  4 567 ✅ +84  14 💤 ±0  0 ❌ ±0 
4 600 runs  +84  4 586 ✅ +84  14 💤 ±0  0 ❌ ±0 

Results for commit 0e6eda2. ± Comparison against earlier commit a76f71c.

Realm Server Test Results

    1 files  ± 0      1 suites  ±0   17m 55s ⏱️ + 4m 35s
2 362 tests +27  2 362 ✅ +27  0 💤 ±0  0 ❌ ±0 
2 445 runs  +27  2 445 ✅ +27  0 💤 ±0  0 ❌ ±0 

Results for commit 0e6eda2. ± Comparison against earlier commit a76f71c.

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review went after the one thing the change rests on — the cache-key identity: that equal capture geometries canonicalize to a single ledger key and distinct ones never collide, that the GET DSL and the POST body resolve to the same identity, that the persisted key and the served _screenshot/ URL round-trip, and that dropping the canonical-only gates left nothing persisting or coalescing under the wrong identity. It did not re-cover the unchanged singular capture-path rendering or the stacked base branch.

No blocking issues. The custom-spec identity is consistent end to end — the ledger key, the served URL, and the coalesce predicate all derive from one full-spec hash — and equal geometries collapse to one canonical form across both surfaces, including the numeric-spelling and scientific-notation clip-offset cases the round-trip depends on. The realm-read check is preserved on the now-universal ledger fast path.

@lukemelia
lukemelia requested review from a team and habdelra August 26, 2026 21:15
…creenshots-extend-the-canonical

Ports the full-capture-identity work onto the batch/envelope capture engine
that landed on main:

- parseScreenshotCaptureSpec keeps main's (raw, format) batch/envelope shape;
  the GET DSL threads its format through and elideDefaults now also drops an
  explicit DEFAULT_CAPTURE_VIEWPORT-sized viewport, so equal capture intents
  hash identically on both surfaces, batch entries included.
- The handler's ledger identity covers custom singular geometry (persist +
  serve under the full spec hash) while batches and fitted-format captures
  stay capture-only: a batch has no single-capture identity, and fitted sits
  outside the canonical (ledger/GET-DSL) serving contract.
- captures[].deviceScaleFactor prefers the engine-reported factor and falls
  back to the spec's declared override on ledger serves.
- The clip-containment test expectation follows the batch-aware validator
  wording ('exceeds the viewport width').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@richardhjtan richardhjtan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖]

This review went after the one thing the change rests on — that the ledger key is a total function of capture intent: which fields the canonical form covers, whether the emitted URL round-trips back to the same identity, and what the deleted canonical-only gates were holding up. It did not re-review the batch or fitted capture paths, or the stacked base branch.

No blocking issues. The identity is correct for everything currently reachable; the asks are about the two fields it silently omits, and the producer check that was deleted rather than upgraded.

  1. envelope and captures are dropped from the canonical form and from the coalesce predicate, and only call-site gates keep that from mattering — threads on canonicalCaptureSpecString and sameCaptureSpec.
  2. The worker's persist-identity-versus-rendered-spec check is gone; the new hash makes restoring it a one-liner — thread on the task.
  3. The served URL percent-encodes the clip commas that its own grammar documents unescaped — thread on canonicalCaptureSpecQuery.

Red check: Boxel CLI Tests is failing on tests/integration/realm-ingest-card.test.ts and tests/integration/search.test.ts. The logs show ERR_CONNECTION_REFUSED against localhost:4201 during prerender standby warmup and a search failed: HTTP 404, which reads as the realm server never coming up rather than anything in this diff — but it needs a re-run or an explanation rather than being left red, since this PR does touch realm.ts.

Comment on lines +803 to +806
if (overrides.clip) {
canonical.clip = sortKeys(overrides.clip);
}
return JSON.stringify(sortKeys(canonical));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The canonical form now covers viewport / dsf / fullPage / clip, but CaptureSpec also admits envelope and captures, and both are dropped here without comment — same in canonicalCaptureSpecQuery.

Nothing reaches it today, and I traced both hash call sites to confirm that: the POST handler only builds a spec under isCaptureFormat(format) && !captureSpec?.captures, and the GET DSL's format roster excludes fitted while its grammar cannot express an envelope. The concern is where that totality lives — in two call sites, not in the type or in this function — while envelope / target params are named in the description as the next thing to land on this DSL. The day CaptureSpec widens, two captures differing only in envelope hash to one ledger key and the cache serves the wrong image, with nothing failing.

Cheapest way to make the omission unrepresentable rather than merely unreached:

export interface CaptureSpec
  extends Omit<ScreenshotCaptureSpec, 'envelope' | 'captures'> {
  format: CaptureFormat;
}

Widening the identity then fails to compile here instead of colliding in the ledger at runtime.

Class: pre-existing shape, newly load-bearing — before this PR the canonical string only ever saw format. Non-blocking.

Comment on lines +119 to +128
return (
(a.viewport?.width ?? null) === (b.viewport?.width ?? null) &&
(a.viewport?.height ?? null) === (b.viewport?.height ?? null) &&
(a.deviceScaleFactor ?? null) === (b.deviceScaleFactor ?? null) &&
(a.fullPage ?? false) === (b.fullPage ?? false) &&
(a.clip?.x ?? null) === (b.clip?.x ?? null) &&
(a.clip?.y ?? null) === (b.clip?.y ?? null) &&
(a.clip?.width ?? null) === (b.clip?.width ?? null) &&
(a.clip?.height ?? null) === (b.clip?.height ?? null)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Same two fields missing here as in canonicalCaptureSpecString (see that thread): envelope and captures are not compared, while the comment above says a twin must match the whole capture identity. Unreachable for the same kind of reason — a job that could carry them has no persist, and the predicate inserts without one.

The ask is just that the two stay in step: whichever way the identity is narrowed or widened, this predicate is the second place that has to change, and nothing here says so.

Class: non-blocking.

Comment on lines 202 to 207
let decodeMs: number | undefined;
let persistMs: number | undefined;
if (persist && response.status === 'ready' && response.base64) {
if (hasCaptureSpecOverrides(captureSpec)) {
// The ledger identity in `persist` is the canonical capture's; it
// cannot represent viewport / scale / fullPage / clip overrides.
// Persisting an override render under it would serve that render on
// the canonical `_screenshot/` URL, so the persist is refused — the
// response still carries the bytes for the caller.
log.error(
`${jobIdentity(jobInfo)} screenshot-card job carries both a persist target and captureSpec overrides; refusing to persist a non-canonical capture under the canonical ledger identity`,
);
} else if (!mediaCacheAdapter) {
if (!mediaCacheAdapter) {
log.warn(
`${jobIdentity(jobInfo)} screenshot-card asked to persist but this worker has no media cache adapter configured; skipping`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This deletes the only check that a persist identity and the spec actually rendered agree, and puts a comment in its place saying it is the producer's job. Both current producers do hold it — the GET lane hashes parsed.spec and threads captureSpecOverrides(spec) from that same value, and the POST handler derives spec and captureSpec from one parse — so this is about the invariant, not a live bug.

But the guard was removed rather than upgraded, and the new identity makes upgrading it cheap: the task holds persist.captureSpecHash, format, and captureSpec, so comparing await captureSpecHash({ format, ...(captureSpec ?? {}) }) against the persisted hash before writing restores exactly what was lost. A mismatch means this render is about to be served on some other spec's durable URL and stay there until the generation bumps; the cost of noticing is one sha256 against a Chrome render.

Class: regression introduced here (a check removed with nothing replacing it). Non-blocking.

Comment on lines +847 to +852
if (overrides.clip) {
searchParams.set(
'clip',
`${overrides.clip.x},${overrides.clip.y},${overrides.clip.width}x${overrides.clip.height}`,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] URLSearchParams.toString() percent-encodes the commas, so what this emits is ?clip=0%2C0%2C400x300 — verified — while the grammar in the description and in the 400 message both tell the caller to write clip=0,0,400x300. It reparses correctly, so this is presentation, but it is the presentation of the public URL this change exists to mint, and , is a valid sub-delim in a query, so nothing requires the escape.

Assembling the query by hand, or unescaping %2C on the way out, makes the served URL match its own documented grammar.

Class: non-blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants