From 780600051348fa3fc34535e23961461faa04ba14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 11:57:09 +0200 Subject: [PATCH 1/4] refactor: extract daemon replay behind application facade --- fallow-baselines/health.json | 8 +- oxlint.config.ts | 4 +- .../__tests__/test-file-size-ratchet.test.ts | 8 +- .../layering/architecture-ownership.test.ts | 14 ++ scripts/layering/architecture-ownership.ts | 22 ++ scripts/layering/daemon-modularity.test.ts | 40 +++- scripts/layering/daemon-modularity.ts | 2 +- .../android-ui-hierarchy-fixtures.ts | 2 +- ...ssion-test-reporter-values-maestro.test.ts | 4 +- .../replay-coordinator-ownership.test.ts | 209 +++++++++++++++--- .../request-router-repair-expired.test.ts | 2 +- .../interaction-ios-tap-outcome.test.ts | 4 +- .../__tests__/session-command-replay.test.ts | 2 +- .../handlers/__tests__/session-replay.test.ts | 92 -------- src/daemon/handlers/session-replay-command.ts | 117 ++++++++++ .../handlers/session-replay-video-owner.ts | 70 ++++++ src/daemon/handlers/session-replay.ts | 34 --- src/daemon/handlers/session-runtime.ts | 8 +- src/daemon/handlers/session.ts | 44 +--- src/daemon/internal-observation.ts | 11 +- .../__tests__/application.test.ts} | 63 +++--- .../__tests__/replay-command-fixture.ts | 25 +++ .../session-replay-runtime.fixtures.ts | 0 src/daemon/replay/index.ts | 8 + .../session-replay-action-runtime.test.ts | 6 +- .../session-replay-builtin-vars.test.ts | 0 ...sion-replay-dispatch-selector-miss.test.ts | 34 +-- ...eplay-divergence-android-occlusion.test.ts | 20 +- ...sion-replay-divergence-publication.test.ts | 18 +- .../session-replay-divergence.test.ts | 20 +- ...on-replay-maestro-error-projection.test.ts | 20 +- .../session-replay-maestro-failure.test.ts | 28 +-- .../session-replay-maestro-label.test.ts | 2 +- ...ion-replay-maestro-session-address.test.ts | 20 +- .../session-replay-repair-acceptance.test.ts | 32 +-- .../session-replay-repair-empty-tail.test.ts | 56 ++--- .../session-replay-repair-hint.test.ts | 0 .../session-replay-repair-loop.test.ts | 69 +++--- ...ion-replay-repair-record-exclusion.test.ts | 46 ++-- .../session-replay-repair-transaction.test.ts | 72 +++--- .../session-replay-repair.fixtures.ts | 10 +- .../__tests__/session-replay-resume.test.ts | 6 +- ...ion-replay-runtime-artifact-ledger.test.ts | 16 +- .../session-replay-runtime-artifacts.test.ts | 2 +- .../session-replay-runtime-binding.test.ts | 28 +-- ...on-replay-runtime-failure-response.test.ts | 30 +-- .../session-replay-runtime-failure.test.ts | 44 ++-- ...ession-replay-runtime-keep-session.test.ts | 32 +-- .../session-replay-runtime-maestro.test.ts | 114 +++++----- .../session-replay-runtime-plan.test.ts | 64 +++--- .../__tests__/session-replay-runtime.test.ts | 66 +++--- .../session-replay-script-source.test.ts | 32 +-- .../session-replay-selector-routes.test.ts | 25 ++- ...n-replay-target-classification-fixtures.ts | 2 +- ...ssion-replay-target-classification.test.ts | 4 +- .../session-replay-target-guard.test.ts | 6 +- .../session-replay-target-token.test.ts | 0 ...replay-target-verification-runtime.test.ts | 76 +++---- .../__tests__/session-test-fail-fast.test.ts | 6 +- .../session-test-infrastructure.test.ts | 2 +- .../__tests__/session-test-runner.test.ts | 8 +- .../session-test-source-discovery.test.ts | 4 +- ...on-test-suite-command-cancellation.test.ts | 2 +- ...ion-test-suite-command-flag-policy.test.ts | 198 +++++++++-------- ...on-test-suite-command-nested-flags.test.ts | 4 +- .../session-test-suite-command-video.test.ts | 75 ++++--- .../session-test-suite-infrastructure.test.ts | 8 +- .../__tests__/session-test-suite.fixtures.ts | 6 +- .../__tests__/session-test-suite.test.ts | 15 +- src/daemon/replay/internal/command-types.ts | 47 ++++ .../internal/native-command.ts} | 40 ++-- .../session-replay-action-runtime.ts | 10 +- .../session-replay-dispatch-narrowing.ts | 2 +- .../session-replay-divergence-publication.ts | 8 +- .../internal}/session-replay-divergence.ts | 20 +- .../internal}/session-replay-heal.ts | 0 .../session-replay-maestro-failure.ts | 8 +- .../session-replay-maestro-observer.ts | 0 .../session-replay-maestro-response.ts | 60 ++--- .../session-replay-maestro-runtime.ts | 77 ++++--- .../internal}/session-replay-repair-hint.ts | 2 +- .../internal}/session-replay-report-action.ts | 0 .../internal}/session-replay-resume.ts | 4 +- .../session-replay-runtime-artifacts.ts | 2 +- .../session-replay-runtime-engine-adapter.ts | 27 ++- ...session-replay-runtime-failure-response.ts | 4 +- .../session-replay-runtime-failure.ts | 10 +- .../internal}/session-replay-runtime-plan.ts | 55 ++--- .../session-replay-runtime-session.ts | 23 +- .../session-replay-suggestion-ranking.ts | 0 .../session-replay-target-classification.ts | 6 +- .../internal}/session-replay-target-token.ts | 0 .../session-replay-target-verification.ts | 26 +-- .../internal}/session-replay-test-policy.ts | 0 .../internal}/session-replay-trace.ts | 0 .../internal}/session-replay-vars.ts | 2 +- .../session-replay-video-recording.ts | 81 +++---- .../internal}/session-test-infrastructure.ts | 4 +- .../internal}/session-test-outcome.ts | 2 +- .../internal}/session-test-shard-devices.ts | 2 +- .../session-test-source-discovery.ts | 2 +- .../internal/test-command.ts} | 152 +++---------- src/daemon/session-action-recorder.ts | 2 +- src/daemon/session-replay-coordinator.ts | 18 +- 104 files changed, 1536 insertions(+), 1281 deletions(-) delete mode 100644 src/daemon/handlers/__tests__/session-replay.test.ts create mode 100644 src/daemon/handlers/session-replay-command.ts create mode 100644 src/daemon/handlers/session-replay-video-owner.ts delete mode 100644 src/daemon/handlers/session-replay.ts rename src/daemon/{handlers/__tests__/session-replay-application.test.ts => replay/__tests__/application.test.ts} (85%) create mode 100644 src/daemon/replay/__tests__/replay-command-fixture.ts rename src/daemon/{handlers => replay}/__tests__/session-replay-runtime.fixtures.ts (100%) create mode 100644 src/daemon/replay/index.ts rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-action-runtime.test.ts (89%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-builtin-vars.test.ts (100%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-dispatch-selector-miss.test.ts (91%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-divergence-android-occlusion.test.ts (82%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-divergence-publication.test.ts (94%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-divergence.test.ts (98%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-maestro-error-projection.test.ts (79%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-maestro-failure.test.ts (93%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-maestro-label.test.ts (94%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-maestro-session-address.test.ts (80%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-acceptance.test.ts (87%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-empty-tail.test.ts (94%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-hint.test.ts (100%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-loop.test.ts (92%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-record-exclusion.test.ts (90%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair-transaction.test.ts (95%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-repair.fixtures.ts (91%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-resume.test.ts (98%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-artifact-ledger.test.ts (90%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-artifacts.test.ts (95%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-binding.test.ts (78%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-failure-response.test.ts (91%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-failure.test.ts (93%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-keep-session.test.ts (88%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-maestro.test.ts (91%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime-plan.test.ts (92%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-runtime.test.ts (92%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-script-source.test.ts (86%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-selector-routes.test.ts (72%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-target-classification-fixtures.ts (96%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-target-classification.test.ts (99%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-target-guard.test.ts (97%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-target-token.test.ts (100%) rename src/daemon/{handlers => replay/internal}/__tests__/session-replay-target-verification-runtime.test.ts (95%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-fail-fast.test.ts (83%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-infrastructure.test.ts (98%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-runner.test.ts (98%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-source-discovery.test.ts (95%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite-command-cancellation.test.ts (96%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite-command-flag-policy.test.ts (59%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite-command-nested-flags.test.ts (94%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite-command-video.test.ts (84%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite-infrastructure.test.ts (91%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite.fixtures.ts (70%) rename src/daemon/{handlers => replay/internal}/__tests__/session-test-suite.test.ts (98%) create mode 100644 src/daemon/replay/internal/command-types.ts rename src/daemon/{handlers/session-replay-runtime.ts => replay/internal/native-command.ts} (91%) rename src/daemon/{handlers => replay/internal}/session-replay-action-runtime.ts (96%) rename src/daemon/{handlers => replay/internal}/session-replay-dispatch-narrowing.ts (98%) rename src/daemon/{handlers => replay/internal}/session-replay-divergence-publication.ts (96%) rename src/daemon/{handlers => replay/internal}/session-replay-divergence.ts (97%) rename src/daemon/{handlers => replay/internal}/session-replay-heal.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-maestro-failure.ts (96%) rename src/daemon/{handlers => replay/internal}/session-replay-maestro-observer.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-maestro-response.ts (65%) rename src/daemon/{handlers => replay/internal}/session-replay-maestro-runtime.ts (87%) rename src/daemon/{handlers => replay/internal}/session-replay-repair-hint.ts (99%) rename src/daemon/{handlers => replay/internal}/session-replay-report-action.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-resume.ts (97%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-artifacts.ts (97%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-engine-adapter.ts (95%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-failure-response.ts (96%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-failure.ts (93%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-plan.ts (81%) rename src/daemon/{handlers => replay/internal}/session-replay-runtime-session.ts (93%) rename src/daemon/{handlers => replay/internal}/session-replay-suggestion-ranking.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-target-classification.ts (98%) rename src/daemon/{handlers => replay/internal}/session-replay-target-token.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-target-verification.ts (96%) rename src/daemon/{handlers => replay/internal}/session-replay-test-policy.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-trace.ts (100%) rename src/daemon/{handlers => replay/internal}/session-replay-vars.ts (97%) rename src/daemon/{handlers => replay/internal}/session-replay-video-recording.ts (69%) rename src/daemon/{handlers => replay/internal}/session-test-infrastructure.ts (93%) rename src/daemon/{handlers => replay/internal}/session-test-outcome.ts (98%) rename src/daemon/{handlers => replay/internal}/session-test-shard-devices.ts (98%) rename src/daemon/{handlers => replay/internal}/session-test-source-discovery.ts (97%) rename src/daemon/{handlers/session-test-suite-command.ts => replay/internal/test-command.ts} (69%) diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index cf888a9e0e..082c1d1a78 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -204,7 +204,7 @@ "count": 1 } }, - "src/daemon/handlers/__tests__/session-test-runner.test.ts": { + "src/daemon/replay/internal/__tests__/session-test-runner.test.ts": { "crap_high": { "count": 1 }, @@ -559,7 +559,7 @@ "src/daemon/client/daemon-client.ts:high impact", "src/cli/parser/args.ts:high impact", "src/daemon/lease-context.ts:high impact", - "src/daemon/handlers/session-replay-heal.ts:high impact", + "src/daemon/replay/internal/session-replay-heal.ts:high impact", "src/utils/output.ts:high impact", "src/daemon/context.ts:high impact", "src/daemon/handlers/session.ts:complexity", @@ -590,7 +590,7 @@ "packages/platform-android/src/settings.ts:complexity", "src/snapshot/snapshot-presentation/text-surface.ts:high impact", "src/daemon/handlers/session-test-sharding.ts:high impact", - "src/daemon/handlers/session-replay-runtime.ts:complexity", + "src/daemon/replay/internal/native-command.ts:complexity", "packages/platform-apple/src/core/debug-symbols/utils.ts:high impact", "packages/platform-linux/src/snapshot.ts:high impact", "src/core/interaction-targeting.ts:high impact", @@ -605,7 +605,7 @@ "src/utils/keyed-lock.ts:high impact", "src/daemon/request-progress-protocol.ts:high impact", "src/cli/replay-test/reporters/format.ts:high impact", - "src/daemon/handlers/session-test-infrastructure.ts:high impact", + "src/daemon/replay/internal/session-test-infrastructure.ts:high impact", "src/daemon/handlers/session-test-artifacts.ts:high impact", "packages/platform-android/src/app-parsers.ts:high impact", "src/daemon/server/http-server.ts:complexity", diff --git a/oxlint.config.ts b/oxlint.config.ts index b1eb981ec0..948be89c53 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -133,7 +133,9 @@ export default defineConfig({ }, }, { - files: ['src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts'], + files: [ + 'src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts', + ], rules: { 'unicorn/prefer-string-raw': 'off', }, diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index c3be9adc2d..a89abb163f 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -36,7 +36,7 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2138, 'src/commands/interaction/runtime/settle.test.ts': 2359, - 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 1963, + 'src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts': 1963, 'packages/platform-apple/src/runner/__tests__/runner-session.test.ts': 1957, 'src/utils/__tests__/daemon-client.test.ts': 1873, 'src/utils/__tests__/output.test.ts': 1861, @@ -51,10 +51,10 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/daemon/handlers/__tests__/find.test.ts': 1199, 'packages/platform-apple/src/core/__tests__/perf.test.ts': 1222, 'src/mcp/__tests__/command-tools.test.ts': 1216, - 'src/daemon/handlers/__tests__/session-replay-divergence.test.ts': 1136, + 'src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts': 1136, 'packages/platform-apple/src/core/__tests__/apps.test.ts': 1146, - 'src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts': 1202, - 'src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts': 1182, + 'src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts': 1202, + 'src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts': 1182, 'src/__tests__/client-metro.test.ts': 1105, 'src/__tests__/cli-network.test.ts': 1092, 'packages/platform-android/src/__tests__/snapshot-helper.test.ts': 1002, diff --git a/scripts/layering/architecture-ownership.test.ts b/scripts/layering/architecture-ownership.test.ts index 4c89a9914d..692058a5f1 100644 --- a/scripts/layering/architecture-ownership.test.ts +++ b/scripts/layering/architecture-ownership.test.ts @@ -41,6 +41,20 @@ test('architecture ownership roots resolve to tracked owners', () => { } }); +test('daemon replay façade pins its named command surface', () => { + const tracked = new Set(listTrackedTypeScriptFiles(repoRoot)); + + for (const declaration of ARCHITECTURE_OWNERSHIP.facades) { + assert.ok(tracked.has(declaration.root), `${declaration.name} root is not tracked`); + const source = fs.readFileSync(path.join(repoRoot, declaration.root), 'utf8'); + assert.deepEqual( + readNamedExports(source), + declaration.exports, + `${declaration.name} façade exports drifted`, + ); + } +}); + test('vocabulary roots are exported contract facades', () => { const manifest = JSON.parse( fs.readFileSync(path.join(repoRoot, 'packages/contracts/package.json'), 'utf8'), diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index fb70db4bed..97db36120a 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -27,10 +27,32 @@ export const LOGICAL_MODULE_POLICIES = [ 'packages/ad-replay/', ], }, + { + name: 'daemon-replay', + roots: ['src/daemon/replay/'], + forbiddenTargetRoots: [ + 'src/daemon/handlers/session-close.ts', + 'src/daemon/handlers/record-runtime.ts', + ], + }, ] as const satisfies readonly LogicalModulePolicy[]; +const DAEMON_REPLAY_FACADE = { + name: 'daemon-replay', + root: 'src/daemon/replay/index.ts', + exports: [ + 'ReplayCommand', + 'ReplaySession', + 'ReplayTestCommand', + 'ReplayTestVideoOwner', + 'runReplayCommand', + 'runReplayTestCommand', + ], +} as const; + export const ARCHITECTURE_OWNERSHIP = { logicalModules: LOGICAL_MODULE_POLICIES, + facades: [DAEMON_REPLAY_FACADE], vocabulary: [ { name: 'client-contract', diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 3de88062ab..dcd367b475 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -119,13 +119,13 @@ test('replay-test rejects request-global and engine-internal imports', () => { 'packages/replay-test/src/internal/scheduler.ts', [ "import { emitRequestProgress } from '../../../../src/request/progress.ts';", - "import { readReplayScriptMetadata } from '../../../../src/daemon/handlers/session-replay-runtime.ts';", + "import { readReplayScriptMetadata } from '../../../../src/daemon/replay/internal/native-command.ts';", "import { parseMaestroProgram } from '../../../../src/compat/maestro/program-ir-parser.ts';", ].join('\n'), ], ['src/request/progress.ts', 'export function emitRequestProgress() {}'], [ - 'src/daemon/handlers/session-replay-runtime.ts', + 'src/daemon/replay/internal/native-command.ts', 'export function readReplayScriptMetadata() {}', ], ['src/compat/maestro/program-ir-parser.ts', 'export function parseMaestroProgram() {}'], @@ -140,7 +140,7 @@ test('replay-test rejects request-global and engine-internal imports', () => { violations.map(({ message }) => message.replace(/;.*/, '')), [ 'replay-test must not import src/request/progress.ts', - 'replay-test must not import src/daemon/handlers/session-replay-runtime.ts', + "packages/replay-test/src/internal/scheduler.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)", 'replay-test must not import src/compat/maestro/program-ir-parser.ts', ], ); @@ -179,6 +179,40 @@ test('internal trees reject deep imports globally, including from daemon', () => assert.match(violations[0]!.message, /must not import maestro's internal tree/); }); +test('daemon replay rejects handler, sibling-owner, and engine deep edges', () => { + const edges = resolveImportEdges( + new Map([ + [ + 'src/daemon/handlers/session.ts', + "import { runReplayCommand } from '../replay/internal/native-command.ts';", + ], + [ + 'src/daemon/replay/internal/test-command.ts', + "import { handleCloseCommand } from '../../handlers/session-close.ts';", + ], + [ + 'packages/ad-replay/src/internal/step-loop.ts', + "import { runReplayCommand } from '../../../../src/daemon/replay/internal/native-command.ts';", + ], + ['src/daemon/replay/internal/native-command.ts', 'export function runReplayCommand() {}'], + ['src/daemon/handlers/session-close.ts', 'export function handleCloseCommand() {}'], + ]), + ); + + const violations = checkDaemonModularityRatchets( + [...baselineEdges(), ...edges], + baselineTypeCycleMembers(), + ); + assert.deepEqual( + violations.map(({ message }) => message.replace(/;.*/, '')), + [ + "src/daemon/handlers/session.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)", + 'daemon-replay must not import src/daemon/handlers/session-close.ts', + "packages/ad-replay/src/internal/step-loop.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)", + ], + ); +}); + test('R9 records zone ceilings and keeps engine files outside the largest component', () => { // One commands file and one engine file traded for two provider-webdriver ones, so the // total stays at the baseline and only the per-zone claims are on trial. diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 9592920b21..e8b6fb150d 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -37,7 +37,7 @@ export const TYPE_CYCLE_BASELINE = Object.values(LARGEST_TYPE_CYCLE_ZONE_CEILING const ENGINE_FILE_PREFIXES = [ 'packages/ad-replay/src/', 'packages/maestro/src/', - 'src/daemon/handlers/session-replay', + 'src/daemon/replay/internal/', 'packages/replay-test/src/', ] as const; diff --git a/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts b/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts index f887175738..2ea3992f26 100644 --- a/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts +++ b/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts @@ -121,7 +121,7 @@ export function parseUiHierarchy( * node (`status_bar_container`, `status_bar_contents`, ...), unlike a default * capture. Shared across the chrome-classification tests * (`core/__tests__/snapshot-chrome-android-statusbar.test.ts`) and the replay - * divergence route test (`daemon/handlers/__tests__/session-replay-divergence.test.ts`) + * divergence route test (`daemon/replay/internal/__tests__/session-replay-divergence.test.ts`) * so both exercise the exact same real screen through `walkNonRawAndroidFixture`. */ export const ANDROID_IME_CAPTURE_RAW_NODES: RawSnapshotNode[] = imeCapture; diff --git a/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts index be22a4ea05..e9c84c3038 100644 --- a/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts @@ -4,9 +4,9 @@ // Both are needed, and neither substitutes for the other: P3 replaced a request-global // AsyncLocalStorage with an explicit per-attempt `onStep` port, and that port has two // independent forwarding chains. The native chain is -// scheduler sink -> runReplayScriptSource -> executeReplayActions -> onStep?.(...) +// scheduler sink -> runReplayCommand -> executeReplayActions -> onStep?.(...) // and the Maestro chain is a different set of links entirely: -// scheduler sink -> runReplayScriptSource -> runTypedMaestroReplay +// scheduler sink -> runReplayCommand -> runTypedMaestroReplay // -> createMaestroReplayObserver({ onStep }) -> actionStarted -> onStep?.(...) // // An `.ad`-only reporter test stays green if any Maestro link is deleted, so `onTestStep` diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index 52afe64029..9341ddd1d4 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -10,7 +10,7 @@ import { parseSync } from 'oxc-parser'; * `ReplayCoordinator`, and a `ReplayCoordinator`/`ReplayResumeStamper` manufactured from a bare * `SessionStore` + session name is repair authority conjured by naming a session, not proof of * holding the request's own coordinator. A behavior test cannot distinguish "the one coordinator - * `runReplayScriptSource` created, threaded through" from "a second one built from the same + * `runReplayCommand` created, threaded through" from "a second one built from the same * store/name" — both produce identical wire output — so this is a structural check over the * import graph, using the same `oxc-parser` AST approach as `scripts/layering/session-state.ts`. * @@ -30,22 +30,22 @@ const COORDINATOR_MODULE = 'src/daemon/session-replay-coordinator.ts'; const TRANSACTION_MODULE = 'src/daemon/session-replay-transaction.ts'; const SESSION_STORE_MODULE = 'src/daemon/session-store.ts'; -const RUNTIME_FILE = 'src/daemon/handlers/session-replay-runtime.ts'; +const RUNTIME_FILE = 'src/daemon/replay/internal/native-command.ts'; /** The divergence-report chain: never a second `ReplayCoordinator`, never a bare `SessionStore`. */ const DIVERGENCE_CHAIN_FILES = [ - 'src/daemon/handlers/session-replay-resume.ts', - 'src/daemon/handlers/session-replay-divergence.ts', - 'src/daemon/handlers/session-replay-target-verification.ts', - 'src/daemon/handlers/session-replay-runtime-failure.ts', - 'src/daemon/handlers/session-replay-runtime-failure-response.ts', + 'src/daemon/replay/internal/session-replay-resume.ts', + 'src/daemon/replay/internal/session-replay-divergence.ts', + 'src/daemon/replay/internal/session-replay-target-verification.ts', + 'src/daemon/replay/internal/session-replay-runtime-failure.ts', + 'src/daemon/replay/internal/session-replay-runtime-failure-response.ts', ] as const; /** Files in `DIVERGENCE_CHAIN_FILES` allowed a TYPE-ONLY `session-store.ts` reference. */ const SESSION_STORE_TYPE_ONLY_ALLOWED = new Set([ - 'src/daemon/handlers/session-replay-divergence.ts', - 'src/daemon/handlers/session-replay-target-verification.ts', - 'src/daemon/handlers/session-replay-runtime-failure.ts', + 'src/daemon/replay/internal/session-replay-divergence.ts', + 'src/daemon/replay/internal/session-replay-target-verification.ts', + 'src/daemon/replay/internal/session-replay-runtime-failure.ts', ]); type ImportSite = { @@ -115,26 +115,183 @@ function importsAnyBinding(site: ImportSite, target: string): boolean { return site.target === target; } -const PRODUCTION_FILES = listProductionSourceFiles(); +type CoordinatorConstructionSite = Readonly<{ + file: string; + kind: 'call' | 'dynamic-import'; + start: number; +}>; -test('createReplayCoordinator has exactly one production call site', () => { - const callers = new Set(); - for (const file of PRODUCTION_FILES) { - for (const site of collectImportSites(file)) { - if ( - site.target === COORDINATOR_MODULE && - importsValueBinding(site, 'createReplayCoordinator') - ) { - callers.add(file); - } +function visitAst(value: unknown, visitor: (node: Record) => void): void { + if (Array.isArray(value)) { + for (const child of value) visitAst(child, visitor); + return; + } + if (!value || typeof value !== 'object') return; + const node = value as Record; + visitor(node); + for (const child of Object.values(node)) visitAst(child, visitor); +} + +function identifierName(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const node = value as { type?: unknown; name?: unknown }; + return node.type === 'Identifier' && typeof node.name === 'string' ? node.name : undefined; +} + +function coordinatorConstructionSites( + file: string, + source = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'), +): CoordinatorConstructionSite[] { + const parsed = parseSync(file, source); + const bindings = coordinatorImportBindings( + file, + parsed.program.body as unknown as readonly Record[], + ); + const sites: CoordinatorConstructionSite[] = []; + visitAst(parsed.program, (node) => { + const site = coordinatorConstructionSiteForNode(file, node, bindings); + if (site) sites.push(site); + }); + return sites; +} + +type CoordinatorImportBindings = Readonly<{ + direct: ReadonlySet; + namespace: ReadonlySet; +}>; + +function coordinatorImportBindings( + file: string, + body: readonly Record[], +): CoordinatorImportBindings { + const direct = new Set(); + const namespace = new Set(); + for (const node of body) { + if (node.type !== 'ImportDeclaration') continue; + const source = node.source as { value?: unknown }; + if (typeof source.value !== 'string') continue; + if (resolveRelativeTarget(file, source.value) !== COORDINATOR_MODULE) continue; + if (node.importKind === 'type') continue; + for (const specifier of node.specifiers as readonly Record[]) { + addCoordinatorImportBinding(specifier, direct, namespace); } } + return { direct, namespace }; +} + +function addCoordinatorImportBinding( + specifier: Record, + direct: Set, + namespace: Set, +): void { + if (specifier.type === 'ImportSpecifier') { + if (specifier.importKind === 'type') return; + if (identifierName(specifier.imported) !== 'createReplayCoordinator') return; + const localName = identifierName(specifier.local); + if (localName) direct.add(localName); + return; + } + if (specifier.type !== 'ImportNamespaceSpecifier') return; + const localName = identifierName(specifier.local); + if (localName) namespace.add(localName); +} + +function coordinatorConstructionSiteForNode( + file: string, + node: Record, + bindings: CoordinatorImportBindings, +): CoordinatorConstructionSite | undefined { + if (node.type === 'ImportExpression') return dynamicCoordinatorImportSite(file, node); + if (node.type !== 'CallExpression') return undefined; + const callee = node.callee as Record | undefined; + return ( + directCoordinatorCallSite(file, node, callee, bindings) ?? + namespaceCoordinatorCallSite(file, node, callee, bindings) + ); +} + +function directCoordinatorCallSite( + file: string, + node: Record, + callee: Record | undefined, + bindings: CoordinatorImportBindings, +): CoordinatorConstructionSite | undefined { + const directCallee = identifierName(callee); + if (!directCallee || !bindings.direct.has(directCallee)) return undefined; + return { file, kind: 'call', start: Number(node.start) }; +} + +function namespaceCoordinatorCallSite( + file: string, + node: Record, + callee: Record | undefined, + bindings: CoordinatorImportBindings, +): CoordinatorConstructionSite | undefined { + if (callee?.type !== 'MemberExpression' || callee.computed === true) return undefined; + const namespace = identifierName(callee.object); + const property = identifierName(callee.property); + if (!namespace || property !== 'createReplayCoordinator' || !bindings.namespace.has(namespace)) { + return undefined; + } + return { file, kind: 'call', start: Number(node.start) }; +} + +function dynamicCoordinatorImportSite( + file: string, + node: Record, +): CoordinatorConstructionSite | undefined { + const sourceNode = node.source as { type?: unknown; value?: unknown } | undefined; + if ( + sourceNode?.type !== 'Literal' || + typeof sourceNode.value !== 'string' || + resolveRelativeTarget(file, sourceNode.value) !== COORDINATOR_MODULE + ) { + return undefined; + } + return { file, kind: 'dynamic-import', start: Number(node.start) }; +} + +const PRODUCTION_FILES = listProductionSourceFiles(); + +test('createReplayCoordinator has exactly one production call site', () => { + const sites = PRODUCTION_FILES.flatMap((file) => coordinatorConstructionSites(file)); assert.deepEqual( - [...callers].sort(), + sites.map(({ file }) => file).sort(), [RUNTIME_FILE], - `createReplayCoordinator must be imported by exactly ${RUNTIME_FILE} — a second production ` + - `caller can manufacture repair authority from a bare SessionStore + session name instead ` + - `of using the request's own coordinator. Found: ${[...callers].sort().join(', ') || '(none)'}`, + `createReplayCoordinator must have exactly one production construction call in ${RUNTIME_FILE} — ` + + `a second caller can manufacture repair authority from a bare SessionStore + session name ` + + `instead of using the request's own coordinator. Found: ${ + sites.map(({ file, kind }) => `${file} (${kind})`).join(', ') || '(none)' + }`, + ); +}); + +test('coordinator ownership scanning catches aliases, namespaces, and dynamic imports', () => { + const source = ` + import { createReplayCoordinator as makeCoordinator } from '../../session-replay-coordinator.ts'; + import * as coordinatorModule from '../../session-replay-coordinator.ts'; + makeCoordinator({}); + coordinatorModule.createReplayCoordinator({}); + void import('../../session-replay-coordinator.ts'); + `; + assert.deepEqual( + coordinatorConstructionSites('src/daemon/replay/internal/probe.ts', source).map( + ({ kind }) => kind, + ), + ['call', 'call', 'dynamic-import'], + ); +}); + +test('daemon replay production files never import the P4a ReplaySessionTransaction projection', () => { + const offenders = PRODUCTION_FILES.filter( + (file) => + file.startsWith('src/daemon/replay/') && + collectImportSites(file).some((site) => importsAnyBinding(site, TRANSACTION_MODULE)), + ); + assert.deepEqual( + offenders, + [], + `daemon replay must reach repair-transaction writes through ReplayCoordinator; found: ${offenders.join(', ')}`, ); }); @@ -168,7 +325,7 @@ test('the divergence-report chain never imports the P4a ReplaySessionTransaction }); test('session-replay-resume.ts holds no SessionStore binding at all', () => { - const file = 'src/daemon/handlers/session-replay-resume.ts'; + const file = 'src/daemon/replay/internal/session-replay-resume.ts'; for (const site of collectImportSites(file)) { assert.equal( importsAnyBinding(site, SESSION_STORE_MODULE), diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 7acafc8540..496b0edb14 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -135,7 +135,7 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick id="a"\n'); - // Compute the plan digest exactly as runReplayScriptSource does (a real agent + // Compute the plan digest exactly as runReplayCommand does (a real agent // takes it from the divergence report's resume.planDigest). const flags = { platform: 'ios' as const }; const digest = inspectAdReplay(fs.readFileSync(scriptPath, 'utf8'), { diff --git a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts index 22315d78ff..afcc18fd7b 100644 --- a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts @@ -14,7 +14,7 @@ import { } from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { SessionScriptWriter } from '../../session-script-writer.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { runReplayForTest } from '../../replay/__tests__/replay-command-fixture.ts'; import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; import { imageViewerNodes, @@ -674,7 +674,7 @@ test('corroborated runtime taps retain target evidence through save and replay', snapshot: snapshot(profileNodes), }), ); - const replayResponse = await runReplayScriptSource({ + const replayResponse = await runReplayForTest({ req: { token: 'test', session: replaySessionName, diff --git a/src/daemon/handlers/__tests__/session-command-replay.test.ts b/src/daemon/handlers/__tests__/session-command-replay.test.ts index e2c4de893b..5c8c54cf06 100644 --- a/src/daemon/handlers/__tests__/session-command-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-command-replay.test.ts @@ -159,7 +159,7 @@ test('replay inherits parent device selectors for each invoked step', async () = expect(invoked[0]?.flags?.udid).toBe('00008150-001849640CF8401C'); }); -// #1900: `replay` routes through `handleSessionReplayCommandGroup` -> `session-replay.ts`, which +// #1900: `replay` routes through the named replay command handler, which // re-invokes each recorded step with no `platform === 'web'` branch anywhere in that path. test('replay inherits the parent web platform selector for each invoked step', async () => { const sessionStore = makeSessionStore(); diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts deleted file mode 100644 index 0ab2591255..0000000000 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * `session-replay.ts` is a routing decision and nothing else: `replay` is one script run, `test` is - * a suite of them, and any other command belongs to a different handler family. Both arms' - * orchestration lives in its own module, so what is left to pin here is the routing itself — - * which arm each command reaches, that the other arm is NOT reached, and that an unrelated command - * is declined rather than swallowed. - * - * Both destinations are mocked, so a wrong edge is observable as the wrong marker rather than as a - * device-level failure: swapping the two delegations flips the first two cases red. - */ -import { beforeEach, expect, test, vi } from 'vitest'; -import path from 'node:path'; -import type { DaemonRequest } from '../../types.ts'; -import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../session-replay-runtime.ts', () => ({ - runReplayScriptSource: vi.fn(async () => ({ ok: true, data: { reached: 'replay-runtime' } })), -})); - -vi.mock('../session-test-suite-command.ts', () => ({ - runReplayTestSuiteCommand: vi.fn(async () => ({ ok: true, data: { reached: 'test-suite' } })), -})); - -import { handleSessionReplayCommands } from '../session-replay.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { runReplayTestSuiteCommand } from '../session-test-suite-command.ts'; - -const mockRunReplayScriptSource = vi.mocked(runReplayScriptSource); -const mockRunReplayTestSuiteCommand = vi.mocked(runReplayTestSuiteCommand); - -beforeEach(() => { - mockRunReplayScriptSource.mockClear(); - mockRunReplayTestSuiteCommand.mockClear(); -}); - -function routerParams(command: string) { - const root = mkdtempForTestSync('agent-device-replay-router-'); - const req: DaemonRequest = { - token: 'token', - session: 'default', - command, - positionals: [], - flags: {}, - }; - return { - req, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore: new SessionStore(path.join(root, 'sessions')), - leaseRegistry: new LeaseRegistry(), - invoke: async () => ({ ok: true as const, data: {} }), - }; -} - -test('a replay request routes to the script-source runtime, not the suite command', async () => { - const params = routerParams('replay'); - - const response = await handleSessionReplayCommands(params); - - expect(response).toMatchObject({ ok: true, data: { reached: 'replay-runtime' } }); - expect(mockRunReplayTestSuiteCommand).not.toHaveBeenCalled(); - // The replay arm narrows the params rather than forwarding the suite's own shape. - expect(mockRunReplayScriptSource).toHaveBeenCalledWith({ - req: params.req, - sessionName: params.sessionName, - logPath: params.logPath, - sessionStore: params.sessionStore, - invoke: params.invoke, - }); -}); - -test('a test request routes to the suite command with the whole parameter set', async () => { - const params = routerParams('test'); - - const response = await handleSessionReplayCommands(params); - - expect(response).toMatchObject({ ok: true, data: { reached: 'test-suite' } }); - expect(mockRunReplayScriptSource).not.toHaveBeenCalled(); - // The suite owns video recording, sharding and device binding, so it receives `params` whole — - // narrowing here is what would silently drop those capabilities. - expect(mockRunReplayTestSuiteCommand).toHaveBeenCalledWith(params); -}); - -test('an unrelated command is declined so another handler family can claim it', async () => { - const response = await handleSessionReplayCommands(routerParams('snapshot')); - - expect(response).toBeNull(); - expect(mockRunReplayScriptSource).not.toHaveBeenCalled(); - expect(mockRunReplayTestSuiteCommand).not.toHaveBeenCalled(); -}); diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts new file mode 100644 index 0000000000..612c121580 --- /dev/null +++ b/src/daemon/handlers/session-replay-command.ts @@ -0,0 +1,117 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonRequest } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import { runReplayCommand, runReplayTestCommand, type ReplaySession } from '../replay/index.ts'; +import { handleCloseCommand } from './session-close.ts'; +import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; +import type { SessionCommandHandler } from './session.ts'; + +export const handleReplayCommand: SessionCommandHandler = async ({ + req, + sessionName, + logPath, + sessionStore, + invoke, + invokeReplayAction, +}) => + await runReplayCommand({ + request: req, + session: createReplaySession(sessionName, logPath, sessionStore), + invoke: invokeReplayAction ?? invoke, + }); + +export const handleReplayTestCommand: SessionCommandHandler = async ({ + req, + sessionName, + logPath, + sessionStore, + leaseRegistry, + invoke, + invokeReplayAction, + bindDevice, + inspectFacts, + bindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, + platformResourceCleanup, +}) => { + if (!platformResourceCleanup) { + throw new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); + } + const video = createReplayTestVideoOwner({ + sessionStore, + bindDevice, + bindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, + }); + return await runReplayTestCommand({ + request: req, + session: createReplaySession(sessionName, logPath, sessionStore), + invoke: invokeReplayAction ?? invoke, + cleanupSession: async (testSessionName) => + await closeReplayTestSession({ + req, + sessionName: testSessionName, + logPath, + sessionStore, + leaseRegistry, + inspectFacts, + bindDevice, + platformResourceCleanup, + }), + ...(video ? { video } : {}), + }); +}; + +function createReplaySession(name: string, logPath: string, store: SessionStore): ReplaySession { + return { name, logPath, store }; +} + +type ReplayTestSessionCleanupParams = Readonly<{ + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; + platformResourceCleanup: PlatformResourceCleanup; +}>; + +async function closeReplayTestSession(params: ReplayTestSessionCleanupParams): Promise { + const { req, sessionName, logPath, sessionStore } = params; + if (!sessionStore.get(sessionName)) return; + const closeResponse = await handleCloseCommand({ + req: { + token: req.token, + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + meta: req.meta, + }, + sessionName, + logPath, + sessionStore, + leaseRegistry: params.leaseRegistry, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + platformResourceCleanup: params.platformResourceCleanup, + }); + if (closeResponse.ok) return; + throw new AppError(closeResponse.error.code, closeResponse.error.message, { + ...(closeResponse.error.details ?? {}), + ...(closeResponse.error.hint ? { hint: closeResponse.error.hint } : {}), + }); +} diff --git a/src/daemon/handlers/session-replay-video-owner.ts b/src/daemon/handlers/session-replay-video-owner.ts new file mode 100644 index 0000000000..c5a3b15f2c --- /dev/null +++ b/src/daemon/handlers/session-replay-video-owner.ts @@ -0,0 +1,70 @@ +import { handleRecordCommand } from './record-runtime.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; +import type { DaemonRequest } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { ReplayTestVideoOwner } from '../replay/index.ts'; + +type ReplayRecordVideoRequest = Parameters[0]; + +export type ReplayTestVideoOwnerParams = Readonly<{ + sessionStore: SessionStore; + bindDevice?: BindDeviceRuntime; + bindExactDevice?: BindExactDeviceRuntime; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; +}>; + +export function createReplayTestVideoOwner( + params: ReplayTestVideoOwnerParams, +): ReplayTestVideoOwner | undefined { + const { + sessionStore, + bindDevice, + bindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, + } = params; + if ( + !bindDevice || + !bindExactDevice || + !screenRecordingAdmissionLedger || + !requestScope || + !retainDeviceExecutionLock || + !throwIfCanceled + ) { + return undefined; + } + const record: ReplayTestVideoOwner['record'] = async (request) => + await handleRecordCommand({ + req: recordRequest(request), + sessionName: request.sessionName, + sessionStore, + bindDevice, + bindExactDevice, + admissionLedger: screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, + }); + return { + throwIfCanceled, + record, + }; +} + +function recordRequest(request: ReplayRecordVideoRequest): DaemonRequest { + return { + token: request.request.token, + session: request.sessionName, + command: 'record', + positionals: request.phase === 'start' ? ['start', request.outputPath ?? ''] : ['stop'], + flags: {}, + meta: request.request.meta, + }; +} diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts deleted file mode 100644 index ceba7a4851..0000000000 --- a/src/daemon/handlers/session-replay.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { DaemonResponse } from '../types.ts'; -import { runReplayScriptSource } from './session-replay-runtime.ts'; -import { - runReplayTestSuiteCommand, - type ReplayTestSuiteCommandParams, -} from './session-test-suite-command.ts'; - -/** - * The replay family's routing decision, and only that: a `replay` request is one script run, a - * `test` request is a suite of them, and anything else belongs to another handler family. Both - * arms' orchestration lives in its own module (`session-replay-runtime.ts`, - * `session-test-suite-command.ts`). - */ -export async function handleSessionReplayCommands( - params: ReplayTestSuiteCommandParams, -): Promise { - const { req, sessionName, logPath, sessionStore, invoke } = params; - - if (req.command === 'replay') { - return await runReplayScriptSource({ - req, - sessionName, - logPath, - sessionStore, - invoke, - }); - } - - if (req.command === 'test') { - return await runReplayTestSuiteCommand(params); - } - - return null; -} diff --git a/src/daemon/handlers/session-runtime.ts b/src/daemon/handlers/session-runtime.ts index 5da503b3f8..06ba281aaf 100644 --- a/src/daemon/handlers/session-runtime.ts +++ b/src/daemon/handlers/session-runtime.ts @@ -11,6 +11,10 @@ import { trimRuntimeValue } from '../../utils/runtime-transport.ts'; import { isAndroidEmulator, isIosSimulator } from '../device-targets.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; +type SessionRuntimeHintsStore = Readonly<{ + getRuntimeHints: SessionStore['getRuntimeHints']; +}>; + // Loopback aliases an emulator/simulator app uses to reach the dev server on the host machine. const ANDROID_EMULATOR_LOOPBACK_HOST = '10.0.2.2'; const IOS_SIMULATOR_LOOPBACK_HOST = '127.0.0.1'; @@ -199,7 +203,7 @@ export function setSessionRuntimeHintsForOpen( } function resolveSessionRuntimeHints( - sessionStore: SessionStore, + sessionStore: SessionRuntimeHintsStore, sessionName: string, device?: DeviceInfo, platform?: RuntimePlatform, @@ -228,7 +232,7 @@ function resolveSessionRuntimeHints( function resolveOpenRuntimeHints(params: { req: DaemonRequest; - sessionStore: SessionStore; + sessionStore: SessionRuntimeHintsStore; sessionName: string; device?: DeviceInfo; platform?: RuntimePlatform; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index abbef60b28..363f285d23 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -12,7 +12,7 @@ import { runBatchCommands } from './session-batch.ts'; import { handleSessionInventoryCommands } from './session-inventory.ts'; import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; -import { handleSessionReplayCommands } from './session-replay.ts'; +import { handleReplayCommand, handleReplayTestCommand } from './session-replay-command.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; import { handleSessionClipboardCommand } from './session-clipboard.ts'; import { handleDoctorCommand } from './session-doctor.ts'; @@ -69,7 +69,9 @@ type SessionCommandParams = Omit & { leaseRegistry: LeaseRegistry; }; -type SessionCommandHandler = (params: SessionCommandParams) => Promise; +export type SessionCommandHandler = ( + params: SessionCommandParams, +) => Promise; const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ req, @@ -126,40 +128,6 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ throwIfCanceled, }); -const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ - req, - sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke, - invokeReplayAction, - bindDevice, - inspectFacts, - bindExactDevice, - screenRecordingAdmissionLedger, - requestScope, - retainDeviceExecutionLock, - throwIfCanceled, - platformResourceCleanup, -}) => - await handleSessionReplayCommands({ - req, - sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke: invokeReplayAction ?? invoke, - bindDevice, - inspectFacts, - bindExactDevice, - screenRecordingAdmissionLedger, - requestScope, - retainDeviceExecutionLock, - throwIfCanceled, - platformResourceCleanup, - }); - /** * Descriptor-driven exhaustive dispatch table for the daemon's `session` * route (mirrors `DISPATCH_HANDLERS` in src/core/dispatch-resolve.ts and @@ -267,8 +235,8 @@ const SESSION_COMMAND_HANDLER_IMPLS = { openResponse, }); }, - replay: handleSessionReplayCommandGroup, - test: handleSessionReplayCommandGroup, + replay: handleReplayCommand, + test: handleReplayTestCommand, batch: async ({ req, sessionName, invoke }) => await runBatchCommands(req, sessionName, invoke), close: async ({ req, diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index 6480b2f40c..1d27684de9 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -1,7 +1,7 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { readSessionRuntimeRevision } from './ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; -import { SessionStore } from './session-store.ts'; +import type { SessionStore } from './session-store.ts'; import type { SessionState } from './types.ts'; declare const INTERNAL_OBSERVATION_EVIDENCE: unique symbol; @@ -59,11 +59,16 @@ type InternalObservationAuthority = Readonly<{ }>; type BoundInternalObservationSession = Readonly<{ - sessionStore: SessionStore; + sessionStore: InternalObservationSessionStore; sessionName: string; signal?: AbortSignal; }>; +type InternalObservationSessionStore = Readonly<{ + get: SessionStore['get']; + set: SessionStore['set']; +}>; + /** * Bind observation authority to one already admitted, locked session. Callers * cannot address another session through the resulting capability, and engines @@ -125,7 +130,7 @@ function storeInternalObservation( * before the response returns to the client. */ function finalizeClientRefPublication(params: { - sessionStore: SessionStore; + sessionStore: InternalObservationSessionStore; sessionName: string; evidence: InternalObservationEvidence; projection: ClientRefPublicationProjection; diff --git a/src/daemon/handlers/__tests__/session-replay-application.test.ts b/src/daemon/replay/__tests__/application.test.ts similarity index 85% rename from src/daemon/handlers/__tests__/session-replay-application.test.ts rename to src/daemon/replay/__tests__/application.test.ts index f324a51d0b..26aad07ce4 100644 --- a/src/daemon/handlers/__tests__/session-replay-application.test.ts +++ b/src/daemon/replay/__tests__/application.test.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import type { RequestProgressEvent } from '@agent-device/contracts/progress'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import { clearRequestCanceled, markRequestCanceled, @@ -14,57 +13,55 @@ import { makeIosSession } from '../../../__tests__/test-utils/session-factories. import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { handleSessionReplayCommands } from '../session-replay.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { + runReplayCommand, + runReplayTestCommand, + type ReplayCommand, + type ReplayTestCommand, +} from '../index.ts'; +import { captureSnapshotWithInteractor } from '../../handlers/snapshot-interactor-capture.ts'; import { legacyDispatchCapture, resetLegacySnapshotCapture, } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); -const noopPlatformResourceCleanup = Object.freeze({ - stopSnapshotHelper: async () => {}, - closeManagedBrowser: async () => {}, - cleanupSessionlessExecutionHost: async () => {}, - retainExecutionHostAfterClose: () => false, -}) satisfies PlatformResourceCleanup; - beforeEach(() => { resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); }); -function replayParams( +function replayCommand( req: DaemonRequest, root: string, sessionStore: SessionStore, invoke: (request: DaemonRequest) => Promise, -) { +): ReplayCommand { return { - req, - sessionName: req.session, - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), + request: req, + session: { + name: req.session, + logPath: path.join(root, 'daemon.log'), + store: sessionStore, + }, invoke, }; } -function suiteParams( +function testCommand( req: DaemonRequest, root: string, sessionStore: SessionStore, invoke: (request: DaemonRequest) => Promise, -) { +): ReplayTestCommand { return { - ...replayParams(req, root, sessionStore, invoke), - platformResourceCleanup: noopPlatformResourceCleanup, + ...replayCommand(req, root, sessionStore, invoke), + cleanupSession: async () => {}, }; } @@ -105,8 +102,8 @@ test('replay routes native and Maestro sources through one application seam and const req = await replayCase.buildRequest(root); const invoked: DaemonRequest[] = []; - const response = await handleSessionReplayCommands( - replayParams(req, root, sessionStore, async (request) => { + const response = await runReplayCommand( + replayCommand(req, root, sessionStore, async (request) => { invoked.push(request); return request.command === 'snapshot' ? { ok: true, data: { createdAt: 0, nodes: [] } } @@ -150,8 +147,8 @@ test('replay admits an annotated target before dispatching its mutation', async }); const invoked = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await handleSessionReplayCommands( - replayParams(baseReplayRequest({ positionals: [filePath] }), root, sessionStore, invoked), + const response = await runReplayCommand( + replayCommand(baseReplayRequest({ positionals: [filePath] }), root, sessionStore, invoked), ); expect(invoked).not.toHaveBeenCalled(); @@ -173,8 +170,8 @@ test('replay projects the exact failure cause and retains artifacts from the fai const filePath = writeReplayFile(root, ['open "Demo"']); mockCaptureSnapshotWithInteractor.mockRejectedValue(new Error('capture unavailable')); - const response = await handleSessionReplayCommands( - replayParams(baseReplayRequest({ positionals: [filePath] }), root, sessionStore, async () => ({ + const response = await runReplayCommand( + replayCommand(baseReplayRequest({ positionals: [filePath] }), root, sessionStore, async () => ({ ok: false, error: { code: 'COMMAND_FAILED', @@ -233,8 +230,8 @@ test('test hosts native and Maestro sources format-neutrally and reports replay const response = await withRequestProgressSink( (event) => events.push(event), async () => - await handleSessionReplayCommands( - suiteParams(req, root, sessionStore, async (request) => { + await runReplayTestCommand( + testCommand(req, root, sessionStore, async (request) => { invoked.push(request); return request.command === 'snapshot' ? { ok: true, data: { createdAt: 0, nodes: [] } } @@ -274,8 +271,8 @@ test('test stops after an active cancellation and does not start the next replay registerRequestAbort(requestId); try { - const response = await handleSessionReplayCommands( - suiteParams(req, root, sessionStore, async (request) => { + const response = await runReplayTestCommand( + testCommand(req, root, sessionStore, async (request) => { invoked.push(request); markRequestCanceled(requestId); return { diff --git a/src/daemon/replay/__tests__/replay-command-fixture.ts b/src/daemon/replay/__tests__/replay-command-fixture.ts new file mode 100644 index 0000000000..b748196865 --- /dev/null +++ b/src/daemon/replay/__tests__/replay-command-fixture.ts @@ -0,0 +1,25 @@ +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; +import type { SessionStore } from '../../session-store.ts'; +import { runReplayCommand } from '../index.ts'; + +export type ReplayCommandTestInput = Readonly<{ + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + invoke: DaemonInvokeFn; + tracePath?: string; + onStep?: ReplayTestAttemptStepSink; +}>; + +export function runReplayForTest(params: ReplayCommandTestInput): Promise { + const { req, sessionName, logPath, sessionStore, invoke, tracePath, onStep } = params; + return runReplayCommand({ + request: req, + session: { name: sessionName, logPath, store: sessionStore }, + invoke, + ...(tracePath === undefined ? {} : { tracePath }), + ...(onStep === undefined ? {} : { onStep }), + }); +} diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts b/src/daemon/replay/__tests__/session-replay-runtime.fixtures.ts similarity index 100% rename from src/daemon/handlers/__tests__/session-replay-runtime.fixtures.ts rename to src/daemon/replay/__tests__/session-replay-runtime.fixtures.ts diff --git a/src/daemon/replay/index.ts b/src/daemon/replay/index.ts new file mode 100644 index 0000000000..4d1ec46446 --- /dev/null +++ b/src/daemon/replay/index.ts @@ -0,0 +1,8 @@ +export { runReplayCommand } from './internal/native-command.ts'; +export { runReplayTestCommand } from './internal/test-command.ts'; +export type { + ReplayCommand, + ReplaySession, + ReplayTestCommand, + ReplayTestVideoOwner, +} from './internal/command-types.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts b/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts similarity index 89% rename from src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts index 1e390cbf92..5811cf1212 100644 --- a/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts @@ -1,8 +1,8 @@ import type { SessionAction } from '@agent-device/contracts/session'; import { expect, test } from 'vitest'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { recordActionEntry } from '../../session-action-recorder.ts'; -import type { DaemonRequest } from '../../types.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { recordActionEntry } from '../../../session-action-recorder.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { invokeReplayAction } from '../session-replay-action-runtime.ts'; import { resolveReplayAction } from '@agent-device/ad-script'; diff --git a/src/daemon/handlers/__tests__/session-replay-builtin-vars.test.ts b/src/daemon/replay/internal/__tests__/session-replay-builtin-vars.test.ts similarity index 100% rename from src/daemon/handlers/__tests__/session-replay-builtin-vars.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-builtin-vars.test.ts diff --git a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts similarity index 91% rename from src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts index dbbedae465..5d2a93cc83 100644 --- a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -4,7 +4,7 @@ * press/click/fill/longpress THROWS an AppError instead of resolving to * `{ok:false}`. `invokeReplayAction` (session-replay-action-runtime.ts) * previously let that throw escape the per-action `if (!response.ok)` - * handling in `runReplayScriptSource`'s loop, so it hit the outer catch and + * handling in `runReplayForTest`'s loop, so it hit the outer catch and * returned a bare `COMMAND_FAILED` with the legacy diagnostics shape instead * of the ADR 0012 `REPLAY_DIVERGENCE` report — breaking the interactive * repair loop (no `resume`, no `screen` refs, no `suggestions`) for the @@ -20,26 +20,26 @@ * zero-match selector) instead of returning it. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { bottomTabsRealCaptureFixture, recordArticleEvidence, @@ -47,7 +47,7 @@ import { import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -76,7 +76,7 @@ function throwSelectorMiss(selector: string): never { } function assertDivergenceShape( - response: Awaited>, + response: Awaited>, expectedResume: { allowed: boolean; from: number } = { allowed: true, from: 1 }, ): { divergence: Record; @@ -126,7 +126,7 @@ test('(a) an ANNOTATED press whose dispatch throws a selector-miss yields REPLAY }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -172,7 +172,7 @@ test('(b) an UNANNOTATED press whose dispatch throws a selector-miss yields REPL }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -202,7 +202,7 @@ test('(c) a fill selector-miss thrown at dispatch yields REPLAY_DIVERGENCE, not }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -234,7 +234,7 @@ test('(d) a thrown NON-AppError at dispatch propagates as an internal error, not }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -267,7 +267,7 @@ test('(e) a thrown AppError with retriable/supportedOn preserves them at the top }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence-android-occlusion.test.ts similarity index 82% rename from src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-divergence-android-occlusion.test.ts index 663ef775f1..0a64b63106 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence-android-occlusion.test.ts @@ -1,26 +1,26 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { makeAndroidSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { ANDROID_QS_SHADE_CAPTURE_RAW_NODES, walkNonRawAndroidFixture, -} from '../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; -import { SessionStore } from '../../session-store.ts'; +} from '../../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; +import { SessionStore } from '../../../session-store.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; import { buildReplayFailureDivergence } from '../session-replay-divergence.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts similarity index 94% rename from src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts index a1dd2e2950..f69ebb8f2b 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts @@ -1,26 +1,26 @@ import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { ReplayDivergence } from '@agent-device/contracts/divergence'; -import { bindInternalObservationAuthority } from '../../internal-observation.ts'; -import { expireRefFrame } from '../../ref-frame.ts'; -import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../session-snapshot.ts'; -import { SessionStore } from '../../session-store.ts'; +import { bindInternalObservationAuthority } from '../../../internal-observation.ts'; +import { expireRefFrame } from '../../../ref-frame.ts'; +import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../../session-snapshot.ts'; +import { SessionStore } from '../../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from '../session-replay-divergence-publication.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts similarity index 98% rename from src/daemon/handlers/__tests__/session-replay-divergence.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts index ccdce4e7e0..bf52e1aaeb 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts @@ -1,12 +1,12 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); @@ -23,18 +23,18 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { return { ...actual, sleep: vi.fn(async () => {}) }; }); -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { makeAndroidSession, makeIosSession, -} from '../../../__tests__/test-utils/session-factories.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; import { ANDROID_IME_CAPTURE_RAW_NODES, walkNonRawAndroidFixture, -} from '../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts'; -import { SessionStore } from '../../session-store.ts'; -import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; +} from '../../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; import { buildReplayFailureDivergence, captureDivergenceObservation, @@ -42,7 +42,7 @@ import { import { legacyDispatchCapture, resetLegacySnapshotCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-error-projection.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts similarity index 79% rename from src/daemon/handlers/__tests__/session-replay-maestro-error-projection.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts index ab78c4ad6a..580e2c8f6f 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-error-projection.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts @@ -3,11 +3,11 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import type { DaemonRequest } from '../../types.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonRequest } from '../../../types.ts'; // A replay error raised before any step runs carries no `failure`, so the route // projects the normalized error directly instead of through the divergence @@ -35,10 +35,12 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { } as unknown as DaemonRequest; const response = await runTypedMaestroReplay({ - req, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, + request: req, + session: { + name: 'default', + logPath: path.join(root, 'daemon.log'), + store: sessionStore, + }, invoke: async () => ({ ok: true, data: {} }), }); diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts similarity index 93% rename from src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts index 2d9517be7a..d1fec75731 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts @@ -1,12 +1,12 @@ -import { noMaestroIncludeSources } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { noMaestroIncludeSources } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { beforeEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; @@ -23,16 +23,16 @@ import { buildTypedMaestroFailureReportProjection, buildTypedMaestroFailureResponse, } from '../session-replay-maestro-failure.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { baseReplayRequest as baseReq } from '../../__tests__/session-replay-runtime.fixtures.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -148,7 +148,7 @@ test('typed Maestro failure diagnostics render expanded selector values without ['appId: com.example.app', '---', '- tapOn: ${TARGET}', ''].join('\n'), ); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { @@ -242,7 +242,7 @@ test('typed Maestro nested scopes retain resolved target values after unwind', a }, ]; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { @@ -307,7 +307,7 @@ test('typed Maestro renders flow-local values when static include resolution fai ['env:', ` FLOW_NAME: ${flowName}`, '---', '- runFlow: ${FLOW_NAME}.yaml', ''].join('\n'), ); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-label.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-label.test.ts similarity index 94% rename from src/daemon/handlers/__tests__/session-replay-maestro-label.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-maestro-label.test.ts index 1a49f01b97..bde6978873 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-label.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-label.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { MaestroActionEvent } from '@agent-device/maestro'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; import { createMaestroReplayObserver } from '../session-replay-maestro-observer.ts'; test('forwards command labels to progress and replay trace projection', () => { diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-session-address.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts similarity index 80% rename from src/daemon/handlers/__tests__/session-replay-maestro-session-address.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts index a0bdc13d96..aeb6aef6e2 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-session-address.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts @@ -3,11 +3,11 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import type { DaemonRequest } from '../../types.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonRequest } from '../../../types.ts'; // The Maestro replay route resolves the store key upstream and then validates the session's // selectors, so its conflict recovery must name that key too — an implicitly cwd-scoped session @@ -37,10 +37,12 @@ test('a typed Maestro selector conflict names the session store key, not "defaul } as unknown as DaemonRequest; const response = await runTypedMaestroReplay({ - req, - sessionName: SCOPED_ADDRESS, - logPath: path.join(root, 'daemon.log'), - sessionStore, + request: req, + session: { + name: SCOPED_ADDRESS, + logPath: path.join(root, 'daemon.log'), + store: sessionStore, + }, invoke: async () => ({ ok: true, data: {} }), }); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-acceptance.test.ts similarity index 87% rename from src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-acceptance.test.ts index f18d338843..2f8c22ad72 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-acceptance.test.ts @@ -6,34 +6,34 @@ * the original recorded, so the fresh replay needs no hand-fixing. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { markRepairTransactionComplete } from '../../session-replay-transaction.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { markRepairTransactionComplete } from '../../../session-replay-transaction.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import type { DaemonRequest } from '../../types.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -93,7 +93,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope }); // --- Leg 1: open records; "click id=save" diverges (renamed to save-v2). --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -121,7 +121,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope }); // --- Leg 2: resume past the step the agent just performed. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -153,7 +153,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope const freshSessionStore = new SessionStore(path.join(freshRoot, 'sessions')); const freshSessionName = 'fresh'; const invokedFresh: DaemonRequest[] = []; - const freshReplay = await runReplayScriptSource({ + const freshReplay = await runReplayForTest({ req: baseReq({ session: freshSessionName, positionals: [healedPath] }), sessionName: freshSessionName, logPath: path.join(freshRoot, 'daemon.log'), @@ -194,7 +194,7 @@ function assertHealedScriptStructure(healedScript: string): void { /** The fresh-session replay dispatched every step with the healed open's flags intact. */ function assertFreshReplayReached( - freshReplay: Awaited>, + freshReplay: Awaited>, invokedFresh: DaemonRequest[], ): void { expect(freshReplay.ok).toBe(true); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-empty-tail.test.ts similarity index 94% rename from src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-empty-tail.test.ts index 5a10820baa..d67f68afe6 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-empty-tail.test.ts @@ -23,32 +23,32 @@ * `caution`/`manual` divergence is no longer a dead end. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { repairSessionBoundary } from '../../session-replay-transaction.ts'; -import type { SessionState } from '../../types.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { repairSessionBoundary } from '../../../session-replay-transaction.ts'; +import type { SessionState } from '../../../types.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; import { bottomTabsRealCaptureFixture, @@ -102,7 +102,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // recorded evidence's real ancestry + the still-present container routes // this to `record-and-heal`, and since it is the plan's LAST step, // resume.from = 3 = actions.length (2) + 1. --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -128,7 +128,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // --- A blind resume at the reported target BEFORE performing the // corrective press is rejected: no new action was recorded since the // divergence, so nothing proves the diverged step was actually repaired. --- - const blindResume = await runReplayScriptSource({ + const blindResume = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { @@ -163,7 +163,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // session.actions, consuming the watermark. Zero steps execute (there is // nothing left in the plan), and the runtime's normal end-of-plan path // flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { @@ -226,7 +226,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // recorded target evidence, routing to `manual`. It is the plan's LAST // step, so the record-and-heal-shaped alternate target is 3 = actions // (2) + 1 — but `resume.from` itself stays at 2, unshifted. --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -259,7 +259,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // corrective press is rejected: no new action was recorded since the // divergence, so nothing proves the diverged step's intent was actually // performed. --- - const blindResume = await runReplayScriptSource({ + const blindResume = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -289,7 +289,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // --- Leg 2: the SAME `--from 3` now succeeds — the corrective press grew // session.actions, consuming the watermark. Zero steps execute, and the // runtime's normal end-of-plan path flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -358,7 +358,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // identity-mismatch/`caution`. It is the plan's LAST step, so the // record-and-heal-shaped alternate target is 3 = actions (2) + 1 — but // `resume.from` itself stays at 2, unshifted (#1262 item 1). --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -386,7 +386,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- A blind resume at the empty-tail target BEFORE performing the // corrective press is rejected. --- - const blindResume = await runReplayScriptSource({ + const blindResume = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -416,7 +416,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Leg 2: the SAME `--from 3` now succeeds — zero steps execute, and // the runtime's normal end-of-plan path flips the transaction COMPLETE. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -476,7 +476,7 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -517,7 +517,7 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t truncated: false, backend: 'xctest', }); - const resumeAtN = await runReplayScriptSource({ + const resumeAtN = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { @@ -576,7 +576,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi // reaching `invoke` for "click" — this is the SAME repair-armed // (`--save-script`) lifecycle a record-and-heal repair uses, just a // different repair sub-flow. --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -604,7 +604,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi // record-and-heal watermark authorizes skipping the unresolved final step, // regardless of how the session's OTHER lifecycle state (armed/held) // looks. --- - const exploitAttempt = await runReplayScriptSource({ + const exploitAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -640,7 +640,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin failSteps: new Set(['click id="article"']), }); - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -671,7 +671,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin // correct digest would find it already cleared and get rejected as // out-of-range, permanently locking the agent out of completing the // repair. --- - const staleDigestAttempt = await runReplayScriptSource({ + const staleDigestAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { @@ -697,7 +697,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin }); // --- The retry with the CORRECT digest still succeeds — never locked out. --- - const retry = await runReplayScriptSource({ + const retry = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-hint.test.ts similarity index 100% rename from src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-hint.test.ts diff --git a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-loop.test.ts similarity index 92% rename from src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-loop.test.ts index 5bd1eaf7b0..653833c68b 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-loop.test.ts @@ -1,4 +1,4 @@ -import { isSessionRecording } from '../../session-script-publication-capability.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; /** * ADR 0012 decision 6: `replay --save-script` arming (R1), the repair-run * boundary watermark (R6, sticky across `--from` legs, no amputation when @@ -11,36 +11,39 @@ import { isSessionRecording } from '../../session-script-publication-capability. * `session.actions` accumulates for real instead of staying empty. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { makeIosSession, repairPublication, -} from '../../../__tests__/test-utils/session-factories.ts'; -import { repairSessionBoundary } from '../../session-replay-transaction.ts'; -import { NO_SCRIPT_PUBLICATION, scriptTargetPath } from '../../session-script-publication-state.ts'; -import type { DaemonRequest, SessionState } from '../../types.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { repairSessionBoundary } from '../../../session-replay-transaction.ts'; +import { + NO_SCRIPT_PUBLICATION, + scriptTargetPath, +} from '../../../session-script-publication-state.ts'; +import type { DaemonRequest, SessionState } from '../../../types.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; /** Repair-transaction status, or `undefined` outside a repair publication. */ function sessionRepairStatus(session: SessionState | undefined) { @@ -109,7 +112,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land // --- Leg 1: arms recording, records open + the verified prefix step, then // diverges at "click id=delete" — never recorded. --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -143,7 +146,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land // --- Leg 2: a --from resume that redundantly passes --save-script — allowed // (it is a resume, not a full replay), and must NOT re-stamp the boundary. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 4, replayPlanDigest: divergence.resume.planDigest }, @@ -172,7 +175,7 @@ test('R2: a fresh FULL replay --save-script on an already-armed session is rejec const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); // First run arms the session (sets saveScriptBoundary). - const first = await runReplayScriptSource({ + const first = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -184,7 +187,7 @@ test('R2: a fresh FULL replay --save-script on an already-armed session is rejec // A SECOND full (non---from) replay --save-script would re-append the prefix. const spy: DaemonRequest[] = []; - const second = await runReplayScriptSource({ + const second = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -207,7 +210,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio // Arm the repair run — session stays repair-armed (recording + // saveScriptBoundary) after it returns. - const first = await runReplayScriptSource({ + const first = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -224,7 +227,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio // is still true, so it would re-append the prefix. Reject it, zero dispatches, // healed slice not duplicated. const spy: DaemonRequest[] = []; - const plain = await runReplayScriptSource({ + const plain = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -267,7 +270,7 @@ test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the s evidence: (req) => (req.command === 'click' ? freshEvidence('x', 'X') : undefined), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -304,7 +307,7 @@ test('R6 preserved session: prior actions are excluded from the healed slice', a evidence: (req) => (req.command === 'click' ? freshEvidence('a', 'A') : undefined), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -329,7 +332,7 @@ test('opt-in: without --save-script, replay neither arms recording nor records e evidence: () => freshEvidence('save', 'Save'), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -359,7 +362,7 @@ test('a thrown/failed dispatch never lands a partial action in session.actions', failSteps: new Set(['click']), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -409,7 +412,7 @@ test('R1 bootstrap: a session created by step 1 (open) arms in time for step 2 t evidence: (req) => (req.command === 'click' ? freshEvidence('save', 'Save') : undefined), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -434,7 +437,7 @@ test('BLOCKER 4: a minimal [open, terminal close] cold-start script arms the tra const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -473,7 +476,7 @@ test("Fix 3: the source plan's terminal close is skipped (never dispatched, neve evidence: (req) => (req.command === 'click' ? freshEvidence('save', 'Save') : undefined), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -503,7 +506,7 @@ test('Fix 3: an ordinary (non-repair) replay still dispatches its terminal close const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); // No --save-script: this is a plain deterministic replay, not a repair. - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath, @@ -528,7 +531,7 @@ test('Fix 3: only the TERMINAL close is skipped during a repair — a mid-plan c openReplacesSession: true, }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -560,7 +563,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett failSteps: new Set(['click id="save"']), }); - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -585,7 +588,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett }); spy.length = 0; - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -624,7 +627,7 @@ test('a --from continuation WITHOUT --save-script that diverges is still held al // Leg 1: `replay --save-script` opens the transaction, arms the session, and // diverges at step 2 — held alive. - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -643,7 +646,7 @@ test('a --from continuation WITHOUT --save-script that diverges is still held al // Leg 2: the `--from 3` continuation carries NO --save-script. It re-diverges // at step 3 — and must STILL be held alive, keyed off the persisted armed // state, so the transaction survives. - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: leg1Divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts similarity index 90% rename from src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts index 8ab61c89d3..3d2f8ea1a7 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts @@ -3,7 +3,7 @@ * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the * `--record` opt-in for the corrective-read case, and the fail-loud empty- * segment guard. Exercised end to end at the same layer the ADR 0012 decision - * 6 lifecycle tests use — `runReplayScriptSource` + `handleCloseCommand` sharing + * 6 lifecycle tests use — `runReplayForTest` + `handleCloseCommand` sharing * a live `SessionStore`, exactly like an agent's separate CLI invocations * against the same daemon session. * @@ -18,7 +18,7 @@ * completely unchanged. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('@agent-device/platform-apple/runner/operations', () => ({ resolveRunnerAppBundleId: vi.fn(), @@ -32,44 +32,44 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { +vi.mock('../../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; }); -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; -import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { handleCloseCommand as handleProductionCloseCommand } from '../../../handlers/session-close.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { LeaseRegistry } from '../../../lease-registry.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { makeIosSession, authoringPublication, -} from '../../../__tests__/test-utils/session-factories.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, -} from './application-lifecycle-runtime-harness.ts'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +} from '../../../handlers/__tests__/application-lifecycle-runtime-harness.ts'; +import { platformResourceCleanup } from '../../../../platform-runtime-resource-cleanup.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -155,7 +155,7 @@ async function armAndDiverge(params: ReturnType, filePath: string) sessionName, evidence: (req) => (req.command === 'click' ? freshEvidence('confirm', 'Confirm') : undefined), }); - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -221,7 +221,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by expect(session.actions.map((a) => a.command)).toEqual(['open', 'press']); // --- `replay --from N+1` resumes to completion (terminal close skipped). --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -269,7 +269,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by sessionStore: fresh.sessionStore, sessionName: fresh.sessionName, }); - const freshRun = await runReplayScriptSource({ + const freshRun = await runReplayForTest({ req: baseReq({ positionals: [healedPath] }), sessionName: fresh.sessionName, logPath: fresh.logPath, @@ -303,7 +303,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste }); expect(session.actions.map((a) => a.command)).toEqual(['open', 'get']); - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -348,7 +348,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste sessionStore: fresh.sessionStore, sessionName: fresh.sessionName, }); - const freshRun = await runReplayScriptSource({ + const freshRun = await runReplayForTest({ req: baseReq({ positionals: [healedPath] }), sessionName: fresh.sessionName, logPath: fresh.logPath, @@ -389,7 +389,7 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h }); expect(session.actions.map((a) => a.command)).toEqual(['open']); - const blindResume = await runReplayScriptSource({ + const blindResume = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts similarity index 95% rename from src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts index a18fdf9827..77b5b6ccef 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts @@ -1,7 +1,7 @@ /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — - * `runReplayScriptSource` + `handleCloseCommand` sharing a live `SessionStore`, + * `runReplayForTest` + `handleCloseCommand` sharing a live `SessionStore`, * exactly like separate CLI invocations against the same daemon. `sendToDaemon`'s * process-level keep-alive (Fix 1's daemon * teardown guard) is a different architectural layer — a client-side process @@ -18,7 +18,7 @@ * the resume completes instead of diverging on lifecycle. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('@agent-device/platform-apple/runner/operations', () => ({ prewarmIosRunnerSession: vi.fn(), @@ -33,59 +33,59 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { +vi.mock('../../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, clearRuntimeHintValues: vi.fn(async () => {}) }; }); -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; -import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { handleCloseCommand as handleProductionCloseCommand } from '../../../handlers/session-close.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { LeaseRegistry } from '../../../lease-registry.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { legacyDispatchCapture, resetLegacySnapshotCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { makeIosSession, makeRepairCompleteSession, repairPublication, -} from '../../../__tests__/test-utils/session-factories.ts'; -import { HEAL_COMPLETE_SENTINEL } from '../../session-script-writer.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { HEAL_COMPLETE_SENTINEL } from '../../../session-script-writer.ts'; import { repairSessionBoundary, repairSessionSourcePath, markRepairTransactionComplete, -} from '../../session-replay-transaction.ts'; +} from '../../../session-replay-transaction.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetPath, scriptTargetForce, -} from '../../session-script-publication-state.ts'; +} from '../../../session-script-publication-state.ts'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; -import type { DaemonRequest, SessionState } from '../../types.ts'; +import type { DaemonRequest, SessionState } from '../../../types.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, -} from './application-lifecycle-runtime-harness.ts'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +} from '../../../handlers/__tests__/application-lifecycle-runtime-harness.ts'; +import { platformResourceCleanup } from '../../../../platform-runtime-resource-cleanup.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -179,7 +179,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res }); // --- Cold `replay drifted.ad --save-script` diverges on the renamed id. --- - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -224,7 +224,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- `replay --from N+1 --plan-digest ` resumes to the end. The // source plan's own terminal `close` (Fix 3) is skipped, so this completes // instead of diverging on lifecycle. --- - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -297,7 +297,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res sessionName: abandoned.sessionName, }); - const abandonedLeg1 = await runReplayScriptSource({ + const abandonedLeg1 = await runReplayForTest({ req: baseReq({ positionals: [abandonedFilePath], flags: { saveScript: true } }), sessionName: abandoned.sessionName, logPath: abandoned.logPath, @@ -345,7 +345,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale ]); const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -369,7 +369,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale expect(tombstone?.sourcePath).toBe(filePath); // A fresh `replay --save-script` on the same key clears the tombstone. - await runReplayScriptSource({ + await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -393,7 +393,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained evidence: (req) => (req.command === 'click' ? freshEvidence('save-v2', 'Save V2') : undefined), }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -441,7 +441,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName }); // Leg 1 arms + diverges (save renamed to save-v2 in the mock tree). - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -461,7 +461,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F // A `--from` continuation targeting the (now reaped) session must surface // SESSION_NOT_FOUND — NOT a REPLAY_DIVERGENCE wrapping the first step's // failure — so the router translates it to REPAIR_SESSION_EXPIRED. - const resumed = await runReplayScriptSource({ + const resumed = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 3, replayPlanDigest: digest } }), sessionName, logPath, @@ -614,7 +614,7 @@ test('#1258 arm-time preflight: an existing --save-script target rejects BEFORE const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const result = await runReplayScriptSource({ + const result = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), sessionName, logPath, @@ -645,7 +645,7 @@ test('#1258: --force skips the arm-time preflight and the replay proceeds despit const spy: DaemonRequest[] = []; const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, spy }); - const result = await runReplayScriptSource({ + const result = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, force: true } }), sessionName, logPath, @@ -686,7 +686,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // Leg 1: `replay --save-script --force` — the LIVE force passes the preflight // (target exists), arms the session, PERSISTS saveScriptForce, then diverges // (nothing published, so the pre-existing target survives). - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, force: true } }), sessionName, logPath, @@ -712,7 +712,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // Leg 2: `replay --from N --save-script` WITHOUT --force — the target still // exists. The persisted saveScriptForce must make the arm-time preflight use // the SAME effective decision publication uses, so this is NOT rejected. - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, @@ -760,7 +760,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // Leg 1: `--save-script= --force` — arms + PERSISTS force for , then // diverges (nothing published). - const leg1 = await runReplayScriptSource({ + const leg1 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: targetA, force: true } }), sessionName, logPath, @@ -794,7 +794,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // `applySaveScriptRetarget` WOULD clear it for ; the preflight must MATCH // that per-target contract and REFUSE here, before any step dispatches, // instead of executing the leg and only refusing at publish time. - const leg2 = await runReplayScriptSource({ + const leg2 = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { saveScript: targetB, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, diff --git a/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts b/src/daemon/replay/internal/__tests__/session-replay-repair.fixtures.ts similarity index 91% rename from src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts rename to src/daemon/replay/internal/__tests__/session-replay-repair.fixtures.ts index 6f4debf9ed..44fb814e42 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair.fixtures.ts @@ -1,4 +1,4 @@ -import { isSessionRecording } from '../../session-script-publication-capability.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; /** * Shared fixtures for the ADR 0012 decision 6 repair-loop tests. The mock * `invoke` in these tests must ACTUALLY record via `sessionStore.recordAction` @@ -7,10 +7,10 @@ import { isSessionRecording } from '../../session-script-publication-capability. * session.actions." This factory keeps the per-test mock declarative (a config * object, no inline branching) so each test body stays linear. */ -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; -import { SessionStore } from '../../session-store.ts'; -import { isInteractiveObservation } from '../../session-action-recorder.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../../types.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { isInteractiveObservation } from '../../../session-action-recorder.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; export function freshEvidence(id: string, label: string): TargetAnnotationV1 { diff --git a/src/daemon/handlers/__tests__/session-replay-resume.test.ts b/src/daemon/replay/internal/__tests__/session-replay-resume.test.ts similarity index 98% rename from src/daemon/handlers/__tests__/session-replay-resume.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-resume.test.ts index 44a90c6f7b..e7a1ef614c 100644 --- a/src/daemon/handlers/__tests__/session-replay-resume.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-resume.test.ts @@ -2,9 +2,9 @@ import type { SessionAction } from '@agent-device/contracts/session'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { buildReplayDivergenceResume } from '../session-replay-resume.ts'; -import { stampPendingRecordAndHealWatermark } from '../../session-replay-coordinator.ts'; -import type { SessionState } from '../../types.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { stampPendingRecordAndHealWatermark } from '../../../session-replay-coordinator.ts'; +import type { SessionState } from '../../../types.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; function action(overrides: Partial = {}): SessionAction { return { ts: 0, command: 'click', positionals: ['label="Save"'], flags: {}, ...overrides }; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts similarity index 90% rename from src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts index 507380f0dd..18bc619ce9 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-artifact-ledger.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts @@ -2,14 +2,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; /** * #1478 P5 follow-up (one daemon-owned artifact ledger): artifact-path @@ -18,7 +18,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * now the single ledger; `dispatchStep` writes it and returns its contents, and * the engine only threads that value. * - * The ledger's one INDEPENDENT observation point is `runReplayScriptSource`'s + * The ledger's one INDEPENDENT observation point is `runReplayForTest`'s * catch block: on a mid-loop throw there is no run outcome to read artifacts * from, so what the failure reports comes from the ledger and nothing else. * That makes this the counterfactual test for the threading — break the ledger @@ -51,7 +51,7 @@ test('a mid-loop throw reports exactly the artifacts of the steps that ran befor ]); const dispatched: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -96,7 +96,7 @@ test('a completed run reports every step’s artifacts through the threaded ledg const filePath = writeReplayFile(root, ['click "First"', 'click "Second"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-artifacts.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifacts.test.ts similarity index 95% rename from src/daemon/handlers/__tests__/session-replay-runtime-artifacts.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-artifacts.test.ts index 8e0fa4cd3f..8c589c28a6 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-artifacts.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifacts.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import { collectReplayActionArtifactPaths } from '../session-replay-runtime-artifacts.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; test('collectReplayActionArtifactPaths includes existing failed action artifacts', () => { const root = mkdtempForTestSync('agent-device-replay-artifacts-'); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-binding.test.ts similarity index 78% rename from src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-binding.test.ts index d0a55cf7c6..5a46245b4b 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-binding.test.ts @@ -1,21 +1,21 @@ import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { resolveTargetDevice } from '../../../core/dispatch-resolve.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { SessionStore } from '../../session-store.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { resolveTargetDevice } from '../../../../core/dispatch-resolve.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { baseReplayRequest as baseReq } from '../../__tests__/session-replay-runtime.fixtures.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); @@ -36,7 +36,7 @@ test('typed Maestro does not resolve a device before an explicit-platform flow n fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'android' }, @@ -63,7 +63,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- launchApp\n- back\n'); const runtime = { metroPort: 8083 }; - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -95,7 +95,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t expect(divergence.resume.from).toBe(2); const invoked: string[] = []; - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure-response.test.ts similarity index 91% rename from src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-failure-response.test.ts index a50caa4370..5e33bbfbba 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure-response.test.ts @@ -1,27 +1,27 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import path from 'node:path'; import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -86,7 +86,7 @@ test('divergence cause and action strings pass through the central redactor at c const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -122,7 +122,7 @@ test('a fill divergence never serializes the typed text at any response level', // message is a realistic selector error, not an echo of the typed text. mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], meta: { responseLevel: level } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -175,7 +175,7 @@ test('a capture-failed screen hint redacts a secret in the capture error', async // The post-failure snapshot capture throws with a secret-bearing message. mockDispatchCommand.mockRejectedValue(new Error('snapshot failed: api_key=sk-live-abc123def456')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -208,7 +208,7 @@ test('an expanded ${VAR} value echoed by a selector error never reaches the publ const filePath = writeReplayFile(root, ['press label="${SECRET}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayEnv: [`SECRET=${sentinel}`] } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -252,7 +252,7 @@ test('an expanded built-in AD_DEVICE_ID never reaches the public divergence', as const filePath = writeReplayFile(root, ['press label="${AD_DEVICE_ID}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { serial: deviceId } }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts similarity index 93% rename from src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts index a6a28361a5..454b9b0e1c 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-failure.test.ts @@ -1,30 +1,30 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonResponse } from '../../types.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import type { DaemonResponse } from '../../../types.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; -import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -52,7 +52,7 @@ test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and c return { ok: true }; }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -128,7 +128,7 @@ test('#1262: a LAST-step failure with NO active session carries NO empty-tail al const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -169,7 +169,7 @@ test('a normalized nested failure preserves typed recovery signals on REPLAY_DIV const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -219,7 +219,7 @@ test('a failing replay step captures an available screen digest with blessed ref backend: 'xctest', }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -269,7 +269,7 @@ test('a failing replay step ranks a re-resolved suggestion when the recorded sel backend: 'xctest', }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -301,7 +301,7 @@ test('divergence screen never masks the original cause when the session already // Intentionally no session stored: simulates the session closing mid-replay. const filePath = writeReplayFile(root, ['click "Save"']); - const response: DaemonResponse = await runReplayScriptSource({ + const response: DaemonResponse = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -362,7 +362,7 @@ test('a failure inside a retry-wrapped runFlow include reports the include file ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [mainPath], flags: { @@ -418,7 +418,7 @@ test('a failure inside a runtime runFlow.when-wrapped include reports the includ ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [mainPath], flags: { @@ -483,7 +483,7 @@ test('typed Maestro failures rank suggestions with Maestro regex selector semant ]; mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -522,7 +522,7 @@ test('typed Maestro failures never serialize input text', async () => { ); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts similarity index 88% rename from src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts index de675f283d..d0ae70642f 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts @@ -1,5 +1,5 @@ import { test, expect, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; /** * #1555 structural-quality review ("topology fix... its subject now lives @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * These six cases stayed daemon-side rather than moving into the package's * `step-loop.test.ts` because they are NOT a test of engine policy through * the façade in isolation — every one drives the full - * `runReplayScriptSource` round trip against a REAL `SessionStore`, and two of + * `runReplayForTest` round trip against a REAL `SessionStore`, and two of * the six (`--keep-session fails explicitly when the completed replay has * no live session`, `--keep-session rejects Maestro YAML before engine * dispatch`) exercise daemon-ONLY authority @@ -24,7 +24,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * OWN terminal-close-suppression decision has its own cheaper, direct * coverage in `packages/ad-replay/src/internal/__tests__/step-loop.test.ts` * (see that file's header). This file's real subject is - * `session-replay-runtime.ts`'s `runReplayScriptSource` — specifically its + * native replay command's `runReplayForTest` — specifically its * `--keep-session` behavior — so it is named and grouped alongside that * file's other `session-replay-runtime-*.test.ts` siblings * (`-plan.test.ts`, `-maestro.test.ts`, `-failure.test.ts`, …) rather than @@ -32,21 +32,21 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; * `session-replay-runtime.test.ts`. */ -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest } from '../../types.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import type { DaemonRequest } from '../../../types.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; test('--keep-session suppresses a close that is terminal among executable actions', async () => { const root = mkdtempForTestSync('agent-device-replay-keep-marker-tail-'); @@ -56,7 +56,7 @@ test('--keep-session suppresses a close that is terminal among executable action const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); const commands: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -81,7 +81,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -109,7 +109,7 @@ test('--keep-session suppresses only the authored terminal close and reports the const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); const commands: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -136,7 +136,7 @@ test('--keep-session preserves an interior close instead of broad command filter const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); const commands: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -159,7 +159,7 @@ test('--keep-session is a no-op for an already close-less script', async () => { const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -182,7 +182,7 @@ test('--keep-session rejects Maestro YAML before engine dispatch', async () => { fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'maestro', replayKeepSession: true }, diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts similarity index 91% rename from src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts index 7c18c5b73b..b7a468e170 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts @@ -1,6 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; // ADR 0012 migration step 2: every replay step failure now attempts a // post-failure screen digest capture + suggestion re-resolution, both via @@ -12,14 +12,14 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // // This file carries the Maestro-heavy majority of what used to live in // session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad` -// runReplayScriptSource tests that happen to share the same runReplayFixture +// runReplayForTest tests that happen to share the same runReplayFixture // helper and mock configuration below. It is a sibling of // session-replay-runtime.test.ts rather than a merge into it because that file // mocks '../../../core/dispatch-resolve.ts' with its own device resolution — vitest // allows only one vi.mock per module per file, so reconciling the two // configurations was out of scope for a pure test-file split (see #1460). -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn(async (flags) => @@ -43,7 +43,7 @@ vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); @@ -52,18 +52,18 @@ import fs from 'node:fs'; import path from 'node:path'; import { Worker } from 'node:worker_threads'; import { PNG } from '@agent-device/capture-kit/png'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; -import { SessionStore } from '../../session-store.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../../types.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, -} from '../../../__tests__/test-utils/replay-script-source.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { seedReplayFixtureSession } from './session-replay-runtime.fixtures.ts'; +} from '../../../../__tests__/test-utils/replay-script-source.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { seedReplayFixtureSession } from '../../__tests__/session-replay-runtime.fixtures.ts'; vi.mocked(captureSnapshotWithInteractor).mockImplementation( captureSnapshotThroughLegacyDispatchFixture, @@ -101,7 +101,7 @@ async function runReplayFixture(params: { const scriptSource = isMaestro ? await maestroScriptSourceBundleFor(scriptPath) : replayScriptSourceBundleFor(scriptPath); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: fixtureReplayRequest({ root, scriptPath, flags: params.flags, isMaestro, scriptSource }), sessionName: 's', logPath: path.join(root, 'log'), @@ -187,7 +187,7 @@ function assertNoUnresolvedInterpolation(calls: CapturedInvocation[]): void { } } -test('runReplayScriptSource dispatches resolved literals with file env overridden by CLI', async () => { +test('runReplayForTest dispatches resolved literals with file env overridden by CLI', async () => { const { response, calls } = await runReplayFixture({ label: 'green', script: @@ -263,7 +263,7 @@ test('.ad replay normalizes resolved gesture and swipe syntax into structured da ); }); -test('runReplayScriptSource reports snapshot diagnostics from per-action session samples', async () => { +test('runReplayForTest reports snapshot diagnostics from per-action session samples', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-samples-'); const scriptPath = path.join(root, 'flow.ad'); // Four warm captures beyond the cold start: the slow-run warning judges warm @@ -281,7 +281,7 @@ test('runReplayScriptSource reports snapshot diagnostics from per-action session ); let captures = 0; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 't', session: 's', @@ -327,7 +327,7 @@ test('runReplayScriptSource reports snapshot diagnostics from per-action session assert.match(String(diagnostics?.warning), /p95 1900ms over 4 captures/); }); -test('runReplayScriptSource reports snapshot diagnostics on replay failure', async () => { +test('runReplayForTest reports snapshot diagnostics on replay failure', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-failure-'); const scriptPath = path.join(root, 'flow.ad'); // Three warm captures precede the failing click so the failure-path summary @@ -345,7 +345,7 @@ test('runReplayScriptSource reports snapshot diagnostics on replay failure', asy ); let captures = 0; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 't', session: 's', @@ -379,7 +379,7 @@ test('runReplayScriptSource reports snapshot diagnostics on replay failure', asy assert.match(String(diagnostics?.warning), /p95 2100ms over 4 captures/); }); -test('runReplayScriptSource applies CLI env overrides before Maestro compat mapping', async () => { +test('runReplayForTest applies CLI env overrides before Maestro compat mapping', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-env', script: [ @@ -431,7 +431,7 @@ test('runReplayScriptSource applies CLI env overrides before Maestro compat mapp ); }); -test('runReplayScriptSource runs Maestro runScript in replay order and exposes output variables', async () => { +test('runReplayForTest runs Maestro runScript in replay order and exposes output variables', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-runtime', files: { @@ -464,7 +464,7 @@ output.result = SERVER_PATH + ':' + json(res.body).appviewDid ); }); -test('runReplayScriptSource supports successful Maestro runScript http.post calls', async () => { +test('runReplayForTest supports successful Maestro runScript http.post calls', async () => { const server = new Worker( ` const http = require('node:http'); @@ -526,7 +526,7 @@ output.result = parsed.method + ':' + json(parsed.body).ok } }); -test('runReplayScriptSource strips prototype pollution keys from runScript json()', async () => { +test('runReplayForTest strips prototype pollution keys from runScript json()', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-json-prototype-keys', files: { @@ -561,7 +561,7 @@ output.result = [ ); }); -test('runReplayScriptSource reports Maestro runScript failures at the runScript step', async () => { +test('runReplayForTest reports Maestro runScript failures at the runScript step', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-fail', files: { @@ -582,7 +582,7 @@ test('runReplayScriptSource reports Maestro runScript failures at the runScript assert.equal(calls.length, 0); }); -test('runReplayScriptSource reports iOS Maestro openLink setup failures before assertions', async () => { +test('runReplayForTest reports iOS Maestro openLink setup failures before assertions', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-ios-openlink-prewarm-fail', script: [ @@ -626,7 +626,7 @@ test('runReplayScriptSource reports iOS Maestro openLink setup failures before a assert.equal(calls[0]?.flags?.maestro?.prewarmRunnerBeforeOpen, true); }); -test('runReplayScriptSource explains empty Maestro runScript JSON bodies', async () => { +test('runReplayForTest explains empty Maestro runScript JSON bodies', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-empty-json', files: { @@ -647,7 +647,7 @@ test('runReplayScriptSource explains empty Maestro runScript JSON bodies', async assert.equal(calls.length, 0); }); -test('runReplayScriptSource rejects Maestro runScript output keys containing dots', async () => { +test('runReplayForTest rejects Maestro runScript output keys containing dots', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-dotted-output', files: { @@ -667,7 +667,7 @@ test('runReplayScriptSource rejects Maestro runScript output keys containing dot assert.equal(calls.length, 0); }); -test('runReplayScriptSource retries Maestro scrollUntilVisible with scroll probes', async () => { +test('runReplayForTest retries Maestro scrollUntilVisible with scroll probes', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -719,7 +719,7 @@ test('runReplayScriptSource retries Maestro scrollUntilVisible with scroll probe ); }); -test('runReplayScriptSource uses semantic iOS dispatch for an exact text tapOn', async () => { +test('runReplayForTest uses semantic iOS dispatch for an exact text tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-visible-text-atomic-ios', @@ -765,7 +765,7 @@ test('runReplayScriptSource uses semantic iOS dispatch for an exact text tapOn', ); }); -test('runReplayScriptSource uses matched Android id geometry for tapOn', async () => { +test('runReplayForTest uses matched Android id geometry for tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-id-matched-geometry', @@ -810,7 +810,7 @@ test('runReplayScriptSource uses matched Android id geometry for tapOn', async ( ); }); -test('runReplayScriptSource captures fresh geometry for tapOn after assertVisible', async () => { +test('runReplayForTest captures fresh geometry for tapOn after assertVisible', async () => { let snapshots = 0; const { response, calls } = await runReplayFixture({ label: 'maestro-assert-visible-tap-fresh-snapshot', @@ -861,7 +861,7 @@ test('runReplayScriptSource captures fresh geometry for tapOn after assertVisibl ); }); -test('runReplayScriptSource scopes duplicate tap targets after native Maestro assertVisible', async () => { +test('runReplayForTest scopes duplicate tap targets after native Maestro assertVisible', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-native-assert-context-duplicate-tap', script: ['appId: demo.app', '---', '- assertVisible: Albums', '- tapOn: Push article', ''].join( @@ -929,7 +929,7 @@ test('runReplayScriptSource scopes duplicate tap targets after native Maestro as ); }); -test('runReplayScriptSource treats absent Maestro assertNotVisible targets as passing', async () => { +test('runReplayForTest treats absent Maestro assertNotVisible targets as passing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-absent', @@ -955,7 +955,7 @@ test('runReplayScriptSource treats absent Maestro assertNotVisible targets as pa assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptSource propagates Maestro assertNotVisible infrastructure failures', async () => { +test('runReplayForTest propagates Maestro assertNotVisible infrastructure failures', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-infra-fail', @@ -978,7 +978,7 @@ test('runReplayScriptSource propagates Maestro assertNotVisible infrastructure f assert.equal(calls.length, 1); }); -test('runReplayScriptSource waits briefly for Maestro assertNotVisible to stabilize', async () => { +test('runReplayForTest waits briefly for Maestro assertNotVisible to stabilize', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1017,7 +1017,7 @@ test('runReplayScriptSource waits briefly for Maestro assertNotVisible to stabil assert.equal(calls.length, 2); }); -test('runReplayScriptSource treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { +test('runReplayForTest treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-extended-wait-not-visible-absent', script: [ @@ -1046,7 +1046,7 @@ test('runReplayScriptSource treats absent Maestro extendedWaitUntil.notVisible t assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptSource resolves Maestro percentage point taps from the admitted runtime viewport', async () => { +test('runReplayForTest resolves Maestro percentage point taps from the admitted runtime viewport', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-tap-point-percent', script: ['appId: demo.app', '---', '- tapOn:', ' point: 20%,20%', ''].join('\n'), @@ -1068,7 +1068,7 @@ test('runReplayScriptSource resolves Maestro percentage point taps from the admi ); }); -test('runReplayScriptSource retries Maestro id tapOn through snapshot coordinates', async () => { +test('runReplayForTest retries Maestro id tapOn through snapshot coordinates', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -1115,7 +1115,7 @@ test('runReplayScriptSource retries Maestro id tapOn through snapshot coordinate ); }); -test('runReplayScriptSource resolves Maestro tapOn index and childOf from snapshots', async () => { +test('runReplayForTest resolves Maestro tapOn index and childOf from snapshots', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-index-childof', @@ -1184,7 +1184,7 @@ test('runReplayScriptSource resolves Maestro tapOn index and childOf from snapsh assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayScriptSource lets snapshot id tap handle Maestro one-point edge controls', async () => { +test('runReplayForTest lets snapshot id tap handle Maestro one-point edge controls', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-edge-rect', @@ -1220,7 +1220,7 @@ test('runReplayScriptSource lets snapshot id tap handle Maestro one-point edge c ); }); -test('runReplayScriptSource resolves a text-entry target once before typing', async () => { +test('runReplayForTest resolves a text-entry target once before typing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-input-text-snapshot', @@ -1269,7 +1269,7 @@ test('runReplayScriptSource resolves a text-entry target once before typing', as ); }); -test('runReplayScriptSource resolves scalar Maestro swipe.from to an element rect', async () => { +test('runReplayForTest resolves scalar Maestro swipe.from to an element rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label', @@ -1326,7 +1326,7 @@ test('runReplayScriptSource resolves scalar Maestro swipe.from to an element rec ); }); -test('runReplayScriptSource anchors scalar Maestro swipe.from to the matched element rect', async () => { +test('runReplayForTest anchors scalar Maestro swipe.from to the matched element rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label-child-rect', @@ -1392,7 +1392,7 @@ test('runReplayScriptSource anchors scalar Maestro swipe.from to the matched ele ); }); -test('runReplayScriptSource resolves Maestro screen swipes from the admitted runtime viewport', async () => { +test('runReplayForTest resolves Maestro screen swipes from the admitted runtime viewport', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-screen-swipe', script: [ @@ -1441,7 +1441,7 @@ test('runReplayScriptSource resolves Maestro screen swipes from the admitted run ); }); -test('runReplayScriptSource delegates Android directional swipes and preserves percentage points', async () => { +test('runReplayForTest delegates Android directional swipes and preserves percentage points', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-screen-swipe-android-midpoint-lane', script: [ @@ -1490,7 +1490,7 @@ test('runReplayScriptSource delegates Android directional swipes and preserves p ); }); -test('runReplayScriptSource maps Maestro enter to keyboard enter', async () => { +test('runReplayForTest maps Maestro enter to keyboard enter', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter', @@ -1509,7 +1509,7 @@ test('runReplayScriptSource maps Maestro enter to keyboard enter', async () => { ); }); -test('runReplayScriptSource waits for Maestro animation screenshots to stabilize', async () => { +test('runReplayForTest waits for Maestro animation screenshots to stabilize', async () => { const calls: CapturedInvocation[] = []; const screenshot = PNG.sync.write(new PNG({ width: 1, height: 1 })); const { response } = await runReplayFixture({ @@ -1535,7 +1535,7 @@ test('runReplayScriptSource waits for Maestro animation screenshots to stabilize assert.equal(calls[1]?.flags?.screenshotNoStabilize, true); }); -test('runReplayScriptSource propagates unsupported keyboard enter dispatch', async () => { +test('runReplayForTest propagates unsupported keyboard enter dispatch', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter-unsupported', @@ -1561,7 +1561,7 @@ test('runReplayScriptSource propagates unsupported keyboard enter dispatch', asy ); }); -test('runReplayScriptSource retries Maestro retry commands until they pass', async () => { +test('runReplayForTest retries Maestro retry commands until they pass', async () => { const calls: CapturedInvocation[] = []; let openAttempts = 0; const { response } = await runReplayFixture({ @@ -1624,7 +1624,7 @@ test('runReplayScriptSource retries Maestro retry commands until they pass', asy assert.equal(calls.filter((call) => call.command === 'snapshot').length > 1, true); }); -test('runReplayScriptSource propagates Maestro runFlow.when runtime errors', async () => { +test('runReplayForTest propagates Maestro runFlow.when runtime errors', async () => { const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-runtime-error', script: [ @@ -1659,7 +1659,7 @@ test('runReplayScriptSource propagates Maestro runFlow.when runtime errors', asy } }); -test('runReplayScriptSource runs Maestro runFlow.when.visible commands when present', async () => { +test('runReplayForTest runs Maestro runFlow.when.visible commands when present', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-run', @@ -1721,7 +1721,7 @@ test('runReplayScriptSource runs Maestro runFlow.when.visible commands when pres ); }); -test('runReplayScriptSource runs nested Maestro runtime commands inside runFlow.when', async () => { +test('runReplayForTest runs nested Maestro runtime commands inside runFlow.when', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1794,7 +1794,7 @@ test('runReplayScriptSource runs nested Maestro runtime commands inside runFlow. ); }); -test('runReplayScriptSource resolves nested Maestro runFlow.when command variables once at execution', async () => { +test('runReplayForTest resolves nested Maestro runFlow.when command variables once at execution', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-nested-vars', @@ -1860,7 +1860,7 @@ test('runReplayScriptSource resolves nested Maestro runFlow.when command variabl ); }); -test('runReplayScriptSource reads shell env from request (client-collected), not daemon process.env', async () => { +test('runReplayForTest reads shell env from request (client-collected), not daemon process.env', async () => { // Ensure the daemon's own process.env does NOT contain AD_VAR_APP. assert.equal(process.env.AD_VAR_APP, undefined); const { response, calls } = await runReplayFixture({ @@ -1873,7 +1873,7 @@ test('runReplayScriptSource reads shell env from request (client-collected), not assert.deepEqual(calls[0]?.positionals, ['client-shell-app']); }); -test('runReplayScriptSource falls back to process.env when request omits replayShellEnv', async () => { +test('runReplayForTest falls back to process.env when request omits replayShellEnv', async () => { const previous = process.env.AD_VAR_APP; process.env.AD_VAR_APP = 'daemon-env-app'; try { @@ -1889,7 +1889,7 @@ test('runReplayScriptSource falls back to process.env when request omits replayS } }); -test('runReplayScriptSource writes per-action timing events to active trace', async () => { +test('runReplayForTest writes per-action timing events to active trace', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-'); const scriptPath = path.join(root, 'flow.ad'); const tracePath = path.join(root, 'trace.ndjson'); @@ -1911,7 +1911,7 @@ test('runReplayScriptSource writes per-action timing events to active trace', as actions: [], }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 't', session: 's', diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts similarity index 92% rename from src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts index 3678c09563..2a4849a67d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts @@ -1,34 +1,34 @@ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; -import { resolveTargetDevice } from '../../../core/dispatch-resolve.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; +import { resolveTargetDevice } from '../../../../core/dispatch-resolve.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { makeAndroidSession, makeIosSession, -} from '../../../__tests__/test-utils/session-factories.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -54,7 +54,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from ]); // First attempt: step 3 fails, capturing a real resume report. - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -77,7 +77,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from // Second attempt: repair app state, resume at the reported step. Steps 1-2 // must never be invoked — the mock throws if they are. const invokedCommands: string[] = []; - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: divergence.resume.from, replayPlanDigest: divergence.resume.planDigest }, @@ -108,7 +108,7 @@ test('resume requires both --from and --plan-digest together', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const fromOnly = await runReplayScriptSource({ + const fromOnly = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 2 } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -120,7 +120,7 @@ test('resume requires both --from and --plan-digest together', async () => { expect(fromOnly.ok).toBe(false); if (!fromOnly.ok) expect(fromOnly.error.code).toBe('INVALID_ARGS'); - const digestOnly = await runReplayScriptSource({ + const digestOnly = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayPlanDigest: 'deadbeef' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -140,7 +140,7 @@ test('resume rejects an out-of-range --from before any action', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 99, replayPlanDigest: 'deadbeef' }, @@ -193,7 +193,7 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' const beforeView = coordinator.view(); const beforeActionsLength = sessionStore.get(sessionName)!.actions.length; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: 2, replayPlanDigest: 'not-the-real-digest' }, @@ -225,7 +225,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -247,7 +247,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = // longer match. fs.writeFileSync(filePath, 'open "Demo"\nclick "Extra"\nclick "Save"\n'); - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayFrom: divergence.resume.from, replayPlanDigest: divergence.resume.planDigest }, @@ -277,7 +277,7 @@ test('resume rejects a digest from a different effective platform or target befo ]); const executionFlags = { platform: 'ios' as const, target: 'mobile' as const }; - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: executionFlags }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -297,7 +297,7 @@ test('resume rejects a digest from a different effective platform or target befo { ...executionFlags, target: 'desktop' as const }, { ...executionFlags, platform: 'android' as const }, ]) { - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { @@ -345,7 +345,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a // directly — the retry block's nested `back` (1st call) succeeds; the // top-level step-2 `back` (2nd call) fails. let backCalls = 0; - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [mainPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -368,7 +368,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a expect(divergence.resume.allowed).toBe(false); expect(divergence.resume.reason).toMatch(/control flow/); - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [mainPath], flags: { @@ -399,7 +399,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -419,7 +419,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => ...session, device: { ...session.device, target: 'tv' }, }); - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { @@ -451,7 +451,7 @@ test('typed Maestro rejects selectors that conflict with an active session', asy fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'android' }, @@ -481,7 +481,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa ); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -516,7 +516,7 @@ test('native replay applies an authored Android platform to its static app open' ); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [replayPath] }), sessionName: 'default', logPath: path.join(root, 'daemon.log'), @@ -543,7 +543,7 @@ test('platform-less typed Maestro replay preserves a resolved Android device', a mockResolveTargetDevice.mockResolvedValue(androidDevice); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' }, @@ -581,7 +581,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); - const firstAttempt = await runReplayScriptSource({ + const firstAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -602,7 +602,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( metroHost: '127.0.0.1', metroPort: 8084, }); - const resumedAttempt = await runReplayScriptSource({ + const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], flags: { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime.test.ts similarity index 92% rename from src/daemon/handlers/__tests__/session-replay-runtime.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-runtime.test.ts index e627a5842a..72da8b55f5 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime.test.ts @@ -1,33 +1,33 @@ -import { isSessionRecording } from '../../session-script-publication-capability.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { makeIosSession, makeRepairArmedSession, -} from '../../../__tests__/test-utils/session-factories.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -45,7 +45,7 @@ test('a successful replay prints one line with the step count and wall time', as sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -74,7 +74,7 @@ test('a close-less replay reports sessionActive: true (real producer, session st sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -95,7 +95,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -134,7 +134,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => return { ok: true as const, data: {} }; }); - const yamlResponse = await runReplayScriptSource({ + const yamlResponse = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios', replayKeepSession: false }, @@ -150,7 +150,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => commands.length = 0; const adPath = writeReplayFile(root, ['open "Generic"']); - const adResponse = await runReplayScriptSource({ + const adResponse = await runReplayForTest({ req: baseReq({ positionals: [adPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -172,7 +172,7 @@ test('bare Maestro YAML requires explicit --maestro routing', async () => { const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -197,7 +197,7 @@ test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real produ const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -230,9 +230,9 @@ test('typed Maestro nested commands receive the runtime hints bound into the pla }); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); - const requests: Parameters[0]['invoke']>[0][] = []; + const requests: Parameters[0]['invoke']>[0][] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -274,7 +274,7 @@ test('typed Maestro writes source-aware redacted step timing traces', async () = fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- inputText: highly-sensitive\n'); fs.writeFileSync(tracePath, ''); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -332,7 +332,7 @@ test('replay trace failures do not change action semantics', async () => { const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- back\n'); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -357,7 +357,7 @@ test('generic replay traces redact typed text', async () => { const tracePath = path.join(root, 'replay-timing.ndjson'); fs.writeFileSync(tracePath, ''); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -382,7 +382,7 @@ test('Maestro YAML rejects .ad repair recording before executing any command', a fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios', saveScript: true }, @@ -411,7 +411,7 @@ test('Maestro YAML cannot append commands to an active .ad repair session', asyn fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [yamlPath], flags: { replayBackend: 'maestro', platform: 'ios' }, @@ -436,7 +436,7 @@ test('replay rejects legacy JSON payload files', async () => { const filePath = path.join(root, 'replay.json'); fs.writeFileSync(filePath, JSON.stringify({ optimizedActions: [] }, null, 2)); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -469,7 +469,7 @@ test('replay rejects an unknown --replay-backend value before any step dispatch const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'unknown' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -498,7 +498,7 @@ test('replay still dispatches a plain .ad script with replayBackend: "maestro"', const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayBackend: 'maestro' } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -519,7 +519,7 @@ test('replay rejects malformed .ad lines with unclosed quotes', async () => { sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [String.raw`click "id=\"broken\"`]); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -561,7 +561,7 @@ test('--update never rewrites the .ad file, even when a re-resolvable suggestion backend: 'xctest', }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -592,7 +592,7 @@ test('a successful --update replay reports healed: 0 (heal is retired, not just sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -613,7 +613,7 @@ test('--update no longer refuses env directives (the guard existed only for rewr sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['env NAME=World', 'open "Demo"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true } }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -631,7 +631,7 @@ test('--update no longer refuses ${VAR} interpolation (the guard existed only fo sessionStore.set(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click label="${NAME}"']); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath], flags: { replayUpdate: true, replayEnv: ['NAME=World'] }, diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/replay/internal/__tests__/session-replay-script-source.test.ts similarity index 86% rename from src/daemon/handlers/__tests__/session-replay-script-source.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-script-source.test.ts index 8c5fb3924a..fb9bbc71d2 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-script-source.test.ts @@ -6,31 +6,31 @@ * path is refused with a message that says so. */ import { beforeEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); import fs from 'node:fs'; import path from 'node:path'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, -} from '../../../__tests__/test-utils/replay-script-source.ts'; -import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay-script-source.ts'; +} from '../../../../__tests__/test-utils/replay-script-source.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay-script-source.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -51,7 +51,7 @@ test('a bundled replay runs after the script file is gone from the daemon host', // Stand in for the remote daemon: nothing at the caller's path exists on this host. fs.rmSync(scriptPath); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 'token', session: 'default', @@ -82,7 +82,7 @@ test('a bundled Maestro replay resolves runFlow includes from the bundle, not th fs.rmSync(root, { recursive: true, force: true }); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 'token', session: 'default', @@ -111,7 +111,7 @@ test('a Maestro include missing from the bundle fails naming the include path', const flow = 'appId: com.example.app\n---\n- runFlow: absent.yaml\n'; fs.writeFileSync(flowPath, flow); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 'token', session: 'default', @@ -144,7 +144,7 @@ test('a request carrying only a path is refused with the client-must-send-source const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\n'); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 'token', session: 'default', @@ -174,7 +174,7 @@ test('a failing step reports the caller-resolved script path and line', async () const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Nope"\n'); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: { token: 'token', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts b/src/daemon/replay/internal/__tests__/session-replay-selector-routes.test.ts similarity index 72% rename from src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-selector-routes.test.ts index 3aabd0fea5..c39c20e0ab 100644 --- a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-selector-routes.test.ts @@ -1,23 +1,26 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { SessionStore } from '../../session-store.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; +import { + baseReplayRequest, + writeReplayFile, +} from '../../__tests__/session-replay-runtime.fixtures.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); @@ -46,7 +49,7 @@ test('replay executes selector reads before reporting a covered-target divergenc ]); const invoked: string[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReplayRequest({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts b/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts similarity index 96% rename from src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts rename to src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts index 16eb01de1b..92a15b4289 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; -import { computeTargetEvidence } from '../../session-target-evidence.ts'; +import { computeTargetEvidence } from '../../../session-target-evidence.ts'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts similarity index 99% rename from src/daemon/handlers/__tests__/session-replay-target-classification.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts index 6e1a27cc3c..abebf229d9 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts @@ -2,9 +2,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { computeTargetEvidence } from '../../session-target-evidence.ts'; +import { computeTargetEvidence } from '../../../session-target-evidence.ts'; import { buildSelectorChainForNode, resolveRecordedTarget } from '@agent-device/selectors'; -import { resolvePressRecordingTarget } from '../../../core/press-retarget.ts'; +import { resolvePressRecordingTarget } from '../../../../core/press-retarget.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; import { bottomTabsRealCaptureFixture, diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-guard.test.ts similarity index 97% rename from src/daemon/handlers/__tests__/session-replay-target-guard.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-target-guard.test.ts index b528db5490..61ea67f5a0 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-guard.test.ts @@ -11,12 +11,12 @@ import type { TargetAnnotationV1, } from '@agent-device/contracts/replay'; import { readNodeLocalIdentity, readNodeStructuralDenotation } from '@agent-device/ad-script'; -import { makeSnapshotState } from '../../../__tests__/test-utils/snapshot-builders.ts'; +import { makeSnapshotState } from '../../../../__tests__/test-utils/snapshot-builders.ts'; import { ref as interactionRef, selector, -} from '../../../commands/interaction/runtime/selector-read-utils.ts'; -import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; +} from '../../../../commands/interaction/runtime/selector-read-utils.ts'; +import { createInteractionDevice } from '../../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; /** The verified-member guard denotation the replay loop mints (identity + structural position). */ function guardFor(node: SnapshotNode, nodes: SnapshotNode[]): ReplayTargetGuardDenotation { diff --git a/src/daemon/handlers/__tests__/session-replay-target-token.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-token.test.ts similarity index 100% rename from src/daemon/handlers/__tests__/session-replay-target-token.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-target-token.test.ts diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts similarity index 95% rename from src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts rename to src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts index b630f50f8b..92497ac397 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts @@ -1,5 +1,5 @@ /** - * ADR 0012 migration step 4, end-to-end: `runReplayScriptSource` must consult + * ADR 0012 migration step 4, end-to-end: `runReplayForTest` must consult * `verifyReplayActionTarget` for every annotated resolved-target action * BEFORE dispatching it, and never send the device action on a non-verified * outcome. Mirrors the mocking pattern of `session-replay-runtime.test.ts` @@ -7,14 +7,14 @@ * for the actual action dispatch). */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); @@ -29,20 +29,20 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { }); import path from 'node:path'; -import type { DaemonRequest } from '../../types.ts'; -import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest } from '../../../types.ts'; +import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; +import { SessionStore } from '../../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, -} from './session-replay-runtime.fixtures.ts'; +} from '../../__tests__/session-replay-runtime.fixtures.ts'; import { legacyDispatchCapture, resetLegacySnapshotCapture, -} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +} from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -110,7 +110,7 @@ test('an unannotated action executes unchanged (old-script pass-through)', async const filePath = writeReplayFile(root, ['click id="save"']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -149,7 +149,7 @@ test('a verified target proceeds to dispatch the action', async () => { }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -175,7 +175,7 @@ test('a verified drag guards both source and destination before dispatch', async mockDispatchCommand.mockResolvedValue({ nodes: DRAG_NODES, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -207,7 +207,7 @@ test('a shifted drag destination diverges before pointer-down even when the sour }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -238,7 +238,7 @@ test('a destination guard refusal reports destination evidence after both prefli ]); mockDispatchCommand.mockResolvedValue({ nodes: DRAG_NODES, backend: 'xctest' }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -277,7 +277,7 @@ test('a selector-miss divergence blocks dispatch and never sends the action', as mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -323,7 +323,7 @@ test('an identity-mismatch divergence reports matchCount and an observed identit }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -375,7 +375,7 @@ test('a recorded-unverifiable annotation is an identity-unverifiable divergence }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -417,7 +417,7 @@ test('a verified annotated action carries the post-resolution guard on its dispa }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -444,7 +444,7 @@ test('an unannotated action never carries a post-resolution guard', async () => const filePath = writeReplayFile(root, ['click id="save"']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -481,7 +481,7 @@ test('a dispatch-time guard mismatch converts to an identity-mismatch target-bin }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -550,7 +550,7 @@ test('a same-identity guard mismatch surfaces the structural position difference backend: 'xctest', }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -605,7 +605,7 @@ test('a target-binding divergence carries a real computed resume (step 5 wiring, ]); mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -699,7 +699,7 @@ test('a transient content-quality capture failure right after launch recovers wi }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -723,7 +723,7 @@ test('a content-quality capture failure that never recovers still fails closed a mockDispatchCommand.mockImplementation(async () => throwContentQualityCaptureFailure()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -794,7 +794,7 @@ test('an iOS sparse-snapshot verdict right after launch recovers within the boun }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -820,7 +820,7 @@ test('an iOS sparse-snapshot verdict that never recovers still fails closed as i mockDispatchCommand.mockImplementation(async () => iosSparseCapture()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -856,7 +856,7 @@ test('a permanent (non-content-quality) capture failure fails fast without retry mockDispatchCommand.mockImplementation(async () => throwPermanentCaptureFailure()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -887,7 +887,7 @@ test('an adb mechanism failure marked retriable at the transport level still fai mockDispatchCommand.mockImplementation(async () => throwAdbMechanismFailureMarkedRetriable()); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -927,7 +927,7 @@ test('an annotated selector wait dispatches with the landmark guard and no eager const filePath = writeReplayFile(root, [WAIT_ANNOTATION, 'wait "label=\\"Screen X\\"" 2000']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -965,7 +965,7 @@ test('a recorded-unverifiable wait annotation refuses before polling with matchC mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1006,7 +1006,7 @@ test("the wait loop's landmark refusal converts into an identity-mismatch diverg }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1053,7 +1053,7 @@ test('a plain wait timeout on an annotated wait stays an action-failure divergen mockDispatchCommand.mockResolvedValue({ nodes: [], truncated: false, backend: 'xctest' }); - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1079,7 +1079,7 @@ test('an annotation on a duration wait is inert (no guard, no refusal)', async ( const filePath = writeReplayFile(root, [WAIT_UNVERIFIABLE_ANNOTATION, 'wait 500']); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1124,7 +1124,7 @@ test('an annotated is step verifies pre-dispatch and threads the post-resolution }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), @@ -1163,7 +1163,7 @@ test('an annotated is step whose recorded identity vanished diverges before disp }); const invoked: DaemonRequest[] = []; - const response = await runReplayScriptSource({ + const response = await runReplayForTest({ req: baseReq({ positionals: [filePath] }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-test-fail-fast.test.ts b/src/daemon/replay/internal/__tests__/session-test-fail-fast.test.ts similarity index 83% rename from src/daemon/handlers/__tests__/session-test-fail-fast.test.ts rename to src/daemon/replay/internal/__tests__/session-test-fail-fast.test.ts index a559f0c4ce..466ad95887 100644 --- a/src/daemon/handlers/__tests__/session-test-fail-fast.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-fail-fast.test.ts @@ -2,9 +2,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; -import { handleSessionCommands } from './session-command-harness.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; +import { handleSessionCommands } from '../../../handlers/__tests__/session-command-harness.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; test('test --fail-fast continues after passing scripts', async () => { const root = mkdtempForTestSync('agent-device-test-fail-fast-pass-'); diff --git a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts b/src/daemon/replay/internal/__tests__/session-test-infrastructure.test.ts similarity index 98% rename from src/daemon/handlers/__tests__/session-test-infrastructure.test.ts rename to src/daemon/replay/internal/__tests__/session-test-infrastructure.test.ts index 017cb9b41a..234305ccbb 100644 --- a/src/daemon/handlers/__tests__/session-test-infrastructure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-infrastructure.test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { isReplayInfrastructureFailure } from '../session-test-infrastructure.ts'; -import type { DaemonResponse } from '../../types.ts'; +import type { DaemonResponse } from '../../../types.ts'; import type { ReplaySuiteTestResult } from '@agent-device/contracts/replay'; test('isReplayInfrastructureFailure accepts shared boot diagnostic reasons', () => { diff --git a/src/daemon/handlers/__tests__/session-test-runner.test.ts b/src/daemon/replay/internal/__tests__/session-test-runner.test.ts similarity index 98% rename from src/daemon/handlers/__tests__/session-test-runner.test.ts rename to src/daemon/replay/internal/__tests__/session-test-runner.test.ts index 7378b99c61..3539bed3b2 100644 --- a/src/daemon/handlers/__tests__/session-test-runner.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-runner.test.ts @@ -9,10 +9,10 @@ import { makeSession, noopInvoke, assertInvalidArgsMessage, -} from './session-test-harness.ts'; -import type { DaemonRequest } from '../../types.ts'; -import { handleSessionCommands } from './session-command-harness.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +} from '../../../handlers/__tests__/session-test-harness.ts'; +import type { DaemonRequest } from '../../../types.ts'; +import { handleSessionCommands } from '../../../handlers/__tests__/session-command-harness.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { const sessionStore = makeSessionStore(); diff --git a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts b/src/daemon/replay/internal/__tests__/session-test-source-discovery.test.ts similarity index 95% rename from src/daemon/handlers/__tests__/session-test-source-discovery.test.ts rename to src/daemon/replay/internal/__tests__/session-test-source-discovery.test.ts index 77cc030680..d35b80afda 100644 --- a/src/daemon/handlers/__tests__/session-test-source-discovery.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-source-discovery.test.ts @@ -3,11 +3,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { buildReplayTestSourceDiscovery } from '../session-test-source-discovery.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, -} from '../../../__tests__/test-utils/replay-script-source.ts'; +} from '../../../../__tests__/test-utils/replay-script-source.ts'; // #1478 P3b: the daemon adapter's inspection capability. #1802 moved expansion and file reading // to the caller, so what is left here is exactly format routing plus per-engine manifest diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-cancellation.test.ts similarity index 96% rename from src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite-command-cancellation.test.ts index ae94c81c46..5e9fd1b1af 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-cancellation.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-cancellation.test.ts @@ -6,7 +6,7 @@ import { markRequestCanceled, registerRequestAbort, } from '@agent-device/host-kit/request'; -import { bindReplayTestAttemptCancellation } from '../session-test-suite-command.ts'; +import { bindReplayTestAttemptCancellation } from '../test-command.ts'; // The daemon half of the replay-test cancellation seam (#1478 P3b). The scheduler only says // "cancel" and "release"; everything here — registry entries, the parent-abort relay, and diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts similarity index 59% rename from src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts index 8086553297..52639b3477 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts @@ -1,20 +1,19 @@ /** * The raw-wire flag policy `test` enforces before a suite runs: replay-only flags must be refused * at the daemon boundary, not silently fanned into every attempt. Moved here with the command - * itself when the test-suite command left `session-replay.ts` (AGENTS.md, tests mirror source + * itself when the test-suite command left the replay command handler (AGENTS.md, tests mirror source * topology). */ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; -import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import { handleSessionReplayCommands } from '../session-replay.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { runReplayTestCommand, type ReplayTestCommand } from '../../index.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; -import { replayCommandFamily } from '../../../commands/replay/index.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { replayCommandFamily } from '../../../../commands/replay/index.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; // --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- @@ -32,6 +31,24 @@ test('raw test-request guards enumerate every daemon-visible replay-only CLI fla assert.deepEqual(guardedDaemonFlags, expectedDaemonFlags); }); +function testCommand( + req: DaemonRequest, + root: string, + sessionStore: SessionStore, + invoke: DaemonInvokeFn, +): ReplayTestCommand { + return { + request: req, + session: { + name: req.session, + logPath: path.join(root, 'daemon.log'), + store: sessionStore, + }, + invoke, + cleanupSession: async () => {}, + }; +} + test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { const root = mkdtempForTestSync('agent-device-test-keep-session-rejected-'); const replayPath = path.join(root, 'flow.ad'); @@ -39,22 +56,21 @@ test('test rejects raw --keep-session with INVALID_ARGS before running the suite const sessionStore = new SessionStore(path.join(root, 'sessions')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayKeepSession: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, - invoke, - }); + const response = await runReplayTestCommand( + testCommand( + { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayKeepSession: true }, + meta: { cwd: root }, + }, + root, + sessionStore, + invoke, + ), + ); if (!response) throw new Error('Expected response'); assert.equal(response.ok, false); @@ -70,24 +86,23 @@ test('test rejects --from with INVALID_ARGS before running the suite', async () fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, - invoke: async () => { - throw new Error('test must not start executing when --from is rejected'); - }, - }); + const response = await runReplayTestCommand( + testCommand( + { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayFrom: 2, replayPlanDigest: 'deadbeef' }, + meta: { cwd: root }, + }, + root, + sessionStore, + async () => { + throw new Error('test must not start executing when --from is rejected'); + }, + ), + ); if (!response) throw new Error('Expected response'); assert.equal(response.ok, false); @@ -102,24 +117,23 @@ test('test rejects --plan-digest alone with INVALID_ARGS before running the suit fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { replayPlanDigest: 'deadbeef' }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, - invoke: async () => { - throw new Error('test must not start executing when --plan-digest is rejected'); - }, - }); + const response = await runReplayTestCommand( + testCommand( + { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayPlanDigest: 'deadbeef' }, + meta: { cwd: root }, + }, + root, + sessionStore, + async () => { + throw new Error('test must not start executing when --plan-digest is rejected'); + }, + ), + ); if (!response) throw new Error('Expected response'); assert.equal(response.ok, false); @@ -135,24 +149,23 @@ test('test rejects --save-script with INVALID_ARGS before running the suite', as fs.writeFileSync(replayPath, 'open "Demo"\nclick "Continue"\n'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { saveScript: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, - invoke: async () => { - throw new Error('test must not start executing when --save-script is rejected'); - }, - }); + const response = await runReplayTestCommand( + testCommand( + { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { saveScript: true }, + meta: { cwd: root }, + }, + root, + sessionStore, + async () => { + throw new Error('test must not start executing when --save-script is rejected'); + }, + ), + ); if (!response) throw new Error('Expected response'); assert.equal(response.ok, false); @@ -168,22 +181,21 @@ test('test rejects raw --force without --save-script before running the suite', const sessionStore = new SessionStore(path.join(root, 'sessions')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - const response = await handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { force: true }, - meta: { cwd: root }, - }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, - invoke, - }); + const response = await runReplayTestCommand( + testCommand( + { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { force: true }, + meta: { cwd: root }, + }, + root, + sessionStore, + invoke, + ), + ); if (!response) throw new Error('Expected response'); assert.equal(response.ok, false); diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-nested-flags.test.ts similarity index 94% rename from src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite-command-nested-flags.test.ts index d6496a3f24..7ba8243395 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-nested-flags.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-nested-flags.test.ts @@ -1,11 +1,11 @@ /** * `buildNestedReplayFlags` — how one suite attempt's flags are projected from the parent `test` * request. Moved here with the function itself when the test-suite command left - * `session-replay.ts`; tests mirror source topology (AGENTS.md). + * the replay command handler; tests mirror source topology (AGENTS.md). */ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { buildNestedReplayFlags } from '../session-test-suite-command.ts'; +import { buildNestedReplayFlags } from '../test-command.ts'; test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => { const parent = { platform: 'android' as const, timeoutMs: 5000 }; diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts similarity index 84% rename from src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts index 50337082a2..19c27f0bc3 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts @@ -1,35 +1,30 @@ -/** - * Per-attempt screen recording inside a `test` suite: the video is finalized exactly once even - * when the request is canceled after recording started. Moved here with the command itself when - * the test-suite command left `session-replay.ts` (AGENTS.md, tests mirror source topology). - */ +/** Per-attempt recording is finalized exactly once after cancellation. */ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; -import { SessionStore } from '../../session-store.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; -import type { DaemonRequest, DaemonResponse } from '../../types.ts'; -import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { handleSessionReplayCommands } from '../session-replay.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { replayScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { SessionStore } from '../../../session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../types.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { runReplayTestCommand } from '../../index.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { replayScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { unavailableBindDevice, unavailableBindExactDevice, -} from '../../__tests__/test-device-runtime-gateway.ts'; -import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; -import type { RecordRuntimeHandlerParams } from '../record-runtime.ts'; +} from '../../../__tests__/test-device-runtime-gateway.ts'; +import { createScreenRecordingAdmissionLedger } from '../../../screen-recording-admission-ledger.ts'; +import type { RecordRuntimeHandlerParams } from '../../../handlers/record-runtime.ts'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; -import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { createReplayTestVideoOwner } from '../../../handlers/session-replay-video-owner.ts'; const recordRuntimeMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), })); -vi.mock('../record-runtime.ts', () => ({ +vi.mock('../../../handlers/record-runtime.ts', () => ({ handleRecordCommand: recordRuntimeMocks.handleRecordCommand, })); @@ -249,33 +244,41 @@ test('test finalizes replay video exactly once when cancellation arrives after s throw cancellation; }); - const responsePromise = handleSessionReplayCommands({ - req: { - token: 'token', - session: 'default', - command: 'test', - positionals: [replayPath], - flags: { - recordVideo: true, - replayKeepSession: false, - saveScript: false, - force: false, - artifactsDir: path.join(root, 'artifacts'), - replayScriptSources: [replayScriptSourceBundleFor(replayPath)], - }, - meta: { cwd: root, requestId: 'record-video-suite' }, + const request: DaemonRequest = { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { + recordVideo: true, + replayKeepSession: false, + saveScript: false, + force: false, + artifactsDir: path.join(root, 'artifacts'), + replayScriptSources: [replayScriptSourceBundleFor(replayPath)], }, - sessionName: 'default', - logPath: path.join(root, 'daemon.log'), + meta: { cwd: root, requestId: 'record-video-suite' }, + }; + const video = createReplayTestVideoOwner({ sessionStore, - leaseRegistry: new LeaseRegistry(), - platformResourceCleanup, bindDevice: unavailableBindDevice, bindExactDevice: unavailableBindExactDevice, screenRecordingAdmissionLedger, requestScope, retainDeviceExecutionLock: async () => {}, throwIfCanceled, + }); + if (!video) throw new Error('Expected replay video owner'); + + const responsePromise = runReplayTestCommand({ + request, + session: { + name: 'default', + logPath: path.join(root, 'daemon.log'), + store: sessionStore, + }, + video, + cleanupSession: async () => {}, invoke: async (nestedReq) => { nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { diff --git a/src/daemon/handlers/__tests__/session-test-suite-infrastructure.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-infrastructure.test.ts similarity index 91% rename from src/daemon/handlers/__tests__/session-test-suite-infrastructure.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite-infrastructure.test.ts index ab47d9e6f4..545b3baf37 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-infrastructure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-infrastructure.test.ts @@ -2,12 +2,12 @@ import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import type { DaemonResponse } from '../../types.ts'; -import { handleSessionCommands } from './session-command-harness.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonResponse } from '../../../types.ts'; +import { handleSessionCommands } from '../../../handlers/__tests__/session-command-harness.ts'; import { expectOkData, makeSessionStore } from './session-test-suite.fixtures.ts'; -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(async () => { throw new Error('no device runner available in this test'); }), diff --git a/src/daemon/handlers/__tests__/session-test-suite.fixtures.ts b/src/daemon/replay/internal/__tests__/session-test-suite.fixtures.ts similarity index 70% rename from src/daemon/handlers/__tests__/session-test-suite.fixtures.ts rename to src/daemon/replay/internal/__tests__/session-test-suite.fixtures.ts index 96bf3382e9..35b798c54f 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.fixtures.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite.fixtures.ts @@ -1,8 +1,8 @@ import { expect } from 'vitest'; import path from 'node:path'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonResponse, DaemonResponseData } from '../../types.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { SessionStore } from '../../../session-store.ts'; +import type { DaemonResponse, DaemonResponseData } from '../../../types.ts'; export function makeSessionStore(): SessionStore { const root = mkdtempForTestSync('agent-device-session-test-suite-'); diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite.test.ts similarity index 98% rename from src/daemon/handlers/__tests__/session-test-suite.test.ts rename to src/daemon/replay/internal/__tests__/session-test-suite.test.ts index e9d59dc1c4..382ec589ba 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite.test.ts @@ -1,6 +1,6 @@ import type { RequestProgressEvent } from '@agent-device/contracts/progress'; import { test, expect, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; // ADR 0012 migration step 2: every replay step failure now attempts a // post-failure screen digest capture + suggestion re-resolution through the @@ -8,7 +8,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // device runner, so without a mock those calls fall through to the real // (slow/hanging) runner dispatch path. Reject fast so failure-path tests keep // exercising `divergence.screen: unavailable` deterministically. -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(async () => { throw new Error('no device runner available in this test'); }), @@ -17,8 +17,11 @@ vi.mock('../snapshot-interactor-capture.ts', () => ({ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts'; -import type { DaemonRequest } from '../../types.ts'; +import { + handleSessionCommands, + mockInspectDeviceRuntimeFacts, +} from '../../../handlers/__tests__/session-command-harness.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { expectOkData, makeSessionStore } from './session-test-suite.fixtures.ts'; import { withRequestProgressSink, @@ -28,12 +31,12 @@ import { registerRequestAbort, } from '@agent-device/host-kit/request'; -import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeAndroidSession, makeMacOsSession, -} from '../../../__tests__/test-utils/session-factories.ts'; +} from '../../../../__tests__/test-utils/session-factories.ts'; const ANDROID_ONE: DeviceInfo = { platform: 'android', diff --git a/src/daemon/replay/internal/command-types.ts b/src/daemon/replay/internal/command-types.ts new file mode 100644 index 0000000000..dba5dba512 --- /dev/null +++ b/src/daemon/replay/internal/command-types.ts @@ -0,0 +1,47 @@ +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; +import type { ReplaySessionStore as ReplaySessionStoreCapability } from '../../session-replay-coordinator.ts'; + +export type ReplaySessionStore = ReplaySessionStoreCapability; + +export type ReplaySession = Readonly<{ + /** The effective SessionStore key selected by request binding. */ + name: string; + logPath: string; + store: ReplaySessionStore; +}>; + +export type ReplayCommand = Readonly<{ + request: DaemonRequest; + session: ReplaySession; + invoke: DaemonInvokeFn; + tracePath?: string; + onStep?: ReplayTestAttemptStepSink; +}>; + +type ReplayRequestContext = Readonly<{ + token: DaemonRequest['token']; + meta: DaemonRequest['meta']; +}>; + +export type ReplayRecordVideoRequest = Readonly<{ + request: ReplayRequestContext; + sessionName: string; + phase: 'start' | 'stop'; + outputPath?: string; +}>; + +export type ReplayRecordVideo = (params: ReplayRecordVideoRequest) => Promise; + +export type ReplayTestVideoOwner = Readonly<{ + record: ReplayRecordVideo; + throwIfCanceled: () => void; +}>; + +type ReplayTestSessionCleanup = (sessionName: string) => Promise; + +export type ReplayTestCommand = ReplayCommand & + Readonly<{ + cleanupSession: ReplayTestSessionCleanup; + video?: ReplayTestVideoOwner; + }>; diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/replay/internal/native-command.ts similarity index 91% rename from src/daemon/handlers/session-replay-runtime.ts rename to src/daemon/replay/internal/native-command.ts index 9ef99bd570..6805364bba 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/replay/internal/native-command.ts @@ -1,28 +1,27 @@ import { asAppError } from '@agent-device/kernel/errors'; -import type { DaemonResponse, SessionState } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { errorResponse } from './response.ts'; +import type { DaemonResponse, SessionState } from '../../types.ts'; +import { errorResponse } from '../../handlers/response.ts'; import { runAdReplay } from '@agent-device/ad-replay'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import { isMaestroYamlPath, maestroBackendRequiredMessage } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import { createReplayCoordinator, type ReplayCoordinator } from '../session-replay-coordinator.ts'; +import { + createReplayCoordinator, + type ReplayCoordinator, +} from '../../session-replay-coordinator.ts'; import { createAdReplayStepRuntime, type ReplayStepContext, } from './session-replay-runtime-engine-adapter.ts'; -import { - prepareReplayPlan, - routeMaestroReplay, - type ReplayScriptSourceRunParams, -} from './session-replay-runtime-plan.ts'; +import { prepareReplayPlan, routeMaestroReplay } from './session-replay-runtime-plan.ts'; import { readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, -} from '../replay-script-source.ts'; +} from '../../replay-script-source.ts'; import { prepareReplaySession } from './session-replay-runtime-session.ts'; +import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; /** * #1555 P5 (decomposition): the replay request's own orchestration — routing, plan resolution, @@ -37,7 +36,7 @@ import { prepareReplaySession } from './session-replay-runtime-session.ts'; * - the `AdReplayStepRuntime` engine adapter (`createAdReplayStepRuntime`, its `build*Failure` * capability implementations, and the `lastResponse`/`lastObservation` side-map mechanics) * lives in `session-replay-runtime-engine-adapter.ts`. - * This file is what remains: the one place `runReplayScriptSource` composes them, and the run's + * This file is what remains: the one place `runReplayCommand` composes them, and the run's * completion (`completeReplayRun`/`requireLiveSessionForKeepSession`), which runs after the engine * loop returns and never touches the step runtime itself. * @@ -50,15 +49,19 @@ import { prepareReplaySession } from './session-replay-runtime-session.ts'; /** * #1802: the run's script text arrives IN THE REQUEST (a replay script source * bundle the caller read and resolved), never as a path this process opens. - * That is why this is `runReplayScriptSource` and no longer the old + * That is why this is `runReplayCommand` and no longer the old * `…ScriptFile`: a remote daemon shares no filesystem with the caller, * so a handler that opened `req.positionals[0]` could only ever work when the * two happened to be the same host. */ -export async function runReplayScriptSource( - params: ReplayScriptSourceRunParams, -): Promise { - const { req, sessionName, logPath, sessionStore, tracePath, onStep, invoke } = params; +export async function runReplayCommand(command: ReplayCommand): Promise { + const { + request: req, + session: { name: sessionName, logPath, store: sessionStore }, + tracePath, + onStep, + invoke, + } = command; const bundle = req.flags?.replayScriptSource; if (!bundle) { return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); @@ -83,10 +86,9 @@ export async function runReplayScriptSource( } const maestroResponse = await routeMaestroReplay({ resolved, - req, keepSession, coordinator, - maestroParams: params, + command, }); if (maestroResponse) return maestroResponse; const planPreparation = prepareReplayPlan({ @@ -193,7 +195,7 @@ export async function runReplayScriptSource( function completeReplayRun(params: { startedAt: number; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; replayed: number; artifactPaths: readonly string[]; snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/replay/internal/session-replay-action-runtime.ts similarity index 96% rename from src/daemon/handlers/session-replay-action-runtime.ts rename to src/daemon/replay/internal/session-replay-action-runtime.ts index b524d93c46..618c99ea8e 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/replay/internal/session-replay-action-runtime.ts @@ -1,17 +1,17 @@ import type { SessionAction } from '@agent-device/contracts/session'; import type { CommandFlags } from '@agent-device/contracts/command'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import { mergeParentFlags } from '../../core/batch.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; +import { mergeParentFlags } from '../../../core/batch.ts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { gesturePayloadFromPositionals, swipePayloadFromPositionals, } from '@agent-device/contracts/gesture-normalization'; -import { buildDisplayPositionals } from '../session-event-action.ts'; +import { buildDisplayPositionals } from '../../session-event-action.ts'; import { appendReplayTraceEvent } from './session-replay-trace.ts'; -import { inferFillText } from '../action-utils.ts'; +import { inferFillText } from '../../action-utils.ts'; import { readRecordedInputVariableName } from '@agent-device/ad-script'; -import { resolveSessionScope } from '../session-routing.ts'; +import { resolveSessionScope } from '../../session-routing.ts'; type ReplayBaseRequest = Omit; diff --git a/src/daemon/handlers/session-replay-dispatch-narrowing.ts b/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts similarity index 98% rename from src/daemon/handlers/session-replay-dispatch-narrowing.ts rename to src/daemon/replay/internal/session-replay-dispatch-narrowing.ts index f870215185..59cbb25c1e 100644 --- a/src/daemon/handlers/session-replay-dispatch-narrowing.ts +++ b/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts @@ -1,4 +1,4 @@ -import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import type { AdReplayDispatchGuard, AdReplayDispatchOutcome, diff --git a/src/daemon/handlers/session-replay-divergence-publication.ts b/src/daemon/replay/internal/session-replay-divergence-publication.ts similarity index 96% rename from src/daemon/handlers/session-replay-divergence-publication.ts rename to src/daemon/replay/internal/session-replay-divergence-publication.ts index 160fd369e7..41eef1ff08 100644 --- a/src/daemon/handlers/session-replay-divergence-publication.ts +++ b/src/daemon/replay/internal/session-replay-divergence-publication.ts @@ -6,8 +6,8 @@ import { boundReplayDivergence, type ReplayDivergence } from '@agent-device/cont import { bindInternalObservationAuthority, type InternalObservationEvidence, -} from '../internal-observation.ts'; -import { SessionStore } from '../session-store.ts'; +} from '../../internal-observation.ts'; +import type { ReplaySessionStore } from './command-types.ts'; /** * Daemon-owned replay projection and publication boundary. The response or @@ -15,7 +15,7 @@ import { SessionStore } from '../session-store.ts'; * synchronously from that exact successful projection. */ export function boundReplayDivergenceForSession(params: { - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; divergence: ReplayDivergence; responseLevel: ResponseLevel | undefined; @@ -109,7 +109,7 @@ function removeUnpublishedOverflowArtifact(artifactPath: string | undefined): vo } function writeReplayDivergenceArtifact( - sessionStore: SessionStore, + sessionStore: ReplaySessionStore, sessionName: string, payload: ReplayDivergence, ): { artifactPath: string } | { artifactUnavailable: true } { diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/replay/internal/session-replay-divergence.ts similarity index 97% rename from src/daemon/handlers/session-replay-divergence.ts rename to src/daemon/replay/internal/session-replay-divergence.ts index 3b71272194..cac8f859b0 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/replay/internal/session-replay-divergence.ts @@ -3,30 +3,30 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { sleep } from '@agent-device/host-kit/retry'; import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapshot-quality-verdict'; -import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; +import { displayLabel, formatRole } from '../../../snapshot/snapshot-lines.ts'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { captureSnapshot } from './snapshot-capture.ts'; +import { captureSnapshot } from '../../handlers/snapshot-capture.ts'; import { collectReplaySelectorCandidates } from './session-replay-heal.ts'; import { buildSelectorCandidates, resolveReplaySuggestionCandidate } from '@agent-device/selectors'; -import { collectSettleChromeRefs } from '../../core/snapshot-chrome.ts'; +import { collectSettleChromeRefs } from '../../../core/snapshot-chrome.ts'; import { buildAndPersistReplayDivergenceResume } from './session-replay-resume.ts'; import { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; import { computeReplayRepairHint, type ReplayRepairHintCapture, } from './session-replay-repair-hint.ts'; -import type { SessionStore } from '../session-store.ts'; -import type { ReplayResumeStamper } from '../session-replay-coordinator.ts'; +import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; import { bindInternalObservationAuthority, type InternalObservationEvidence, -} from '../internal-observation.ts'; +} from '../../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; -import type { SessionState } from '../types.ts'; +import type { SessionState } from '../../types.ts'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, createReplayDivergenceSanitizer, @@ -55,7 +55,7 @@ export async function buildReplayFailureDivergence(params: { sourceLine: number; session: SessionState | undefined; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; @@ -238,7 +238,7 @@ const DIVERGENCE_CAPTURE_RETRY_DELAYS_MS = [300, 500, 800, 1200, 2000, 3000, 400 export async function captureDivergenceObservation(params: { session: SessionState; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; action: ReplayReportAction; retryLaunchRace?: boolean; @@ -294,7 +294,7 @@ type DivergenceCaptureAttempt = { async function captureDivergenceObservationAttempt(params: { session: SessionState; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; flags: CommandFlags; }): Promise { diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/replay/internal/session-replay-heal.ts similarity index 100% rename from src/daemon/handlers/session-replay-heal.ts rename to src/daemon/replay/internal/session-replay-heal.ts diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/replay/internal/session-replay-maestro-failure.ts similarity index 96% rename from src/daemon/handlers/session-replay-maestro-failure.ts rename to src/daemon/replay/internal/session-replay-maestro-failure.ts index ed550dbd29..44ef0f518e 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/replay/internal/session-replay-maestro-failure.ts @@ -10,8 +10,8 @@ import { } from '@agent-device/contracts/divergence'; import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import { SessionStore } from '../session-store.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; +import type { ReplaySessionStore } from './command-types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { @@ -27,7 +27,7 @@ import { buildReplayDivergenceFailureResponseFromDescriptor, hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; -import { adaptMaestroFailureSnapshot } from '../adapters/maestro/failure-snapshot.ts'; +import { adaptMaestroFailureSnapshot } from '../../adapters/maestro/failure-snapshot.ts'; export type MaestroFailureReportAction = Pick< ReplayReportAction, @@ -67,7 +67,7 @@ export async function buildTypedMaestroFailureResponse(params: { readonly replayPath: string; readonly req: DaemonRequest; readonly sessionName: string; - readonly sessionStore: SessionStore; + readonly sessionStore: ReplaySessionStore; readonly logPath: string; readonly snapshotDiagnostics?: SnapshotDiagnosticsSummary; }): Promise { diff --git a/src/daemon/handlers/session-replay-maestro-observer.ts b/src/daemon/replay/internal/session-replay-maestro-observer.ts similarity index 100% rename from src/daemon/handlers/session-replay-maestro-observer.ts rename to src/daemon/replay/internal/session-replay-maestro-observer.ts diff --git a/src/daemon/handlers/session-replay-maestro-response.ts b/src/daemon/replay/internal/session-replay-maestro-response.ts similarity index 65% rename from src/daemon/handlers/session-replay-maestro-response.ts rename to src/daemon/replay/internal/session-replay-maestro-response.ts index 88b472dd3f..756b1817cc 100644 --- a/src/daemon/handlers/session-replay-maestro-response.ts +++ b/src/daemon/replay/internal/session-replay-maestro-response.ts @@ -2,18 +2,24 @@ import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import type { MaestroExecutionOutcome } from '@agent-device/maestro'; import { normalizeError } from '@agent-device/kernel/errors'; import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; -import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; +import type { DaemonResponse } from '../../types.ts'; import { buildTypedMaestroFailureResponse } from './session-replay-maestro-failure.ts'; +import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; -export function buildTypedMaestroSuccessResponse(params: { +type TypedMaestroSuccessResponseParams = Readonly<{ + command: ReplayCommand; outcome: Extract; startedAt: number; - sessionName: string; - sessionStore: SessionStore; snapshotStart: number; -}): DaemonResponse { - const { outcome, startedAt, sessionName, sessionStore, snapshotStart } = params; +}>; + +export function buildTypedMaestroSuccessResponse( + params: TypedMaestroSuccessResponseParams, +): DaemonResponse { + const { command, outcome, startedAt, snapshotStart } = params; + const { + session: { name: sessionName, store: sessionStore }, + } = command; const snapshotDiagnostics = readSnapshotDiagnostics(sessionStore, sessionName, snapshotStart); const replayed = outcome.replayed; return { @@ -31,34 +37,36 @@ export function buildTypedMaestroSuccessResponse(params: { }; } -export async function buildTypedMaestroReplayErrorResponse(params: { - req: DaemonRequest; +type TypedMaestroErrorResponseParams = Readonly<{ + command: ReplayCommand; /** The entry flow's caller-resolved display path (the replay script source bundle's entry). */ replayPath: string; state: { snapshotStart: number; }; outcome: Extract; - sessionName: string; - sessionStore: SessionStore; - logPath: string; -}): Promise { - const { failure } = params.outcome; - const normalizedError = normalizeError(params.outcome.error); +}>; + +export async function buildTypedMaestroReplayErrorResponse( + params: TypedMaestroErrorResponseParams, +): Promise { + const { command, replayPath, state, outcome } = params; + const { + request: req, + session: { name: sessionName, store: sessionStore, logPath }, + } = command; + const { failure } = outcome; + const normalizedError = normalizeError(outcome.error); if (failure) { return await buildTypedMaestroFailureResponse({ error: normalizedError, failure, - replayPath: params.replayPath, - req: params.req, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - logPath: params.logPath, - snapshotDiagnostics: readSnapshotDiagnostics( - params.sessionStore, - params.sessionName, - params.state.snapshotStart, - ), + replayPath, + req, + sessionName, + sessionStore, + logPath, + snapshotDiagnostics: readSnapshotDiagnostics(sessionStore, sessionName, state.snapshotStart), }); } // The normalized error IS the wire error shape. Returning it whole keeps the @@ -68,7 +76,7 @@ export async function buildTypedMaestroReplayErrorResponse(params: { } function readSnapshotDiagnostics( - sessionStore: SessionStore, + sessionStore: ReplaySessionStore, sessionName: string, snapshotStart: number, ) { diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/replay/internal/session-replay-maestro-runtime.ts similarity index 87% rename from src/daemon/handlers/session-replay-maestro-runtime.ts rename to src/daemon/replay/internal/session-replay-maestro-runtime.ts index b4fedcc5e3..072f0cb7c8 100644 --- a/src/daemon/handlers/session-replay-maestro-runtime.ts +++ b/src/daemon/replay/internal/session-replay-maestro-runtime.ts @@ -7,7 +7,7 @@ import { type MaestroPlatform, } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; -import { resolveTargetDevice } from '../../core/dispatch-resolve.ts'; +import { resolveTargetDevice } from '../../../core/dispatch-resolve.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { stripUndefined } from '@agent-device/kernel/record'; import { @@ -16,36 +16,25 @@ import { readReplayCliEnvEntries, readReplayShellEnvSource, } from '@agent-device/ad-script'; -import { createDaemonMaestroRuntimePort } from '../adapters/maestro/daemon-runtime-port.ts'; +import { createDaemonMaestroRuntimePort } from '../../adapters/maestro/daemon-runtime-port.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import { assertSessionSelectorMatches } from '../session-selector.ts'; -import { SessionStore } from '../session-store.ts'; -import { errorResponse } from './response.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; +import { assertSessionSelectorMatches } from '../../session-selector.ts'; +import { errorResponse } from '../../handlers/response.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; import { createMaestroReplayObserver } from './session-replay-maestro-observer.ts'; -import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; import { buildTypedMaestroReplayErrorResponse, buildTypedMaestroSuccessResponse, } from './session-replay-maestro-response.ts'; -import { resolveEffectiveOpenRuntimeHints } from './session-runtime.ts'; -import { buildMaestroReplayTargetDeviceResolutionOptions } from '../replay-device-selection.ts'; +import { resolveEffectiveOpenRuntimeHints } from '../../handlers/session-runtime.ts'; +import { buildMaestroReplayTargetDeviceResolutionOptions } from '../../replay-device-selection.ts'; import { readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, -} from '../replay-script-source.ts'; +} from '../../replay-script-source.ts'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; - -type TypedMaestroReplayParams = { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - tracePath?: string; - onStep?: ReplayTestAttemptStepSink; - invoke: DaemonInvokeFn; -}; +import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; type TypedMaestroReplayState = { snapshotStart: number; @@ -68,10 +57,8 @@ type MaestroReplayBinding = Pick< 'device' | 'platform' | 'target' | 'runtimeHints' >; -export async function runTypedMaestroReplay( - params: TypedMaestroReplayParams, -): Promise { - const { req } = params; +export async function runTypedMaestroReplay(command: ReplayCommand): Promise { + const { request: req } = command; const bundle = req.flags?.replayScriptSource; if (!bundle) return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); if (req.flags?.saveScript !== undefined) { @@ -84,14 +71,14 @@ export async function runTypedMaestroReplay( const state: TypedMaestroReplayState = { snapshotStart: 0 }; try { return await executeTypedMaestroReplay({ - ...params, + ...command, bundle, startedAt, state, }); } catch (error) { return await buildTypedMaestroReplayErrorResponse({ - ...params, + command, replayPath: bundle.entry, state, outcome: { ok: false, error }, @@ -100,13 +87,22 @@ export async function runTypedMaestroReplay( } async function executeTypedMaestroReplay( - params: TypedMaestroReplayParams & { + params: ReplayCommand & { bundle: ReplayScriptSourceBundle; startedAt: number; state: TypedMaestroReplayState; }, ): Promise { - const { req, sessionName, sessionStore, tracePath, onStep, invoke, state } = params; + const { + request: req, + session: { name: sessionName, store: sessionStore }, + tracePath, + onStep, + invoke, + bundle, + startedAt, + state, + } = params; const context = await prepareTypedMaestroReplay(params); const port = createMaestroReplayPort({ req, @@ -128,7 +124,7 @@ async function executeTypedMaestroReplay( planDigest: req.flags?.replayPlanDigest, // #1802: `runFlow` includes resolve out of the caller's bundle, so a local // and a remote run compile the same flow closure. - readSource: (includePath) => readReplayScriptSourceFile(params.bundle, includePath), + readSource: (includePath) => readReplayScriptSourceFile(bundle, includePath), observer: createMaestroReplayObserver({ filePath: context.filePath, tracePath, @@ -137,25 +133,28 @@ async function executeTypedMaestroReplay( }); if (!outcome.ok) { return await buildTypedMaestroReplayErrorResponse({ - ...params, - replayPath: params.bundle.entry, + command: params, + replayPath: bundle.entry, state, outcome, }); } return buildTypedMaestroSuccessResponse({ outcome, - startedAt: params.startedAt, - sessionName, - sessionStore, + command: params, + startedAt, snapshotStart: state.snapshotStart, }); } async function prepareTypedMaestroReplay( - params: TypedMaestroReplayParams & { bundle: ReplayScriptSourceBundle }, + params: ReplayCommand & { bundle: ReplayScriptSourceBundle }, ): Promise { - const { req, bundle, sessionName, sessionStore } = params; + const { + request: req, + session: { name: sessionName, store: sessionStore }, + bundle, + } = params; const filePath = bundle.entry; const flow = inspectMaestroFlow(readReplayScriptSourceFile(bundle, filePath), filePath); // `sessionName` is the resolved store key, so the lookup carries the address a selector @@ -188,9 +187,9 @@ async function prepareTypedMaestroReplay( async function resolveMaestroReplayBinding(params: { req: DaemonRequest; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; - session: ReturnType; + session: ReturnType; flow: MaestroFlow; }): Promise { const { req, sessionStore, sessionName, session, flow } = params; @@ -226,7 +225,7 @@ async function resolveMaestroReplayBinding(params: { async function completeMaestroRuntimeBinding( params: { req: DaemonRequest; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; flow: MaestroFlow; } & MaestroReplayBinding, diff --git a/src/daemon/handlers/session-replay-repair-hint.ts b/src/daemon/replay/internal/session-replay-repair-hint.ts similarity index 99% rename from src/daemon/handlers/session-replay-repair-hint.ts rename to src/daemon/replay/internal/session-replay-repair-hint.ts index fd4a216fbf..81fc7260a4 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/replay/internal/session-replay-repair-hint.ts @@ -24,7 +24,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; import { matchesAncestryPrefix, buildAncestryChain, buildIndexMap } from '@agent-device/ad-script'; import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/contracts/replay'; -import { computeScrollRegionKey, scrollRegionKeysEqual } from '../session-target-evidence.ts'; +import { computeScrollRegionKey, scrollRegionKeysEqual } from '../../session-target-evidence.ts'; export type ReplayRepairHintCapture = | { state: 'available'; nodes: SnapshotNode[] } diff --git a/src/daemon/handlers/session-replay-report-action.ts b/src/daemon/replay/internal/session-replay-report-action.ts similarity index 100% rename from src/daemon/handlers/session-replay-report-action.ts rename to src/daemon/replay/internal/session-replay-report-action.ts diff --git a/src/daemon/handlers/session-replay-resume.ts b/src/daemon/replay/internal/session-replay-resume.ts similarity index 97% rename from src/daemon/handlers/session-replay-resume.ts rename to src/daemon/replay/internal/session-replay-resume.ts index 5cee032d16..3082824752 100644 --- a/src/daemon/handlers/session-replay-resume.ts +++ b/src/daemon/replay/internal/session-replay-resume.ts @@ -1,6 +1,6 @@ import type { SessionAction } from '@agent-device/contracts/session'; import type { ReplayDivergenceResume, ReplayRepairHint } from '@agent-device/contracts/divergence'; -import type { ReplayResumeStamper } from '../session-replay-coordinator.ts'; +import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; /** * Builds the `resume` object and, through the caller's `ReplayResumeStamper` — the narrow @@ -50,7 +50,7 @@ export function buildAndPersistReplayDivergenceResume(params: { * `from` is at most `actions.length + 1` — never further out of range. That * boundary case (`record-and-heal` diverged on the plan's LAST step) is a * legal EMPTY-TAIL resume, not an error: the runtime loop - * (`runReplayScriptSource`) executes zero steps and reaches the normal + * (`runReplayCommand`) executes zero steps and reaches the normal * end-of-plan completion path, correctly flipping a repair transaction * COMPLETE. Rejecting it would send the agent to `close` instead — * which discards the just-recorded corrective action, since commit is gated diff --git a/src/daemon/handlers/session-replay-runtime-artifacts.ts b/src/daemon/replay/internal/session-replay-runtime-artifacts.ts similarity index 97% rename from src/daemon/handlers/session-replay-runtime-artifacts.ts rename to src/daemon/replay/internal/session-replay-runtime-artifacts.ts index 4995c78d1d..bd7c0b549a 100644 --- a/src/daemon/handlers/session-replay-runtime-artifacts.ts +++ b/src/daemon/replay/internal/session-replay-runtime-artifacts.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import type { DaemonResponse } from '../types.ts'; +import type { DaemonResponse } from '../../types.ts'; export function collectReplayActionArtifactPaths(response: DaemonResponse): string[] { const candidates = response.ok diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts similarity index 95% rename from src/daemon/handlers/session-replay-runtime-engine-adapter.ts rename to src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts index 931b791c60..2bf1265b68 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts @@ -1,12 +1,11 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { errorResponse } from './response.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; +import { errorResponse } from '../../handlers/response.ts'; import { readReplaySelectorDisplayValue } from '@agent-device/selectors'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; -import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; +import type { ReplayCoordinator } from '../../session-replay-coordinator.ts'; import { invokeReplayAction } from './session-replay-action-runtime.ts'; import type { AdReplayStepFailure, AdReplayStepRuntime } from '@agent-device/ad-replay'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; @@ -28,11 +27,12 @@ import { type TargetBindingDivergenceContext, } from './session-replay-target-verification.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { ReplaySessionStore } from './command-types.ts'; /** * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out - * of `session-replay-runtime.ts`, which now only constructs a `ReplayStepContext` and calls - * `createAdReplayStepRuntime`. See that file's `runReplayScriptSource` for the request-level + * of `native-command.ts`, which now only constructs a `ReplayStepContext` and calls + * `createAdReplayStepRuntime`. See that file's `runReplayCommand` for the request-level * orchestration this adapter plugs into. * * The wire-narrowing concern (guard threading, `details` bag -> typed @@ -63,7 +63,7 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; * can end a step (`dispatchStep`, the three `build*Failure` capabilities, and * `handleActionFailure`) records the wire response it just built here before * projecting it down to the neutral `AdReplayStepOutcome`/`AdReplayStepFailure` - * the engine actually sees; `readLastResponse` lets `runReplayScriptSource` + * the engine actually sees; `readLastResponse` lets `runReplayCommand` * recover the exact final response once `runAdReplay` reports which step * failed, so the client-visible wire output never changes even though the * engine itself never touches it. @@ -91,11 +91,11 @@ export function createAdReplayStepRuntime(params: { ctx: ReplayStepContext; req: DaemonRequest; /** - * The run's ONE artifact ledger, owned by `runReplayScriptSource`. `dispatchStep` + * The run's ONE artifact ledger, owned by `runReplayCommand`. `dispatchStep` * is its only writer, and returns its contents for the engine to thread as a * plain value — the engine keeps no accumulator of its own (#1478 P5 * follow-up; see `@agent-device/ad-replay`'s `step-loop.ts` header). Also what - * `runReplayScriptSource`'s catch block reports, so a mid-loop throw still names + * `runReplayCommand`'s catch block reports, so a mid-loop throw still names * the artifacts collected up to that point. */ artifactPaths: Set; @@ -330,7 +330,7 @@ export function createAdReplayStepRuntime(params: { export type ReplayStepContext = { replayReq: DaemonRequest; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; resolved: string; actions: SessionAction[]; @@ -432,12 +432,15 @@ function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): bo return typeof kind === 'string' && kind !== 'action-failure'; } -function readSessionSnapshotSampleCount(sessionStore: SessionStore, sessionName: string): number { +function readSessionSnapshotSampleCount( + sessionStore: ReplaySessionStore, + sessionName: string, +): number { return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; } function readSessionSnapshotSamplesSince( - sessionStore: SessionStore, + sessionStore: ReplaySessionStore, sessionName: string, start: number, ): SnapshotTimingSample[] { diff --git a/src/daemon/handlers/session-replay-runtime-failure-response.ts b/src/daemon/replay/internal/session-replay-runtime-failure-response.ts similarity index 96% rename from src/daemon/handlers/session-replay-runtime-failure-response.ts rename to src/daemon/replay/internal/session-replay-runtime-failure-response.ts index 10fc5857cf..d3bfd30d03 100644 --- a/src/daemon/handlers/session-replay-runtime-failure-response.ts +++ b/src/daemon/replay/internal/session-replay-runtime-failure-response.ts @@ -2,8 +2,8 @@ import type { SessionAction } from '@agent-device/contracts/session'; import { scrubReplayVarValues, type ReplayVarScrubEntry } from '@agent-device/contracts/divergence'; import { formatDivergenceActionLabel } from '@agent-device/ad-script'; import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; -import { buildDisplayPositionals } from '../session-event-action.ts'; -import type { DaemonResponse } from '../types.ts'; +import { buildDisplayPositionals } from '../../session-event-action.ts'; +import type { DaemonResponse } from '../../types.ts'; export type ReplayFailureCause = Extract['error']; diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/replay/internal/session-replay-runtime-failure.ts similarity index 93% rename from src/daemon/handlers/session-replay-runtime-failure.ts rename to src/daemon/replay/internal/session-replay-runtime-failure.ts index ef4e02abca..1a0007e841 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/replay/internal/session-replay-runtime-failure.ts @@ -5,15 +5,15 @@ import { type SnapshotDiagnosticsSummary, type SnapshotTimingSample, } from '@agent-device/contracts/capture'; -import type { SessionStore } from '../session-store.ts'; -import type { ReplayResumeStamper } from '../session-replay-coordinator.ts'; -import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { buildReplayFailureDivergence } from './session-replay-divergence.ts'; import { buildReplayDivergenceFailureResponse, hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; +import type { ReplaySessionStore } from './command-types.ts'; export async function withReplayFailureDiagnostics(params: { response: DaemonResponse; @@ -28,7 +28,7 @@ export async function withReplayFailureDiagnostics(params: { scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; @@ -54,7 +54,7 @@ async function withReplayFailureContext(params: { scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/replay/internal/session-replay-runtime-plan.ts similarity index 81% rename from src/daemon/handlers/session-replay-runtime-plan.ts rename to src/daemon/replay/internal/session-replay-runtime-plan.ts index 7365071dd8..672fb9142e 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/replay/internal/session-replay-runtime-plan.ts @@ -1,10 +1,10 @@ import type { SessionAction } from '@agent-device/contracts/session'; import type { CommandFlags } from '@agent-device/contracts/command'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; -import { errorResponse } from './response.ts'; -import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; +import type { ReplayCoordinator } from '../../session-replay-coordinator.ts'; +import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; +import { errorResponse } from '../../handlers/response.ts'; +import { buildReplayScriptPlatformFlags } from '../../replay-device-selection.ts'; import { inspectAdReplay, type AdReplayManifest, @@ -20,54 +20,31 @@ import { } from '@agent-device/ad-script'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; import { runTypedMaestroReplay } from './session-replay-maestro-runtime.ts'; -import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; /** - * #1555 P5 (decomposition): `runReplayScriptSource`'s (`session-replay-runtime.ts`) plan-side + * #1555 P5 (decomposition): `runReplayCommand`'s (`native-command.ts`) plan-side * helpers — everything that inspects the script, resolves its `--from`/`--plan-digest` entry * point, and routes a Maestro-format request, before any session-mutating work begins. Extracted * verbatim; `buildReplayMetadataFlags` (below) was already here from the #1555 review pass — see * its own comment for why it, alone among the digest/resume math, stayed daemon-side. */ -/** - * `runReplayScriptSource`'s own parameter shape, named here (rather than derived at the call site - * via `Parameters`) so `routeMaestroReplay` below can reference it - * without importing back from `session-replay-runtime.ts` — that direction would be a cycle now - * that the Maestro routing decision lives in this module instead of alongside the function it - * routes for. `session-replay-runtime.ts` imports this type instead of restating the params. - */ -export type ReplayScriptSourceRunParams = { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - tracePath?: string; - /** - * Per-attempt step sink supplied by the replay-test scheduler through its host (#1478 P3). - * Threaded alongside `tracePath` rather than read from request-global storage, so a direct - * `replay` simply has no sink and emits nothing. - */ - onStep?: ReplayTestAttemptStepSink; - invoke: DaemonInvokeFn; -}; - /** * Routes a Maestro-format request to the typed Maestro engine, rejecting * `--keep-session` (native-`.ad`-only lifecycle) and an active `.ad` * `--save-script` repair boundary first. Returns `undefined` for a non-Maestro - * request so `runReplayScriptSource` continues down the native `.ad` path — - * extracted from `runReplayScriptSource` itself (fallow complexity) rather than + * request so the replay command continues down the native `.ad` path — + * extracted from the replay command itself (fallow complexity) rather than * split further, since every branch here is this one routing decision. */ export async function routeMaestroReplay(params: { resolved: string; - req: DaemonRequest; keepSession: boolean; coordinator: ReplayCoordinator; - maestroParams: ReplayScriptSourceRunParams; + command: ReplayCommand; }): Promise { - const { resolved, req, keepSession, coordinator, maestroParams } = params; + const { resolved, keepSession, coordinator, command } = params; + const { request: req } = command; if (resolveReplayFormat(resolved, req.flags?.replayBackend) !== 'maestro') return undefined; if (keepSession) { return errorResponse( @@ -81,7 +58,7 @@ export async function routeMaestroReplay(params: { 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', ); } - return await runTypedMaestroReplay(maestroParams); + return await runTypedMaestroReplay(command); } export type PreparedReplayPlan = { @@ -105,7 +82,7 @@ export type PreparedReplayPlan = { export function prepareReplayPlan(params: { req: DaemonRequest; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; tracePath: string | undefined; resolved: string; /** The entry script's text, taken from the request's replay script source bundle (#1802). */ @@ -156,7 +133,7 @@ export function prepareReplayPlan(params: { * `inspectAdReplay` that reaches this point with a non-Maestro request) * matches `parseReplayInput` exactly, byte for * byte, before any plan/session work begins. `replayBackend: 'maestro'` still - * passes here because `runReplayScriptSource` has already routed a real + * passes here because `runReplayCommand` has already routed a real * Maestro-format request to `runTypedMaestroReplay` above; only a * stray/unknown value reaches this branch. */ @@ -257,12 +234,12 @@ function buildPreparedReplayVarSources(params: { * resume math as a `resolveEntryIndex` closure, both computed from the SAME * effective platform/target precedence this file used to apply itself. Only * `buildReplayMetadataFlags` stays here: it builds the REQUEST's flags (used - * throughout `runReplayScriptSource`, not just for the digest), which is a + * throughout `runReplayCommand`, not just for the digest), which is a * daemon/wire concern the manifest has no reason to own. * * Module-private as of the #1555 P5 decomposition: its one caller, * `applyReplayMetadata`, now lives in this same file (it used to live in - * `session-replay-runtime.ts`). + * `native-command.ts`). */ function buildReplayMetadataFlags( flags: CommandFlags | undefined, diff --git a/src/daemon/handlers/session-replay-runtime-session.ts b/src/daemon/replay/internal/session-replay-runtime-session.ts similarity index 93% rename from src/daemon/handlers/session-replay-runtime-session.ts rename to src/daemon/replay/internal/session-replay-runtime-session.ts index 5e8876edd5..099df0babe 100644 --- a/src/daemon/handlers/session-replay-runtime-session.ts +++ b/src/daemon/replay/internal/session-replay-runtime-session.ts @@ -1,18 +1,21 @@ import fs from 'node:fs'; -import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { expandSessionPath } from '../session-paths.ts'; -import { errorResponse, noActiveSessionError } from './response.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import type { ReplaySessionStore } from './command-types.ts'; +import { expandSessionPath } from '../../session-paths.ts'; +import { errorResponse, noActiveSessionError } from '../../handlers/response.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetForce, scriptTargetPath, type SessionScriptPublicationState, -} from '../session-script-publication-state.ts'; -import { healedScriptSiblingPath, type ReplayCoordinator } from '../session-replay-coordinator.ts'; +} from '../../session-script-publication-state.ts'; +import { + healedScriptSiblingPath, + type ReplayCoordinator, +} from '../../session-replay-coordinator.ts'; /** - * #1555 P5 (decomposition): `runReplayScriptSource`'s (`session-replay-runtime.ts`) session + * #1555 P5 (decomposition): `runReplayCommand`'s (`native-command.ts`) session * preparation — the repair-preflight/resume-consumption/save-script-arming work that runs after * `prepareReplayPlan` (`session-replay-runtime-plan.ts`) accepts a plan but before the engine step * loop dispatches step 1. Extracted verbatim. `prepareReplaySession` is the one entry point; @@ -23,7 +26,7 @@ import { healedScriptSiblingPath, type ReplayCoordinator } from '../session-repl export function prepareReplaySession(params: { req: DaemonRequest; entryIndex: number; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; sourcePath: string; coordinator: ReplayCoordinator; @@ -43,7 +46,7 @@ export function prepareReplaySession(params: { function validateReplaySessionEntry(params: { entryIndex: number; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; coordinator: ReplayCoordinator; }): DaemonResponse | undefined { @@ -83,7 +86,7 @@ function rejectSaveScriptArming(params: { function prepareSaveScriptSession(params: { req: DaemonRequest; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; sourcePath: string; coordinator: ReplayCoordinator; diff --git a/src/daemon/handlers/session-replay-suggestion-ranking.ts b/src/daemon/replay/internal/session-replay-suggestion-ranking.ts similarity index 100% rename from src/daemon/handlers/session-replay-suggestion-ranking.ts rename to src/daemon/replay/internal/session-replay-suggestion-ranking.ts diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/replay/internal/session-replay-target-classification.ts similarity index 98% rename from src/daemon/handlers/session-replay-target-classification.ts rename to src/daemon/replay/internal/session-replay-target-classification.ts index 120c5c5062..23113aeb06 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/replay/internal/session-replay-target-classification.ts @@ -18,7 +18,7 @@ * immediately: pass-through, unchanged behavior. * * This module is wired ONLY from the replay/test step loop - * (`session-replay-runtime.ts`) — never from live interactive command + * (`native-command.ts`) — never from live interactive command * dispatch — so `resolveInteractionTarget`'s live click/press/fill/ * longpress/get path is completely unaffected. * @@ -32,7 +32,7 @@ import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import { findNodeByRef, normalizeRef, type SnapshotNode } from '@agent-device/kernel/snapshot'; -import { findNodeByLabel } from '../../core/snapshot-node-lookup.ts'; +import { findNodeByLabel } from '../../../core/snapshot-node-lookup.ts'; import { annotationLocalIdentity, buildAncestryChain, @@ -48,7 +48,7 @@ import { computeScrollRegionKey, scrollRegionKeysEqual, orderByViewportPosition, -} from '../session-target-evidence.ts'; +} from '../../session-target-evidence.ts'; import { resolveRecordedTarget } from '@agent-device/selectors'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/replay/internal/session-replay-target-token.ts similarity index 100% rename from src/daemon/handlers/session-replay-target-token.ts rename to src/daemon/replay/internal/session-replay-target-token.ts diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/replay/internal/session-replay-target-verification.ts similarity index 96% rename from src/daemon/handlers/session-replay-target-verification.ts rename to src/daemon/replay/internal/session-replay-target-verification.ts index 85abe58c0a..1dc8535a3c 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/replay/internal/session-replay-target-verification.ts @@ -3,7 +3,7 @@ import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; +import { displayLabel, formatRole } from '../../../snapshot/snapshot-lines.ts'; import { annotationLocalIdentity, formatDivergenceActionLabel, @@ -28,13 +28,12 @@ import { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON, } from '@agent-device/contracts/replay'; -import { resolveTargetIdentityVerification } from '../../core/command-descriptor/registry.ts'; -import { parseWaitPositionals } from '../../core/wait-positionals.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import type { ReplayResumeStamper } from '../session-replay-coordinator.ts'; -import type { InternalObservationEvidence } from '../internal-observation.ts'; -import { boundedLocalIdentity } from '../session-target-evidence.ts'; +import { resolveTargetIdentityVerification } from '../../../core/command-descriptor/registry.ts'; +import { parseWaitPositionals } from '../../../core/wait-positionals.ts'; +import type { DaemonResponse, SessionState } from '../../types.ts'; +import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; +import type { InternalObservationEvidence } from '../../internal-observation.ts'; +import { boundedLocalIdentity } from '../../session-target-evidence.ts'; import { buildDivergenceScreen, captureDivergenceObservation, @@ -43,6 +42,7 @@ import { type DivergenceObservation, } from './session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; +import type { ReplaySessionStore } from './command-types.ts'; import { computeReplayRepairHint, type ReplayRepairHintCapture, @@ -111,7 +111,7 @@ export type TargetBindingDivergenceContext = { replayPath: string; artifactPaths: string[]; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; responseLevel: ResponseLevel | undefined; @@ -273,7 +273,7 @@ export function buildTargetBindingFailureResponse( async function captureFreshObservation(params: { session: SessionState | undefined; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; action: SessionAction; unavailableHint: string; @@ -295,7 +295,7 @@ export async function buildRecordedUnverifiableFailureResponse( params: { session: SessionState | undefined; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; action: SessionAction; }, @@ -332,7 +332,7 @@ export async function buildPostDispatchTargetBindingFailureResponse( params: { session: SessionState | undefined; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; logPath: string; action: SessionAction; }, @@ -384,7 +384,7 @@ export function resolveTargetVerificationEntry(params: { action: SessionAction; resolvedAction: SessionAction; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; targetRole?: 'source' | 'destination'; }): AdReplayVerificationEntry { const { action, resolvedAction, sessionName, sessionStore, targetRole } = params; diff --git a/src/daemon/handlers/session-replay-test-policy.ts b/src/daemon/replay/internal/session-replay-test-policy.ts similarity index 100% rename from src/daemon/handlers/session-replay-test-policy.ts rename to src/daemon/replay/internal/session-replay-test-policy.ts diff --git a/src/daemon/handlers/session-replay-trace.ts b/src/daemon/replay/internal/session-replay-trace.ts similarity index 100% rename from src/daemon/handlers/session-replay-trace.ts rename to src/daemon/replay/internal/session-replay-trace.ts diff --git a/src/daemon/handlers/session-replay-vars.ts b/src/daemon/replay/internal/session-replay-vars.ts similarity index 97% rename from src/daemon/handlers/session-replay-vars.ts rename to src/daemon/replay/internal/session-replay-vars.ts index 05387b6534..8611e09bc0 100644 --- a/src/daemon/handlers/session-replay-vars.ts +++ b/src/daemon/replay/internal/session-replay-vars.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type { DaemonRequest } from '../types.ts'; +import type { DaemonRequest } from '../../types.ts'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; export function buildReplayBuiltinVars(params: { diff --git a/src/daemon/handlers/session-replay-video-recording.ts b/src/daemon/replay/internal/session-replay-video-recording.ts similarity index 69% rename from src/daemon/handlers/session-replay-video-recording.ts rename to src/daemon/replay/internal/session-replay-video-recording.ts index 0084bb0903..454def2ad2 100644 --- a/src/daemon/handlers/session-replay-video-recording.ts +++ b/src/daemon/replay/internal/session-replay-video-recording.ts @@ -1,18 +1,18 @@ import path from 'node:path'; -import type { DaemonOpenLifecycle, DaemonRequest, DaemonResponse } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; +import type { DaemonOpenLifecycle, DaemonRequest, DaemonResponse } from '../../types.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { sleep } from '@agent-device/host-kit/retry'; -import { handleRecordCommand } from './record-runtime.ts'; -import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; -import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; -import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; +import type { + ReplayRecordVideoRequest, + ReplaySessionStore, + ReplayTestVideoOwner, +} from './command-types.ts'; import { defaultRecordingPath, recordingExtensionForPlatform, -} from '../../recording/output-path.ts'; +} from '../../../recording/output-path.ts'; const REPLAY_TEST_VIDEO_RECORDING_PREROLL_MS = 1_000; const REPLAY_TEST_VIDEO_RECORDING_TAIL_MS = 3_000; @@ -29,14 +29,9 @@ export function buildReplayTestVideoOpenLifecycle( type ReplayTestVideoRecordingParams = { req: DaemonRequest; sessionName: string; - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; artifactsDir: string | undefined; - bindDevice: BindDeviceRuntime; - bindExactDevice: BindExactDeviceRuntime; - screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; - requestScope: PlatformRequestScope; - retainDeviceExecutionLock(deviceId: string): Promise; - throwIfCanceled(): void; + video: ReplayTestVideoOwner; appendTimingEvent: (event: Record) => void; }; @@ -61,25 +56,10 @@ export async function startReplayTestVideoRecordingIfReady( phase: 'replay_test_video_recording_start', data: { session: sessionName, videoPath }, }); - params.throwIfCanceled(); - const startResponse = await handleRecordCommand({ - req: { - token: req.token, - session: sessionName, - command: 'record', - positionals: ['start', videoPath], - flags: {}, - meta: req.meta, - }, - sessionName, - sessionStore, - bindDevice: params.bindDevice, - bindExactDevice: params.bindExactDevice, - admissionLedger: params.screenRecordingAdmissionLedger, - requestScope: params.requestScope, - retainDeviceExecutionLock: params.retainDeviceExecutionLock, - throwIfCanceled: params.throwIfCanceled, - }); + params.video.throwIfCanceled(); + const startResponse = await params.video.record( + replayVideoRecordRequest({ req, sessionName, phase: 'start', outputPath: videoPath }), + ); if (!startResponse.ok) { appendVideoTimingEvent(appendTimingEvent, { type: 'video_recording_start_failed', @@ -123,24 +103,9 @@ export async function finalizeReplayTestVideoRecording( const tailStartedAt = Date.now(); await sleep(REPLAY_TEST_VIDEO_RECORDING_TAIL_MS); const stopStartedAt = Date.now(); - const stopResponse = await handleRecordCommand({ - req: { - token: req.token, - session: sessionName, - command: 'record', - positionals: ['stop'], - flags: {}, - meta: req.meta, - }, - sessionName, - sessionStore, - bindDevice: params.bindDevice, - bindExactDevice: params.bindExactDevice, - admissionLedger: params.screenRecordingAdmissionLedger, - requestScope: params.requestScope, - retainDeviceExecutionLock: params.retainDeviceExecutionLock, - throwIfCanceled: params.throwIfCanceled, - }); + const stopResponse = await params.video.record( + replayVideoRecordRequest({ req, sessionName, phase: 'stop' }), + ); collectReplayActionArtifactPaths(stopResponse).forEach((entry) => artifactPaths.add(entry)); appendVideoTimingEvent(appendTimingEvent, { type: 'video_recording_stop', @@ -168,3 +133,17 @@ function appendVideoTimingEvent( ): void { appendTimingEvent({ ...event, ts: new Date().toISOString() }); } + +function replayVideoRecordRequest(params: { + req: DaemonRequest; + sessionName: string; + phase: ReplayRecordVideoRequest['phase']; + outputPath?: string; +}): ReplayRecordVideoRequest { + return { + request: { token: params.req.token, meta: params.req.meta }, + sessionName: params.sessionName, + phase: params.phase, + outputPath: params.outputPath, + }; +} diff --git a/src/daemon/handlers/session-test-infrastructure.ts b/src/daemon/replay/internal/session-test-infrastructure.ts similarity index 93% rename from src/daemon/handlers/session-test-infrastructure.ts rename to src/daemon/replay/internal/session-test-infrastructure.ts index 319b1fe053..0a9c81ff51 100644 --- a/src/daemon/handlers/session-test-infrastructure.ts +++ b/src/daemon/replay/internal/session-test-infrastructure.ts @@ -1,7 +1,7 @@ import { isInfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; -import type { DaemonResponse } from '../types.ts'; +import type { DaemonResponse } from '../../types.ts'; import type { ReplaySuiteTestResult } from '@agent-device/contracts/replay'; -import { isDeviceClaimConflictReason } from '../device-claim-conflict.ts'; +import { isDeviceClaimConflictReason } from '../../device-claim-conflict.ts'; const REPLAY_INFRASTRUCTURE_FAILURE_MESSAGE_PATTERNS = [ 'failed to start daemon', diff --git a/src/daemon/handlers/session-test-outcome.ts b/src/daemon/replay/internal/session-test-outcome.ts similarity index 98% rename from src/daemon/handlers/session-test-outcome.ts rename to src/daemon/replay/internal/session-test-outcome.ts index 64fda0ab9c..426ed38fba 100644 --- a/src/daemon/handlers/session-test-outcome.ts +++ b/src/daemon/replay/internal/session-test-outcome.ts @@ -1,5 +1,5 @@ import { readSnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; -import type { DaemonResponse } from '../types.ts'; +import type { DaemonResponse } from '../../types.ts'; import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts'; import type { ReplayTestAttemptFailed, ReplayTestAttemptOutcome } from '@agent-device/replay-test'; diff --git a/src/daemon/handlers/session-test-shard-devices.ts b/src/daemon/replay/internal/session-test-shard-devices.ts similarity index 98% rename from src/daemon/handlers/session-test-shard-devices.ts rename to src/daemon/replay/internal/session-test-shard-devices.ts index 30de11fc60..c432210a77 100644 --- a/src/daemon/handlers/session-test-shard-devices.ts +++ b/src/daemon/replay/internal/session-test-shard-devices.ts @@ -1,6 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; -import { listDeviceInventory } from '../../request/device-inventory-context.ts'; +import { listDeviceInventory } from '../../../request/device-inventory-context.ts'; import { resolveAndroidSerialAllowlist, resolveIosSimulatorDeviceSetPath, diff --git a/src/daemon/handlers/session-test-source-discovery.ts b/src/daemon/replay/internal/session-test-source-discovery.ts similarity index 97% rename from src/daemon/handlers/session-test-source-discovery.ts rename to src/daemon/replay/internal/session-test-source-discovery.ts index 35d2af8694..54bb8fec40 100644 --- a/src/daemon/handlers/session-test-source-discovery.ts +++ b/src/daemon/replay/internal/session-test-source-discovery.ts @@ -1,7 +1,7 @@ import { inspectMaestroFlow } from '@agent-device/maestro'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import { readReplayScriptMetadata, resolveReplayFormat } from '@agent-device/ad-script'; -import { readReplayScriptSourceFile } from '../replay-script-source.ts'; +import { readReplayScriptSourceFile } from '../../replay-script-source.ts'; import type { ReplayTestDiscoverSources, ReplayTestManifest, diff --git a/src/daemon/handlers/session-test-suite-command.ts b/src/daemon/replay/internal/test-command.ts similarity index 69% rename from src/daemon/handlers/session-test-suite-command.ts rename to src/daemon/replay/internal/test-command.ts index 8b153d8402..d6572021d6 100644 --- a/src/daemon/handlers/session-test-suite-command.ts +++ b/src/daemon/replay/internal/test-command.ts @@ -1,27 +1,21 @@ -/** - * The `test` command's own orchestration: harness-flag admission, suite translation, and the - * scheduler run whose every attempt is a nested `replay`. Extracted from - * `handleSessionReplayCommands` (`session-replay.ts`), which is now the routing decision alone — - * this is the half that grew every time the suite gained a capability (video recording, shards, - * per-attempt step sinks, and #1802's per-source script bundles). - */ +/** Runs the replay-test scheduler and its nested replay attempts. */ import type { CommandFlags } from '@agent-device/contracts/command'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; -import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../replay-script-source.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay-script-source.ts'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { ReplayTestCommand } from './command-types.ts'; import { runReplayTestSuite, type ReplayTestBindAttemptCancellation, type ReplayTestShardContext, type ReplayTestSuiteRequest, } from '@agent-device/replay-test'; -import { handleCloseCommand } from './session-close.ts'; -import { runReplayScriptSource } from './session-replay-runtime.ts'; +import { runReplayCommand } from './native-command.ts'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse } from '../../handlers/response.ts'; import { AppError, asAppError } from '@agent-device/kernel/errors'; import { emitRequestProgress, @@ -40,21 +34,12 @@ import { readReplayTestShardSelection, } from './session-test-shard-devices.ts'; import { toReplayTestAttemptOutcome, toReplayTestFinalizeFailure } from './session-test-outcome.ts'; -import type { LeaseRegistry } from '../lease-registry.ts'; -import type { - BindDeviceRuntime, - BindExactDeviceRuntime, - InspectDeviceRuntimeFacts, -} from '../request-runtime-binding.ts'; -import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; -import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import { buildReplayTestVideoOpenLifecycle, finalizeReplayTestVideoRecording, startReplayTestVideoRecordingIfReady, } from './session-replay-video-recording.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from './session-replay-test-policy.ts'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; /** * Binds one replay-test attempt to daemon request cancellation (#1478 P3b). @@ -146,36 +131,15 @@ function stripReplayTestHarnessFlags(flags: CommandFlags | undefined): CommandFl return Object.keys(nestedFlags).length > 0 ? nestedFlags : undefined; } -export type ReplayTestSuiteCommandParams = { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; - invoke: DaemonInvokeFn; - bindDevice?: BindDeviceRuntime; - inspectFacts?: InspectDeviceRuntimeFacts; - bindExactDevice?: BindExactDeviceRuntime; - screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; - requestScope?: PlatformRequestScope; - retainDeviceExecutionLock?: (deviceId: string) => Promise; - throwIfCanceled?: () => void; - platformResourceCleanup?: PlatformResourceCleanup; -}; - -export async function runReplayTestSuiteCommand( - params: ReplayTestSuiteCommandParams, -): Promise { - const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; - if (!params.platformResourceCleanup) { - throw new AppError( - 'INTERNAL_ERROR', - 'Platform resource cleanup was not supplied by root runtime composition', - ); - } - const platformResourceCleanup = params.platformResourceCleanup; - const replayVideoRuntime = resolveReplayVideoRuntime(params); - if (req.flags?.recordVideo === true && replayVideoRuntime === undefined) { +export async function runReplayTestCommand(command: ReplayTestCommand): Promise { + const { + request: req, + session: { name: sessionName, logPath, store: sessionStore }, + invoke, + cleanupSession, + video, + } = command; + if (req.flags?.recordVideo === true && video === undefined) { return errorResponse( 'COMMAND_FAILED', 'Screen-recording runtime is not configured for replay video capture', @@ -242,21 +206,21 @@ export async function runReplayTestSuiteCommand( sourceBundle: sourceBundlesByPath.get(filePath), }); - const videoRecordingParams = replayVideoRuntime + const videoRecordingParams = video ? { req, sessionName: testSessionName, sessionStore, artifactsDir, appendTimingEvent, - ...replayVideoRuntime, + video, } : undefined; const openLifecycle = videoRecordingParams ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) : undefined; - const replayResponse = await runReplayScriptSource({ - req: { + const replayResponse = await runReplayCommand({ + request: { ...req, command: 'replay', session: testSessionName, @@ -275,9 +239,11 @@ export async function runReplayTestSuiteCommand( } : {}), }, - sessionName: testSessionName, - logPath, - sessionStore, + session: { + name: testSessionName, + logPath, + store: sessionStore, + }, tracePath, onStep, invoke: async (nestedReq) => { @@ -297,7 +263,7 @@ export async function runReplayTestSuiteCommand( artifactsDir, appendTimingEvent, }) => { - if (!replayVideoRuntime) return undefined; + if (!video) return undefined; return toReplayTestFinalizeFailure( await finalizeReplayTestVideoRecording({ req, @@ -306,81 +272,19 @@ export async function runReplayTestSuiteCommand( artifactsDir, appendTimingEvent, artifactPaths, - ...replayVideoRuntime, + video, }), ); }, discoverSources: buildReplayTestSourceDiscovery(sourceBundles, req.flags?.replayBackend), resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), - cleanupSession: async (testSessionName) => { - if (!sessionStore.get(testSessionName)) return; - const closeResponse = await handleCloseCommand({ - req: { - token: req.token, - session: testSessionName, - command: 'close', - positionals: [], - flags: {}, - meta: req.meta, - }, - sessionName: testSessionName, - logPath, - sessionStore, - leaseRegistry, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, - platformResourceCleanup, - }); - if (!closeResponse.ok) { - throw new AppError(closeResponse.error.code, closeResponse.error.message, { - ...(closeResponse.error.details ?? {}), - ...(closeResponse.error.hint ? { hint: closeResponse.error.hint } : {}), - }); - } - }, + cleanupSession, }); return outcome.status === 'completed' ? { ok: true, data: outcome.data } : errorResponse(outcome.error.code, outcome.error.message); } -type ReplayVideoRuntime = Readonly<{ - bindDevice: BindDeviceRuntime; - bindExactDevice: BindExactDeviceRuntime; - screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; - requestScope: PlatformRequestScope; - retainDeviceExecutionLock(deviceId: string): Promise; - throwIfCanceled(): void; -}>; - -function resolveReplayVideoRuntime(params: { - bindDevice?: BindDeviceRuntime; - bindExactDevice?: BindExactDeviceRuntime; - screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; - requestScope?: PlatformRequestScope; - retainDeviceExecutionLock?: (deviceId: string) => Promise; - throwIfCanceled?: () => void; -}): ReplayVideoRuntime | undefined { - if ( - !params.bindDevice || - !params.bindExactDevice || - !params.screenRecordingAdmissionLedger || - !params.requestScope || - !params.retainDeviceExecutionLock || - !params.throwIfCanceled - ) { - return undefined; - } - return { - bindDevice: params.bindDevice, - bindExactDevice: params.bindExactDevice, - screenRecordingAdmissionLedger: params.screenRecordingAdmissionLedger, - requestScope: params.requestScope, - retainDeviceExecutionLock: params.retainDeviceExecutionLock, - throwIfCanceled: params.throwIfCanceled, - }; -} - /** * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). * diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index e437bbc552..1daebef6fa 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -282,7 +282,7 @@ const OBSERVATION_ONLY_COMMANDS: ReadonlySet = new Set(['snapshot', 'get * Two facts, ANDed, and the second is the one that matters: * 1. the command is observation-only (above); and * 2. it is NOT a replay plan step (`internal.replayPlanStep`, stamped by - * `invokeResolvedReplayAction`, `handlers/session-replay-action-runtime.ts`). + * `invokeResolvedReplayAction`, `daemon/replay/internal/session-replay-action-runtime.ts`). * * (2) is why this is a PROVENANCE rule, not a command-class rule. Replayed * plan steps dispatch through the ordinary request path and land in diff --git a/src/daemon/session-replay-coordinator.ts b/src/daemon/session-replay-coordinator.ts index 367721d2db..0738c74452 100644 --- a/src/daemon/session-replay-coordinator.ts +++ b/src/daemon/session-replay-coordinator.ts @@ -5,8 +5,7 @@ import { type ReplayDivergenceResume, type ReplayRepairHint, } from '@agent-device/contracts/divergence'; -import type { DaemonResponse, SessionState } from './types.ts'; -import type { SessionStore } from './session-store.ts'; +import type { DaemonResponse, SessionRef, SessionRuntimeHints, SessionState } from './types.ts'; import { armRepairStep, isUncommittedRepairSession, @@ -23,7 +22,7 @@ import { * live session"); it owns every write this request performs against that session's repair * lifecycle — arming, demotion for a `--from` rerun, completion, hold-on-divergence stamping, the * `pendingRecordAndHeal` corrective watermark, and reap-tombstone clearing — - * so `session-replay-runtime.ts` and `session-replay-resume.ts` reach neither the P4a projection + * so the replay command and its resume helper reach neither the P4a projection * nor `session.pendingRecordAndHeal` directly. * * Close-time sequencing (`session-close.ts`, `session-close-script.ts`: the platform-close @@ -46,7 +45,7 @@ export type ReplaySessionView = Readonly<{ /** * The one capability the divergence-report chain (`session-replay-resume.ts`, * `session-replay-divergence.ts`, `session-replay-target-verification.ts`) needs from a - * `ReplayCoordinator`, bound to the SAME instance the owning `runReplayScriptSource` call + * `ReplayCoordinator`, bound to the SAME instance the owning replay command call * created — never a second construction from a bare session name. Carries no `SessionStore` * and cannot name or reacquire a different session. */ @@ -93,8 +92,17 @@ export type ReplayCoordinator = { readonly resumeStamper: ReplayResumeStamper; }; +export type ReplaySessionStore = Readonly<{ + get: (sessionName: string) => SessionState | undefined; + set: (sessionName: string, session: SessionState) => void; + lookup: (address: string) => SessionRef | undefined; + getRuntimeHints: (sessionName: string) => SessionRuntimeHints | undefined; + ensureSessionDir: (sessionName: string) => string; + clearRepairTombstone: (sessionName: string) => void; +}>; + export function createReplayCoordinator(params: { - sessionStore: SessionStore; + sessionStore: ReplaySessionStore; sessionName: string; }): ReplayCoordinator { const { sessionStore, sessionName } = params; From eef908865f3174441b0cfb53bd0d878c85b82001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 13:45:06 +0200 Subject: [PATCH 2/4] fix: address replay facade review findings --- .../src/internal/__tests__/step-loop.test.ts | 6 +- packages/ad-replay/src/internal/inspect.ts | 2 +- packages/ad-replay/src/internal/step-loop.ts | 6 +- .../src/internal/target-verification.ts | 2 +- .../target-annotation-identity.test.ts | 4 +- .../target-annotation-classification.ts | 2 +- .../internal/target-annotation-identity.ts | 6 +- packages/contracts/src/replay-divergence.ts | 2 +- packages/contracts/src/target-annotation.ts | 6 +- .../__tests__/session-test-runtime.test.ts | 2 +- scripts/layering/architecture-ownership.ts | 30 +++---- scripts/layering/daemon-modularity.test.ts | 8 +- .../replay-coordinator-ownership.test.ts | 44 ++-------- .../__tests__/session-command-harness.ts | 6 +- .../__tests__/session-replay-command.test.ts | 53 ++++++++++++ src/daemon/handlers/session-command-input.ts | 52 ++++++++++++ src/daemon/handlers/session-replay-command.ts | 2 +- .../handlers/session-replay-video-owner.ts | 2 +- src/daemon/handlers/session.ts | 57 +------------ .../replay/__tests__/application.test.ts | 8 +- src/daemon/replay/index.ts | 8 +- ...sion-replay-dispatch-selector-miss.test.ts | 2 +- ...ion-replay-repair-record-exclusion.test.ts | 2 +- .../session-replay-repair-transaction.test.ts | 2 +- ...ion-replay-runtime-artifact-ledger.test.ts | 2 +- ...ession-replay-runtime-keep-session.test.ts | 4 +- .../session-replay-runtime-maestro.test.ts | 82 +++++++++---------- ...replay-target-verification-runtime.test.ts | 2 +- ...ion-test-suite-command-flag-policy.test.ts | 3 +- src/daemon/replay/internal/command-types.ts | 12 +-- .../session-replay-maestro-runtime.ts | 37 +++++---- .../session-replay-video-recording.ts | 25 ++++-- src/daemon/replay/internal/test-command.ts | 5 +- 33 files changed, 262 insertions(+), 224 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-replay-command.test.ts create mode 100644 src/daemon/handlers/session-command-input.ts diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index 795b5a0229..7a8685da17 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -11,10 +11,10 @@ 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` (`runReplayScriptSource`) does. The equivalent - * daemon-level assertions (full `SessionStore`/`runReplayScriptSource` round + * `src/daemon/replay/internal/native-command.ts` (`runReplayCommand`) does. The equivalent + * daemon-level assertions (full `SessionStore`/`runReplayCommand` round * trip, including the `--keep-session` live-session postcondition) live in - * `src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts` + * `src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts` * (renamed from `session-replay-terminal-lifecycle.test.ts` by the #1555 * structural-quality review — see that file's own header for the rationale); * this file covers the SAME suppression decision at the cheaper, diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts index 6cb63be596..69e6aed911 100644 --- a/packages/ad-replay/src/internal/inspect.ts +++ b/packages/ad-replay/src/internal/inspect.ts @@ -56,7 +56,7 @@ export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string * `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: `runReplayScriptSource`'s top-level catch (`asAppError`) + * case separately: `runReplayCommand`'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. diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index af5119e167..a9f0814759 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -15,7 +15,7 @@ import type { /** * #1478 P5 stage C2b: the `.ad` step-loop ENGINE policy, split out of - * `session-replay-runtime.ts`'s `executeReplayActions` / + * `src/daemon/replay/internal/native-command.ts`'s replay orchestration / * `resolveReplayStepResponse` / `buildReplayActionFailure`. Everything that * touches a real device, a snapshot, `SessionStore`, or the P4b repair * coordinator is daemon authority and stays behind the narrow @@ -36,7 +36,7 @@ import type { * * #1554 fold-in (rebase onto main's `replay --keep-session`): main grew a * terminal-close-suppression decision independently, daemon-side, as - * `session-replay-terminal-lifecycle.ts`'s `resolveSuppressedTerminalCloseIndex` + * `runAdReplay`'s `resolveSuppressedTerminalCloseIndex` * / `countExecutedReplayActions`, generalizing the repair-only physical-last- * index check this module already had (`isRepairArmedTerminalCloseAction`) to * "terminal among EXECUTABLE actions" and adding `--keep-session` as a second @@ -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` (`runReplayScriptSource`'s, read by its + * entries to the daemon's own `Set` (`runReplayCommand`'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 diff --git a/packages/ad-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts index 970dab1273..838304d63b 100644 --- a/packages/ad-replay/src/internal/target-verification.ts +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -1,6 +1,6 @@ /** * #1478 P5 stage C2a: the target-verification ENGINE policy — moved verbatim - * out of `src/daemon/handlers/session-replay-target-verification.ts`, which + * out of `src/daemon/replay/internal/session-replay-target-verification.ts`, which * keeps the DAEMON-AUTHORITY half (capture, `SessionStore`, resume stamping, * wire projection into `DaemonResponse`). This module decides, over already- * available plain values, whether/how a recorded target-binding annotation diff --git a/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts index 62bc8e7acc..95b3414bde 100644 --- a/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts @@ -14,8 +14,8 @@ import { // `packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts`. // This file covers the local-identity + ancestry-prefix matching primitives. // The record/replay-shared CLASSIFICATION core built on top of them -// (`classifyTargetBindingMatch`) is engine-owned policy and stays in -// `@agent-device/ad-replay`'s `target-identity.ts` (#1478 P5 review). +// (`classifyTargetBindingMatch`) is shared script vocabulary and stays in +// `@agent-device/ad-script`'s `target-annotation-classification.ts` (#1478 P5 review). // --------------------------------------------------------------------------- // Leaf-anchored ancestry prefix matching: root-side truncation + inserted diff --git a/packages/ad-script/src/internal/target-annotation-classification.ts b/packages/ad-script/src/internal/target-annotation-classification.ts index f16396b16a..bc0a4ead6a 100644 --- a/packages/ad-script/src/internal/target-annotation-classification.ts +++ b/packages/ad-script/src/internal/target-annotation-classification.ts @@ -10,7 +10,7 @@ * than script vocabulary. In practice its only real consumers were the * daemon's RECORD-time self-check (`src/daemon/session-target-evidence.ts`) * and its REPLAY-time classification wrapper - * (`src/daemon/handlers/session-replay-target-classification.ts`) — both + * (`src/daemon/replay/internal/session-replay-target-classification.ts`) — both * daemon files, neither reachable through `inspectAdReplay`/`runAdReplay`. * It interprets `TargetAnnotationV1` evidence semantics shared beyond the * engine (record-time AND replay-time both need the SAME verdict by diff --git a/packages/ad-script/src/internal/target-annotation-identity.ts b/packages/ad-script/src/internal/target-annotation-identity.ts index 3f8e8dd303..765f58c1ee 100644 --- a/packages/ad-script/src/internal/target-annotation-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -3,7 +3,7 @@ * prefix matching over versioned `.ad` target-binding evidence, plus the * bounded diagnostic diffs built on top of it. Both the writer (over * `SnapshotNode`-derived values, `src/daemon/session-target-evidence.ts`) and - * replay-time verification (`src/daemon/handlers/session-replay-target-classification.ts` and + * replay-time verification (`src/daemon/replay/internal/session-replay-target-classification.ts` and * `src/commands/interaction/runtime/selector-wait.ts`) share this verbatim so both * sides compute the SAME identity/ancestry match by construction (#1478 P5 * review, "genuinely shared recording vocabulary" relocated to its owner). @@ -50,7 +50,7 @@ type IdentityTreeNode = Pick; * normalized (NFC, label whitespace collapse, `normalizeType` role) AND * 256-byte field-capped, on every path. Shared by the record-time writer * (`src/daemon/session-target-evidence.ts`), replay-time verification - * (`src/daemon/handlers/session-replay-target-verification.ts`), and the + * (`src/daemon/replay/internal/session-replay-target-verification.ts`), and the * dispatch-side post-resolution guard * (`src/commands/interaction/runtime/resolution.ts`), so all three compute * a node's identity with byte-identical semantics. @@ -199,7 +199,7 @@ export function matchesAncestryPrefix( // Diagnostic diffs (decision 3): bounded, best-effort mismatch descriptions // shared by the record-time classification core and replay-time verification // (#1478 P5 stage C2a) — moved here verbatim from -// `src/daemon/handlers/session-replay-target-classification.ts` so both +// `src/daemon/replay/internal/session-replay-target-classification.ts` so both // callers depend on one definition instead of two copies. // --------------------------------------------------------------------------- diff --git a/packages/contracts/src/replay-divergence.ts b/packages/contracts/src/replay-divergence.ts index a962b0cfd2..0c36135e2b 100644 --- a/packages/contracts/src/replay-divergence.ts +++ b/packages/contracts/src/replay-divergence.ts @@ -218,7 +218,7 @@ export type ReplayDivergenceOverflow = { /** * ADR 0012 decision 6, R3: the daemon-computed repair routing hint. Always * defined (never absent/null) — the mapping in - * `src/daemon/handlers/session-replay-repair-hint.ts` is total, defaulting + * `src/daemon/replay/internal/session-replay-repair-hint.ts` is total, defaulting * to `manual` whenever no safer routing can be proven. A small fixed token, * so it is carried at every response level (including `--level digest`) and * every projection (text, JSON, client `AppError`, MCP `structuredContent`). diff --git a/packages/contracts/src/target-annotation.ts b/packages/contracts/src/target-annotation.ts index f667665edf..8422332a08 100644 --- a/packages/contracts/src/target-annotation.ts +++ b/packages/contracts/src/target-annotation.ts @@ -2,9 +2,11 @@ // action. // // Shared vocabulary, not a replay internal — the daemon writes it (8 modules), `replay/` parses and -// verifies it, and `commands/` reads it back. It was declared in `replay/target-identity.ts` +// verifies it, and `commands/` reads it back. It was previously declared in +// `packages/ad-replay/src/internal/target-identity.ts` // alongside the parsing logic, which meant `SessionAction` could not be stated without depending on -// the replay zone. The parsing and classification logic stays there; only the shape moved. +// the replay zone. The parsing and classification logic now lives in `@agent-device/ad-script`; +// only the shape moved. export type TargetAncestryEntry = { role: string; label?: string }; export type TargetScrollRegion = { role: string; id?: string; label?: string }; diff --git a/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts b/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts index 60481798a0..d6fa795565 100644 --- a/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts +++ b/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts @@ -10,7 +10,7 @@ import type { ReplayTestAttemptOutcome } from '../session-test-types.ts'; // What the scheduler owes its host around cancellation (#1478 P3b): cancel exactly once when // an attempt times out, and always release when it settles. How the daemon then maps that onto // its request registry is the adapter's contract, pinned in -// `src/daemon/handlers/__tests__/session-replay-cancellation.test.ts`. +// `src/daemon/replay/internal/__tests__/session-test-suite-command-cancellation.test.ts`. const cancellations: Array<{ attemptId: string; canceled: number; released: number }> = []; function trackCancellation() { diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 97db36120a..70018d0911 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -1,9 +1,20 @@ +type FacadeDeclaration = Readonly<{ + root: string; + exports: readonly string[]; +}>; + export type LogicalModulePolicy = Readonly<{ name: string; roots: readonly string[]; forbiddenTargetRoots: readonly string[]; + facade?: FacadeDeclaration; }>; +const DAEMON_REPLAY_FACADE = { + root: 'src/daemon/replay/index.ts', + exports: ['ReplaySession', 'ReplayTestVideoOwner', 'runReplayCommand', 'runReplayTestCommand'], +} as const; + export const LOGICAL_MODULE_POLICIES = [ { name: 'ad-replay', @@ -33,26 +44,17 @@ export const LOGICAL_MODULE_POLICIES = [ forbiddenTargetRoots: [ 'src/daemon/handlers/session-close.ts', 'src/daemon/handlers/record-runtime.ts', + 'src/daemon/session-store.ts', ], + facade: DAEMON_REPLAY_FACADE, }, ] as const satisfies readonly LogicalModulePolicy[]; -const DAEMON_REPLAY_FACADE = { - name: 'daemon-replay', - root: 'src/daemon/replay/index.ts', - exports: [ - 'ReplayCommand', - 'ReplaySession', - 'ReplayTestCommand', - 'ReplayTestVideoOwner', - 'runReplayCommand', - 'runReplayTestCommand', - ], -} as const; - export const ARCHITECTURE_OWNERSHIP = { logicalModules: LOGICAL_MODULE_POLICIES, - facades: [DAEMON_REPLAY_FACADE], + facades: LOGICAL_MODULE_POLICIES.flatMap((module) => + module.facade ? [{ name: module.name, ...module.facade }] : [], + ), vocabulary: [ { name: 'client-contract', diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index dcd367b475..6baf4ec0b5 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -179,7 +179,7 @@ test('internal trees reject deep imports globally, including from daemon', () => assert.match(violations[0]!.message, /must not import maestro's internal tree/); }); -test('daemon replay rejects handler, sibling-owner, and engine deep edges', () => { +test('daemon replay rejects handler, owner, session-store, and engine deep edges', () => { const edges = resolveImportEdges( new Map([ [ @@ -190,12 +190,17 @@ test('daemon replay rejects handler, sibling-owner, and engine deep edges', () = 'src/daemon/replay/internal/test-command.ts', "import { handleCloseCommand } from '../../handlers/session-close.ts';", ], + [ + 'src/daemon/replay/internal/command-types.ts', + "import { SessionStore } from '../../session-store.ts';", + ], [ 'packages/ad-replay/src/internal/step-loop.ts', "import { runReplayCommand } from '../../../../src/daemon/replay/internal/native-command.ts';", ], ['src/daemon/replay/internal/native-command.ts', 'export function runReplayCommand() {}'], ['src/daemon/handlers/session-close.ts', 'export function handleCloseCommand() {}'], + ['src/daemon/session-store.ts', 'export class SessionStore {}'], ]), ); @@ -208,6 +213,7 @@ test('daemon replay rejects handler, sibling-owner, and engine deep edges', () = [ "src/daemon/handlers/session.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)", 'daemon-replay must not import src/daemon/handlers/session-close.ts', + 'daemon-replay must not import src/daemon/session-store.ts', "packages/ad-replay/src/internal/step-loop.ts must not import daemon-replay's internal tree (src/daemon/replay/internal/native-command.ts)", ], ); diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index 9341ddd1d4..f9541670db 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -18,11 +18,9 @@ import { parseSync } from 'oxc-parser'; * `session-replay-target-verification.ts`, `session-replay-runtime-failure.ts`, * `session-replay-runtime-failure-response.ts`) reaches resume-stamping ONLY through a * `ReplayResumeStamper` value handed to it by its caller — never by importing the factory, - * the P4a `session-replay-transaction.ts` projection, or a live `SessionStore` binding of its - * own. A type-only reference to `SessionStore` (an already-typed parameter whose value some - * upstream caller supplies) is not authority and is allowed for files with an unrelated, - * legitimate reason to type one — `session-replay-resume.ts` has none post-fix, so it is held to - * a stricter "no `session-store.ts` import at all" bar than the other four. + * the P4a `session-replay-transaction.ts` projection, or a `SessionStore` binding of its own. + * Every file in this chain therefore has the same structural bar: no import from + * `session-store.ts` at all. */ const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); @@ -41,13 +39,6 @@ const DIVERGENCE_CHAIN_FILES = [ 'src/daemon/replay/internal/session-replay-runtime-failure-response.ts', ] as const; -/** Files in `DIVERGENCE_CHAIN_FILES` allowed a TYPE-ONLY `session-store.ts` reference. */ -const SESSION_STORE_TYPE_ONLY_ALLOWED = new Set([ - 'src/daemon/replay/internal/session-replay-divergence.ts', - 'src/daemon/replay/internal/session-replay-target-verification.ts', - 'src/daemon/replay/internal/session-replay-runtime-failure.ts', -]); - type ImportSite = { file: string; /** Repo-root-relative path the specifier resolves to, or `null` for an unresolved/external specifier. */ @@ -324,35 +315,14 @@ test('the divergence-report chain never imports the P4a ReplaySessionTransaction } }); -test('session-replay-resume.ts holds no SessionStore binding at all', () => { - const file = 'src/daemon/replay/internal/session-replay-resume.ts'; - for (const site of collectImportSites(file)) { - assert.equal( - importsAnyBinding(site, SESSION_STORE_MODULE), - false, - `${file} imports session-store.ts — after the P4b resume-stamper fix it has no legitimate ` + - `remaining reason to hold a SessionStore binding (value or type); it can only act through ` + - `the ReplayResumeStamper it is handed.`, - ); - } -}); - -test('the remaining divergence-report chain holds SessionStore only as a type', () => { +test('the divergence-report chain never imports SessionStore', () => { for (const file of DIVERGENCE_CHAIN_FILES) { - if (!SESSION_STORE_TYPE_ONLY_ALLOWED.has(file)) continue; for (const site of collectImportSites(file)) { - if (site.target !== SESSION_STORE_MODULE) continue; - const valueImported = site.bindings.some( - (binding) => - binding.name === 'SessionStore' && !binding.typeOnly && !site.declarationTypeOnly, - ); assert.equal( - valueImported, + importsAnyBinding(site, SESSION_STORE_MODULE), false, - `${file} value-imports SessionStore from session-store.ts — it may only reference the ` + - `type (import type { SessionStore }) to annotate a parameter handed to it; a value ` + - `import is capability to construct or statically call the store, which this file must ` + - `never hold.`, + `${file} imports session-store.ts — the divergence-report chain must receive the ` + + `request's ReplayResumeStamper and never hold a SessionStore binding of its own.`, ); } } diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index e2f9095693..c9d2198580 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -1,7 +1,5 @@ -import { - handleSessionCommands as handleProductionSessionCommands, - type SessionCommandInput, -} from '../session.ts'; +import { handleSessionCommands as handleProductionSessionCommands } from '../session.ts'; +import type { SessionCommandInput } from '../session-command-input.ts'; import type { AppDeploymentInput, AppDeploymentResult, diff --git a/src/daemon/handlers/__tests__/session-replay-command.test.ts b/src/daemon/handlers/__tests__/session-replay-command.test.ts new file mode 100644 index 0000000000..45c7e70ee2 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-command.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { + unavailableBindDevice, + unavailableBindExactDevice, +} from '../../__tests__/test-device-runtime-gateway.ts'; +import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import type { SessionCommandParams } from '../session-command-input.ts'; +import { handleReplayTestCommand } from '../session-replay-command.ts'; +import { makeSessionStore } from './session-test-harness.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; + +test('replay test handler threads the complete video owner into the application command', async () => { + const root = mkdtempForTestSync('agent-device-replay-handler-video-'); + const params: SessionCommandParams = { + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [path.join(root, 'suite.ad')], + flags: { recordVideo: true }, + meta: { cwd: root, requestId: 'replay-handler-video' }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore: makeSessionStore(), + leaseRegistry: new LeaseRegistry(), + invoke: async () => ({ ok: true, data: {} }), + bindDevice: unavailableBindDevice, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger: createScreenRecordingAdmissionLedger(), + requestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + retainDeviceExecutionLock: async () => {}, + throwIfCanceled: () => {}, + reconcileOrphanedDeviceClaim: async () => ({ + status: 'retained' as const, + reason: 'handler-test' as const, + }), + platformResourceCleanup, + }; + + const response = await handleReplayTestCommand(params); + + if (!response || response.ok) throw new Error('Expected a missing-source error response'); + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).not.toContain('Screen-recording runtime is not configured'); +}); diff --git a/src/daemon/handlers/session-command-input.ts b/src/daemon/handlers/session-command-input.ts new file mode 100644 index 0000000000..bfe10e079c --- /dev/null +++ b/src/daemon/handlers/session-command-input.ts @@ -0,0 +1,52 @@ +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; +import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; +import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import type { AudioProbeAdmissionLedger } from '../audio-probe-admission-ledger.ts'; +import type { DeviceClaimReconciler } from '../device-claims.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; +import type { + BindDeviceRuntime, + BindExactDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { PerfCaptureAdmissionLedger } from '../perf-capture-admission-ledger.ts'; + +export type SessionCommandInput = { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + leaseRegistry?: LeaseRegistry; + leaseLifecycleProvider?: LeaseLifecycleProvider; + providerAppCatalog?: ProviderAppCatalog; + invoke: DaemonInvokeFn; + invokeReplayAction?: DaemonInvokeFn; + /** Request-scoped Android adb transport override, opaque to the daemon. */ + androidAdbExecutor?: unknown; + bindDevice?: BindDeviceRuntime; + inspectFacts?: InspectDeviceRuntimeFacts; + bindExactDevice?: BindExactDeviceRuntime; + appLogAdmissionLedger?: AppLogAdmissionLedger; + audioProbeAdmissionLedger?: AudioProbeAdmissionLedger; + perfCaptureAdmissionLedger?: PerfCaptureAdmissionLedger; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + hostDiagnostics?: HostDiagnostics; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + platformResourceCleanup?: PlatformResourceCleanup; +}; + +export type SessionCommandParams = Omit & { + leaseRegistry: LeaseRegistry; +}; + +export type SessionCommandHandler = ( + params: SessionCommandParams, +) => Promise; diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index 612c121580..a2e20b8594 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -7,7 +7,7 @@ import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-r import { runReplayCommand, runReplayTestCommand, type ReplaySession } from '../replay/index.ts'; import { handleCloseCommand } from './session-close.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; -import type { SessionCommandHandler } from './session.ts'; +import type { SessionCommandHandler } from './session-command-input.ts'; export const handleReplayCommand: SessionCommandHandler = async ({ req, diff --git a/src/daemon/handlers/session-replay-video-owner.ts b/src/daemon/handlers/session-replay-video-owner.ts index c5a3b15f2c..c941f37048 100644 --- a/src/daemon/handlers/session-replay-video-owner.ts +++ b/src/daemon/handlers/session-replay-video-owner.ts @@ -63,7 +63,7 @@ function recordRequest(request: ReplayRecordVideoRequest): DaemonRequest { token: request.request.token, session: request.sessionName, command: 'record', - positionals: request.phase === 'start' ? ['start', request.outputPath ?? ''] : ['stop'], + positionals: request.phase === 'start' ? ['start', request.outputPath] : ['stop'], flags: {}, meta: request.request.meta, }; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 363f285d23..ed63fc1603 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,5 +1,4 @@ -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; +import type { DaemonResponse } from '../types.ts'; import { handleReleaseMaterializedPathsCommand } from './session-app-source-deployment.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; @@ -17,61 +16,9 @@ import { handleSessionScriptPublication } from './session-script-publication.ts' import { handleSessionClipboardCommand } from './session-clipboard.ts'; import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; +import type { SessionCommandHandler, SessionCommandInput } from './session-command-input.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; -import type { - BindDeviceRuntime, - BindExactDeviceRuntime, - InspectDeviceRuntimeFacts, -} from '../request-runtime-binding.ts'; -import type { DeviceClaimReconciler } from '../device-claims.ts'; -import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; -import type { AudioProbeAdmissionLedger } from '../audio-probe-admission-ledger.ts'; -import type { PerfCaptureAdmissionLedger } from '../perf-capture-admission-ledger.ts'; -import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; -import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; -import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; -import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; - -export type SessionCommandInput = { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - leaseRegistry?: LeaseRegistry; - leaseLifecycleProvider?: LeaseLifecycleProvider; - providerAppCatalog?: ProviderAppCatalog; - invoke: DaemonInvokeFn; - invokeReplayAction?: DaemonInvokeFn; - /** - * Request-scoped Android adb transport override, opaque to the daemon (the - * `transportOverrides` pattern from `@agent-device/contracts/host-diagnostics`); the - * platform owner narrows it at its own boundary. - */ - androidAdbExecutor?: unknown; - bindDevice?: BindDeviceRuntime; - inspectFacts?: InspectDeviceRuntimeFacts; - bindExactDevice?: BindExactDeviceRuntime; - appLogAdmissionLedger?: AppLogAdmissionLedger; - audioProbeAdmissionLedger?: AudioProbeAdmissionLedger; - perfCaptureAdmissionLedger?: PerfCaptureAdmissionLedger; - screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; - hostDiagnostics?: HostDiagnostics; - requestScope?: PlatformRequestScope; - retainDeviceExecutionLock?: (deviceId: string) => Promise; - throwIfCanceled?: () => void; - reconcileOrphanedDeviceClaim: DeviceClaimReconciler; - platformResourceCleanup?: PlatformResourceCleanup; -}; - -type SessionCommandParams = Omit & { - leaseRegistry: LeaseRegistry; -}; - -export type SessionCommandHandler = ( - params: SessionCommandParams, -) => Promise; const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ req, diff --git a/src/daemon/replay/__tests__/application.test.ts b/src/daemon/replay/__tests__/application.test.ts index 26aad07ce4..c1c31a3d30 100644 --- a/src/daemon/replay/__tests__/application.test.ts +++ b/src/daemon/replay/__tests__/application.test.ts @@ -13,12 +13,8 @@ import { makeIosSession } from '../../../__tests__/test-utils/session-factories. import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { SessionStore } from '../../session-store.ts'; -import { - runReplayCommand, - runReplayTestCommand, - type ReplayCommand, - type ReplayTestCommand, -} from '../index.ts'; +import { runReplayCommand, runReplayTestCommand } from '../index.ts'; +import type { ReplayCommand, ReplayTestCommand } from '../internal/command-types.ts'; import { captureSnapshotWithInteractor } from '../../handlers/snapshot-interactor-capture.ts'; import { legacyDispatchCapture, diff --git a/src/daemon/replay/index.ts b/src/daemon/replay/index.ts index 4d1ec46446..8ad0b041ee 100644 --- a/src/daemon/replay/index.ts +++ b/src/daemon/replay/index.ts @@ -1,8 +1,4 @@ +/** Public replay application seam for daemon handlers and their admitted owner capabilities. */ export { runReplayCommand } from './internal/native-command.ts'; export { runReplayTestCommand } from './internal/test-command.ts'; -export type { - ReplayCommand, - ReplaySession, - ReplayTestCommand, - ReplayTestVideoOwner, -} from './internal/command-types.ts'; +export type { ReplaySession, ReplayTestVideoOwner } from './internal/command-types.ts'; diff --git a/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts index 5d2a93cc83..40d086889c 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -4,7 +4,7 @@ * press/click/fill/longpress THROWS an AppError instead of resolving to * `{ok:false}`. `invokeReplayAction` (session-replay-action-runtime.ts) * previously let that throw escape the per-action `if (!response.ok)` - * handling in `runReplayForTest`'s loop, so it hit the outer catch and + * handling in `runReplayCommand`'s loop, so it hit the outer catch and * returned a bare `COMMAND_FAILED` with the legacy diagnostics shape instead * of the ADR 0012 `REPLAY_DIVERGENCE` report — breaking the interactive * repair loop (no `resume`, no `screen` refs, no `suggestions`) for the diff --git a/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts index 3d2f8ea1a7..6efe93de6e 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-record-exclusion.test.ts @@ -3,7 +3,7 @@ * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the * `--record` opt-in for the corrective-read case, and the fail-loud empty- * segment guard. Exercised end to end at the same layer the ADR 0012 decision - * 6 lifecycle tests use — `runReplayForTest` + `handleCloseCommand` sharing + * 6 lifecycle tests use — `runReplayCommand` + `handleCloseCommand` sharing * a live `SessionStore`, exactly like an agent's separate CLI invocations * against the same daemon session. * diff --git a/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts index 77b5b6ccef..7b53997078 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts @@ -1,7 +1,7 @@ /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — - * `runReplayForTest` + `handleCloseCommand` sharing a live `SessionStore`, + * `runReplayCommand` + `handleCloseCommand` sharing a live `SessionStore`, * exactly like separate CLI invocations against the same daemon. `sendToDaemon`'s * process-level keep-alive (Fix 1's daemon * teardown guard) is a different architectural layer — a client-side process diff --git a/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts index 18bc619ce9..5a0a140ff0 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-artifact-ledger.test.ts @@ -18,7 +18,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' * now the single ledger; `dispatchStep` writes it and returns its contents, and * the engine only threads that value. * - * The ledger's one INDEPENDENT observation point is `runReplayForTest`'s + * The ledger's one INDEPENDENT observation point is `runReplayCommand`'s * catch block: on a mid-loop throw there is no run outcome to read artifacts * from, so what the failure reports comes from the ledger and nothing else. * That makes this the counterfactual test for the threading — break the ledger diff --git a/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts index d0ae70642f..1e5833723c 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-keep-session.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' * These six cases stayed daemon-side rather than moving into the package's * `step-loop.test.ts` because they are NOT a test of engine policy through * the façade in isolation — every one drives the full - * `runReplayForTest` round trip against a REAL `SessionStore`, and two of + * `runReplayCommand` round trip against a REAL `SessionStore`, and two of * the six (`--keep-session fails explicitly when the completed replay has * no live session`, `--keep-session rejects Maestro YAML before engine * dispatch`) exercise daemon-ONLY authority @@ -24,7 +24,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' * OWN terminal-close-suppression decision has its own cheaper, direct * coverage in `packages/ad-replay/src/internal/__tests__/step-loop.test.ts` * (see that file's header). This file's real subject is - * native replay command's `runReplayForTest` — specifically its + * native replay command's `runReplayCommand` — specifically its * `--keep-session` behavior — so it is named and grouped alongside that * file's other `session-replay-runtime-*.test.ts` siblings * (`-plan.test.ts`, `-maestro.test.ts`, `-failure.test.ts`, …) rather than diff --git a/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts index b7a468e170..6160092f78 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts @@ -12,7 +12,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' // // This file carries the Maestro-heavy majority of what used to live in // session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad` -// runReplayForTest tests that happen to share the same runReplayFixture +// runReplayCommand tests that happen to share the same runReplayFixture // helper and mock configuration below. It is a sibling of // session-replay-runtime.test.ts rather than a merge into it because that file // mocks '../../../core/dispatch-resolve.ts' with its own device resolution — vitest @@ -187,7 +187,7 @@ function assertNoUnresolvedInterpolation(calls: CapturedInvocation[]): void { } } -test('runReplayForTest dispatches resolved literals with file env overridden by CLI', async () => { +test('runReplayCommand dispatches resolved literals with file env overridden by CLI', async () => { const { response, calls } = await runReplayFixture({ label: 'green', script: @@ -263,7 +263,7 @@ test('.ad replay normalizes resolved gesture and swipe syntax into structured da ); }); -test('runReplayForTest reports snapshot diagnostics from per-action session samples', async () => { +test('runReplayCommand reports snapshot diagnostics from per-action session samples', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-samples-'); const scriptPath = path.join(root, 'flow.ad'); // Four warm captures beyond the cold start: the slow-run warning judges warm @@ -327,7 +327,7 @@ test('runReplayForTest reports snapshot diagnostics from per-action session samp assert.match(String(diagnostics?.warning), /p95 1900ms over 4 captures/); }); -test('runReplayForTest reports snapshot diagnostics on replay failure', async () => { +test('runReplayCommand reports snapshot diagnostics on replay failure', async () => { const root = mkdtempForTestSync('agent-device-replay-snapshot-failure-'); const scriptPath = path.join(root, 'flow.ad'); // Three warm captures precede the failing click so the failure-path summary @@ -379,7 +379,7 @@ test('runReplayForTest reports snapshot diagnostics on replay failure', async () assert.match(String(diagnostics?.warning), /p95 2100ms over 4 captures/); }); -test('runReplayForTest applies CLI env overrides before Maestro compat mapping', async () => { +test('runReplayCommand applies CLI env overrides before Maestro compat mapping', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-env', script: [ @@ -431,7 +431,7 @@ test('runReplayForTest applies CLI env overrides before Maestro compat mapping', ); }); -test('runReplayForTest runs Maestro runScript in replay order and exposes output variables', async () => { +test('runReplayCommand runs Maestro runScript in replay order and exposes output variables', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-runtime', files: { @@ -464,7 +464,7 @@ output.result = SERVER_PATH + ':' + json(res.body).appviewDid ); }); -test('runReplayForTest supports successful Maestro runScript http.post calls', async () => { +test('runReplayCommand supports successful Maestro runScript http.post calls', async () => { const server = new Worker( ` const http = require('node:http'); @@ -526,7 +526,7 @@ output.result = parsed.method + ':' + json(parsed.body).ok } }); -test('runReplayForTest strips prototype pollution keys from runScript json()', async () => { +test('runReplayCommand strips prototype pollution keys from runScript json()', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-json-prototype-keys', files: { @@ -561,7 +561,7 @@ output.result = [ ); }); -test('runReplayForTest reports Maestro runScript failures at the runScript step', async () => { +test('runReplayCommand reports Maestro runScript failures at the runScript step', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-fail', files: { @@ -582,7 +582,7 @@ test('runReplayForTest reports Maestro runScript failures at the runScript step' assert.equal(calls.length, 0); }); -test('runReplayForTest reports iOS Maestro openLink setup failures before assertions', async () => { +test('runReplayCommand reports iOS Maestro openLink setup failures before assertions', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-ios-openlink-prewarm-fail', script: [ @@ -626,7 +626,7 @@ test('runReplayForTest reports iOS Maestro openLink setup failures before assert assert.equal(calls[0]?.flags?.maestro?.prewarmRunnerBeforeOpen, true); }); -test('runReplayForTest explains empty Maestro runScript JSON bodies', async () => { +test('runReplayCommand explains empty Maestro runScript JSON bodies', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-empty-json', files: { @@ -647,7 +647,7 @@ test('runReplayForTest explains empty Maestro runScript JSON bodies', async () = assert.equal(calls.length, 0); }); -test('runReplayForTest rejects Maestro runScript output keys containing dots', async () => { +test('runReplayCommand rejects Maestro runScript output keys containing dots', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-runscript-dotted-output', files: { @@ -667,7 +667,7 @@ test('runReplayForTest rejects Maestro runScript output keys containing dots', a assert.equal(calls.length, 0); }); -test('runReplayForTest retries Maestro scrollUntilVisible with scroll probes', async () => { +test('runReplayCommand retries Maestro scrollUntilVisible with scroll probes', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -719,7 +719,7 @@ test('runReplayForTest retries Maestro scrollUntilVisible with scroll probes', a ); }); -test('runReplayForTest uses semantic iOS dispatch for an exact text tapOn', async () => { +test('runReplayCommand uses semantic iOS dispatch for an exact text tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-visible-text-atomic-ios', @@ -765,7 +765,7 @@ test('runReplayForTest uses semantic iOS dispatch for an exact text tapOn', asyn ); }); -test('runReplayForTest uses matched Android id geometry for tapOn', async () => { +test('runReplayCommand uses matched Android id geometry for tapOn', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-id-matched-geometry', @@ -810,7 +810,7 @@ test('runReplayForTest uses matched Android id geometry for tapOn', async () => ); }); -test('runReplayForTest captures fresh geometry for tapOn after assertVisible', async () => { +test('runReplayCommand captures fresh geometry for tapOn after assertVisible', async () => { let snapshots = 0; const { response, calls } = await runReplayFixture({ label: 'maestro-assert-visible-tap-fresh-snapshot', @@ -861,7 +861,7 @@ test('runReplayForTest captures fresh geometry for tapOn after assertVisible', a ); }); -test('runReplayForTest scopes duplicate tap targets after native Maestro assertVisible', async () => { +test('runReplayCommand scopes duplicate tap targets after native Maestro assertVisible', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-native-assert-context-duplicate-tap', script: ['appId: demo.app', '---', '- assertVisible: Albums', '- tapOn: Push article', ''].join( @@ -929,7 +929,7 @@ test('runReplayForTest scopes duplicate tap targets after native Maestro assertV ); }); -test('runReplayForTest treats absent Maestro assertNotVisible targets as passing', async () => { +test('runReplayCommand treats absent Maestro assertNotVisible targets as passing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-absent', @@ -955,7 +955,7 @@ test('runReplayForTest treats absent Maestro assertNotVisible targets as passing assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayForTest propagates Maestro assertNotVisible infrastructure failures', async () => { +test('runReplayCommand propagates Maestro assertNotVisible infrastructure failures', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-assert-not-visible-infra-fail', @@ -978,7 +978,7 @@ test('runReplayForTest propagates Maestro assertNotVisible infrastructure failur assert.equal(calls.length, 1); }); -test('runReplayForTest waits briefly for Maestro assertNotVisible to stabilize', async () => { +test('runReplayCommand waits briefly for Maestro assertNotVisible to stabilize', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1017,7 +1017,7 @@ test('runReplayForTest waits briefly for Maestro assertNotVisible to stabilize', assert.equal(calls.length, 2); }); -test('runReplayForTest treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { +test('runReplayCommand treats absent Maestro extendedWaitUntil.notVisible targets as passing', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-extended-wait-not-visible-absent', script: [ @@ -1046,7 +1046,7 @@ test('runReplayForTest treats absent Maestro extendedWaitUntil.notVisible target assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayForTest resolves Maestro percentage point taps from the admitted runtime viewport', async () => { +test('runReplayCommand resolves Maestro percentage point taps from the admitted runtime viewport', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-tap-point-percent', script: ['appId: demo.app', '---', '- tapOn:', ' point: 20%,20%', ''].join('\n'), @@ -1068,7 +1068,7 @@ test('runReplayForTest resolves Maestro percentage point taps from the admitted ); }); -test('runReplayForTest retries Maestro id tapOn through snapshot coordinates', async () => { +test('runReplayCommand retries Maestro id tapOn through snapshot coordinates', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; const { response } = await runReplayFixture({ @@ -1115,7 +1115,7 @@ test('runReplayForTest retries Maestro id tapOn through snapshot coordinates', a ); }); -test('runReplayForTest resolves Maestro tapOn index and childOf from snapshots', async () => { +test('runReplayCommand resolves Maestro tapOn index and childOf from snapshots', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-index-childof', @@ -1184,7 +1184,7 @@ test('runReplayForTest resolves Maestro tapOn index and childOf from snapshots', assert.equal(calls[0]?.flags?.noRecord, true); }); -test('runReplayForTest lets snapshot id tap handle Maestro one-point edge controls', async () => { +test('runReplayCommand lets snapshot id tap handle Maestro one-point edge controls', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-edge-rect', @@ -1220,7 +1220,7 @@ test('runReplayForTest lets snapshot id tap handle Maestro one-point edge contro ); }); -test('runReplayForTest resolves a text-entry target once before typing', async () => { +test('runReplayCommand resolves a text-entry target once before typing', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-input-text-snapshot', @@ -1269,7 +1269,7 @@ test('runReplayForTest resolves a text-entry target once before typing', async ( ); }); -test('runReplayForTest resolves scalar Maestro swipe.from to an element rect', async () => { +test('runReplayCommand resolves scalar Maestro swipe.from to an element rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label', @@ -1326,7 +1326,7 @@ test('runReplayForTest resolves scalar Maestro swipe.from to an element rect', a ); }); -test('runReplayForTest anchors scalar Maestro swipe.from to the matched element rect', async () => { +test('runReplayCommand anchors scalar Maestro swipe.from to the matched element rect', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-swipe-label-child-rect', @@ -1392,7 +1392,7 @@ test('runReplayForTest anchors scalar Maestro swipe.from to the matched element ); }); -test('runReplayForTest resolves Maestro screen swipes from the admitted runtime viewport', async () => { +test('runReplayCommand resolves Maestro screen swipes from the admitted runtime viewport', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-screen-swipe', script: [ @@ -1441,7 +1441,7 @@ test('runReplayForTest resolves Maestro screen swipes from the admitted runtime ); }); -test('runReplayForTest delegates Android directional swipes and preserves percentage points', async () => { +test('runReplayCommand delegates Android directional swipes and preserves percentage points', async () => { const { response, calls } = await runReplayFixture({ label: 'maestro-screen-swipe-android-midpoint-lane', script: [ @@ -1490,7 +1490,7 @@ test('runReplayForTest delegates Android directional swipes and preserves percen ); }); -test('runReplayForTest maps Maestro enter to keyboard enter', async () => { +test('runReplayCommand maps Maestro enter to keyboard enter', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter', @@ -1509,7 +1509,7 @@ test('runReplayForTest maps Maestro enter to keyboard enter', async () => { ); }); -test('runReplayForTest waits for Maestro animation screenshots to stabilize', async () => { +test('runReplayCommand waits for Maestro animation screenshots to stabilize', async () => { const calls: CapturedInvocation[] = []; const screenshot = PNG.sync.write(new PNG({ width: 1, height: 1 })); const { response } = await runReplayFixture({ @@ -1535,7 +1535,7 @@ test('runReplayForTest waits for Maestro animation screenshots to stabilize', as assert.equal(calls[1]?.flags?.screenshotNoStabilize, true); }); -test('runReplayForTest propagates unsupported keyboard enter dispatch', async () => { +test('runReplayCommand propagates unsupported keyboard enter dispatch', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-press-enter-unsupported', @@ -1561,7 +1561,7 @@ test('runReplayForTest propagates unsupported keyboard enter dispatch', async () ); }); -test('runReplayForTest retries Maestro retry commands until they pass', async () => { +test('runReplayCommand retries Maestro retry commands until they pass', async () => { const calls: CapturedInvocation[] = []; let openAttempts = 0; const { response } = await runReplayFixture({ @@ -1624,7 +1624,7 @@ test('runReplayForTest retries Maestro retry commands until they pass', async () assert.equal(calls.filter((call) => call.command === 'snapshot').length > 1, true); }); -test('runReplayForTest propagates Maestro runFlow.when runtime errors', async () => { +test('runReplayCommand propagates Maestro runFlow.when runtime errors', async () => { const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-runtime-error', script: [ @@ -1659,7 +1659,7 @@ test('runReplayForTest propagates Maestro runFlow.when runtime errors', async () } }); -test('runReplayForTest runs Maestro runFlow.when.visible commands when present', async () => { +test('runReplayCommand runs Maestro runFlow.when.visible commands when present', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-visible-run', @@ -1721,7 +1721,7 @@ test('runReplayForTest runs Maestro runFlow.when.visible commands when present', ); }); -test('runReplayForTest runs nested Maestro runtime commands inside runFlow.when', async () => { +test('runReplayCommand runs nested Maestro runtime commands inside runFlow.when', async () => { const calls: CapturedInvocation[] = []; let snapshots = 0; const { response } = await runReplayFixture({ @@ -1794,7 +1794,7 @@ test('runReplayForTest runs nested Maestro runtime commands inside runFlow.when' ); }); -test('runReplayForTest resolves nested Maestro runFlow.when command variables once at execution', async () => { +test('runReplayCommand resolves nested Maestro runFlow.when command variables once at execution', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-run-flow-when-nested-vars', @@ -1860,7 +1860,7 @@ test('runReplayForTest resolves nested Maestro runFlow.when command variables on ); }); -test('runReplayForTest reads shell env from request (client-collected), not daemon process.env', async () => { +test('runReplayCommand reads shell env from request (client-collected), not daemon process.env', async () => { // Ensure the daemon's own process.env does NOT contain AD_VAR_APP. assert.equal(process.env.AD_VAR_APP, undefined); const { response, calls } = await runReplayFixture({ @@ -1873,7 +1873,7 @@ test('runReplayForTest reads shell env from request (client-collected), not daem assert.deepEqual(calls[0]?.positionals, ['client-shell-app']); }); -test('runReplayForTest falls back to process.env when request omits replayShellEnv', async () => { +test('runReplayCommand falls back to process.env when request omits replayShellEnv', async () => { const previous = process.env.AD_VAR_APP; process.env.AD_VAR_APP = 'daemon-env-app'; try { @@ -1889,7 +1889,7 @@ test('runReplayForTest falls back to process.env when request omits replayShellE } }); -test('runReplayForTest writes per-action timing events to active trace', async () => { +test('runReplayCommand writes per-action timing events to active trace', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-'); const scriptPath = path.join(root, 'flow.ad'); const tracePath = path.join(root, 'trace.ndjson'); diff --git a/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts index 92497ac397..4683a5f9f6 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts @@ -1,5 +1,5 @@ /** - * ADR 0012 migration step 4, end-to-end: `runReplayForTest` must consult + * ADR 0012 migration step 4, end-to-end: `runReplayCommand` must consult * `verifyReplayActionTarget` for every annotated resolved-target action * BEFORE dispatching it, and never send the device action on a non-verified * outcome. Mirrors the mocking pattern of `session-replay-runtime.test.ts` diff --git a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts index 52639b3477..0b126bab93 100644 --- a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts @@ -9,7 +9,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import { SessionStore } from '../../../session-store.ts'; -import { runReplayTestCommand, type ReplayTestCommand } from '../../index.ts'; +import { runReplayTestCommand } from '../../index.ts'; +import type { ReplayTestCommand } from '../command-types.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; import { replayCommandFamily } from '../../../../commands/replay/index.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; diff --git a/src/daemon/replay/internal/command-types.ts b/src/daemon/replay/internal/command-types.ts index dba5dba512..ff70fdc977 100644 --- a/src/daemon/replay/internal/command-types.ts +++ b/src/daemon/replay/internal/command-types.ts @@ -24,12 +24,12 @@ type ReplayRequestContext = Readonly<{ meta: DaemonRequest['meta']; }>; -export type ReplayRecordVideoRequest = Readonly<{ - request: ReplayRequestContext; - sessionName: string; - phase: 'start' | 'stop'; - outputPath?: string; -}>; +export type ReplayRecordVideoRequest = Readonly< + { request: ReplayRequestContext; sessionName: string } & ( + | { phase: 'start'; outputPath: string } + | { phase: 'stop' } + ) +>; export type ReplayRecordVideo = (params: ReplayRecordVideoRequest) => Promise; diff --git a/src/daemon/replay/internal/session-replay-maestro-runtime.ts b/src/daemon/replay/internal/session-replay-maestro-runtime.ts index 072f0cb7c8..9c9e3f93c8 100644 --- a/src/daemon/replay/internal/session-replay-maestro-runtime.ts +++ b/src/daemon/replay/internal/session-replay-maestro-runtime.ts @@ -40,6 +40,17 @@ type TypedMaestroReplayState = { snapshotStart: number; }; +type TypedMaestroReplayPreparation = Readonly<{ + command: ReplayCommand; + bundle: ReplayScriptSourceBundle; +}>; + +type TypedMaestroReplayExecution = TypedMaestroReplayPreparation & + Readonly<{ + startedAt: number; + state: TypedMaestroReplayState; + }>; + type TypedMaestroReplayContext = { filePath: string; flow: MaestroFlow; @@ -71,7 +82,7 @@ export async function runTypedMaestroReplay(command: ReplayCommand): Promise { + const { command, bundle, startedAt, state } = params; const { request: req, session: { name: sessionName, store: sessionStore }, tracePath, onStep, invoke, - bundle, - startedAt, - state, - } = params; - const context = await prepareTypedMaestroReplay(params); + } = command; + const context = await prepareTypedMaestroReplay({ command, bundle }); const port = createMaestroReplayPort({ req, invoke, @@ -133,7 +138,7 @@ async function executeTypedMaestroReplay( }); if (!outcome.ok) { return await buildTypedMaestroReplayErrorResponse({ - command: params, + command, replayPath: bundle.entry, state, outcome, @@ -141,20 +146,20 @@ async function executeTypedMaestroReplay( } return buildTypedMaestroSuccessResponse({ outcome, - command: params, + command, startedAt, snapshotStart: state.snapshotStart, }); } async function prepareTypedMaestroReplay( - params: ReplayCommand & { bundle: ReplayScriptSourceBundle }, + params: TypedMaestroReplayPreparation, ): Promise { + const { command, bundle } = params; const { request: req, session: { name: sessionName, store: sessionStore }, - bundle, - } = params; + } = command; const filePath = bundle.entry; const flow = inspectMaestroFlow(readReplayScriptSourceFile(bundle, filePath), filePath); // `sessionName` is the resolved store key, so the lookup carries the address a selector diff --git a/src/daemon/replay/internal/session-replay-video-recording.ts b/src/daemon/replay/internal/session-replay-video-recording.ts index 454def2ad2..8702e85772 100644 --- a/src/daemon/replay/internal/session-replay-video-recording.ts +++ b/src/daemon/replay/internal/session-replay-video-recording.ts @@ -134,16 +134,27 @@ function appendVideoTimingEvent( appendTimingEvent({ ...event, ts: new Date().toISOString() }); } -function replayVideoRecordRequest(params: { +type ReplayVideoRecordRequestParams = Readonly<{ req: DaemonRequest; sessionName: string; - phase: ReplayRecordVideoRequest['phase']; - outputPath?: string; -}): ReplayRecordVideoRequest { +}> & + ({ phase: 'start'; outputPath: string } | { phase: 'stop' }); + +function replayVideoRecordRequest( + params: ReplayVideoRecordRequestParams, +): ReplayRecordVideoRequest { + const request = { token: params.req.token, meta: params.req.meta }; + if (params.phase === 'start') { + return { + request, + sessionName: params.sessionName, + phase: 'start', + outputPath: params.outputPath, + }; + } return { - request: { token: params.req.token, meta: params.req.meta }, + request, sessionName: params.sessionName, - phase: params.phase, - outputPath: params.outputPath, + phase: 'stop', }; } diff --git a/src/daemon/replay/internal/test-command.ts b/src/daemon/replay/internal/test-command.ts index d6572021d6..1687ee0ebe 100644 --- a/src/daemon/replay/internal/test-command.ts +++ b/src/daemon/replay/internal/test-command.ts @@ -5,7 +5,7 @@ import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay-script-source.ts'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; -import { SessionStore } from '../../session-store.ts'; +import { expandSessionPath } from '../../session-paths.ts'; import type { ReplayTestCommand } from './command-types.ts'; import { runReplayTestSuite, @@ -313,8 +313,7 @@ function toReplayTestSuiteRequest(req: DaemonRequest, sessionName: string): Repl cwd, requestId: req.meta?.requestId, platformFilter: flags.platform, - artifactsDir: - artifactsDir === undefined ? undefined : SessionStore.expandHome(artifactsDir, cwd), + artifactsDir: artifactsDir === undefined ? undefined : expandSessionPath(artifactsDir, cwd), failFast: flags.failFast === true, retries: numberFlag(flags.retries), timeoutMs: numberFlag(flags.timeoutMs), From e04bef9882a52013e52fcb2da43cc7a99cc387b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 14:05:23 +0200 Subject: [PATCH 3/4] test: close replay ownership import scan gap --- .../replay-coordinator-ownership.test.ts | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index f9541670db..e88c5dbcfb 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -74,29 +74,66 @@ function resolveRelativeTarget(fromFile: string, spec: string): string | null { return resolved.startsWith('src/') ? resolved : null; } -function collectImportSites(file: string): ImportSite[] { - const source = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'); +function collectImportSites( + file: string, + source = fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'), +): ImportSite[] { const parsed = parseSync(file, source); const sites: ImportSite[] = []; - for (const node of parsed.program.body) { - if (node.type !== 'ImportDeclaration') continue; - const target = resolveRelativeTarget(file, node.source.value); - const bindings = node.specifiers - .filter((specifier) => specifier.type === 'ImportSpecifier') - .map((specifier) => ({ - name: specifier.imported.type === 'Identifier' ? specifier.imported.name : '', - typeOnly: specifier.importKind === 'type', - })); + visitAst(parsed.program, (node) => { + if (node.type === 'ImportDeclaration') { + const target = importSourceTarget(file, node.source); + const specifiers = node.specifiers as readonly Record[]; + const bindings = specifiers + .filter((specifier) => specifier.type === 'ImportSpecifier') + .map((specifier) => ({ + name: identifierName(specifier.imported) ?? '', + typeOnly: specifier.importKind === 'type', + })); + sites.push({ + file, + target, + bindings, + declarationTypeOnly: node.importKind === 'type', + }); + return; + } + if (node.type === 'ImportExpression') { + sites.push({ + file, + target: importSourceTarget(file, node.source), + bindings: [], + declarationTypeOnly: false, + }); + return; + } + if (node.type !== 'ExportNamedDeclaration' && node.type !== 'ExportAllDeclaration') return; + if (!node.source) return; + const bindings = + node.type === 'ExportNamedDeclaration' + ? (node.specifiers as readonly Record[]) + .filter((specifier) => specifier.type === 'ExportSpecifier') + .map((specifier) => ({ + name: identifierName(specifier.local) ?? '', + typeOnly: node.exportKind === 'type' || specifier.exportKind === 'type', + })) + : []; sites.push({ file, - target, + target: importSourceTarget(file, node.source), bindings, - declarationTypeOnly: node.importKind === 'type', + declarationTypeOnly: node.exportKind === 'type', }); - } + }); return sites; } +function importSourceTarget(file: string, source: unknown): string | null { + if (!source || typeof source !== 'object') return null; + const value = (source as { value?: unknown }).value; + return typeof value === 'string' ? resolveRelativeTarget(file, value) : null; +} + function importsValueBinding(site: ImportSite, name: string): boolean { if (site.declarationTypeOnly) return false; return site.bindings.some((binding) => binding.name === name && !binding.typeOnly); @@ -327,3 +364,15 @@ test('the divergence-report chain never imports SessionStore', () => { } } }); + +test('SessionStore ownership scanning catches dynamic imports and re-exports', () => { + const sites = collectImportSites( + 'src/daemon/replay/internal/probe.ts', + [ + "void import('../../session-store.ts');", + "export { SessionStore } from '../../session-store.ts';", + "export * from '../../session-store.ts';", + ].join('\n'), + ); + assert.equal(sites.filter((site) => importsAnyBinding(site, SESSION_STORE_MODULE)).length, 3); +}); From 66e02f04bb4c0e98ed052e61c2f09735e834f81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 15:08:00 +0200 Subject: [PATCH 4/4] fix: tighten replay capability boundaries --- .../__tests__/test-file-size-ratchet.test.ts | 2 +- scripts/layering/model.test.ts | 16 +++ scripts/layering/model.ts | 70 +++++++++- .../__tests__/internal-observation.test.ts | 28 ++-- .../replay-coordinator-ownership.test.ts | 123 ++++++++++++++++-- src/daemon/handlers/session-replay-command.ts | 36 ++++- src/daemon/internal-observation.ts | 20 +-- .../replay/__tests__/application.test.ts | 9 +- .../__tests__/replay-command-fixture.ts | 3 +- .../__tests__/replay-session-fixture.ts | 30 +++++ ...eplay-divergence-android-occlusion.test.ts | 9 +- ...sion-replay-divergence-publication.test.ts | 44 +++++-- .../session-replay-divergence.test.ts | 66 +++------- ...on-replay-maestro-error-projection.test.ts | 7 +- .../session-replay-maestro-failure.test.ts | 5 +- ...ion-replay-maestro-session-address.test.ts | 7 +- .../session-replay-runtime-plan.test.ts | 4 +- ...ion-test-suite-command-flag-policy.test.ts | 9 +- .../session-test-suite-command-video.test.ts | 9 +- src/daemon/replay/internal/command-types.ts | 19 ++- src/daemon/replay/internal/native-command.ts | 8 +- .../session-replay-divergence-publication.ts | 14 +- .../internal/session-replay-divergence.ts | 34 +++-- .../session-replay-maestro-failure.ts | 10 +- .../session-replay-maestro-response.ts | 18 +-- .../session-replay-maestro-runtime.ts | 31 ++++- .../session-replay-runtime-engine-adapter.ts | 35 +++-- .../session-replay-runtime-failure.ts | 8 +- .../internal/session-replay-runtime-plan.ts | 2 +- .../session-replay-runtime-session.ts | 14 +- .../session-replay-target-verification.ts | 25 ++-- .../session-replay-video-recording.ts | 4 +- src/daemon/replay/internal/test-command.ts | 14 +- src/daemon/session-replay-coordinator.ts | 83 ++++++------ 34 files changed, 549 insertions(+), 267 deletions(-) create mode 100644 src/daemon/replay/internal/__tests__/replay-session-fixture.ts diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index a89abb163f..0d9f6f5d0e 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -51,7 +51,7 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/daemon/handlers/__tests__/find.test.ts': 1199, 'packages/platform-apple/src/core/__tests__/perf.test.ts': 1222, 'src/mcp/__tests__/command-tools.test.ts': 1216, - 'src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts': 1136, + 'src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts': 1100, 'packages/platform-apple/src/core/__tests__/apps.test.ts': 1146, 'src/daemon/replay/internal/__tests__/session-replay-repair-transaction.test.ts': 1202, 'src/daemon/replay/internal/__tests__/session-replay-target-verification-runtime.test.ts': 1182, diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 4f73697062..467c912114 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -52,6 +52,22 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export ); }); +test('parseImports detects multiline dynamic imports', () => { + const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n')); + + assert.deepEqual(edges, [ + { spec: '../multiline.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] }, + ]); +}); + +test('parseImports resolves constant-template dynamic imports', () => { + const edges = parseImports('void import(`../template.ts`);'); + + assert.deepEqual(edges, [ + { spec: '../template.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] }, + ]); +}); + test('parseImports retains named source symbols without changing edge-kind detection', () => { const edges = parseImports( [ diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index cfc0649c31..8b5f4d1d62 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -1,5 +1,7 @@ import path from 'node:path'; import { PLATFORMS } from '@agent-device/kernel/device'; +import { parseSync } from 'oxc-parser'; +import { visitAst } from './layering-ast.ts'; export type ImportEdge = { spec: string; @@ -105,13 +107,60 @@ export function classifyZone(zone: string): ZoneClassification { return 'unclassified'; } -function scanDynamicImports(line: string, lineNo: number): ImportEdge[] { - const edges: ImportEdge[] = []; - const re = /import\s*\(\s*['"]([^'"]+)['"]/g; - let match: RegExpExecArray | null; - while ((match = re.exec(line))) { - edges.push({ spec: match[1]!, dynamic: true, typeOnly: false, line: lineNo, symbols: [] }); +function sourceLine(source: string, offset: number | null | undefined): number { + const start = typeof offset === 'number' && offset >= 0 ? offset : 0; + return source.slice(0, start).split('\n').length; +} + +function literalSpecifier(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record.type === 'Literal' && typeof record.value === 'string') return record.value; + if (record.type === 'TemplateLiteral') { + const expressions = record.expressions; + const quasis = record.quasis; + if (!Array.isArray(expressions) || expressions.length > 0 || !Array.isArray(quasis)) { + return undefined; + } + const quasi = quasis[0]; + if (quasi === null || typeof quasi !== 'object') return undefined; + const value = (quasi as Record).value; + if (value === null || typeof value !== 'object') return undefined; + const cooked = (value as Record).cooked; + return typeof cooked === 'string' ? cooked : undefined; + } + if ( + record.type === 'ParenthesizedExpression' || + record.type === 'TSAsExpression' || + record.type === 'TSTypeAssertion' || + record.type === 'TSSatisfiesExpression' || + record.type === 'TSNonNullExpression' + ) { + return literalSpecifier(record.expression); } + if (record.type === 'BinaryExpression' && record.operator === '+') { + const left = literalSpecifier(record.left); + const right = literalSpecifier(record.right); + return left === undefined || right === undefined ? undefined : left + right; + } + return undefined; +} + +function scanDynamicImports(source: string): ImportEdge[] { + const edges: ImportEdge[] = []; + const parsed = parseSync('layering-imports.ts', source); + visitAst(parsed.program, (node) => { + if (node.type !== 'ImportExpression') return; + const spec = literalSpecifier(node.source); + if (spec === undefined) return; + edges.push({ + spec, + dynamic: true, + typeOnly: false, + line: sourceLine(source, node.start as number | undefined), + symbols: [], + }); + }); return edges; } @@ -187,9 +236,16 @@ function scanFromImport(lines: string[], index: number): ImportEdge | null { export function parseImports(source: string): ImportEdge[] { const lines = source.split('\n'); + const dynamicImports = scanDynamicImports(source); + const dynamicImportsByLine = new Map(); + for (const edge of dynamicImports) { + const lineEdges = dynamicImportsByLine.get(edge.line) ?? []; + lineEdges.push(edge); + dynamicImportsByLine.set(edge.line, lineEdges); + } const edges: ImportEdge[] = []; for (let index = 0; index < lines.length; index++) { - edges.push(...scanDynamicImports(lines[index]!, index + 1)); + edges.push(...(dynamicImportsByLine.get(index + 1) ?? [])); const sideEffect = scanSideEffectImport(lines[index]!, index + 1); if (sideEffect) { edges.push(sideEffect); diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 29923869ad..5dd6263692 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -35,26 +35,36 @@ function scenario() { markSessionPartialRefsIssued(session, ['e1']); sessionStore.set(sessionName, session); const captured = snapshot('e2', 'Internal capture'); - const authority = bindInternalObservationAuthority({ - sessionStore, - sessionName, - }); + const authority = bindAuthority(sessionStore, sessionName); const stored = authority.store(captured); return { sessionStore, sessionName, session, prior, captured, authority, ...stored }; } function publishCurrent(input: ReturnType, signal?: AbortSignal) { - const authority = bindInternalObservationAuthority({ - sessionStore: input.sessionStore, - sessionName: input.sessionName, - ...(signal ? { signal } : {}), - }); + const authority = bindAuthority(input.sessionStore, input.sessionName, signal); return authority.finalize(input.evidence, { refsGeneration: input.refsGeneration, refs: ['@e2'], }); } +function bindAuthority(sessionStore: SessionStore, sessionName: string, signal?: AbortSignal) { + return bindInternalObservationAuthority({ + sessionStore: { + get: () => sessionStore.get(sessionName), + update: (mutate) => { + const session = sessionStore.get(sessionName); + if (!session) return false; + mutate(session); + sessionStore.set(sessionName, session); + return true; + }, + }, + sessionName, + ...(signal ? { signal } : {}), + }); +} + test('publishes exactly the validated outward refs from a current internal capture', () => { const input = scenario(); diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index e88c5dbcfb..c41b5bec56 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -41,6 +41,7 @@ const DIVERGENCE_CHAIN_FILES = [ type ImportSite = { file: string; + kind: 'import' | 'dynamic-import' | 'named-reexport' | 'all-reexport'; /** Repo-root-relative path the specifier resolves to, or `null` for an unresolved/external specifier. */ target: string | null; /** A named import binding at this site: its exported name and whether IT is type-only. */ @@ -92,6 +93,7 @@ function collectImportSites( })); sites.push({ file, + kind: 'import', target, bindings, declarationTypeOnly: node.importKind === 'type', @@ -101,6 +103,7 @@ function collectImportSites( if (node.type === 'ImportExpression') { sites.push({ file, + kind: 'dynamic-import', target: importSourceTarget(file, node.source), bindings: [], declarationTypeOnly: false, @@ -120,6 +123,7 @@ function collectImportSites( : []; sites.push({ file, + kind: node.type === 'ExportNamedDeclaration' ? 'named-reexport' : 'all-reexport', target: importSourceTarget(file, node.source), bindings, declarationTypeOnly: node.exportKind === 'type', @@ -129,9 +133,54 @@ function collectImportSites( } function importSourceTarget(file: string, source: unknown): string | null { - if (!source || typeof source !== 'object') return null; - const value = (source as { value?: unknown }).value; - return typeof value === 'string' ? resolveRelativeTarget(file, value) : null; + const value = constantSpecifier(source); + return value === undefined ? null : resolveRelativeTarget(file, value); +} + +function objectRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined; +} + +function templateSpecifier(node: Record): string | undefined { + const expressions = node.expressions; + const quasis = node.quasis; + if (!Array.isArray(expressions) || expressions.length !== 0 || !Array.isArray(quasis)) { + return undefined; + } + const quasi = objectRecord(quasis[0]); + const value = quasi ? objectRecord(quasi.value) : undefined; + return value && typeof value.cooked === 'string' ? value.cooked : undefined; +} + +function wrappedSpecifier(node: Record): string | undefined { + return constantSpecifier(node.expression); +} + +function binarySpecifier(node: Record): string | undefined { + if (node.operator !== '+') return undefined; + const left = constantSpecifier(node.left); + const right = constantSpecifier(node.right); + return left !== undefined && right !== undefined ? left + right : undefined; +} + +function literalSpecifier(node: Record): string | undefined { + return typeof node.value === 'string' ? node.value : undefined; +} + +const SPECIFIER_READERS: Record) => string | undefined> = { + Literal: literalSpecifier, + TemplateLiteral: templateSpecifier, + TSAsExpression: wrappedSpecifier, + TSTypeAssertion: wrappedSpecifier, + ChainExpression: wrappedSpecifier, + ParenthesizedExpression: wrappedSpecifier, + BinaryExpression: binarySpecifier, +}; + +function constantSpecifier(value: unknown): string | undefined { + const node = objectRecord(value); + if (!node) return undefined; + return SPECIFIER_READERS[String(node.type)]?.(node); } function importsValueBinding(site: ImportSite, name: string): boolean { @@ -196,9 +245,7 @@ function coordinatorImportBindings( const namespace = new Set(); for (const node of body) { if (node.type !== 'ImportDeclaration') continue; - const source = node.source as { value?: unknown }; - if (typeof source.value !== 'string') continue; - if (resolveRelativeTarget(file, source.value) !== COORDINATOR_MODULE) continue; + if (importSourceTarget(file, node.source) !== COORDINATOR_MODULE) continue; if (node.importKind === 'type') continue; for (const specifier of node.specifiers as readonly Record[]) { addCoordinatorImportBinding(specifier, direct, namespace); @@ -268,17 +315,36 @@ function dynamicCoordinatorImportSite( file: string, node: Record, ): CoordinatorConstructionSite | undefined { - const sourceNode = node.source as { type?: unknown; value?: unknown } | undefined; - if ( - sourceNode?.type !== 'Literal' || - typeof sourceNode.value !== 'string' || - resolveRelativeTarget(file, sourceNode.value) !== COORDINATOR_MODULE - ) { + if (importSourceTarget(file, node.source) !== COORDINATOR_MODULE) { return undefined; } return { file, kind: 'dynamic-import', start: Number(node.start) }; } +function coordinatorReexportSites( + files: readonly string[], + sourceOverrides: ReadonlyMap = new Map(), +): ImportSite[] { + return files.flatMap((file) => + collectImportSites(file, sourceOverrides.get(file)).filter( + (site) => + (site.kind === 'named-reexport' || site.kind === 'all-reexport') && + site.target === COORDINATOR_MODULE, + ), + ); +} + +function unresolvedDynamicImportSites( + files: readonly string[], + sourceOverrides: ReadonlyMap = new Map(), +): ImportSite[] { + return files.flatMap((file) => + collectImportSites(file, sourceOverrides.get(file)).filter( + (site) => site.kind === 'dynamic-import' && site.target === null, + ), + ); +} + const PRODUCTION_FILES = listProductionSourceFiles(); test('createReplayCoordinator has exactly one production call site', () => { @@ -292,6 +358,31 @@ test('createReplayCoordinator has exactly one production call site', () => { sites.map(({ file, kind }) => `${file} (${kind})`).join(', ') || '(none)' }`, ); + assert.deepEqual( + coordinatorReexportSites(PRODUCTION_FILES), + [], + 'createReplayCoordinator must not be re-exported from a production module', + ); +}); + +test('coordinator ownership scanning catches re-exports and unresolved dynamic imports', () => { + const probe = 'src/daemon/replay/internal/probe.ts'; + const source = ` + export { createReplayCoordinator } from '../../session-replay-coordinator.ts'; + export * from '../../session-replay-coordinator.ts'; + const target = '../../session-replay-coordinator.ts'; + void import(target); + void import( + \`../../session-replay-coordinator.ts\` + ); + `; + const overrides = new Map([[probe, source]]); + assert.equal(coordinatorReexportSites([probe], overrides).length, 2); + assert.equal(unresolvedDynamicImportSites([probe], overrides).length, 1); + assert.deepEqual( + coordinatorConstructionSites(probe, source).map(({ kind }) => kind), + ['dynamic-import'], + ); }); test('coordinator ownership scanning catches aliases, namespaces, and dynamic imports', () => { @@ -365,6 +456,14 @@ test('the divergence-report chain never imports SessionStore', () => { } }); +test('the divergence-report chain rejects unresolved dynamic imports', () => { + assert.deepEqual( + unresolvedDynamicImportSites(DIVERGENCE_CHAIN_FILES), + [], + 'protected replay imports must resolve their dynamic specifier or fail closed', + ); +}); + test('SessionStore ownership scanning catches dynamic imports and re-exports', () => { const sites = collectImportSites( 'src/daemon/replay/internal/probe.ts', diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index a2e20b8594..4b95bb50bc 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { DaemonRequest } from '../types.ts'; +import type { DaemonRequest, SessionState } from '../types.ts'; import type { SessionStore } from '../session-store.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; @@ -58,6 +58,8 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ return await runReplayTestCommand({ request: req, session: createReplaySession(sessionName, logPath, sessionStore), + createSession: (testSessionName, testLogPath) => + createReplaySession(testSessionName, testLogPath, sessionStore), invoke: invokeReplayAction ?? invoke, cleanupSession: async (testSessionName) => await closeReplayTestSession({ @@ -74,8 +76,36 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ }); }; -function createReplaySession(name: string, logPath: string, store: SessionStore): ReplaySession { - return { name, logPath, store }; +export function createReplaySession( + name: string, + logPath: string, + store: SessionStore, +): ReplaySession { + const updateSession = (mutate: (session: SessionState) => void): boolean => { + const session = store.get(name); + if (!session) return false; + mutate(session); + store.set(name, session); + return true; + }; + return { + name, + logPath, + store: { + get: () => store.get(name), + lookup: () => store.lookup(name), + getRuntimeHints: () => store.getRuntimeHints(name), + ensureSessionDir: () => store.ensureSessionDir(name), + }, + mutationStore: { + update: updateSession, + clearRepairTombstone: () => store.clearRepairTombstone(name), + }, + observationStore: { + get: () => store.get(name), + update: updateSession, + }, + }; } type ReplayTestSessionCleanupParams = Readonly<{ diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index 1d27684de9..8ac278d5ac 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -1,7 +1,6 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { readSessionRuntimeRevision } from './ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; -import type { SessionStore } from './session-store.ts'; import type { SessionState } from './types.ts'; declare const INTERNAL_OBSERVATION_EVIDENCE: unique symbol; @@ -65,8 +64,8 @@ type BoundInternalObservationSession = Readonly<{ }>; type InternalObservationSessionStore = Readonly<{ - get: SessionStore['get']; - set: SessionStore['set']; + get: () => SessionState | undefined; + update: (mutate: (session: SessionState) => void) => boolean; }>; /** @@ -97,12 +96,17 @@ function storeInternalObservation( snapshot: SnapshotState, ): StoredInternalObservation { const { sessionStore, sessionName } = params; - const session = sessionStore.get(sessionName); - if (!session) { + let storedSession: SessionState | undefined; + if ( + !sessionStore.update((session) => { + setSessionSnapshot(session, snapshot); + storedSession = session; + }) || + !storedSession + ) { throw new Error('Internal observation session is no longer available.'); } - setSessionSnapshot(session, snapshot); - sessionStore.set(sessionName, session); + const session = storedSession; const snapshotGeneration = session.snapshotGeneration; if (snapshotGeneration === undefined) { throw new Error('Internal observation did not establish a snapshot generation.'); @@ -167,7 +171,7 @@ function isCurrentLineage( params: Pick, lineage: InternalObservationLineage, ): boolean { - const current = params.sessionStore.get(params.sessionName); + const current = params.sessionStore.get(); return ( params.sessionName === lineage.sessionName && current === lineage.session && diff --git a/src/daemon/replay/__tests__/application.test.ts b/src/daemon/replay/__tests__/application.test.ts index c1c31a3d30..8c3a7b2fdf 100644 --- a/src/daemon/replay/__tests__/application.test.ts +++ b/src/daemon/replay/__tests__/application.test.ts @@ -13,6 +13,7 @@ import { makeIosSession } from '../../../__tests__/test-utils/session-factories. import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { SessionStore } from '../../session-store.ts'; +import { createReplaySession } from '../../handlers/session-replay-command.ts'; import { runReplayCommand, runReplayTestCommand } from '../index.ts'; import type { ReplayCommand, ReplayTestCommand } from '../internal/command-types.ts'; import { captureSnapshotWithInteractor } from '../../handlers/snapshot-interactor-capture.ts'; @@ -40,11 +41,7 @@ function replayCommand( ): ReplayCommand { return { request: req, - session: { - name: req.session, - logPath: path.join(root, 'daemon.log'), - store: sessionStore, - }, + session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), invoke, }; } @@ -57,6 +54,8 @@ function testCommand( ): ReplayTestCommand { return { ...replayCommand(req, root, sessionStore, invoke), + createSession: (sessionName, logPath) => + createReplaySession(sessionName, logPath, sessionStore), cleanupSession: async () => {}, }; } diff --git a/src/daemon/replay/__tests__/replay-command-fixture.ts b/src/daemon/replay/__tests__/replay-command-fixture.ts index b748196865..d6544ac396 100644 --- a/src/daemon/replay/__tests__/replay-command-fixture.ts +++ b/src/daemon/replay/__tests__/replay-command-fixture.ts @@ -1,6 +1,7 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; import type { SessionStore } from '../../session-store.ts'; +import { createReplaySession } from '../../handlers/session-replay-command.ts'; import { runReplayCommand } from '../index.ts'; export type ReplayCommandTestInput = Readonly<{ @@ -17,7 +18,7 @@ export function runReplayForTest(params: ReplayCommandTestInput): Promise ({ @@ -57,11 +59,20 @@ function scenario(refCount = 20) { })), }; const authority = bindInternalObservationAuthority({ - sessionStore, + sessionStore: replaySession.observationStore, sessionName, }); const observation = authority.store(snapshot); - return { root, sessionStore, sessionName, session, prior, snapshot, ...observation }; + return { + root, + sessionStore, + replaySession, + sessionName, + session, + prior, + snapshot, + ...observation, + }; } function divergence( @@ -111,7 +122,7 @@ test('internal divergence capture updates observation without publishing client const observation = await captureDivergenceObservation({ session: input.session, sessionName: input.sessionName, - sessionStore: input.sessionStore, + observationStore: input.replaySession.observationStore, logPath: path.join(input.root, 'daemon.log'), action: { command: 'press', @@ -135,7 +146,8 @@ test.each([ const input = scenario(); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input), responseLevel: level, @@ -154,7 +166,8 @@ test('missing capture evidence fails closed instead of exposing unauthorized ref const input = scenario(); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input), responseLevel: 'default', @@ -174,7 +187,8 @@ test('missing capture evidence fails closed instead of exposing unauthorized ref test('degenerate projected refs fail closed when publication normalizes to empty', () => { const input = scenario(); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input, { screen: { @@ -220,14 +234,16 @@ test('an empty outward projection consumes its one-shot capture evidence', () => }); const first = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: empty, responseLevel: 'default', evidence: input.evidence, }); const second = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input), responseLevel: 'default', @@ -248,7 +264,8 @@ test('an empty outward projection consumes its one-shot capture evidence', () => test('successful overflow publishes the refs exposed by the exact artifact projection', () => { const input = scenario(); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input, { cause: { code: 'COMMAND_FAILED', message: 'x'.repeat(40_000) }, @@ -276,7 +293,8 @@ test('failed overflow artifact with no inline refs publishes nothing', () => { fs.writeFileSync(blockedArtifactDir, 'not a directory'); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input, { cause: { code: 'COMMAND_FAILED', message: 'x'.repeat(40_000) }, @@ -299,7 +317,8 @@ test('stale capture suppresses outward refs and preserves newer authority', () = }); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input), responseLevel: 'default', @@ -317,7 +336,8 @@ test('cancellation after capture suppresses outward refs without reactivating an controller.abort(); const result = boundReplayDivergenceForSession({ - sessionStore: input.sessionStore, + sessionStore: input.replaySession.store, + observationStore: input.replaySession.observationStore, sessionName: input.sessionName, divergence: divergence(input), responseLevel: 'default', diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts index bf52e1aaeb..8accf328ab 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts @@ -34,11 +34,11 @@ import { walkNonRawAndroidFixture, } from '../../../../__tests__/test-utils/android-ui-hierarchy-fixtures.ts'; import { SessionStore } from '../../../session-store.ts'; -import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; import { buildReplayFailureDivergence, captureDivergenceObservation, } from '../session-replay-divergence.ts'; +import { replayDivergenceForTest } from './replay-session-fixture.ts'; import { legacyDispatchCapture, resetLegacySnapshotCapture, @@ -85,10 +85,7 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -185,10 +182,7 @@ test('buildReplayFailureDivergence excludes keyboard chrome from screen.refs and index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -296,10 +290,7 @@ test('buildReplayFailureDivergence drops unlabeled non-interactive structural no index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -411,10 +402,7 @@ test('buildReplayFailureDivergence keeps an app inputAccessoryView control in sc index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -469,10 +457,7 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -557,10 +542,7 @@ test('buildReplayFailureDivergence: a system-overlay window survives into screen index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -652,10 +634,7 @@ test('buildReplayFailureDivergence: a fully-captured overlay dismiss-target enum index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -751,10 +730,7 @@ test('buildReplayFailureDivergence: when a system overlay mass-covers the app, t index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -829,10 +805,7 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -883,10 +856,7 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -988,10 +958,7 @@ test('buildReplayFailureDivergence: routes through the freshness-retry wrapper a index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -1055,10 +1022,7 @@ test('buildReplayFailureDivergence: divergence capture drops the action snapshot index: 0, sourcePath: path.join(root, 'flow.ad'), sourceLine: 1, - session: sessionStore.get(sessionName), - sessionName, - sessionStore, - resumeStamper: createReplayCoordinator({ sessionStore, sessionName }).resumeStamper, + ...replayDivergenceForTest(sessionStore, sessionName), logPath: path.join(root, 'daemon.log'), responseLevel: 'default', planActions: [action], @@ -1117,9 +1081,9 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr }; const observation = await captureDivergenceObservation({ - session: sessionStore.get(sessionName)!, + session: replayDivergenceForTest(sessionStore, sessionName).session!, sessionName, - sessionStore, + observationStore: replayDivergenceForTest(sessionStore, sessionName).observationStore, logPath: path.join(root, 'daemon.log'), action, retryLaunchRace: true, diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts index 580e2c8f6f..5918d76c52 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts @@ -4,6 +4,7 @@ import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; import { SessionStore } from '../../../session-store.ts'; +import { createReplaySession } from '../../../handlers/session-replay-command.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; @@ -36,11 +37,7 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { const response = await runTypedMaestroReplay({ request: req, - session: { - name: 'default', - logPath: path.join(root, 'daemon.log'), - store: sessionStore, - }, + session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), invoke: async () => ({ ok: true, data: {} }), }); diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts index d1fec75731..d86ebc1728 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts @@ -28,6 +28,7 @@ import { SessionStore } from '../../../session-store.ts'; import { captureSnapshotWithInteractor } from '../../../handlers/snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq } from '../../__tests__/session-replay-runtime.fixtures.ts'; +import { replaySessionForTest } from './replay-session-fixture.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { captureSnapshotThroughLegacyDispatchFixture, @@ -56,6 +57,7 @@ async function buildFailureScenario( const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); sessionStore.set(sessionName, makeIosSession(sessionName)); + const replaySession = replaySessionForTest(sessionStore, sessionName); mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); const failure = await captureMaestroFailure(command, path.join(root, 'flow.yaml')); const response = await buildTypedMaestroFailureResponse({ @@ -64,7 +66,8 @@ async function buildFailureScenario( replayPath: path.join(root, 'flow.yaml'), req: baseReq({ flags: { replayBackend: 'maestro', platform: 'ios' } }), sessionName, - sessionStore, + sessionStore: replaySession.store, + observationStore: replaySession.observationStore, logPath: path.join(root, 'daemon.log'), }); if (response.ok) throw new Error('expected typed Maestro failure response'); diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts index aeb6aef6e2..86fef7487a 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts @@ -4,6 +4,7 @@ import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; import { SessionStore } from '../../../session-store.ts'; +import { createReplaySession } from '../../../handlers/session-replay-command.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; @@ -38,11 +39,7 @@ test('a typed Maestro selector conflict names the session store key, not "defaul const response = await runTypedMaestroReplay({ request: req, - session: { - name: SCOPED_ADDRESS, - logPath: path.join(root, 'daemon.log'), - store: sessionStore, - }, + session: createReplaySession(SCOPED_ADDRESS, path.join(root, 'daemon.log'), sessionStore), invoke: async () => ({ ok: true, data: {} }), }); diff --git a/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts index 2a4849a67d..ced01ebb4a 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-runtime-plan.test.ts @@ -14,7 +14,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayForTest } from '../../__tests__/replay-command-fixture.ts'; import { SessionStore } from '../../../session-store.ts'; -import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; import { resolveTargetDevice } from '../../../../core/dispatch-resolve.ts'; import { captureSnapshotThroughLegacyDispatchFixture, @@ -29,6 +28,7 @@ import { baseReplayRequest as baseReq, writeReplayFile, } from '../../__tests__/session-replay-runtime.fixtures.ts'; +import { replayCoordinatorForTest } from './replay-session-fixture.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -181,7 +181,7 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' // below matches `expectedFrom`, so it WOULD clear) and // `prepareSaveScriptSession`'s `demoteForRerunIfArmed` — have something // real to mutate if this rejected request ever reaches them. - const coordinator = createReplayCoordinator({ sessionStore, sessionName }); + const coordinator = replayCoordinatorForTest(sessionStore, sessionName); coordinator.armStep({ saveScript: true, force: undefined, sourcePath: filePath, firstArm: true }); const armedSession = sessionStore.get(sessionName)!; // `actionsCountAtDivergence: 999` keeps `describeUnperformedRecordAndHeal` diff --git a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts index 0b126bab93..abb1688caa 100644 --- a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts @@ -9,6 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import { SessionStore } from '../../../session-store.ts'; +import { createReplaySession } from '../../../handlers/session-replay-command.ts'; import { runReplayTestCommand } from '../../index.ts'; import type { ReplayTestCommand } from '../command-types.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; @@ -40,11 +41,9 @@ function testCommand( ): ReplayTestCommand { return { request: req, - session: { - name: req.session, - logPath: path.join(root, 'daemon.log'), - store: sessionStore, - }, + session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), + createSession: (sessionName, logPath) => + createReplaySession(sessionName, logPath, sessionStore), invoke, cleanupSession: async () => {}, }; diff --git a/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts index 19c27f0bc3..847fc74a4d 100644 --- a/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts @@ -19,6 +19,7 @@ import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; import { createReplayTestVideoOwner } from '../../../handlers/session-replay-video-owner.ts'; +import { createReplaySession } from '../../../handlers/session-replay-command.ts'; const recordRuntimeMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), @@ -272,11 +273,9 @@ test('test finalizes replay video exactly once when cancellation arrives after s const responsePromise = runReplayTestCommand({ request, - session: { - name: 'default', - logPath: path.join(root, 'daemon.log'), - store: sessionStore, - }, + session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), + createSession: (sessionName, logPath) => + createReplaySession(sessionName, logPath, sessionStore), video, cleanupSession: async () => {}, invoke: async (nestedReq) => { diff --git a/src/daemon/replay/internal/command-types.ts b/src/daemon/replay/internal/command-types.ts index ff70fdc977..920fc5519e 100644 --- a/src/daemon/replay/internal/command-types.ts +++ b/src/daemon/replay/internal/command-types.ts @@ -1,14 +1,28 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; -import type { ReplaySessionStore as ReplaySessionStoreCapability } from '../../session-replay-coordinator.ts'; +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; +import type { + ReplaySessionMutationStore as ReplaySessionMutationStoreCapability, + ReplaySessionStore as ReplaySessionStoreCapability, +} from '../../session-replay-coordinator.ts'; export type ReplaySessionStore = ReplaySessionStoreCapability; +export type ReplaySessionMutationStore = ReplaySessionMutationStoreCapability; +export type ReplaySessionObservationStore = Readonly<{ + get: () => SessionState | undefined; + update: (mutate: (session: SessionState) => void) => boolean; +}>; + +export type ReplayTestSessionFactory = (sessionName: string, logPath: string) => ReplaySession; export type ReplaySession = Readonly<{ /** The effective SessionStore key selected by request binding. */ name: string; logPath: string; store: ReplaySessionStore; + /** Bound repair writes; replay internals never receive an unbound SessionStore setter. */ + mutationStore: ReplaySessionMutationStore; + /** Bound observation writes used only by the existing ref-publication owner. */ + observationStore: ReplaySessionObservationStore; }>; export type ReplayCommand = Readonly<{ @@ -42,6 +56,7 @@ type ReplayTestSessionCleanup = (sessionName: string) => Promise; export type ReplayTestCommand = ReplayCommand & Readonly<{ + createSession: ReplayTestSessionFactory; cleanupSession: ReplayTestSessionCleanup; video?: ReplayTestVideoOwner; }>; diff --git a/src/daemon/replay/internal/native-command.ts b/src/daemon/replay/internal/native-command.ts index 6805364bba..7a2c891341 100644 --- a/src/daemon/replay/internal/native-command.ts +++ b/src/daemon/replay/internal/native-command.ts @@ -57,7 +57,7 @@ import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; export async function runReplayCommand(command: ReplayCommand): Promise { const { request: req, - session: { name: sessionName, logPath, store: sessionStore }, + session: { name: sessionName, logPath, store: sessionStore, mutationStore, observationStore }, tracePath, onStep, invoke, @@ -78,7 +78,7 @@ export async function runReplayCommand(command: ReplayCommand): Promise(); // #1478 P4b: the one locked coordinator this request reaches the repair // transaction and resume watermark through. - const coordinator = createReplayCoordinator({ sessionStore, sessionName }); + const coordinator = createReplayCoordinator({ sessionStore, mutationStore }); try { resolved = bundle.entry; if (isMaestroYamlPath(resolved) && req.flags?.replayBackend !== 'maestro') { @@ -115,7 +115,6 @@ export async function runReplayCommand(command: ReplayCommand): Promise { const artifactProjection = redactDiagnosticData(payload); - const result = writeReplayDivergenceArtifact(sessionStore, sessionName, artifactProjection); + const result = writeReplayDivergenceArtifact(sessionStore, artifactProjection); if ('artifactPath' in result) { overflowProjection = artifactProjection; overflowArtifactPath = result.artifactPath; @@ -43,7 +44,7 @@ export function boundReplayDivergenceForSession(params: { if (screen.state !== 'available' || screen.refs.length === 0) { if (params.evidence) { bindInternalObservationAuthority({ - sessionStore, + sessionStore: observationStore, sessionName, ...(params.signal ? { signal: params.signal } : {}), }).finalize(params.evidence, { @@ -59,7 +60,7 @@ export function boundReplayDivergenceForSession(params: { } const observationAuthority = bindInternalObservationAuthority({ - sessionStore, + sessionStore: observationStore, sessionName, ...(params.signal ? { signal: params.signal } : {}), }); @@ -110,11 +111,10 @@ function removeUnpublishedOverflowArtifact(artifactPath: string | undefined): vo function writeReplayDivergenceArtifact( sessionStore: ReplaySessionStore, - sessionName: string, payload: ReplayDivergence, ): { artifactPath: string } | { artifactUnavailable: true } { try { - const dir = path.join(sessionStore.ensureSessionDir(sessionName), 'replay-divergence'); + const dir = path.join(sessionStore.ensureSessionDir(), 'replay-divergence'); fs.mkdirSync(dir, { recursive: true }); const fileName = `${Date.now()}-step${payload.step.index}.json`; const artifactPath = path.join(dir, fileName); diff --git a/src/daemon/replay/internal/session-replay-divergence.ts b/src/daemon/replay/internal/session-replay-divergence.ts index cac8f859b0..e908a907ce 100644 --- a/src/daemon/replay/internal/session-replay-divergence.ts +++ b/src/daemon/replay/internal/session-replay-divergence.ts @@ -17,7 +17,7 @@ import { computeReplayRepairHint, type ReplayRepairHintCapture, } from './session-replay-repair-hint.ts'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplaySessionObservationStore, ReplaySessionStore } from './command-types.ts'; import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; import { bindInternalObservationAuthority, @@ -56,6 +56,7 @@ export async function buildReplayFailureDivergence(params: { session: SessionState | undefined; sessionName: string; sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; @@ -77,6 +78,7 @@ export async function buildReplayFailureDivergence(params: { session, sessionName, sessionStore, + observationStore, resumeStamper, logPath, responseLevel, @@ -94,7 +96,13 @@ export async function buildReplayFailureDivergence(params: { }; const observation = session - ? await captureDivergenceObservation({ session, sessionName, sessionStore, logPath, action }) + ? await captureDivergenceObservation({ + session, + sessionName, + observationStore, + logPath, + action, + }) : ({ state: 'unavailable', reason: 'no-session', @@ -148,6 +156,7 @@ export async function buildReplayFailureDivergence(params: { return boundReplayDivergenceForSession({ sessionStore, + observationStore, sessionName, divergence, responseLevel, @@ -238,12 +247,19 @@ const DIVERGENCE_CAPTURE_RETRY_DELAYS_MS = [300, 500, 800, 1200, 2000, 3000, 400 export async function captureDivergenceObservation(params: { session: SessionState; sessionName: string; - sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; action: ReplayReportAction; retryLaunchRace?: boolean; }): Promise { - const { session, sessionName, sessionStore, logPath, action, retryLaunchRace = false } = params; + const { + session, + sessionName, + observationStore, + logPath, + action, + retryLaunchRace = false, + } = params; const flags = divergenceCaptureFlags(action); // Anchored BEFORE the first attempt, not after: a slow first capture // shrinks the retry budget rather than getting a free `DEADLINE_MS` on top @@ -253,7 +269,7 @@ export async function captureDivergenceObservation(params: { let attempt = await captureDivergenceObservationAttempt({ session, sessionName, - sessionStore, + observationStore, logPath, flags, }); @@ -267,7 +283,7 @@ export async function captureDivergenceObservation(params: { attempt = await captureDivergenceObservationAttempt({ session, sessionName, - sessionStore, + observationStore, logPath, flags, }); @@ -294,11 +310,11 @@ type DivergenceCaptureAttempt = { async function captureDivergenceObservationAttempt(params: { session: SessionState; sessionName: string; - sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; flags: CommandFlags; }): Promise { - const { session, sessionName, sessionStore, logPath, flags } = params; + const { session, sessionName, observationStore, logPath, flags } = params; try { const capture = await captureSnapshot({ device: session.device, @@ -318,7 +334,7 @@ async function captureDivergenceObservationAttempt(params: { }; } const observationAuthority = bindInternalObservationAuthority({ - sessionStore, + sessionStore: observationStore, sessionName, }); const stored = observationAuthority.store(snapshot); diff --git a/src/daemon/replay/internal/session-replay-maestro-failure.ts b/src/daemon/replay/internal/session-replay-maestro-failure.ts index 44ef0f518e..dc9976e233 100644 --- a/src/daemon/replay/internal/session-replay-maestro-failure.ts +++ b/src/daemon/replay/internal/session-replay-maestro-failure.ts @@ -11,7 +11,7 @@ import { import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplaySessionObservationStore, ReplaySessionStore } from './command-types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { @@ -68,10 +68,11 @@ export async function buildTypedMaestroFailureResponse(params: { readonly req: DaemonRequest; readonly sessionName: string; readonly sessionStore: ReplaySessionStore; + readonly observationStore: ReplaySessionObservationStore; readonly logPath: string; readonly snapshotDiagnostics?: SnapshotDiagnosticsSummary; }): Promise { - const { failure, replayPath, req, sessionName, sessionStore, logPath } = params; + const { failure, replayPath, req, sessionName, sessionStore, observationStore, logPath } = params; const requestSignal = getRequestSignal(req.meta?.requestId); const report = buildTypedMaestroFailureReportProjection(failure, req); const cause = hoistReplayFailureCauseDiagnosticMeta(params.error); @@ -82,12 +83,12 @@ export async function buildTypedMaestroFailureResponse(params: { message: sanitize(cause.message), ...(cause.hint ? { hint: sanitize(cause.hint) } : {}), }; - const session = sessionStore.get(sessionName); + const session = observationStore.get(); const observation = session ? await captureDivergenceObservation({ session, sessionName, - sessionStore, + observationStore, logPath, action: report.action, }) @@ -137,6 +138,7 @@ export async function buildTypedMaestroFailureResponse(params: { }; const bounded = boundReplayDivergenceForSession({ sessionStore, + observationStore, sessionName, divergence, responseLevel: req.meta?.responseLevel, diff --git a/src/daemon/replay/internal/session-replay-maestro-response.ts b/src/daemon/replay/internal/session-replay-maestro-response.ts index 756b1817cc..d86d89ab4d 100644 --- a/src/daemon/replay/internal/session-replay-maestro-response.ts +++ b/src/daemon/replay/internal/session-replay-maestro-response.ts @@ -20,7 +20,7 @@ export function buildTypedMaestroSuccessResponse( const { session: { name: sessionName, store: sessionStore }, } = command; - const snapshotDiagnostics = readSnapshotDiagnostics(sessionStore, sessionName, snapshotStart); + const snapshotDiagnostics = readSnapshotDiagnostics(sessionStore, snapshotStart); const replayed = outcome.replayed; return { ok: true, @@ -28,7 +28,7 @@ export function buildTypedMaestroSuccessResponse( replayed, healed: 0, session: sessionName, - sessionActive: sessionStore.get(sessionName) !== undefined, + sessionActive: sessionStore.get() !== undefined, artifactPaths: outcome.artifactPaths, ...(outcome.warnings ? { warnings: outcome.warnings } : {}), ...(snapshotDiagnostics ? { snapshotDiagnostics } : {}), @@ -53,7 +53,7 @@ export async function buildTypedMaestroReplayErrorResponse( const { command, replayPath, state, outcome } = params; const { request: req, - session: { name: sessionName, store: sessionStore, logPath }, + session: { name: sessionName, store: sessionStore, observationStore, logPath }, } = command; const { failure } = outcome; const normalizedError = normalizeError(outcome.error); @@ -65,8 +65,9 @@ export async function buildTypedMaestroReplayErrorResponse( req, sessionName, sessionStore, + observationStore, logPath, - snapshotDiagnostics: readSnapshotDiagnostics(sessionStore, sessionName, state.snapshotStart), + snapshotDiagnostics: readSnapshotDiagnostics(sessionStore, state.snapshotStart), }); } // The normalized error IS the wire error shape. Returning it whole keeps the @@ -75,13 +76,8 @@ export async function buildTypedMaestroReplayErrorResponse( return { ok: false, error: normalizedError }; } -function readSnapshotDiagnostics( - sessionStore: ReplaySessionStore, - sessionName: string, - snapshotStart: number, -) { - const samples = - sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(snapshotStart) ?? []; +function readSnapshotDiagnostics(sessionStore: ReplaySessionStore, snapshotStart: number) { + const samples = sessionStore.get()?.snapshotDiagnostics?.samples.slice(snapshotStart) ?? []; return summarizeSnapshotTimingSamples(samples); } diff --git a/src/daemon/replay/internal/session-replay-maestro-runtime.ts b/src/daemon/replay/internal/session-replay-maestro-runtime.ts index 9c9e3f93c8..a009f23e67 100644 --- a/src/daemon/replay/internal/session-replay-maestro-runtime.ts +++ b/src/daemon/replay/internal/session-replay-maestro-runtime.ts @@ -103,7 +103,7 @@ async function executeTypedMaestroReplay( const { command, bundle, startedAt, state } = params; const { request: req, - session: { name: sessionName, store: sessionStore }, + session: { store: sessionStore }, tracePath, onStep, invoke, @@ -117,7 +117,7 @@ async function executeTypedMaestroReplay( runtimeHints: context.runtimeHints, sourcePath: context.filePath, }); - state.snapshotStart = sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; + state.snapshotStart = sessionStore.get()?.snapshotDiagnostics?.samples.length ?? 0; const outcome = await executeMaestroFlow(context.flow, port, { defaults: context.defaults, env: context.env, @@ -164,7 +164,7 @@ async function prepareTypedMaestroReplay( const flow = inspectMaestroFlow(readReplayScriptSourceFile(bundle, filePath), filePath); // `sessionName` is the resolved store key, so the lookup carries the address a selector // conflict must tell the caller to close or reuse. - const sessionRef = sessionStore.lookup(sessionName); + const sessionRef = sessionStore.lookup(); const session = sessionRef?.session; if (sessionRef) assertSessionSelectorMatches(sessionRef, req.flags); const binding = await resolveMaestroReplayBinding({ @@ -208,7 +208,7 @@ async function resolveMaestroReplayBinding(params: { buildMaestroReplayTargetDeviceResolutionOptions(flow.appTarget, requestedPlatform), )); const platform = resolveMaestroPlatform(req, device); - const runtimeHints = resolveEffectiveOpenRuntimeHints({ + const runtimeHints = resolveReplayRuntimeHints({ req, sessionStore, sessionName, @@ -244,7 +244,7 @@ async function completeMaestroRuntimeBinding( device, platform: params.platform, target: resolveMaestroTarget(params.req, device), - runtimeHints: resolveEffectiveOpenRuntimeHints({ + runtimeHints: resolveReplayRuntimeHints({ req: params.req, sessionStore: params.sessionStore, sessionName: params.sessionName, @@ -254,6 +254,27 @@ async function completeMaestroRuntimeBinding( }; } +function resolveReplayRuntimeHints(params: { + req: DaemonRequest; + sessionStore: ReplaySessionStore; + sessionName: string; + device?: DeviceInfo; + platform?: Extract; +}): ReturnType { + return resolveEffectiveOpenRuntimeHints({ + req: params.req, + sessionStore: { + getRuntimeHints: (requestedSessionName) => + requestedSessionName === params.sessionName + ? params.sessionStore.getRuntimeHints() + : undefined, + }, + sessionName: params.sessionName, + device: params.device, + platform: params.platform, + }); +} + function requiresDeviceRuntimeDefaults( runtimeHints: ReturnType, ): boolean { diff --git a/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts index 2bf1265b68..8efba3c9e6 100644 --- a/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts @@ -27,7 +27,7 @@ import { type TargetBindingDivergenceContext, } from './session-replay-target-verification.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplaySessionObservationStore, ReplaySessionStore } from './command-types.ts'; /** * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out @@ -130,6 +130,7 @@ export function createAdReplayStepRuntime(params: { artifactPaths: [...stepArtifactPaths], sessionName: ctx.sessionName, sessionStore: ctx.sessionStore, + observationStore: ctx.observationStore, resumeStamper: ctx.coordinator.resumeStamper, responseLevel: ctx.responseLevel, scrubVars, @@ -152,14 +153,13 @@ export function createAdReplayStepRuntime(params: { return resolveTargetVerificationEntry({ action, resolvedAction, - sessionName: ctx.sessionName, sessionStore: ctx.sessionStore, targetRole, }); }, async captureObservation(action, _index, options) { - const session = ctx.sessionStore.get(ctx.sessionName); + const session = ctx.observationStore.get(); // #1385: this is the pre-dispatch gate a step right after `open // --relaunch` can race — the app may still be launching/mounting when // this capture lands, producing a transient `capture-failed` / @@ -170,7 +170,7 @@ export function createAdReplayStepRuntime(params: { ? await captureDivergenceObservation({ session, sessionName: ctx.sessionName, - sessionStore: ctx.sessionStore, + observationStore: ctx.observationStore, logPath: ctx.logPath, action, retryLaunchRace: options.retryLaunchRace, @@ -187,7 +187,7 @@ export function createAdReplayStepRuntime(params: { }, classifyTarget({ action, token, nodes }) { - const session = ctx.sessionStore.get(ctx.sessionName); + const session = ctx.sessionStore.get(); return classifyPreDispatchTarget({ // Only ever called right after a successful `captureObservation`, // which itself only reaches `state: 'available'` when a session is @@ -233,9 +233,9 @@ export function createAdReplayStepRuntime(params: { const response = await buildRecordedUnverifiableFailureResponse( buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), { - session: ctx.sessionStore.get(ctx.sessionName), + session: ctx.observationStore.get(), sessionName: ctx.sessionName, - sessionStore: ctx.sessionStore, + observationStore: ctx.observationStore, logPath: ctx.logPath, action, }, @@ -268,9 +268,9 @@ export function createAdReplayStepRuntime(params: { buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), evidence, { - session: ctx.sessionStore.get(ctx.sessionName), + session: ctx.observationStore.get(), sessionName: ctx.sessionName, - sessionStore: ctx.sessionStore, + observationStore: ctx.observationStore, logPath: ctx.logPath, action, }, @@ -313,9 +313,8 @@ export function createAdReplayStepRuntime(params: { isRepairArmed: () => ctx.coordinator.view()?.repairBoundary !== undefined, describeStepValue: (action) => describeReplayStepValue(action), onStep, - diagnosticsMarker: () => readSessionSnapshotSampleCount(ctx.sessionStore, ctx.sessionName), - diagnosticsSince: (marker) => - readSessionSnapshotSamplesSince(ctx.sessionStore, ctx.sessionName, marker), + diagnosticsMarker: () => readSessionSnapshotSampleCount(ctx.sessionStore), + diagnosticsSince: (marker) => readSessionSnapshotSamplesSince(ctx.sessionStore, marker), }; return { runtime, readLastResponse: () => lastResponse }; } @@ -331,6 +330,7 @@ export type ReplayStepContext = { replayReq: DaemonRequest; sessionName: string; sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; resolved: string; actions: SessionAction[]; @@ -391,6 +391,7 @@ async function buildReplayActionFailure( req, sessionName: ctx.sessionName, sessionStore: ctx.sessionStore, + observationStore: ctx.observationStore, resumeStamper: ctx.coordinator.resumeStamper, logPath: ctx.logPath, planActions: ctx.actions, @@ -432,17 +433,13 @@ function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): bo return typeof kind === 'string' && kind !== 'action-failure'; } -function readSessionSnapshotSampleCount( - sessionStore: ReplaySessionStore, - sessionName: string, -): number { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; +function readSessionSnapshotSampleCount(sessionStore: ReplaySessionStore): number { + return sessionStore.get()?.snapshotDiagnostics?.samples.length ?? 0; } function readSessionSnapshotSamplesSince( sessionStore: ReplaySessionStore, - sessionName: string, start: number, ): SnapshotTimingSample[] { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(start) ?? []; + return sessionStore.get()?.snapshotDiagnostics?.samples.slice(start) ?? []; } diff --git a/src/daemon/replay/internal/session-replay-runtime-failure.ts b/src/daemon/replay/internal/session-replay-runtime-failure.ts index 1a0007e841..ec3adb3bf3 100644 --- a/src/daemon/replay/internal/session-replay-runtime-failure.ts +++ b/src/daemon/replay/internal/session-replay-runtime-failure.ts @@ -13,7 +13,7 @@ import { hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplaySessionObservationStore, ReplaySessionStore } from './command-types.ts'; export async function withReplayFailureDiagnostics(params: { response: DaemonResponse; @@ -29,6 +29,7 @@ export async function withReplayFailureDiagnostics(params: { req: DaemonRequest; sessionName: string; sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; @@ -55,6 +56,7 @@ async function withReplayFailureContext(params: { req: DaemonRequest; sessionName: string; sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; logPath: string; @@ -74,6 +76,7 @@ async function withReplayFailureContext(params: { req, sessionName, sessionStore, + observationStore, resumeStamper, logPath, planActions, @@ -88,9 +91,10 @@ async function withReplayFailureContext(params: { index, sourcePath: failureSource?.path ?? sourcePath, sourceLine: failureSource?.line ?? sourceLine, - session: sessionStore.get(sessionName), + session: observationStore.get(), sessionName, sessionStore, + observationStore, resumeStamper, logPath, responseLevel: req.meta?.responseLevel, diff --git a/src/daemon/replay/internal/session-replay-runtime-plan.ts b/src/daemon/replay/internal/session-replay-runtime-plan.ts index 672fb9142e..8c57a6d5dd 100644 --- a/src/daemon/replay/internal/session-replay-runtime-plan.ts +++ b/src/daemon/replay/internal/session-replay-runtime-plan.ts @@ -95,7 +95,7 @@ export function prepareReplayPlan(params: { const { manifest, replayReq } = inspectReplayPlanManifest(req, script); const { metadata, actions, actionLines, actionSourcePaths, planDigest } = manifest; - const preEntrySession = sessionStore.get(sessionName); + const preEntrySession = sessionStore.get(); const entryIndexResult = resolveReplayPlanEntryIndex({ req, coordinator, diff --git a/src/daemon/replay/internal/session-replay-runtime-session.ts b/src/daemon/replay/internal/session-replay-runtime-session.ts index 099df0babe..17ca04635f 100644 --- a/src/daemon/replay/internal/session-replay-runtime-session.ts +++ b/src/daemon/replay/internal/session-replay-runtime-session.ts @@ -27,32 +27,29 @@ export function prepareReplaySession(params: { req: DaemonRequest; entryIndex: number; sessionStore: ReplaySessionStore; - sessionName: string; sourcePath: string; coordinator: ReplayCoordinator; }): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, entryIndex, sessionStore, sessionName, sourcePath, coordinator } = params; + const { req, entryIndex, sessionStore, sourcePath, coordinator } = params; const sessionPreflight = validateReplaySessionEntry({ entryIndex, sessionStore, - sessionName, coordinator, }); if (sessionPreflight) return { ok: false, response: sessionPreflight }; consumeReplayResumeState({ req, coordinator }); - return prepareSaveScriptSession({ req, sessionStore, sessionName, sourcePath, coordinator }); + return prepareSaveScriptSession({ req, sessionStore, sourcePath, coordinator }); } function validateReplaySessionEntry(params: { entryIndex: number; sessionStore: ReplaySessionStore; - sessionName: string; coordinator: ReplayCoordinator; }): DaemonResponse | undefined { const repairPreflight = preflightReplayAgainstActiveRepair(params); if (repairPreflight) return repairPreflight; - if (params.entryIndex > 0 && !params.sessionStore.get(params.sessionName)) { + if (params.entryIndex > 0 && !params.sessionStore.get()) { return noActiveSessionError(); } return undefined; @@ -87,12 +84,11 @@ function rejectSaveScriptArming(params: { function prepareSaveScriptSession(params: { req: DaemonRequest; sessionStore: ReplaySessionStore; - sessionName: string; sourcePath: string; coordinator: ReplayCoordinator; }): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, sessionStore, sessionName, sourcePath, coordinator } = params; - const preRunSession = sessionStore.get(sessionName); + const { req, sessionStore, sourcePath, coordinator } = params; + const preRunSession = sessionStore.get(); const { saveScript, force } = req.flags ?? {}; const rejection = rejectSaveScriptArming({ saveScript, diff --git a/src/daemon/replay/internal/session-replay-target-verification.ts b/src/daemon/replay/internal/session-replay-target-verification.ts index 1dc8535a3c..9d1e34b581 100644 --- a/src/daemon/replay/internal/session-replay-target-verification.ts +++ b/src/daemon/replay/internal/session-replay-target-verification.ts @@ -42,7 +42,7 @@ import { type DivergenceObservation, } from './session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { ReplaySessionObservationStore, ReplaySessionStore } from './command-types.ts'; import { computeReplayRepairHint, type ReplayRepairHintCapture, @@ -112,6 +112,7 @@ export type TargetBindingDivergenceContext = { artifactPaths: string[]; sessionName: string; sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; responseLevel: ResponseLevel | undefined; @@ -217,6 +218,7 @@ function buildTargetBindingDivergenceResponse( }; const bounded = boundReplayDivergenceForSession({ sessionStore, + observationStore: context.observationStore, sessionName, divergence, responseLevel, @@ -273,14 +275,20 @@ export function buildTargetBindingFailureResponse( async function captureFreshObservation(params: { session: SessionState | undefined; sessionName: string; - sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; action: SessionAction; unavailableHint: string; }): Promise { - const { session, sessionName, sessionStore, logPath, action, unavailableHint } = params; + const { session, sessionName, observationStore, logPath, action, unavailableHint } = params; return session - ? await captureDivergenceObservation({ session, sessionName, sessionStore, logPath, action }) + ? await captureDivergenceObservation({ + session, + sessionName, + observationStore, + logPath, + action, + }) : { state: 'unavailable', reason: 'no-session', hint: unavailableHint }; } @@ -295,7 +303,7 @@ export async function buildRecordedUnverifiableFailureResponse( params: { session: SessionState | undefined; sessionName: string; - sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; action: SessionAction; }, @@ -332,7 +340,7 @@ export async function buildPostDispatchTargetBindingFailureResponse( params: { session: SessionState | undefined; sessionName: string; - sessionStore: ReplaySessionStore; + observationStore: ReplaySessionObservationStore; logPath: string; action: SessionAction; }, @@ -383,12 +391,11 @@ function publicationEvidenceFrom( export function resolveTargetVerificationEntry(params: { action: SessionAction; resolvedAction: SessionAction; - sessionName: string; sessionStore: ReplaySessionStore; targetRole?: 'source' | 'destination'; }): AdReplayVerificationEntry { - const { action, resolvedAction, sessionName, sessionStore, targetRole } = params; - const session = sessionStore.get(sessionName); + const { action, resolvedAction, sessionStore, targetRole } = params; + const session = sessionStore.get(); if (!session) return { kind: 'inactive' }; if (resolveTargetIdentityVerification(action.command) === 'post-resolution') { const parsed = parseWaitPositionals(resolvedAction.positionals ?? []); diff --git a/src/daemon/replay/internal/session-replay-video-recording.ts b/src/daemon/replay/internal/session-replay-video-recording.ts index 8702e85772..3fe2036920 100644 --- a/src/daemon/replay/internal/session-replay-video-recording.ts +++ b/src/daemon/replay/internal/session-replay-video-recording.ts @@ -40,7 +40,7 @@ export async function startReplayTestVideoRecordingIfReady( ): Promise { const { req, sessionName, sessionStore, artifactsDir, appendTimingEvent } = params; if (req.flags?.recordVideo !== true) return undefined; - const activeSession = sessionStore.get(sessionName); + const activeSession = sessionStore.get(); if (!activeSession || activeSession.screenRecording) return undefined; const extension = recordingExtensionForPlatform(activeSession.device.platform); @@ -93,7 +93,7 @@ export async function finalizeReplayTestVideoRecording( ): Promise { const { req, sessionName, sessionStore, artifactPaths, appendTimingEvent } = params; if (req.flags?.recordVideo !== true) return undefined; - if (!sessionStore.get(sessionName)?.screenRecording) return undefined; + if (!sessionStore.get()?.screenRecording) return undefined; appendVideoTimingEvent(appendTimingEvent, { type: 'video_tail_start', diff --git a/src/daemon/replay/internal/test-command.ts b/src/daemon/replay/internal/test-command.ts index 1687ee0ebe..6d8801084c 100644 --- a/src/daemon/replay/internal/test-command.ts +++ b/src/daemon/replay/internal/test-command.ts @@ -134,7 +134,8 @@ function stripReplayTestHarnessFlags(flags: CommandFlags | undefined): CommandFl export async function runReplayTestCommand(command: ReplayTestCommand): Promise { const { request: req, - session: { name: sessionName, logPath, store: sessionStore }, + session: { name: sessionName, logPath }, + createSession, invoke, cleanupSession, video, @@ -206,11 +207,12 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< sourceBundle: sourceBundlesByPath.get(filePath), }); + const attemptSession = createSession(testSessionName, logPath); const videoRecordingParams = video ? { req, sessionName: testSessionName, - sessionStore, + sessionStore: attemptSession.store, artifactsDir, appendTimingEvent, video, @@ -239,11 +241,7 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< } : {}), }, - session: { - name: testSessionName, - logPath, - store: sessionStore, - }, + session: attemptSession, tracePath, onStep, invoke: async (nestedReq) => { @@ -268,7 +266,7 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< await finalizeReplayTestVideoRecording({ req, sessionName: testSessionName, - sessionStore, + sessionStore: createSession(testSessionName, logPath).store, artifactsDir, appendTimingEvent, artifactPaths, diff --git a/src/daemon/session-replay-coordinator.ts b/src/daemon/session-replay-coordinator.ts index 0738c74452..86814c2202 100644 --- a/src/daemon/session-replay-coordinator.ts +++ b/src/daemon/session-replay-coordinator.ts @@ -5,7 +5,7 @@ import { type ReplayDivergenceResume, type ReplayRepairHint, } from '@agent-device/contracts/divergence'; -import type { DaemonResponse, SessionRef, SessionRuntimeHints, SessionState } from './types.ts'; +import type { DaemonResponse, SessionRuntimeHints, SessionState } from './types.ts'; import { armRepairStep, isUncommittedRepairSession, @@ -18,10 +18,10 @@ import { * `ReplayCoordinator` (#1478 P4b): the single daemon-owned gateway a native `.ad` replay request * uses to reach the P4a `ReplaySessionTransaction` projection (`session-replay-transaction.ts`) * and the corrective-resume watermark. One instance is scoped to one locked replay request - * (`sessionStore` + `sessionName`, ADR 0012 decision 6's "the repair transaction spans the whole - * live session"); it owns every write this request performs against that session's repair - * lifecycle — arming, demotion for a `--from` rerun, completion, hold-on-divergence stamping, the - * `pendingRecordAndHeal` corrective watermark, and reap-tombstone clearing — + * (the request-bound read and mutation capabilities, ADR 0012 decision 6's "the repair + * transaction spans the whole live session"); it owns every write this request performs against + * that session's repair lifecycle — arming, demotion for a `--from` rerun, completion, + * hold-on-divergence stamping, the `pendingRecordAndHeal` corrective watermark, and reap-tombstone clearing — * so the replay command and its resume helper reach neither the P4a projection * nor `session.pendingRecordAndHeal` directly. * @@ -93,28 +93,35 @@ export type ReplayCoordinator = { }; export type ReplaySessionStore = Readonly<{ - get: (sessionName: string) => SessionState | undefined; - set: (sessionName: string, session: SessionState) => void; - lookup: (address: string) => SessionRef | undefined; - getRuntimeHints: (sessionName: string) => SessionRuntimeHints | undefined; - ensureSessionDir: (sessionName: string) => string; - clearRepairTombstone: (sessionName: string) => void; + get: () => Readonly | undefined; + lookup: () => + | Readonly<{ + address: string; + session: Readonly; + }> + | undefined; + getRuntimeHints: () => SessionRuntimeHints | undefined; + ensureSessionDir: () => string; +}>; + +export type ReplaySessionMutationStore = Readonly<{ + update: (mutate: (session: SessionState) => void) => boolean; + clearRepairTombstone: () => void; }>; export function createReplayCoordinator(params: { sessionStore: ReplaySessionStore; - sessionName: string; + mutationStore: ReplaySessionMutationStore; }): ReplayCoordinator { - const { sessionStore, sessionName } = params; - const current = (): SessionState | undefined => sessionStore.get(sessionName); + const { sessionStore, mutationStore } = params; + const current = (): Readonly | undefined => sessionStore.get(); const resumeStamper: ReplayResumeStamper = { sessionExists: () => current() !== undefined, stampCorrectiveWatermark(watermarkParams): void { - const session = current(); - if (!session) return; - stampPendingRecordAndHealWatermark({ session, ...watermarkParams }); - sessionStore.set(sessionName, session); + mutationStore.update((session) => { + stampPendingRecordAndHealWatermark({ session, ...watermarkParams }); + }); }, }; @@ -129,29 +136,25 @@ export function createReplayCoordinator(params: { }, armStep(stepParams): void { - const session = current(); - if (!session) return; - armRepairStep(session, { - saveScript: stepParams.saveScript, - force: stepParams.force, - sourcePath: stepParams.sourcePath, - healedSiblingPath: healedScriptSiblingPath(stepParams.sourcePath), - firstArm: stepParams.firstArm, + mutationStore.update((session) => { + armRepairStep(session, { + saveScript: stepParams.saveScript, + force: stepParams.force, + sourcePath: stepParams.sourcePath, + healedSiblingPath: healedScriptSiblingPath(stepParams.sourcePath), + firstArm: stepParams.firstArm, + }); }); - sessionStore.set(sessionName, session); }, demoteForRerunIfArmed(): void { - const session = current(); - if (!session || repairSessionBoundary(session) === undefined) return; - resetRepairCompletionForRerun(session); + if (repairSessionBoundary(current()) === undefined) return; + mutationStore.update(resetRepairCompletionForRerun); }, markCompleteIfArmed(): void { - const session = current(); - if (!session || repairSessionBoundary(session) === undefined) return; - markRepairTransactionComplete(session); - sessionStore.set(sessionName, session); + if (repairSessionBoundary(current()) === undefined) return; + mutationStore.update(markRepairTransactionComplete); }, markSessionHeldIfArmed(response): DaemonResponse { @@ -168,14 +171,16 @@ export function createReplayCoordinator(params: { }, clearTombstone(): void { - sessionStore.clearRepairTombstone(sessionName); + mutationStore.clearRepairTombstone(); }, clearCorrectiveWatermarkIfExpected(expectedFrom): void { - const session = current(); - if (!session || session.pendingRecordAndHeal?.expectedFrom !== expectedFrom) return; - clearPendingRecordAndHealWatermark(session); - sessionStore.set(sessionName, session); + if (current()?.pendingRecordAndHeal?.expectedFrom !== expectedFrom) return; + mutationStore.update((session) => { + if (session.pendingRecordAndHeal?.expectedFrom === expectedFrom) { + clearPendingRecordAndHealWatermark(session); + } + }); }, resumeStamper,