Skip to content

feat(record): export a recording from a copy of what its recorder wrote - #2631

Merged
thymikee merged 10 commits into
feat/recording-stop-observationfrom
feat/recording-stop-checkpoints
Sep 15, 2026
Merged

thymikee merged 10 commits into
feat/recording-stop-observationfrom
feat/recording-stop-checkpoints

Conversation

@thymikee

@thymikee thymikee commented Sep 15, 2026

Copy link
Copy Markdown
Member

Stacked on #2613. Implements the write path of ADR 0024 2.3 for the first two backends.

Summary

A stop now collects what its recorder wrote and produces the export from that copy, so the caller's path is never a file a recorder is still writing, and a stop that dies partway is driven again from the copy instead of re-signalling a recorder that already exited.

  • ScreenRecordingOutputHost gains the four local steps a copy needs: collectFromRecorder, writeExportFromCollected, retireRecorderFile, discardCollectedFile. Only the host can see a path, so retireRecorderFile is what answers retired rather than retirable. ScreenRecordingFinalizer.validatePlayable exposes the playability rule for a collected copy.
  • iOS Simulator: simctl records to <name>.native.mp4; the stop copies to .collected.mp4, checks playability there, writes the caller's path once, then retires the recorder's file.
  • Android: chunks are pulled to .collected siblings and copied out; chunk and --client-output-path naming is unchanged. The 180s-limit disclosure now rides the stop's journal, so a retried stop keeps it.
  • 44 files, 707 net production lines — past the size note. Android can come out as its own layer if you want it.

Validation

