refactor: share screenshot capture and artifact persistence#78
Conversation
Live host screenshot RPCs and CLI offline replay screenshots now both flow through a single `captureScreenshotResult(...)` helper in `src/screenshot/capture.ts`, mirroring the snapshot capture refactor in #69. The shared seam owns: - temp-file allocation under the session artifacts directory - renderer screenshot invocation (with optional `showCursor` threaded through) - the union of the live and offline runtime invariants (sessionId, profileName, pngSizeBytes, requested vs returned artifactPath, sha256) - allowlisted public `ScreenshotResult` construction - Zod validation through `ScreenshotResultSchema` *before* any rename or manifest append side effect - the final `screenshot-<seq>-<profile>.png` rename - artifact manifest entry creation with the same metadata fields as before - temp-file cleanup on failure Public CLI/RPC behavior, screenshot filenames, manifest entries, and `screenshot --json` output are unchanged (verified by the `dogfood/issue-64-share-screenshot-capture/` proof bundle: byte-identical PNGs and identical manifest metadata before and after, against parent commit b2d5068). Tracking #64.
|
/coder-agents-review |
There was a problem hiding this comment.
Clean, well-executed deduplication refactor. The shared captureScreenshotResult helper eliminates ~170 lines of duplicated logic across live host and offline replay paths, unifies the invariant sets (adding sha256 enforcement to live, artifactPath to offline), and introduces pre-side-effect Zod validation that neither old path had. The 10 new unit tests cover the success path, all five invariants, schema validation gating, and three distinct failure-cleanup scenarios with real filesystem assertions. Dogfood proof demonstrates byte-identical PNGs and field-identical JSON envelopes before and after.
As Pariston put it: "I tried to build a case against this and could not. The problem is correctly understood. The solution is proportional to the duplication."
3 P3, 2 P4, 3 Nit, 3 Note. All are test coverage gaps and style observations; no correctness or behavioral issues found. Netero's first pass was clean. 11 of 17 panel reviewers returned no findings.
test/unit/commands/screenshot.test.ts:366
P4 [DEREM-7] The implementation plan (Phase 4) explicitly called for removing duplicated assertions about manifest-entry internals and result construction, now owned by test/unit/screenshot/capture.test.ts. This file was not modified and the PR body does not mention the deviation. The tests work (vitest module mocks intercept all imports regardless of caller), but they now assert on transitive dependencies of captureScreenshotResult as if they were direct dependencies of the command module. Keeping them is conservative; the deviation from the plan should be acknowledged.
(Mafu-san P4, Bisky Note)
🤖
🤖 This review was automatically generated with Coder Agents.
- DEREM-1 (P3): add showCursor: false test as a distinct value vs undefined - DEREM-2 (P3): add empty-precondition tests for sessionDir / profileName / expectedSessionId - DEREM-3 (P3): replace 'G1 orphan' jargon with a plain explanation in the manifest-append-fails test - DEREM-4 (Nit): add temp-file ENOENT check inside the invariant violation loop, matching the rigor of the renderer-throws and rename-fails tests - DEREM-5 (Nit): point the CHANGELOG entry to PR #78 rather than the tracking issue - DEREM-6 (Nit): build the public result as a typed CapturedScreenshotResult intermediate before parseScreenshotResult, so a misspelled field surfaces at compile time (mirrors src/snapshot/capture.ts) - DEREM-7 (P4): trim duplicated rename / createArtifactEntry / appendArtifact assertions from test/unit/commands/screenshot.test.ts; those manifest-entry internals are now exercised by the dedicated capture.test.ts unit suite - DEREM-8 (Note): narrow the helper's backend parameter to Pick<RendererBackend, 'screenshot'> so future edits cannot reach for unrelated lifecycle methods - DEREM-9 (Note): refine the helper return type to ScreenshotResult & { sha256: string } so the runtime invariant is visible to TypeScript at every callsite DEREM-10 (P4) and DEREM-11 (Note) are deferred per the reviewer's own framing (parser duplication is shared with snapshot capture and is not this PR's debt; two of the three test fakes predate this PR).
|
Re: review body P4 [DEREM-7] about Done in dc38272. Removed the duplicated This brings Phase 4 of the implementation plan into compliance — the deviation you flagged was an oversight, not a deliberate tradeoff. |
|
/coder-agents-review |
There was a problem hiding this comment.
All 9 fixed findings from R1 verified against dc38272. Every fix addresses the root cause. The showCursor: false test now guards the ternary, precondition invariants have explicit coverage, the "G1 orphan" jargon is gone, the typed intermediate catches field-name typos at compile time, and the narrowed backend type prevents lifecycle method creep. Well done.
DEREM-10 and DEREM-11 were author-accepted without tickets. These need a human decision: file tickets or explicitly accept the gaps.
As Meruem put it: "I tried to build a case against this change and could not. The problem is correctly understood. The solution is proportional."
1 P3, 4 Nit, 1 Note new this round. The P3 is the test comment claiming tracking that doesn't exist (DEREM-12, from Netero). The nits are style consistency items. 8 of 12 panel reviewers returned no new findings.
🤖 This review was automatically generated with Coder Agents.
- DEREM-12 (P3): replace unverified "tracked as a follow-up" wording with an explicit reference to the filed follow-up issue (#79 — rename rollback when manifest append fails) - DEREM-13 (Nit): drop the `as unknown as Partial<ScreenshotResult>` casts; `pngSizeBytes: 0` and `cols: 0` are valid `number` values - DEREM-14 (Nit): align the commands.sh header examples with the `before`/`after` labels actually written to disk and used by the README - DEREM-15 (Nit): extract a local `PNG_HEADER` constant in capture.test.ts so the magic bytes are named in one place - DEREM-16 (Nit): match `src/snapshot/capture.ts`'s invariant wording (`'sessionDir must be non-empty'` etc.) - DEREM-17 (Note): replace the `as CapturedScreenshotResult` cast with a post-parse `invariant(sha256 !== undefined, ...)` plus an explicit intermediate, so a future schema change that strips `sha256` would fail loudly instead of silently regressing Also filed three follow-up issues per reviewer's request for human decisions on the deferred items: - #79 — rename rollback when manifest append fails (DEREM-12 / R1 risk) - #80 — generic parseValidatedResult<T> helper (DEREM-10) - #81 — shared test/helpers/fakeBackend.ts (DEREM-11)
|
/coder-agents-review |
There was a problem hiding this comment.
All 15 non-deferred findings addressed across 3 rounds. Every fix targets the cited root cause. The two deferrals have concrete tickets (#79, #80, #81). No corrections were silently dropped.
The shared captureScreenshotResult helper is clean, well-typed, and thoroughly tested. The Pick<RendererBackend, 'screenshot'> narrowing prevents lifecycle creep. The CapturedScreenshotResult intersection type propagates the sha256 guarantee without casts. The post-parse invariant defends against future schema regression. The dogfood bundle provides reproducible before/after evidence.
As Hisoka put it, after pulling every thread: "♠"
1 Nit, 1 Note this round. 8 of 8 panel reviewers returned no blocking findings.
test/unit/commands/screenshot.test.ts:494
Note [DEREM-20] The PR description's validation section says "22 tests pass (10 new in screenshot/capture.test.ts)" but HEAD has 23 tests (11 in capture.test.ts + 12 in screenshot.test.ts). The showCursor: false test added for DEREM-1 incremented the count. CI is green, so this is cosmetic.
(Mafu-san)
🤖
🤖 This review was automatically generated with Coder Agents.
Previously the five invariant cases lived inside a single `it()` and a `for` loop, so a failure in case 2 hid the status of cases 3-5. Migrating to `it.each(...)` runs each row as an independent test (with the case name in the title), matching the per-invariant style used by `test/unit/snapshot/capture.test.ts` and giving precise failure surface when a single invariant regresses. No behavior change to `captureScreenshotResult`. Verified by `npm run test -- test/unit/screenshot/capture.test.ts` (15 tests, all pass). Also reflects the updated PR description note from review (DEREM-20) that the suite reports more independent tests (15 in `screenshot/capture.test.ts` after this change).
|
Re: review body Note [DEREM-20] about the PR description test count: Updated the PR body to reflect the current test counts after DEREM-1 (
The PR description and the validation summary now match HEAD. |
|
/coder-agents-review |
There was a problem hiding this comment.
R4: DEREM-19 fixed in 10833df (invariant cases migrated to it.each, 15 independent tests). DEREM-20 fixed (PR description updated to 27 unit tests). Netero and all 3 panel reviewers confirm a clean state. Zero open findings across 20 total (17 fixed, 2 deferred with tickets, 1 dropped).
Nothing left to address.
🤖 This review was automatically generated with Coder Agents.
Closes #64.
Summary
Live host screenshot RPCs and CLI offline replay screenshots now both flow through a single
captureScreenshotResult(...)helper insrc/screenshot/capture.ts, mirroring the snapshot capture refactor in #69.The shared seam owns:
artifacts/directorybackend.screenshot(...)invocation (with optionalshowCursorthreaded through)sessionId,profileName,pngSizeBytes, requested vs returnedartifactPath,sha256)ScreenshotResultconstructionScreenshotResultSchemabefore any rename or manifest append side effect, plus a post-parsesha256invariantscreenshot-<seq>-<profile>.pngrenameparseScreenshotResult(...)is also exported from the shared module and used by the CLI to validate live RPC responses; the offline path returns a typedScreenshotResultdirectly from the helper. The helper accepts a narrowPick<RendererBackend, 'screenshot'>and returnsCapturedScreenshotResult = ScreenshotResult & { sha256: string }, so callers see the runtime guarantee at the type level.What did NOT change
ScreenshotResultSchema,ScreenshotParamsSchema, and the artifact manifest schemaartifacts/layoutscreenshot --jsonoutputsha256is now an enforced invariant on every successful screenshot result. The realghostty-webandlibghostty-vtbackends already always emit it; only test fakes that did not exercise the capture path omitted it.CONTEXT.md
Already updated during the design grilling with three new domain terms (Screenshot Result, Screenshot Artifact, Screenshot Capture) and the corresponding relationships. No ADR was added; the refactor is internal and reversible.
Validation
npm run verify— green: format:check + lint + typecheck + 1153 tests + build + smoke:installnpm run test -- test/unit/screenshot/capture.test.ts test/unit/commands/screenshot.test.ts— 27 tests pass (15 inscreenshot/capture.test.ts, 12 incommands/screenshot.test.ts)npm run test -- test/integration/host-renderer-rpc.test.ts— 8/8 pass (live screenshot integration)Dogfood proof
dogfood/issue-64-share-screenshot-capture/contains a reproduciblecommands.shplus committedbefore/(parent commitb2d5068, generated in a temporarygit worktree) andafter/(this branch) directories with:Verified parity:
sha256-summary.txtsessionIdand the absoluteartifactPath)id/sessionId/createdAt)cmp -s)See
dogfood/issue-64-share-screenshot-capture/README.mdfor the reviewer reproduction snippet.Follow-up issues filed during review
parseValidatedResult<T>helper for snapshot+screenshot+future capture modulestest/helpers/fakeBackend.tsto consolidate the fourcreateFakeBackendfactories📋 Implementation Plan
Issue #64: Share screenshot capture and artifact persistence
Context
Issue:
coder/agent-tty#64— "Share screenshot capture and artifact persistence across live and offline replay".The blocker, issue #60, is already closed and shipped as PR #69. That snapshot refactor created
src/snapshot/capture.ts, which is the direct precedent for this work.Current duplicated screenshot paths verified during grilling:
src/host/hostMain.tslivescreenshotRPC handler resolves the profile and renderer, allocates a temporary PNG path, callsbackend.screenshot(...), validates several invariants, renames the temp PNG to its final artifact filename, appends a screenshot manifest entry, and returns a hand-builtScreenshotResult.src/cli/commands/screenshot.tsoffline replay fallback does almost the same temp-file, renderer, rename, manifest, and result-construction work insiderunOfflineScreenshot(...).artifactPath === temporaryOutputPath; the offline path checkssha256 !== undefined.The agreed design is to deepen screenshots the same way snapshots were deepened: one shared rendered-artifact capture seam owns all screenshot artifact persistence and public result construction.
Goals
sha256, cursor metadata, renderer metadata, render-profile hashes, and CLI/RPC JSON shapes.Non-goals
ScreenshotResultSchema,ScreenshotParamsSchema, or artifact manifest schema.expectedSessionIdAPI.Design decisions from grilling
RendererBackendto the helper, not a custom callback.profileName: string; profile resolution remains at the caller-specific layer.ScreenshotResult.result.sessionId === expectedSessionIdresult.profileName === profileNameresult.pngSizeBytes > 0result.artifactPath === temporaryOutputPathresult.sha256 !== undefinedexpectedSessionIdis required.catch { rm(tmp, { force: true }).catch(() => undefined); throw error; }behavior.src/screenshot/capture.ts.parseScreenshotResultinto the shared module; the CLI parses live RPC responses inline and uses offline replay's already-validated typed result directly.src/host/hostMain.ts, then returncaptureScreenshotResult(...)directly.test/unit/screenshot/capture.test.tsas the main unit surface and trim duplicated result-construction assertions from caller tests.dogfood/that demonstrates live and offline screenshot parity before/after the refactor.CONTEXT.mdhas already been updated during grilling withScreenshot Result,Screenshot Artifact, andScreenshot Capture; no ADR is needed.Implementation plan
Phase 0 — Verify strict invariant assumptions
Before changing code, inspect the real renderer backends and existing test fakes that implement
RendererBackend.screenshot(...).Confirm that successful screenshot results always:
artifactPaththat was passed intobackend.screenshot(...); andsha256.If a real renderer backend violates either assumption, stop and reassess the union-invariant decision instead of silently weakening or working around it. If only test fakes violate the assumptions, update the fakes to match the real backend contract.
Quality gate after Phase 0:
Phase 1 — Add shared screenshot capture module
Create
src/screenshot/capture.ts.Exports:
Implementation details:
renameandrmfromnode:fs/promisesandulidfor.tmp-screenshot-<ulid>.png.ensureArtifactsDir,artifactPath,screenshotFilenameappendArtifact,createArtifactEntryScreenshotResultSchemamakeCliError(ERROR_CODES.PROTOCOL_ERROR, ...)invariant(...)parseScreenshotResult(...)should mirrorparseSnapshotResult(...)style fromsrc/snapshot/capture.ts.captureScreenshotResult(...)should:sessionDir,profileName, andexpectedSessionId;await ensureArtifactsDir(sessionDir);temporaryOutputPath = artifactPath(sessionDir, `.tmp-screenshot-${ulid()}.png`);backend.screenshot(temporaryOutputPath, showCursor === undefined ? undefined : { showCursor });const sha256 = result.sha256; invariant(sha256 !== undefined, 'renderer screenshot must produce sha256')so later manifest/result construction is not forced to treat the hash as optional;filename = screenshotFilename(result.capturedAtSeq, result.profileName);finalArtifactPath = artifactPath(sessionDir, filename);artifactPath: finalArtifactPathand exactly the fields currently returned;parseScreenshotResult(publicResult, 'Screenshot result validation failed.')before any rename or manifest append side effect;await rename(temporaryOutputPath, finalArtifactPath);kind: 'screenshot'filenamesessionIdcapturedAtSeqsha256profileName,cols,rows,pngSizeBytes,cursorVisible,rendererBackend,pixelWidth,pixelHeight,renderProfileHashrename(temporaryOutputPath, finalArtifactPath)semantics exactly. Do not add pre-existence checks, copy/unlink fallback, collision protection, deduplication, or manifest-entry dedupe logic as part of this refactor.try/catch; on failure remove onlytemporaryOutputPathwith force and rethrow.'Screenshot result validation failed.') for helper-side validation failures. This is an intentional weird-path message change from the old offline-only parse message in exchange for one shared validated seam.persistScreenshotArtifact(...)unless implementation reveals a real caller; the current agreed surface should stay minimal.Quality gate after Phase 1:
npm run typecheckonly if useful, otherwise continue to Phase 2 and validate after callers are wired.Phase 2 — Wire live host RPC to the shared module
Modify
src/host/hostMain.ts.rename,rm,ulid,appendArtifact,createArtifactEntry,ensureArtifactsDir,artifactPath, andscreenshotFilename, but keep any import still used elsewhere inhostMain.ts.captureScreenshotResultfrom../screenshot/capture.js.screenshotRPC handler:params as ScreenshotParamsrequestedProfileName ?? DEFAULT_RENDER_PROFILE_NAMEwith the existing error mappingrendererManager.getBackend(rendererName, profile, replayInput)Quality gate after Phase 2:
RendererBackendtype compatibility.Phase 3 — Wire offline replay screenshot path to the shared module
Modify
src/cli/commands/screenshot.ts.parseScreenshotResult(...)and import it from../../screenshot/capture.js.captureScreenshotResultfrom the same module.rename,rm,ulid,appendArtifact,createArtifactEntry,ensureArtifactsDir,artifactPath,screenshotFilename,ScreenshotResultSchema, andinvariant.resolveScreenshotRequest(...),formatScreenshotLines(...), session lookup, live/offline routing, andHOST_UNREACHABLEfallback behavior unchanged.runOfflineScreenshot(...)body with a thin wrapper:runScreenshotCommand(...)so each branch produces a typedScreenshotResult:parseScreenshotResult(rawResult, 'Unexpected response from host')HOST_UNREACHABLEfallback:await runOfflineScreenshot(...)await runOfflineScreenshot(...)rawResult: unknownandinvalidResultMessagebranch-state variables.manifest.status === 'running'unchanged unless import/formatting forces movement.Quality gate after Phase 3:
npm run test -- test/unit/commands/screenshot.test.tsUse
mise run test -- ...if this repo's current script accepts passthrough in the implementation environment; otherwise use the npm script form.Phase 4 — Add and adjust tests
Add
test/unit/screenshot/capture.test.tsmodeled aftertest/unit/snapshot/capture.test.tsbut with a fakeRendererBackend.Primary coverage:
backend.screenshot(...)with that temp pathshowCursorthrough when provided and omits options when undefinedscreenshot-<seq>-<profile>.pngScreenshotResultwhoseartifactPathis the final pathartifactPaththan requestedsha256ScreenshotResultSchemaand surfaced asPROTOCOL_ERRORbefore rename or manifest append;test/unit/snapshot/capture.test.tspatterns and prefer real-fs-induced failures where practical. Avoid brittle ESM mocks unless the existing test style already uses them.Adjust existing tests:
test/unit/commands/screenshot.test.ts: keep routing, fallback, request construction, output formatting, and CLI error behavior; remove duplicated assertions about manifest entry internals and result construction now owned bytest/unit/screenshot/capture.test.ts.Quality gates after Phase 4:
Also run the existing host renderer RPC screenshot integration test and relevant e2e artifact-manifest test after locating their exact paths/names.
Phase 5 — Dogfood proof bundle
Create a fresh minimal dogfood proof under
dogfood/issue-64-share-screenshot-capture/.Bundle/script requirements:
commands.shwith reproducible commands and an isolated absoluteAGENT_TTY_HOME.git worktreeor equivalent isolated checkout ofmainto generate the pre-refactor baseline, then run the same script against the refactor branch.before/andafter/directories, ormain/andrefactor/directories, containing:--show-cursor--show-cursor--show-cursor--show-cursorREADME.mdshould list the compared fields and conclusion.dogfood/convention for comparable proof bundles. If not, commit the script/README and include generated artifact paths, hashes, and attached screenshots/video in the PR or handoff notes.Dogfood method:
test/fixtures/apps/color-gridorhello-prompt; prefer the fixture already used by screenshot/e2e tests.sha256values.reference-dark), because profile variance is not part of this refactor.--show-cursor, because cursor metadata is an explicit preservation criterion.If exact before/after branch comparison is impractical inside the implementation workspace, produce the after bundle plus a README section explaining the limitation and include automated test evidence that verifies unchanged fields. Do not silently skip dogfooding.
Quality gate after Phase 5:
bash dogfood/issue-64-share-screenshot-capture/commands.shfrom a clean repo state with isolatedAGENT_TTY_HOME.Phase 6 — Documentation finalization
CONTEXT.mdhas already been updated during the grilling session with:and relationships tying screenshot capture, result, and artifact together.
Implementation should:
Quality gate after Phase 6:
or
mise run format-checkif preferred/available.Validation plan
Run narrow checks first, then broader checks if touched areas warrant it. The repo scripts were verified in
package.json/mise.toml:npm run test,npm run test:integration,npm run test:e2e,npm run typecheck,npm run lint,npm run format:check,npm run verify, andmise run ciexist.Targeted checks:
After locating the exact existing screenshot host/RPC and e2e artifact-manifest test files, run them with the confirmed scripts, for example:
Use
npm run test:integration/npm run test:e2efor broader serial layer checks when the exact target file is unclear or when the touched behavior is broad enough to justify the full layer.Repo quality checks:
Preferred final check before handoff if time permits:
Fallback if
miseis unavailable:Acceptance criteria
kind,filename,sessionId,capturedAtSeq,sha256,profileName, terminal dimensions, PNG size, cursor visibility, renderer backend, pixel dimensions, and render-profile hash behavior.sha256remains present for successful screenshots.showCursorbehavior is unchanged.screenshot --jsonoutput remain schema-compatible and behavior-compatible.Risks and mitigations
sha256or return a staleartifactPath.RendererBackendcontract; if a real backend omitssha256, stop and reassess the invariant instead of weakening silently.Unexpected response from host) and use a clear shared-module validation message for local capture failures; keep existing command tests for error envelopes.Handoff notes
CONTEXT.mdupdate only.invariant(...)and existing storage helpers; do not write manifest-like files with ad hocfs.writeFile()logic.AGENT_TTY_HOMEfor all dogfood and CLI validation.Advisor review incorporated
Advisor review was requested after the initial plan draft. The plan now incorporates the review by:
sha256and requestedartifactPathbefore implementation;ScreenshotResultbeforerename(...)or manifest append;sha256narrowing explicit before manifest/result construction;rename(temp, final)semantics exactly;package.jsonandmise.toml; andGenerated with
mux• Model:anthropic:claude-opus-4-7• Thinking:max