Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,11 @@ task touches:
- Destination guard: portable selector-targeted `wait` near the end of an open-to-destination script
that confirms a landmark on the ready destination screen before replay hands the live session to
its caller.
- Replay script source bundle: every script file one `replay`/`test` run needs, read and resolved by
the CALLER and shipped inside the request — an entry display path plus a resolved-path-to-text map
covering the `.ad` script or the Maestro flow and its `runFlow` includes. The daemon executes only
what the bundle carries and resolves no caller path, so a local run and a run against a remote
daemon read identical bytes (#1802). Avoid: script upload, flow payload.
- Screen-recording facet: runtime facet that starts platform screen/video capture and returns a live
handle plus its durable descriptor. It is distinct from script recording.
- Live resource handle: process-local authority to finish or forcibly dispose active app-log,
Expand Down
45 changes: 45 additions & 0 deletions packages/ad-replay/src/internal/__tests__/inspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { inspectAdReplay } from '../inspect.ts';

const SCRIPT = 'context platform=ios\nopen "Demo"\nclick label="Save"\n';

test('inspectAdReplay parses script text into actions, a line table and header metadata', () => {
const manifest = inspectAdReplay(SCRIPT);

assert.deepEqual(
manifest.actions.map((action) => action.command),
['open', 'click'],
);
assert.deepEqual(manifest.actionLines, [2, 3]);
assert.equal(manifest.metadata.platform, 'ios');
});

/**
* ADR 0012's `--from`/`--plan-digest` resume quotes a digest back from a divergence report, so the
* digest for a given script must not move. #1802 changed only HOW the script reaches the daemon
* (a replay script source bundle instead of a path it opened itself); the digest is computed over
* the same text and stays byte-identical, which is what keeps a resume issued before the change
* valid after it.
*/
test('inspectAdReplay pins the plan digest for a known script', () => {
assert.equal(
inspectAdReplay(SCRIPT).planDigest,
inspectAdReplay(SCRIPT, { platform: 'ios' }).planDigest,
);
assert.equal(
inspectAdReplay(SCRIPT).planDigest,
'0641236777b11822d90d446022965629ec99ac2ba1af2d5c637f06dd684fe695',
);
});

test('inspectAdReplay rejects a legacy JSON replay payload', () => {
assert.throws(
() => inspectAdReplay('[{"command":"open"}]'),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('JSON replay payloads are no longer supported'),
);
});
4 changes: 2 additions & 2 deletions packages/ad-replay/src/internal/__tests__/step-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay';
* terminal-close suppression) is engine-private — never re-exported by the
* façade (`packages/ad-replay/src/index.ts`) — so these tests exercise it
* only through `runAdReplay` itself, the same way the daemon's own
* `session-replay-runtime.ts` (`runReplayScriptFile`) does. The equivalent
* daemon-level assertions (full `SessionStore`/`runReplayScriptFile` round
* `session-replay-runtime.ts` (`runReplayScriptSource`) does. The equivalent
* daemon-level assertions (full `SessionStore`/`runReplayScriptSource` round
* trip, including the `--keep-session` live-session postcondition) live in
* `src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts`
* (renamed from `session-replay-terminal-lifecycle.test.ts` by the #1555
Expand Down
14 changes: 7 additions & 7 deletions packages/ad-replay/src/internal/inspect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fs from 'node:fs';
import { AppError } from '@agent-device/kernel/errors';
import type { SessionAction } from '@agent-device/contracts/session';
import {
Expand All @@ -12,7 +11,7 @@ import { resolveReplayEntryIndex, type PendingRecordAndHeal } from './resume.ts'

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

/**
* Reads `sourcePath` once and returns its parsed actions/line table, header
* metadata, plan digest, and resume-index resolver. Throws
* Parses one `.ad` script's TEXT and returns its actions/line table, header
* metadata, plan digest, and resume-index resolver. Takes the script itself,
* never a path: #1802 made the CALLER read every script file a replay run
* needs, so nothing below this façade opens a file. Throws
* `AppError('INVALID_ARGS', …)` for the one source format `.ad` replay no
* longer accepts — a legacy JSON replay payload — matching the daemon's
* prior explicit rejection exactly. Callers do not need to check for this
* case separately: `runReplayScriptFile`'s top-level catch (`asAppError`)
* case separately: `runReplayScriptSource`'s top-level catch (`asAppError`)
* maps a thrown `AppError` straight to the same `errorResponse` the old
* explicit branch built, so this is not a behavior change, only where the
* check lives.
Expand All @@ -67,10 +68,9 @@ export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string
* `open` wins; absent that, the `context platform=`/`target=` header line.
*/
export function inspectAdReplay(
sourcePath: string,
script: string,
digestFlags?: AdReplayDigestFlags,
): AdReplayManifest {
const script = fs.readFileSync(sourcePath, 'utf8');
const firstNonWhitespace = script.trimStart()[0];
if (firstNonWhitespace === '{' || firstNonWhitespace === '[') {
throw new AppError(
Expand Down
2 changes: 1 addition & 1 deletion packages/ad-replay/src/internal/step-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import type {
*
* #1478 P5 follow-up (one daemon-owned artifact ledger): artifact-path
* accumulation used to be DOUBLE-WRITTEN — `dispatchStep` added each step's
* entries to the daemon's own `Set` (`runReplayScriptFile`'s, read by its
* entries to the daemon's own `Set` (`runReplayScriptSource`'s, read by its
* catch block so a mid-loop throw still reports what was collected) AND
* returned them for this loop to add to a second `Set` of its own. Two
* mutable collections, kept in sync by hand, with no single owner. The
Expand Down
18 changes: 3 additions & 15 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import type {
} from './remote-config-fields.ts';
import type { ScreenshotRequestFlags } from './screenshot.ts';
import type { RecordingScope } from './recording-scope.ts';
import type { ReplayRequestFields } from './replay-request-fields.ts';

export type CliFlags = CloudProviderProfileFields &
RemoteConfigMetroOptions &
ScreenshotRequestFlags & {
ScreenshotRequestFlags &
ReplayRequestFields & {
json: boolean;
config?: string;
remoteConfig?: string;
Expand Down Expand Up @@ -139,23 +141,9 @@ export type CliFlags = CloudProviderProfileFields &
record?: boolean;
retainPaths?: boolean;
retentionMs?: number;
replayUpdate?: boolean;
replayMaestro?: boolean;
replayEnv?: string[];
replayShellEnv?: Record<string, string>;
replayFrom?: number;
replayPlanDigest?: string;
/** Replay: leave the session active by suppressing an authored terminal close in native .ad. */
replayKeepSession?: boolean;
failFast?: boolean;
timeoutMs?: number;
retries?: number;
recordVideo?: boolean;
artifactsDir?: string;
reporter?: string[];
reportJunit?: string;
shardAll?: number;
shardSplit?: number;
steps?: string;
stepsFile?: string;
findFirst?: boolean;
Expand Down
97 changes: 43 additions & 54 deletions packages/contracts/src/client-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,62 +15,51 @@ import type {
SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import type { DaemonBatchStep } from './batch-step.ts';
import type { ReplayRequestFields } from './replay-request-fields.ts';
import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts';

export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
positionals?: string[];
kind?: string;
out?: string;
artifact?: string;
dsym?: string;
searchPath?: string;
interactiveOnly?: boolean;
depth?: number;
scope?: string;
raw?: boolean;
customActions?: boolean;
forceFull?: boolean;
count?: number;
fps?: number;
recordingScope?: RecordingScope;
quality?: RecordingExportQuality;
hideTouches?: boolean;
intervalMs?: number;
delayMs?: number;
durationMs?: number;
holdMs?: number;
jitterPx?: number;
pixels?: number;
doubleTap?: boolean;
verify?: boolean;
settle?: boolean;
settleQuietMs?: number;
clickButton?: ClickButton;
pauseMs?: number;
pattern?: SwipePattern;
headless?: boolean;
restart?: boolean;
replayUpdate?: boolean;
replayBackend?: string;
replayEnv?: string[];
replayShellEnv?: Record<string, string>;
replayFrom?: number;
replayPlanDigest?: string;
replayKeepSession?: boolean;
failFast?: boolean;
timeoutMs?: number;
retries?: number;
recordVideo?: boolean;
artifactsDir?: string;
shardAll?: number;
shardSplit?: number;
findFirst?: boolean;
findLast?: boolean;
networkInclude?: NetworkIncludeMode;
batchOnError?: 'stop';
batchMaxSteps?: number;
batchSteps?: DaemonBatchStep[];
};
export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> &
ReplayRequestFields & {
positionals?: string[];
kind?: string;
out?: string;
artifact?: string;
dsym?: string;
searchPath?: string;
interactiveOnly?: boolean;
depth?: number;
scope?: string;
raw?: boolean;
customActions?: boolean;
forceFull?: boolean;
count?: number;
fps?: number;
recordingScope?: RecordingScope;
quality?: RecordingExportQuality;
hideTouches?: boolean;
intervalMs?: number;
delayMs?: number;
durationMs?: number;
holdMs?: number;
jitterPx?: number;
pixels?: number;
doubleTap?: boolean;
verify?: boolean;
settle?: boolean;
settleQuietMs?: number;
clickButton?: ClickButton;
pauseMs?: number;
pattern?: SwipePattern;
headless?: boolean;
restart?: boolean;
replayBackend?: string;
findFirst?: boolean;
findLast?: boolean;
networkInclude?: NetworkIncludeMode;
batchOnError?: 'stop';
batchMaxSteps?: number;
batchSteps?: DaemonBatchStep[];
};

export type InternalRequestOptions = AgentDeviceClientConfig &
AgentDeviceSelectionOptions &
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { RefFrameEffect } from '../ref-frame-effect.ts';
export { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON } from '../replay.ts';
export type {
ReplayCommandResult,
ReplayScriptSourceBundle,
ReplaySuiteAttemptFailure,
ReplaySuiteResult,
ReplaySuiteTestFailed,
Expand Down
34 changes: 34 additions & 0 deletions packages/contracts/src/replay-request-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ReplayScriptSourceBundle } from './replay.ts';

/**
* The replay/test request vocabulary, declared once.
*
* `CliFlags` and `CommandExecutionOptions` are two views of the same request — the flags a CLI
* invocation parses and the options a programmatic call passes — and every replay/suite field
* appears in both. Stating them here keeps the two views from drifting field by field; each view
* adds only what is genuinely its own (`replayMaestro` and the reporter flags are CLI-side,
* `replayBackend` is the resolved engine the client sends).
*/
export type ReplayRequestFields = {
replayUpdate?: boolean;
replayEnv?: string[];
replayShellEnv?: Record<string, string>;
/**
* #1802: the caller-read script text `replay` executes. The daemon never opens a caller path,
* so this is the ONLY source a replay run reads.
*/
replayScriptSource?: ReplayScriptSourceBundle;
/** #1802: `test`'s caller-side discovery result — one bundle per discovered source, in run order. */
replayScriptSources?: ReplayScriptSourceBundle[];
replayFrom?: number;
replayPlanDigest?: string;
/** Replay: leave the session active by suppressing an authored terminal close in native .ad. */
replayKeepSession?: boolean;
failFast?: boolean;
timeoutMs?: number;
retries?: number;
recordVideo?: boolean;
artifactsDir?: string;
shardAll?: number;
shardSplit?: number;
};
22 changes: 22 additions & 0 deletions packages/contracts/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,25 @@ export type ReplaySuiteResult = {
tests: ReplaySuiteTestResult[];
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
};

/**
* A **replay script source bundle**: every script file one replay run needs,
* read and resolved by the CALLER and shipped with the request.
*
* `replay <path>` used to send only the path, which the daemon then opened on
* ITS filesystem. That works only while caller and daemon share a disk; against
* a remote daemon it fails with `ENOENT` on a path the caller can read (#1802).
* The bundle removes the class: the daemon never resolves a caller path, so a
* local run and a remote run read exactly the same bytes.
*
* `entry` is the caller-resolved absolute path of the script that was invoked —
* it is also the display path every error, line reference, and
* `actionSourcePaths` entry is stated in, and it is always a key of `files`.
* `files` maps each caller-resolved absolute path to that file's text: one
* entry for a native `.ad` script, plus one per transitively included flow for
* Maestro YAML `runFlow`.
*/
export type ReplayScriptSourceBundle = Readonly<{
entry: string;
files: Readonly<Record<string, string>>;
}>;
7 changes: 7 additions & 0 deletions packages/maestro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export {
type MaestroFlow,
} from './internal/facade-execution.ts';

export type { MaestroSourceReader } from './internal/program-loader.ts';

export {
collectMaestroFlowSources,
type MaestroOptionalSourceReader,
} from './internal/source-closure.ts';

export {
exportReplayActionsToMaestro,
MAESTRO_SELECTOR_PROJECTION,
Expand Down
Loading
Loading