Skip to content

Commit 60f6356

Browse files
authored
fix: read replay scripts on the caller and ship them with the request (#1810)
* fix: read replay scripts on the caller and ship them with the request Closes #1802 * test: assert the caller-side replay path as a substring, not a hand-escaped regex * perf(cli): load the Maestro engine only when a replay entry is a flow 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. * refactor: share the replay request field vocabulary across the CLI and 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.
1 parent 3908559 commit 60f6356

89 files changed

Lines changed: 2268 additions & 852 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTEXT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ task touches:
247247
- Destination guard: portable selector-targeted `wait` near the end of an open-to-destination script
248248
that confirms a landmark on the ready destination screen before replay hands the live session to
249249
its caller.
250+
- Replay script source bundle: every script file one `replay`/`test` run needs, read and resolved by
251+
the CALLER and shipped inside the request — an entry display path plus a resolved-path-to-text map
252+
covering the `.ad` script or the Maestro flow and its `runFlow` includes. The daemon executes only
253+
what the bundle carries and resolves no caller path, so a local run and a run against a remote
254+
daemon read identical bytes (#1802). Avoid: script upload, flow payload.
250255
- Screen-recording facet: runtime facet that starts platform screen/video capture and returns a live
251256
handle plus its durable descriptor. It is distinct from script recording.
252257
- Live resource handle: process-local authority to finish or forcibly dispose active app-log,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { AppError } from '@agent-device/kernel/errors';
4+
import { inspectAdReplay } from '../inspect.ts';
5+
6+
const SCRIPT = 'context platform=ios\nopen "Demo"\nclick label="Save"\n';
7+
8+
test('inspectAdReplay parses script text into actions, a line table and header metadata', () => {
9+
const manifest = inspectAdReplay(SCRIPT);
10+
11+
assert.deepEqual(
12+
manifest.actions.map((action) => action.command),
13+
['open', 'click'],
14+
);
15+
assert.deepEqual(manifest.actionLines, [2, 3]);
16+
assert.equal(manifest.metadata.platform, 'ios');
17+
});
18+
19+
/**
20+
* ADR 0012's `--from`/`--plan-digest` resume quotes a digest back from a divergence report, so the
21+
* digest for a given script must not move. #1802 changed only HOW the script reaches the daemon
22+
* (a replay script source bundle instead of a path it opened itself); the digest is computed over
23+
* the same text and stays byte-identical, which is what keeps a resume issued before the change
24+
* valid after it.
25+
*/
26+
test('inspectAdReplay pins the plan digest for a known script', () => {
27+
assert.equal(
28+
inspectAdReplay(SCRIPT).planDigest,
29+
inspectAdReplay(SCRIPT, { platform: 'ios' }).planDigest,
30+
);
31+
assert.equal(
32+
inspectAdReplay(SCRIPT).planDigest,
33+
'0641236777b11822d90d446022965629ec99ac2ba1af2d5c637f06dd684fe695',
34+
);
35+
});
36+
37+
test('inspectAdReplay rejects a legacy JSON replay payload', () => {
38+
assert.throws(
39+
() => inspectAdReplay('[{"command":"open"}]'),
40+
(error: unknown) =>
41+
error instanceof AppError &&
42+
error.code === 'INVALID_ARGS' &&
43+
error.message.includes('JSON replay payloads are no longer supported'),
44+
);
45+
});

packages/ad-replay/src/internal/__tests__/step-loop.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay';
1111
* terminal-close suppression) is engine-private — never re-exported by the
1212
* façade (`packages/ad-replay/src/index.ts`) — so these tests exercise it
1313
* only through `runAdReplay` itself, the same way the daemon's own
14-
* `session-replay-runtime.ts` (`runReplayScriptFile`) does. The equivalent
15-
* daemon-level assertions (full `SessionStore`/`runReplayScriptFile` round
14+
* `session-replay-runtime.ts` (`runReplayScriptSource`) does. The equivalent
15+
* daemon-level assertions (full `SessionStore`/`runReplayScriptSource` round
1616
* trip, including the `--keep-session` live-session postcondition) live in
1717
* `src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts`
1818
* (renamed from `session-replay-terminal-lifecycle.test.ts` by the #1555

packages/ad-replay/src/internal/inspect.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import fs from 'node:fs';
21
import { AppError } from '@agent-device/kernel/errors';
32
import type { SessionAction } from '@agent-device/contracts/session';
43
import {
@@ -12,7 +11,7 @@ import { resolveReplayEntryIndex, type PendingRecordAndHeal } from './resume.ts'
1211

1312
/**
1413
* #1478 P5 stage C2b: the read-only `.ad` inspection façade. Moved out of
15-
* `session-replay-runtime.ts`'s old `parseReplayScript` (the fs read + the
14+
* `session-replay-runtime.ts`'s old `parseReplayScript` (the
1615
* legacy-JSON-payload rejection it guarded) plus the `parseReplayInput`
1716
* composition (`src/compat/replay-input.ts`) it fed into — this is the same
1817
* `parseReplayScriptDetailed` + `readReplayScriptMetadata` pair
@@ -50,12 +49,14 @@ export type AdReplayManifest = Readonly<{
5049
export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string }>;
5150

5251
/**
53-
* Reads `sourcePath` once and returns its parsed actions/line table, header
54-
* metadata, plan digest, and resume-index resolver. Throws
52+
* Parses one `.ad` script's TEXT and returns its actions/line table, header
53+
* metadata, plan digest, and resume-index resolver. Takes the script itself,
54+
* never a path: #1802 made the CALLER read every script file a replay run
55+
* needs, so nothing below this façade opens a file. Throws
5556
* `AppError('INVALID_ARGS', …)` for the one source format `.ad` replay no
5657
* longer accepts — a legacy JSON replay payload — matching the daemon's
5758
* prior explicit rejection exactly. Callers do not need to check for this
58-
* case separately: `runReplayScriptFile`'s top-level catch (`asAppError`)
59+
* case separately: `runReplayScriptSource`'s top-level catch (`asAppError`)
5960
* maps a thrown `AppError` straight to the same `errorResponse` the old
6061
* explicit branch built, so this is not a behavior change, only where the
6162
* check lives.
@@ -67,10 +68,9 @@ export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string
6768
* `open` wins; absent that, the `context platform=`/`target=` header line.
6869
*/
6970
export function inspectAdReplay(
70-
sourcePath: string,
71+
script: string,
7172
digestFlags?: AdReplayDigestFlags,
7273
): AdReplayManifest {
73-
const script = fs.readFileSync(sourcePath, 'utf8');
7474
const firstNonWhitespace = script.trimStart()[0];
7575
if (firstNonWhitespace === '{' || firstNonWhitespace === '[') {
7676
throw new AppError(

packages/ad-replay/src/internal/step-loop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import type {
7171
*
7272
* #1478 P5 follow-up (one daemon-owned artifact ledger): artifact-path
7373
* accumulation used to be DOUBLE-WRITTEN — `dispatchStep` added each step's
74-
* entries to the daemon's own `Set` (`runReplayScriptFile`'s, read by its
74+
* entries to the daemon's own `Set` (`runReplayScriptSource`'s, read by its
7575
* catch block so a mid-loop throw still reports what was collected) AND
7676
* returned them for this loop to add to a second `Set` of its own. Two
7777
* mutable collections, kept in sync by hand, with no single owner. The

packages/contracts/src/cli-flags.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import type {
2020
} from './remote-config-fields.ts';
2121
import type { ScreenshotRequestFlags } from './screenshot.ts';
2222
import type { RecordingScope } from './recording-scope.ts';
23+
import type { ReplayRequestFields } from './replay-request-fields.ts';
2324

2425
export type CliFlags = CloudProviderProfileFields &
2526
RemoteConfigMetroOptions &
26-
ScreenshotRequestFlags & {
27+
ScreenshotRequestFlags &
28+
ReplayRequestFields & {
2729
json: boolean;
2830
config?: string;
2931
remoteConfig?: string;
@@ -139,23 +141,9 @@ export type CliFlags = CloudProviderProfileFields &
139141
record?: boolean;
140142
retainPaths?: boolean;
141143
retentionMs?: number;
142-
replayUpdate?: boolean;
143144
replayMaestro?: boolean;
144-
replayEnv?: string[];
145-
replayShellEnv?: Record<string, string>;
146-
replayFrom?: number;
147-
replayPlanDigest?: string;
148-
/** Replay: leave the session active by suppressing an authored terminal close in native .ad. */
149-
replayKeepSession?: boolean;
150-
failFast?: boolean;
151-
timeoutMs?: number;
152-
retries?: number;
153-
recordVideo?: boolean;
154-
artifactsDir?: string;
155145
reporter?: string[];
156146
reportJunit?: string;
157-
shardAll?: number;
158-
shardSplit?: number;
159147
steps?: string;
160148
stepsFile?: string;
161149
findFirst?: boolean;

packages/contracts/src/client-request.ts

Lines changed: 43 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -15,62 +15,51 @@ import type {
1515
SessionRuntimeHints,
1616
} from '@agent-device/kernel/contracts';
1717
import type { DaemonBatchStep } from './batch-step.ts';
18+
import type { ReplayRequestFields } from './replay-request-fields.ts';
1819
import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts';
1920

20-
export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
21-
positionals?: string[];
22-
kind?: string;
23-
out?: string;
24-
artifact?: string;
25-
dsym?: string;
26-
searchPath?: string;
27-
interactiveOnly?: boolean;
28-
depth?: number;
29-
scope?: string;
30-
raw?: boolean;
31-
customActions?: boolean;
32-
forceFull?: boolean;
33-
count?: number;
34-
fps?: number;
35-
recordingScope?: RecordingScope;
36-
quality?: RecordingExportQuality;
37-
hideTouches?: boolean;
38-
intervalMs?: number;
39-
delayMs?: number;
40-
durationMs?: number;
41-
holdMs?: number;
42-
jitterPx?: number;
43-
pixels?: number;
44-
doubleTap?: boolean;
45-
verify?: boolean;
46-
settle?: boolean;
47-
settleQuietMs?: number;
48-
clickButton?: ClickButton;
49-
pauseMs?: number;
50-
pattern?: SwipePattern;
51-
headless?: boolean;
52-
restart?: boolean;
53-
replayUpdate?: boolean;
54-
replayBackend?: string;
55-
replayEnv?: string[];
56-
replayShellEnv?: Record<string, string>;
57-
replayFrom?: number;
58-
replayPlanDigest?: string;
59-
replayKeepSession?: boolean;
60-
failFast?: boolean;
61-
timeoutMs?: number;
62-
retries?: number;
63-
recordVideo?: boolean;
64-
artifactsDir?: string;
65-
shardAll?: number;
66-
shardSplit?: number;
67-
findFirst?: boolean;
68-
findLast?: boolean;
69-
networkInclude?: NetworkIncludeMode;
70-
batchOnError?: 'stop';
71-
batchMaxSteps?: number;
72-
batchSteps?: DaemonBatchStep[];
73-
};
21+
export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> &
22+
ReplayRequestFields & {
23+
positionals?: string[];
24+
kind?: string;
25+
out?: string;
26+
artifact?: string;
27+
dsym?: string;
28+
searchPath?: string;
29+
interactiveOnly?: boolean;
30+
depth?: number;
31+
scope?: string;
32+
raw?: boolean;
33+
customActions?: boolean;
34+
forceFull?: boolean;
35+
count?: number;
36+
fps?: number;
37+
recordingScope?: RecordingScope;
38+
quality?: RecordingExportQuality;
39+
hideTouches?: boolean;
40+
intervalMs?: number;
41+
delayMs?: number;
42+
durationMs?: number;
43+
holdMs?: number;
44+
jitterPx?: number;
45+
pixels?: number;
46+
doubleTap?: boolean;
47+
verify?: boolean;
48+
settle?: boolean;
49+
settleQuietMs?: number;
50+
clickButton?: ClickButton;
51+
pauseMs?: number;
52+
pattern?: SwipePattern;
53+
headless?: boolean;
54+
restart?: boolean;
55+
replayBackend?: string;
56+
findFirst?: boolean;
57+
findLast?: boolean;
58+
networkInclude?: NetworkIncludeMode;
59+
batchOnError?: 'stop';
60+
batchMaxSteps?: number;
61+
batchSteps?: DaemonBatchStep[];
62+
};
7463

7564
export type InternalRequestOptions = AgentDeviceClientConfig &
7665
AgentDeviceSelectionOptions &

packages/contracts/src/facades/replay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type { RefFrameEffect } from '../ref-frame-effect.ts';
22
export { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON } from '../replay.ts';
33
export type {
44
ReplayCommandResult,
5+
ReplayScriptSourceBundle,
56
ReplaySuiteAttemptFailure,
67
ReplaySuiteResult,
78
ReplaySuiteTestFailed,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ReplayScriptSourceBundle } from './replay.ts';
2+
3+
/**
4+
* The replay/test request vocabulary, declared once.
5+
*
6+
* `CliFlags` and `CommandExecutionOptions` are two views of the same request — the flags a CLI
7+
* invocation parses and the options a programmatic call passes — and every replay/suite field
8+
* appears in both. Stating them here keeps the two views from drifting field by field; each view
9+
* adds only what is genuinely its own (`replayMaestro` and the reporter flags are CLI-side,
10+
* `replayBackend` is the resolved engine the client sends).
11+
*/
12+
export type ReplayRequestFields = {
13+
replayUpdate?: boolean;
14+
replayEnv?: string[];
15+
replayShellEnv?: Record<string, string>;
16+
/**
17+
* #1802: the caller-read script text `replay` executes. The daemon never opens a caller path,
18+
* so this is the ONLY source a replay run reads.
19+
*/
20+
replayScriptSource?: ReplayScriptSourceBundle;
21+
/** #1802: `test`'s caller-side discovery result — one bundle per discovered source, in run order. */
22+
replayScriptSources?: ReplayScriptSourceBundle[];
23+
replayFrom?: number;
24+
replayPlanDigest?: string;
25+
/** Replay: leave the session active by suppressing an authored terminal close in native .ad. */
26+
replayKeepSession?: boolean;
27+
failFast?: boolean;
28+
timeoutMs?: number;
29+
retries?: number;
30+
recordVideo?: boolean;
31+
artifactsDir?: string;
32+
shardAll?: number;
33+
shardSplit?: number;
34+
};

packages/contracts/src/replay.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,25 @@ export type ReplaySuiteResult = {
141141
tests: ReplaySuiteTestResult[];
142142
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
143143
};
144+
145+
/**
146+
* A **replay script source bundle**: every script file one replay run needs,
147+
* read and resolved by the CALLER and shipped with the request.
148+
*
149+
* `replay <path>` used to send only the path, which the daemon then opened on
150+
* ITS filesystem. That works only while caller and daemon share a disk; against
151+
* a remote daemon it fails with `ENOENT` on a path the caller can read (#1802).
152+
* The bundle removes the class: the daemon never resolves a caller path, so a
153+
* local run and a remote run read exactly the same bytes.
154+
*
155+
* `entry` is the caller-resolved absolute path of the script that was invoked —
156+
* it is also the display path every error, line reference, and
157+
* `actionSourcePaths` entry is stated in, and it is always a key of `files`.
158+
* `files` maps each caller-resolved absolute path to that file's text: one
159+
* entry for a native `.ad` script, plus one per transitively included flow for
160+
* Maestro YAML `runFlow`.
161+
*/
162+
export type ReplayScriptSourceBundle = Readonly<{
163+
entry: string;
164+
files: Readonly<Record<string, string>>;
165+
}>;

0 commit comments

Comments
 (0)