pnpm check:affected --run and pnpm check:tooling pass at 17e7a67961 (restacked on #2613 c69438a5fb, which now also fixes the Android presence probe this branch's dispositions read).

Live, on the current head: record start/record stop on iPhone 17 simulator and emulator-5554 each produced a playable MP4 with recorder: confirmed, nativePathDisposition: retired, and no .native/.collected sibling left on host or device; a stop whose daemon was killed after the export answered retired from the device, not from the marker it had frozen.

Mutations each fail a named test: recorder writing the export, disposition hardcoded, collect skipping playability, marker published after disposal.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.57 MB 4.58 MB +5.6 kB
Package (unpacked) 4.57 MB 4.58 MB +5.6 kB
Package (download) 1.36 MB 1.36 MB +1.5 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.6 ms 30.8 ms +1.2 ms
CLI --help 81.4 ms 80.9 ms -0.4 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at e2ad807, stacked on #2613. Exporting from a collected copy is the right idea, but a retried stop can lose data, and the design is larger than it needs to be.

The finalization checkpoint drops fields. encodeFinalization at stop-checkpoints.ts#L62 does not carry chunks or capturedDurationMs. On Android, if persistNativeManifest or cleanupChunks throws after that checkpoint, the retry takes the learned.finalization branch (stop-sequence.ts#L91) without them, so a chunked recording over 180 s returns only outPath and silently loses part-002 onward. Can the codec derive its keys from ScreenRecordingFinalization, so every served field round-trips? A stop-sequence test that replays a journaled chunked finalization and asserts result.chunks would catch this.

The stop clock is not journaled. finalizeAndroidRecording reads stoppedAtMs = Date.now() on every call (finalize.ts#L63). A retry that resumes with collectedPath journaled skips the stop, so exportCollectedChunks measures up to the retry time and can report an idle-tail warning sized by the gap between attempts. Journaling stoppedAtMs with the stop observation would fix this.

Could an abandoned stop leave full-size files behind? cleanupAppleRecording and cleanupSimulator in recovery.ts do not appear to remove the .native.mp4 or .collected.mp4 siblings (runtime.ts#L104), and restart reattach is unreattachable, so session close, daemon restart or forced cleanup after a failed stop would leave them next to --out. Should every terminal path dispose of both files, or should the CHANGELOG say leftovers are expected?

Is playability now checked up to three times on the same bytes? collect validates the copy in completion.ts, finalize.complete validates the byte-identical export again, and on Android pullChunks already requires each chunk playable. That adds waits and a validator spawn to every iOS and Android record stop. Could validation happen once, on the collected copy?

The chunk-naming rule now has two owners: chunkFilePairs at finalize.ts#L249 re-implements what pullChunks (chunks.ts#L118) owns, while pullChunks' clientOutputPath parameter has no production caller left. Can one of them own the rule and the dead branch go? Smaller cleanups: duplicate JSDoc above chunkFilePairs (L239, L244), the repeated comment in runtime.fixtures.ts#L100-L108, a re-export between imports at stop-sequence.ts#L13, and hand-written output stubs in the HarmonyOS and web fixtures instead of recordingFileStore().outputs.

Resume is the headline behavior, but createStopProgress (transitions.ts#L305) has no test, and every resume test hand-builds DurableCaptureProgress. Can one test run a failing record stop and then a second one through the real fence and lease, asserting one SIGINT, one collect copy, and the served result?

At about 787 net production lines, could the host interface be two steps instead of four: collect that copies and validates once, and publish that renames the collected copy onto the export, removes the recorder file and returns the disposition? That would remove discardCollectedFile and the second playability check, and Android could land as its own PR. Does ADR 0024 2.3 first need to say whether finalize.complete still owns playability, and whether the collected copy may be renamed into the export on the same volume?

Smoke Tests fails while waiting for the fixture home screen in the iOS input scenario. That scenario runs no record command and does not reach the recording routes this diff changes, so it looks unrelated.

A live resume run is still needed on an iOS simulator and an Android emulator: make the first record stop fail after the collect checkpoint (for example an adb failure during persistNativeManifest on a recording over 180 s), then run a second record stop. It should show no second SIGINT in --debug output, recorder: confirmed with the right nativePathDisposition, all chunks and capturedDurationMs on Android, and no .native.mp4 or .collected.mp4 left after an abandoned stop. The happy-path runs in the PR body do not reach this route. Next: fix the checkpoint round-trip and the stop clock, then add the resume test and live run.

…exists

ADR 0024 2.3 makes a stop resumable because a manifest that names durable artifacts
lets it be. A stop that dies between signalling the recorder and writing the export
loses every fact it established, so the next attempt re-signals a recorder that may
have exited and re-runs a finalizer that already overlaid the video.

`finish` now receives the manifest the stop is allowed to write into while it holds
its fence, and the recording's sequence names the three steps it commits between:
signal the recorder, collect a copy past the playability sniff, and finalize that copy
into the export instead of the recorder's own path. Each step records its checkpoint
before the next begins, so a retry re-signals only a recorder nobody confirmed, collects
once, and never overlays a finished export twice.

A checkpoint the next attempt cannot vouch for comes back absent rather than half-trusted:
resuming from a claim about an artifact that was never written skips the step that would
have noticed it.
…bpath

The stop sequence is only needed by a backend that drives its stop through it, so it stays
out of the capture-kit façade that every backend evaluates at import time.
…orts

ADR 0024 2.3 has a stop collect its recorder's artifact and write the export from that copy, which
means moving bytes the platform packages do not own and cannot reach: R13 keeps `node:fs` out of
them. The output host is the seam that already prepares an output path, so it gains the four steps
the question names — pull the recorder's file to a collected path, write the export from that copy,
retire the recorder's file while answering whether it worked, and discard the copy once the export
stands. Only the host can see a path, so only the host can say `retired` rather than `retirable`.

The finalizer now exposes its playability rule on its own, so a collected copy is examined with the
same patience and the same verdict the export gets; a container sniff alone would let a file no
player can read become the served video. A finalization also carries the chunk files and captured
length an export is served as, because those facts are decided with the export and a replay that
reuses a committed finalization still has to serve them.
`simctl recordVideo` wrote the file the caller named, so a stop that failed partway left the caller's
path holding bytes the next attempt had to reason about, and the only honest answer about that path
was `retirable` — the recorder's file and the served video were one file.

The recorder is now told a `.native` sibling, and the stop copies it to a `.collected` sibling,
checks playability there, and writes the caller's path once from that copy before retiring the
recorder's file and reporting whether the removal went through. A stop that dies before the export
exists leaves it absent with the copy in place, and a replay resumes from the copy instead of
signalling a recorder that already exited.

A recording whose file cannot be read now names the recorder exit that explains it whether that
 surfaces during collection or during finalization, and the exit travels in the stop's own journal, so
the second attempt still says it.
…erve copies

`adb screenrecord` writes on the device and `record stop` pulled each chunk straight onto the paths the
caller named, so a stop that died between the pull and the overlay left the caller's video holding
pulled-but-unfinalized bytes, and a retry re-signalled a recorder that had already exited to earn them
again.

The chunks now land on `.collected` siblings, the first is checked with the shared playability rule
there, and the caller's paths are written from copies once the export is what the finalizer overlays.
Because both sets follow one naming rule from their own base, an attempt that never ran the pull can
still name the collected files a previous attempt left, which is what makes the stop resumable. The
180s platform limit is disclosed by the stop step that learned it and journalled there, so a stop that
has to be driven again keeps the disclosure.

`completion.ts` no longer completes anything once the shared builder owns the answer, so the live
snapshot it also built moved to `live-snapshot.ts` and a failed cleanup's outcome joined the cleanup
that returns it.
Two backends adopted the collected copy in the same change, and each fixture grew the same host file
model to fail a stop that names a file it never wrote. The model is the answer to one question — where
would the output host be holding these bytes — so it lives behind one fixture subpath now, next to the
MP4 fixture the Android suite already reads.
…and the facts

Turning a collected set into an export answers three questions — put the bytes where the caller looks,
ask the finalizer to overlay them, and say what the export turned out to be — and holding all three in
one body made each of them hard to read against the others.
The scenario asserted the pull targeted the caller's own path, which is exactly the claim ADR 0024 2.3
retired. It now names the collected sibling and checks the real host afterwards: the caller's video is
there, and the copy the stop made is not.
@thymikee
thymikee force-pushed the feat/recording-stop-checkpoints branch from e2ad807 to 17e7a67 Compare September 15, 2026 13:30
@thymikee

Copy link
Copy Markdown
Member Author

Restacked on #2613 after its probe fix; new head 17e7a67961, check:affected --run green, Validation above is the current head.

Answering the size report directly: +6.3 kB unpacked is over the 3 kB note, and 707 net production lines is over the size guideline, so say the word and I'll cut Android (c6f3af2731, plus its scenarios) into its own layer under this one — it is a separate backend and shares only the stop sequence and the output host verbs. Left as one PR otherwise, since the copy rule reads better with both backends beside it.

@thymikee
thymikee added this pull request to stack #2635 September 15, 2026 13:39
…ayability once

A retried `record stop` replays the finalization its first attempt journaled. The checkpoint codec
kept only four of the six fields, so a chunked Android recording whose completion marker or chunk
disposal failed came back with only `outPath`: part-002 onward and `capturedDurationMs` were gone.
The codec is now keyed over every `ScreenRecordingFinalization` field, so a field added to that type
does not compile until it round-trips, and a finalization whose chunks or length cannot be read is
redone rather than served short.

The stop clock is journaled with the recorder signal. A stop resumed from its collected copy used to
read `Date.now()` again and size Android's idle-tail warning by the gap between attempts.

Playability is checked once, on the export. The collected copy of a recorder that already exited is
the same bytes, and Android's pull already retries until each chunk plays, so the extra validator
spawn only added latency to every iOS and Android stop. `validatePlayable` is gone from the finalizer.

The output host has two verbs instead of four: `copy` and `remove`. Collect and export were the same
copy, and retire and discard were the same removal; HarmonyOS and web fixtures now use the shared
file store instead of throwing stubs.

Android chunk paths have one naming rule, `chunkPathAt`, used by the pull, the served set and the
completion-marker check, and `pullChunks` loses its unused client-path branch.

A test drives a failing stop and a second stop through the real fence and lease and asserts one
signal, one collect and the served result. The CHANGELOG says an abandoned stop keeps its video in
the `.native`/`.collected` siblings, which are then the only copy.
@thymikee

Copy link
Copy Markdown
Member Author

Answered the review at e2ad807 in b64efa7, on top of 17e7a67.

Checkpoint round-trip. ScreenRecordingFinalization now lives in @agent-device/contracts/recording-stop-progress, and the codec in stop-checkpoints.ts has one reader and one writer per field, keyed over every field of that type. A new field does not compile until it round-trips. chunks and capturedDurationMs are journaled; a finalization whose chunks or length cannot be read is redone, not served short. Tests: serves every field a journaled finalization carries (sequence) and a retried commit serves every chunk and the captured length its first attempt finalized (Android, the completion marker write fails once). Making the chunk and length readers return nothing fails the Android test with expected undefined to deeply equal [...].

Stop clock. The sequence reads the clock before the signal and journals stoppedAtMs with the observation; finalize gets the journaled value. Android no longer calls Date.now(). Test: a stop resumed after its collect measures the window its first attempt signalled; reading now() instead of the journaled value fails it with the idle-tail warning.

Leftover siblings. Kept, and the CHANGELOG now says so. After an abandoned stop the .native/.collected files are the only copy of the video (the recorder used to leave it at --out), so deleting them on session close would lose the recording.

Playability. Checked once, on the export. validatePlayable is gone from the finalizer contract, the host and both collect steps. simctl has exited before collect, so the copy is final bytes, and Android's pull already retries until each chunk plays.

Host surface. collectFromRecorder, writeExportFromCollected, retireRecorderFile and discardCollectedFile are now two verbs, copy({ from, to }) and remove(path). The HarmonyOS and web fixtures use recordingFileStore().outputs instead of throwing stubs.

Why not rename the collected copy into the export. The overlay is not idempotent. If the copy is renamed and the stop dies after the overlay but before the finalization checkpoint, the retry cannot tell a raw export from an overlaid one and would overlay twice or serve a raw file. The copy is what makes that step safe to redo.

Chunk naming. One rule, chunkPathAt in recording/chunk-path.ts, used by pullChunks, the served/collected pairs and the completion-marker check in manifest-validation.ts (a third copy). pullChunks lost its unused client-path branch. The duplicate JSDoc, the repeated fixture comment and the re-export between imports are fixed.

Resume through the fence. a record stop that fails after collecting resumes through the fence without a second signal runs the real sequence behind the real live handle through finishLiveScreenRecording twice: one signal, one collect, the checkpoints on the open manifest after the failure, and the served result after the retry. Making createStopProgress().record a no-op fails it.

Size. Net production lines go from about 696 to 625 against #2613.

Live runs on a build whose source differs from b64efa7 only in one integration-test regex (dedicated iOS 26.2 simulator and a Pixel_7_review emulator; the first stop was made to fail after collect by putting a directory at --out):

  • iOS: the first record stop failed with EISDIR on the export copy, and the manifest stayed open with stopObservation: confirmed, stoppedAtMs: 1789481017533 and collectedPath. No recordVideo process was left. After removing the directory, the second stop returned recorder: confirmed, nativePathDisposition: retired, and a 13.3 s playable ios.mp4. Both siblings were gone, and the completed manifest still held stoppedAtMs: 1789481017533, so the recorder was not signalled again.
  • Android, 197 s recording: the first stop failed the same way, with both collected chunks on the host and both chunks still on the device. 60 s later the second stop returned chunks and.mp4 (188.6 s) and and.part-002.mp4 (47.9 s), capturedDurationMs: 236566, recorder: confirmed and nativePathDisposition: retired. The device chunks and the collected siblings were gone, and stoppedAtMs was unchanged. There was no idle-tail warning, although the retry came 60 s after the signal.

A failure after the finalization checkpoint (the completion marker write) cannot be provoked on demand without fault injection, so that route is covered by the Android unit test above, not by a device run.

Gate at b64efa7: pnpm check:affected --run passed format, lint, typecheck, layering, fallow, build, package, the node and macOS integration lanes, vitest-related (3,410 tests) and the model gates. scripts/node-test-tmpdir.test.ts timed out once on a busy host and passed on rerun; this PR does not touch it. The remaining gates after it (production-exports, mutation-model and the others) pass when run one by one.

…ion is journaled, and never serve a refused export

ADR 0024 2.3 records the collected checkpoint only after the copy passes the container sniff. The
simulator copy now gets that sniff (`finalize.sniff`: ftyp + moov, no validator spawn); the export
still gets the full playability verdict once.

The collected copy is removed by a `discard` step the sequence runs after the finalization is
journaled, and again on a replay. Removing it inside `finalize` left a crash window in which the
checkpoint named a copy that no longer existed, so every later stop failed.

When the export copy or the finalizer fails, the caller's paths are removed before the error
returns. A killed simctl used to leave an unplayable file at `--out` next to a failed stop.
@thymikee

Copy link
Copy Markdown
Member Author

Follow-up in e41d66e, after checking b64efa7 against ADR 0024 2.3 and an independent read of it.

The caller's path never holds a refused export. In b64efa7, the export was copied to --out before complete checked it, so a killed simctl left an unplayable file at --out next to a failed stop. When the export copy or complete fails, both backends now remove the paths they served before the error returns, and the collected copy stays for the retry. Tests: a simulator export the finalizer refuses leaves the caller path empty and the copy for the retry and the chunked Android version. Each fails if its removal is dropped.

The collected checkpoint follows the sniff again. ADR 0024 2.3 records collected only after the copy passes the container sniff. b64efa7 dropped the check from the simulator collect along with the full validator. The new finalize.sniff checks ftyp + moov (EBML for WebM) without spawning the validator, so the export still gets the full check exactly once. Android's pull already checks each chunk.

The copy is kept until its finalization is journaled. Removing it inside finalize left a crash window in which the checkpoint named a copy that was gone, so every later stop would fail on it. A discard step now runs after the finalization is journaled, and again on a replay. Test: discards the collected copy only once its finalization is journaled, on a replay too. It fails if discard runs before the journal write.

Live, on e41d66e, with the same injection as before (a directory at --out makes the first stop fail after collect):

  • iOS: the first stop failed with EISDIR. The manifest was open with stopObservation: confirmed, stoppedAtMs: 1789482459123 and collectedPath, and no recorder was alive. The second stop returned recorder: confirmed, nativePathDisposition: retired and a 20.7 s playable ios.mp4. The siblings were gone, and stoppedAtMs was unchanged in the completed manifest.
  • Android, 197 s: the first stop failed after collecting both chunks. The second stop, 60 s later, returned and.mp4 (215.6 s) and and.part-002.mp4 (27.3 s), capturedDurationMs: 242880, recorder: confirmed and nativePathDisposition: retired. Device chunks and collected siblings were gone, stoppedAtMs was unchanged, and there was no idle-tail warning.

Gate at e41d66e: pnpm check:affected --run passed end to end.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee
thymikee merged commit 7f49312 into main Sep 15, 2026
18 checks passed
@thymikee
thymikee deleted the feat/recording-stop-checkpoints branch September 15, 2026 15:23
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at e41d66e, the head that merged, as a follow-up to the review at e2ad807. The earlier points are fixed: the finalization checkpoint round-trips chunks and capturedDurationMs, stoppedAtMs is journaled, the collected copy stays until its finalization is journaled, playability is checked once, and a refused export leaves nothing at --out. One defect remains and is now on main.

An abandoned stop can still lose its video. After an abandoned iOS stop, the video exists only in <out>.native.mp4 and <out>.collected.mp4. A later record start --out <same path> runs outputs.prepare(nativePath), and its forced rmSync deletes the kept native file before simctl starts. That recording's stop then copies over the kept .collected.mp4 (runtime.ts#L105, #L166). On Android, pullChunks in chunks.ts overwrites the kept collected chunks the same way. The CHANGELOG says these files are the only copy of the video, so nothing deletes them. A user who records again to the same --out after a failed stop silently loses the earlier recording. Should the start path refuse with a typed error that names the leftover sibling when no open recording owns it? If losing it is intended, the CHANGELOG should say that a later recording to the same --out replaces the kept files.

Do the refused-export tests guard the retry route? The iOS test in runtime.test.ts#L287 and the Android one in finalize.test.ts stub complete with a plain Error and match its message. Production throws COMMAND_FAILED with reason recording-output-unplayable and retriable: true. A change that drops retriable would still pass. Throwing the production error from the fixture and asserting those fields would catch it.

On size, about 749 net production lines are over the guideline. Most of it is the Android move, and the copy, complete and remove-on-refusal blocks in completion.ts and finalize.ts have the same shape. Could a follow-up share one "export from collected" helper in capture-kit?

All checks passed at this head. The live resume runs in the PR body cover the copy-failure route; a finalizer refusal of a real unplayable export has unit coverage only. Next: a small follow-up that makes a new recording refuse, or document replacing, an abandoned recording's kept files.

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant