Skip to content

fix: read replay scripts on the caller and ship them with the request - #1810

Merged
thymikee merged 4 commits into
mainfrom
fix/1802-replay-script-source-bundle
Aug 18, 2026
Merged

fix: read replay scripts on the caller and ship them with the request#1810
thymikee merged 4 commits into
mainfrom
fix/1802-replay-script-source-bundle

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

replay <path> and test <path-or-glob> now name files on the caller's filesystem, everywhere.
The client resolves the path, reads the script (and, for Maestro, every runFlow include it can
reach), and sends the text with the request. The daemon executes only what arrived and opens no
caller path at all.

Before: the path was resolved on the caller but opened on the daemon. On one host that works, so a
flow passes locally and then fails on its first remote run with ENOENT on a path the caller can
read. Against a remote daemon replay simply did not work.

After:

export AGENT_DEVICE_DAEMON_BASE_URL=...
export AGENT_DEVICE_DAEMON_AUTH_TOKEN=...
agent-device replay ./flows/login.ad --platform ios     # runs; the daemon never sees ./flows
agent-device test './e2e/*.ad' --platform ios           # globs expand on the runner, each source ships its text
agent-device replay ./flows/typo.ad                     # "replay script not found on this machine: ./flows/typo.ad"

The one typed concept is a replay script source bundle (CONTEXT.md, Recording & replay): an
entry display path plus a resolved-path-to-text map. It rides in the request as
replayScriptSource (one run) / replayScriptSources (one per test source), the same way
replayShellEnv already carries caller-local input. Local and remote use the identical path — there
is no remote-only branch, which is what stops "passes locally, ENOENT in CI" from coming back.

What moved:

  • runReplayScriptFilerunReplayScriptSource; it reads the bundle, never req.positionals[0].
    inspectAdReplay takes script text; replay-device-selection reads the bundle (it previously
    swallowed its own ENOENT and silently skipped pre-binding on every remote replay).
  • createMaestroProgramLoader takes a readSource port — fs on the caller, bundle lookup on the
    daemon — so runFlow includes resolve identically on both. packages/maestro no longer imports
    node:fs.
  • test path/glob expansion moved to the caller (src/replay/source-discovery.ts); the daemon
    capability now only inspects the sources it was sent, in the order they arrived. Sharding,
    ordering, and platform filtering are unchanged.
  • No path fallback. A request carrying only a path is refused with a message naming what is missing
    and what to do, instead of an ENOENT for a file that exists on the machine that ran the command.
    Older clients against a newer daemon get that message; the field is additive, so a newer client
    against an older daemon is the normal command-error case ADR 0006 describes.

--save-script against a remote daemon is now refused with a typed INVALID_ARGS naming the
split. Reading was fixable by sending content; writing is not symmetric — the healed .ad is
published by the daemon's SessionScriptWriter at session teardown, on the daemon's disk, next to a
source path that does not exist there. Returning the healed script in the response would mean new
teardown→response plumbing well outside this change, so option (b) from the design: refuse loudly
now, follow up separately. Local --save-script behavior is untouched.

Closes #1802

Validation

Regression tests, proven red first. With only the client writer reverted to origin/main,
src/__tests__/cli-replay-script-sources.test.ts fails on the wire payload:

FAIL  src/__tests__/cli-replay-script-sources.test.ts > replay against a remote daemon sends the script content, not just the path
+ undefined
- '/private/tmp/.../flows/login.ad'
   assert.equal(bundle?.entry, path.join(cwd, 'flows/login.ad'));

With the daemon side reverted (session-replay-runtime{,-plan}.ts, ad-replay/inspect.ts),
src/daemon/handlers/__tests__/session-replay-script-source.test.ts fails on both halves — the run
that must not touch the disk, and the refusal that must exist:

FAIL  > a bundled replay runs after the script file is gone from the daemon host
  expected false to be true      (the daemon opened the caller path and got ENOENT)
FAIL  > a request carrying only a path is refused with the client-must-send-sources message
  expected true to be false      (a bare path was silently accepted)

Also covered: Maestro include collection (transitive, through repeat/retry/inline
runFlow.commands and lifecycle hooks, deduplicated, cycle-safe), a missing include naming its own
path, the bundle byte cap, the caller-side not-found error, and the remote --save-script refusal.
inspectAdReplay's plan digest is pinned to a literal so an ADR 0012 --from/--plan-digest
resume issued before this change stays valid after it.

Live, iPhone 17 simulator (1604B975-…), repo CLI, isolated state dirs.

Local, relative path from the flow's own directory — Replayed 3 steps in 3.1s. A wrong selector
still names the caller's path and line: Divergence at step 3 (/…/flows/broken.ad:4).

Remote mode via agent-device proxy --port 4319, driven from a different cwd and a different
state dir with AGENT_DEVICE_DAEMON_BASE_URL/AGENT_DEVICE_DAEMON_AUTH_TOKENreplay ./settings.adReplayed 3 steps in 3.2s. The proof that the daemon opens nothing: the script was
deleted one second after the client started, while the run was still in flight —

== removed ./vanishing.ad from the caller's disk (nothing at that path any more)
Replayed 3 steps in 2.9s
ls: vanishing.ad: No such file or directory

replay ./settings.ad --save-script over the same proxy → --save-script is not supported against a remote daemon…; replay ./no-such-flow.adreplay script not found on this machine: ./no-such-flow.ad, with no daemon round-trip.

Honest limitation of the live run: a single host cannot reproduce the reporter's original ENOENT,
because the daemon shares the caller's filesystem and pre-fix code resolves through meta.cwd. The
cross-filesystem property is what the deleted-script run above and the daemon-side regression test
demonstrate.

All sessions closed, both daemons stopped, the proxy killed, and the simulator shut down.
pnpm check:affected --run green (check:daemon-wire-compat: 151 declarations, 0 changed — the new
fields sit inside the CommandFlags/InternalRequestOptions waiver ADR 0006's additive rule
covers). One unrelated contention flake on the way (scripts/fuzz/harness.test.ts 5s timeout);
passes in isolation and on the final green run.

Tradeoffs and follow-ups

  • A Maestro runFlow include whose path is templated on a value only the daemon binds cannot be
    collected before the run. Collection is best effort past the entry flow (when:-guarded includes
    may never execute), and a run that does need such a file fails naming exactly which file was
    missing from the sources sent. Includes templated on env:/AD_VAR_*/-e values are resolved,
    through the Maestro package's own interpolation.
  • Bundles are inlined in the request and capped at 2 MB total; .ad scripts and flows are kilobytes,
    and the upload route stays reserved for binaries.
  • --save-script remote support (returning the healed script in the teardown response) is the
    follow-up left open.

Scope

89 files. Scope stayed in the replay/test command family and its packages, with three deliberate
edges: the remote request-prep gate (--save-script refusal), the @agent-device/maestro source
port, and @agent-device/replay-test's discovery port, which no longer takes inputs or a cwd.

A pure refactor has been split out in response to review: moving the test-suite orchestration
out of handleSessionReplayCommands was not needed by this fix, and now lives in its own stacked PR
(#1826, session-replay.ts 413 -> 34 lines, session-test-suite-command.ts +410). Removing it took
this PR from 91 files / +2646 -1208 to 89 files / +2268 -852 — 356 fewer added and 356 fewer
removed lines, all of it moved code. What is left is the fix and what the fix itself requires.

Four commits: the fix; a CodeQL follow-up replacing a hand-escaped regex in a new test with a
substring assertion; the CLI cold-start fix below; and one small refactor the fix does require —
the new replay flags appear in both CliFlags and CommandExecutionOptions, which fallow flags as
a clone, so ReplayRequestFields declares them once, and the suite handler's missing-sources
rejection travels the typed-error path its sibling rejections already use so the fix adds no branch
to handleSessionReplayCommands. Docs: CONTEXT.md glossary, agent-device help scripting, and
the command reference.

session-replay.ts stays over the 300-line tripwire it was already over before this PR (382 -> 413
lines); #1826 is the follow-up that fixes that, and is deliberately not required for this one.

CLI cold start

The size job caught a real regression in the first version of this change: the command registry
evaluates every command family's module on startup, --help included, so a static
@agent-device/maestro import in the new script-source builder put the YAML parser on the cold
path — CLI --help 67.4 ms -> 95.7 ms (+28.3) and a 131 kB startup chunk, for a format most
invocations never touch.

The engine now loads on demand inside loadReplayScriptSourceBundle, reached only once an entry
resolves to a flow; the native .ad branch reads its entry file and nothing else. That made the one
bundle constructor async, so DaemonWriter gained a sibling AsyncDaemonWriter — replay/test and
batch declare it, every other family's writer stays synchronous and untouched, and the registry
awaits whichever kind a command declares. One construction path is preserved: CLI, Node client and
MCP still converge on loadReplayScriptSourceBundle.

CI's own base-vs-PR run after the fix: CLI --help 65.3 -> 66.2 ms (+0.8), with CLI --version
moving +0.7 ms on the same run — and --version cannot be affected by this change, so the remainder
is that run's drift. Package total is +10.0 kB raw / +3.1 kB gzip.

src/__tests__/cli-startup-import-closure.test.ts — the AST walker that already keeps node:http
off the cold path (#1681) — now holds the same line for the engine, and was proven red by restoring
the static import. Walking the built output confirms it independently of wall clock: the Maestro
chunk is absent from dist/src/cli.js's 46-chunk static closure and reachable only through
script-source-bundle.js -> src4.js's dynamic import.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.28 MB 2.28 MB +1.5 kB
JS gzip 749.3 kB 750.1 kB +782 B
npm tarball 871.2 kB 871.3 kB +157 B
npm unpacked 3.04 MB 3.04 MB +1.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.0 ms 27.7 ms -0.4 ms
CLI --help 69.6 ms 67.5 ms -2.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/script-source-bundle.js +104.5 kB +33.2 kB
dist/src/src4.js +59.7 kB +17.5 kB
dist/src/internal/daemon.js -45.5 kB -13.2 kB
dist/src/session2.js -17.7 kB -5.5 kB
dist/src/screenshot-result.js -12.3 kB -3.6 kB

Comment thread src/__tests__/cli-client-commands.test.ts Fixed
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-18 13:00 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head b7c66b8. The caller-owned replay source-bundle design is sound: replay/test payload preparation, daemon no-filesystem execution, Maestro include closure, ADR 0006 additive fields, and ADR 0012 digest/repair compatibility are substantively covered. One readiness blocker: CodeQL reports a new high-severity incomplete-escaping alert at src/__tests__/cli-client-commands.test.ts:759, where new RegExp(missingPath.replace(/[.]/g, '\\.')) escapes only dots. It is test-only, but the security gate is genuinely red; use a substring assertion or complete regex escaping and refresh CodeQL. Linux/iOS smoke are still running. Residual evidence: the live remote run covers replay, while the newly caller-side remote test path is unit-covered but not live-verified.

@thymikee

Copy link
Copy Markdown
Member Author

CodeQL alert addressed at head 6ae91bc (test: assert the caller-side replay path as a substring, not a hand-escaped regex) — the js-ts analysis on that head is green and the alert is closed. Remaining residual evidence as stated in the PR body: remote test (caller-side glob expansion) is unit-covered but not live-verified; remote replay was.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed the CLI startup regression the size report caught

The size bot was right, and the cause was mine: the command registry evaluates every command family's module on startup — --help included — so src/commands/replay/index.ts statically importing the new script-source builder dragged @agent-device/maestro (and the YAML parser behind it) onto the cold path, for a format most invocations never touch.

@agent-device/maestro now loads on demand, inside loadReplayScriptSourceBundle, and only once an entry actually resolves to a flow (resolveReplayFormat(...) === 'maestro'). The native .ad branch reads its entry file and touches nothing else. That made the one bundle constructor async, so DaemonWriter gained a sibling AsyncDaemonWriter — replay/test and batch declare it, every other family's writer stays synchronous and untouched, and the registry awaits whichever kind a command declares. src/replay/source-discovery.ts and resolveReplayFormat were already clean (node:fs/node:path and string work only); no other engine reaches the startup path.

Guarded, not just fixed. src/__tests__/cli-startup-import-closure.test.ts — the existing AST walker that keeps node:http/node:https off the cold path (#1681) — now holds the same line for the Maestro engine. Proven red by restoring the static import:

FAIL > the CLI startup import closure never evaluates the Maestro engine
- []
+ [ "replay/script-source-bundle.ts -> @agent-device/maestro" ]

Numbers — matched builds on one machine, 15 runs each, via the same scripts/size-report.mjs the size job uses:

Scenario Before After Diff
CLI --help (median) 60.4 ms 47.2 ms −13.2 ms
CLI --help (min) 57.7 ms 44.3 ms −13.4 ms
CLI --version (median) 35.3 ms 29.0 ms −6.3 ms

--version cannot be affected by this change, so treat its −6.3 ms as the machine's noise floor and the --help gain as roughly 7–13 ms locally; CI's base-vs-PR comparison on a quiet runner is the number to trust.

Chunks: the engine left the startup graph entirely and became its own on-demand chunk.

Chunk Before After
dist/src/script-source-bundle.js 131.0 kB 108.6 kB
dist/src/src4.js (Maestro + YAML) 59.7 kB, dynamic only

Wall-clock-free confirmation, walking the static import graph of the built output from dist/src/cli.js:

static closure of dist/src/cli.js: 46 chunks
maestro engine statically reachable: NO
maestro engine behind a dynamic import: dist/src/script-source-bundle.js -> dist/src/src4.js

(The engine is located by its own Maestro runFlow cycle detected string, so this identifies the real chunk rather than trusting a name.)

pnpm check:affected --run green; the full unit suite passes (583 files / 4270 tests).

@thymikee

Copy link
Copy Markdown
Member Author

CI confirms it: startup regression gone

The size job's own base-vs-PR run on a quiet runner, after ac2b9ee:

Scenario Base Current Diff
CLI --help 65.3 ms 66.2 ms +0.8 ms
CLI --version 26.0 ms 26.8 ms +0.7 ms

Was 67.4 → 95.7 ms (+28.3). --version moved by the same +0.7 ms and cannot be affected by this change, so the remaining +0.8 ms on --help is that run's drift, not a cost.

Package total is now +10.0 kB raw / +3.1 kB gzip (was +131 kB of startup chunk): script-source-bundle.js +108.6 kB and the Maestro chunk src4.js +59.7 kB are largely offset by daemon.js −44.3 kB, session2.js −17.6 kB and screenshot-result.js −12.2 kB as the split moved code off shared chunks. src4.js is reachable only through the dynamic import, so none of it is on the cold path.

@thymikee
thymikee force-pushed the fix/1802-replay-script-source-bundle branch from ac2b9ee to 7018c3d Compare August 18, 2026 09:55
@thymikee

Copy link
Copy Markdown
Member Author

Code delta review at ac2b9ee is clean: the CodeQL fix is resolved, Maestro loads only for flow input, async writer propagation reaches every production caller, the planted import-closure regression is meaningful, size evidence restores CLI startup, and exact-head CI is green. Readiness remains blocked because the PR body was overwritten with the unrelated #1801 remote-diagnostics feature: wrong linked issue, behavior, 38-file scope, routes, validation, and wire claims. Restore an accurate #1802 replay-source description, including the remote replay proof and remote-test live-evidence limitation, before ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

PR description restored

Apologies for the confusion — the description on this PR was briefly overwritten with the text of a different PR (#1801, remote diagnostics). That was a stray gh pr edit from a parallel session landing on the wrong PR number; nothing about the branch, its commits, or its diff was touched.

The body is now the #1802 replay-script-source-bundle description again, with the CLI cold-start section re-added after the restore (it had been written after the point the restore was taken from). Verified before and after editing: Closes #1802 present, the summary is the replay-source-bundle one, and no remote-diagnostics text remains.

Also, the branch has been rebased onto current main (through #1795) — head is now 7018c3d5f. The rebase was clean; I checked line-level that it reverted nothing from the 9 new commits: this branch changes 0 lines in src/core/command-descriptor/registry.ts, .oxfmtrc.json and src/platforms/apple, and of the four files touched by both sides the only removed lines are the two this PR deliberately replaces. #1786's hover family is a plain synchronous DaemonWriter, so the AsyncDaemonWriter split leaves it untouched.

Re-run on the rebased head: typecheck clean, wire-compat + replay-compat green (151 declarations, 0 changed, 0 removed, 0 added), pnpm check:affected --run green.

@thymikee

Copy link
Copy Markdown
Member Author

Clean re-review at 7018c3d: the rebased series is range-diff equivalent to the previously reviewed code, and the PR body is restored to the actual #1802 replay-source change with correct 91-file scope, validation, cold-start fix, and remote-test evidence limitation. Exact-head CI is fully green and merge state is CLEAN. Ready-for-human.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 18, 2026
The command registry evaluates every command family on CLI startup, so the replay script-source builder's static @agent-device/maestro import put the YAML parser on the --help path. It now loads on demand behind the format check, and the startup import-closure guard covers the engine the way it already covers node:http.
…d client views

The new replay script-source flags appear in both CliFlags and CommandExecutionOptions, which fallow flagged as a clone; ReplayRequestFields declares them once. The test-suite handler's missing-sources rejection now travels the typed-error path its sibling rejections already use, so the fix adds no branch to handleSessionReplayCommands.
@thymikee

Copy link
Copy Markdown
Member Author

Split the pure refactor out — this PR is smaller

Fair challenge, and you were right about which part. Moving the test-suite orchestration out of handleSessionReplayCommands was a tidy-up I did while in the file; the fix never needed it. It is now its own stacked PR, #1826 (draft, based on this branch, retargets to main once this merges).

Files Added Removed
Before 91 2646 1208
After 89 2268 852
Difference −2 −378 −356

Almost all of it is the move: session-replay.ts 413 → 34 lines against session-test-suite-command.ts +410, plus the two test files that follow the two symbols it takes with it.

What stayed, because the fix genuinely requires it:

  • ReplayRequestFields — the new replay flags have to appear in both CliFlags and CommandExecutionOptions, which fallow flags as a 19-line clone. Declaring them once is the fix's own footprint, not a drive-by.
  • A one-line rerouting of the suite handler's missing-sources rejection through the typed-error path its sibling rejections already take, instead of adding a second refusal shape. That is what keeps handleSessionReplayCommands under the complexity gate without the extraction — I checked: with the branch added naively it goes to 14 cyclomatic and fallow fails; routed this way it stays where it was, and fallow audit --base origin/main reports no issues in 89 changed files.

So the extraction is genuinely optional for this PR, which is the test it had to pass to be split out.

session-replay.ts remains over the 300-line tripwire — it was already over before this PR (382 → 413), and #1826 is the follow-up that actually fixes that. Flagging rather than hiding it.

Also rebased onto current main (through #1809/#1817/#1816); head is now 1dcd0fcfe. pnpm format && pnpm check:affected --run green on both branches.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 1dcd0fcf after trimming: code and architecture are clean. The unrelated suite-orchestration move is split to #1826; this PR’s 89-file scope now matches its body and retains only the caller-side replay/test source-bundle path and required edges. The previous CodeQL regex alert is fixed, startup laziness remains guarded, and the deleted-source/bare-path/include regressions plus live proxy run exercise the shipped route. All completed checks are green; final merge readiness waits for Coverage, Affected-check Selector, and iOS Smoke still pending.

@thymikee
thymikee merged commit 60f6356 into main Aug 18, 2026
33 checks passed
@thymikee
thymikee deleted the fix/1802-replay-script-source-bundle branch August 18, 2026 12:56
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.

replay cannot take a path against a remote daemon

2 participants