From e8a6c0164569818fd05b46ec2f54d4a8ed5d8e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 23:10:41 +0200 Subject: [PATCH 1/3] refactor: dissolve caller-side replay ownership --- docs/adr/0018-unified-event-journal.md | 2 +- .../adr/0020-composable-recorded-fragments.md | 2 +- fallow-baselines/health.json | 2 +- .../src/internal/__tests__/script.test.ts | 2 +- .../internal/target-annotation-identity.ts | 5 +- scripts/layering/check.ts | 3 + scripts/layering/daemon-modularity.test.ts | 9 +- scripts/layering/daemon-modularity.ts | 5 +- scripts/layering/model.ts | 1 - scripts/layering/replay-ownership.test.ts | 29 ++++ scripts/layering/replay-ownership.ts | 17 ++ .../cli-startup-import-closure.test.ts | 21 +++ .../test-utils/property-arbitraries.ts | 3 +- .../test-utils/replay-script-source.ts | 4 +- src/cli.ts | 4 +- src/cli/commands/generic.ts | 2 +- src/cli/commands/router-types.ts | 2 +- .../replay-test}/__tests__/progress.test.ts | 0 .../__tests__/reporters-default.test.ts | 0 .../__tests__/reporters-spec.test.ts | 0 ...ssion-test-reporter-values-maestro.test.ts | 13 +- .../session-test-reporter-values.test.ts | 15 +- .../test => cli/replay-test}/progress.ts | 0 .../reporters/__tests__/custom.test.ts | 0 .../reporters/__tests__/junit.test.ts | 0 .../reporters/__tests__/progress.test.ts | 0 .../reporters/__tests__/registry.test.ts | 0 .../replay-test}/reporters/custom.ts | 0 .../replay-test}/reporters/default.ts | 0 .../replay-test}/reporters/format.ts | 0 .../replay-test}/reporters/junit.ts | 0 .../replay-test}/reporters/progress.ts | 0 .../replay-test}/reporters/registry.ts | 0 .../replay-test}/reporters/spec.ts | 0 .../replay-test}/reporters/types.ts | 0 .../test => cli/replay-test}/reporting.ts | 0 src/{replay/test => cli/replay-test}/trace.ts | 0 .../replay}/ad-script-round-trip.test.ts | 0 src/commands/replay/index.ts | 4 +- .../replay}/script-source-bundle.test.ts | 18 +- src/commands/replay/script-source-bundle.ts | 89 ++++++++++ .../replay}/source-discovery.test.ts | 2 +- src/{ => commands}/replay/source-discovery.ts | 0 .../__tests__/replay-script-source.test.ts | 17 ++ .../request-save-script-transports.test.ts | 2 +- .../session-replay-script-source.test.ts | 2 +- .../session-replay-maestro-runtime.ts | 2 +- .../handlers/session-replay-repair-hint.ts | 3 +- src/daemon/handlers/session-replay-runtime.ts | 2 +- .../handlers/session-test-source-discovery.ts | 2 +- .../handlers/session-test-suite-command.ts | 2 +- src/daemon/replay-device-selection.ts | 2 +- src/daemon/replay-script-source.ts | 20 +++ src/replay/script-source-bundle.ts | 154 ------------------ 54 files changed, 239 insertions(+), 223 deletions(-) create mode 100644 scripts/layering/replay-ownership.test.ts create mode 100644 scripts/layering/replay-ownership.ts rename src/{replay/test => cli/replay-test}/__tests__/progress.test.ts (100%) rename src/{replay/test => cli/replay-test}/__tests__/reporters-default.test.ts (100%) rename src/{replay/test => cli/replay-test}/__tests__/reporters-spec.test.ts (100%) rename src/{daemon/handlers => cli/replay-test}/__tests__/session-test-reporter-values-maestro.test.ts (96%) rename src/{daemon/handlers => cli/replay-test}/__tests__/session-test-reporter-values.test.ts (96%) rename src/{replay/test => cli/replay-test}/progress.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/__tests__/custom.test.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/__tests__/junit.test.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/__tests__/progress.test.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/__tests__/registry.test.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/custom.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/default.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/format.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/junit.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/progress.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/registry.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/spec.ts (100%) rename src/{replay/test => cli/replay-test}/reporters/types.ts (100%) rename src/{replay/test => cli/replay-test}/reporting.ts (100%) rename src/{replay/test => cli/replay-test}/trace.ts (100%) rename src/{replay/__tests__ => commands/replay}/ad-script-round-trip.test.ts (100%) rename src/{replay/__tests__ => commands/replay}/script-source-bundle.test.ts (80%) create mode 100644 src/commands/replay/script-source-bundle.ts rename src/{replay/__tests__ => commands/replay}/source-discovery.test.ts (99%) rename src/{ => commands}/replay/source-discovery.ts (100%) create mode 100644 src/daemon/__tests__/replay-script-source.test.ts create mode 100644 src/daemon/replay-script-source.ts delete mode 100644 src/replay/script-source-bundle.ts diff --git a/docs/adr/0018-unified-event-journal.md b/docs/adr/0018-unified-event-journal.md index 37c12678ea..e57a5dc960 100644 --- a/docs/adr/0018-unified-event-journal.md +++ b/docs/adr/0018-unified-event-journal.md @@ -72,7 +72,7 @@ redaction discipline, and sink (inventoried 2026-07-24): request via `meta.requestProgress`; disabled under `--json`. Events are written to the wire **unredacted** today. 4. **Replay timing trace** (`src/daemon/handlers/session-replay-trace.ts`, - `session-test-runtime.ts`, read by `src/replay/test/trace.ts`). Per-**attempt** + `session-test-runtime.ts`, read by `src/cli/replay-test/trace.ts`). Per-**attempt** `replay-timing.ndjson` files whose paths are created dynamically inside each attempt — written by **two different helpers, one of which redacts and one of which does not** (`appendReplayTraceEvent` vs `appendReplayTestTimingEvent`). diff --git a/docs/adr/0020-composable-recorded-fragments.md b/docs/adr/0020-composable-recorded-fragments.md index f31c853694..00e45b7622 100644 --- a/docs/adr/0020-composable-recorded-fragments.md +++ b/docs/adr/0020-composable-recorded-fragments.md @@ -562,7 +562,7 @@ violation before the production change is trusted. The audited seam tests are `packages/ad-script/src/internal/__tests__/script.test.ts`, `packages/ad-replay/src/internal/__tests__/plan-digest.test.ts`, -`src/replay/__tests__/script-source-bundle.test.ts`, and +`src/commands/replay/script-source-bundle.test.ts`, and `packages/maestro/src/internal/__tests__/replay-plan.test.ts`, `source-closure.test.ts`, `program-loader.test.ts`, `src/daemon/handlers/__tests__/session-replay-runtime.test.ts`. The planted-red checks below are diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 0eaf1c3c40..5c88ac45b3 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -604,7 +604,7 @@ "packages/provider-webdriver/src/webdriver-utils.ts:high impact", "src/utils/keyed-lock.ts:high impact", "src/daemon/request-progress-protocol.ts:high impact", - "src/replay/test/reporters/format.ts:high impact", + "src/cli/replay-test/reporters/format.ts:high impact", "src/daemon/handlers/session-test-infrastructure.ts:high impact", "src/daemon/handlers/session-test-artifacts.ts:high impact", "packages/platform-android/src/app-parsers.ts:high impact", diff --git a/packages/ad-script/src/internal/__tests__/script.test.ts b/packages/ad-script/src/internal/__tests__/script.test.ts index 484f9c7e57..23bd7bb4c5 100644 --- a/packages/ad-script/src/internal/__tests__/script.test.ts +++ b/packages/ad-script/src/internal/__tests__/script.test.ts @@ -680,7 +680,7 @@ test('formatDivergenceActionLabel categorically drops fill/type text but keeps t }); // The property test asserting "serializing a parsed script is a fixed point -// for generated scripts" stays at `src/replay/__tests__/ad-script-round-trip.test.ts`: +// for generated scripts" stays at `src/commands/replay/ad-script-round-trip.test.ts`: // its script generator (`replayScriptArb`) is derived from the root command // catalog and selector grammar (`src/__tests__/test-utils/property-arbitraries.ts`), // which this package cannot import without an R11 package→root-src escape diff --git a/packages/ad-script/src/internal/target-annotation-identity.ts b/packages/ad-script/src/internal/target-annotation-identity.ts index 919856ea92..3f8e8dd303 100644 --- a/packages/ad-script/src/internal/target-annotation-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -3,9 +3,8 @@ * 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`, - * `src/commands/interaction/runtime/selector-wait.ts`, and the shared - * replay-zone tree helpers in `src/replay/`) share this verbatim so both + * replay-time verification (`src/daemon/handlers/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). * diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 08f310f396..072f002946 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -104,6 +104,7 @@ import { import { runtimeExecutionIntegrityViolations } from './runtime-execution-policy.ts'; import { sourceExecutionCompatibilityViolations } from './source-execution-policy.ts'; import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts'; +import { replayOwnershipViolations } from './replay-ownership.ts'; import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { @@ -551,6 +552,7 @@ export const LAYERING_RULE_IDS = [ 'package-boundaries', 'platform-package-policy', 'retired-platforms-zone', + 'replay-ownership', ] as const; export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number]; @@ -589,6 +591,7 @@ export const LAYERING_RULES: Readonly> = { { untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) }, ), 'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)), + 'replay-ownership': (context) => replayOwnershipViolations(context.sourceFiles), }; export function main(): number { diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index f4b59e28e4..19ce570e6f 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -118,12 +118,15 @@ 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/replay/script.ts';", + "import { readReplayScriptMetadata } from '../../../../src/daemon/handlers/session-replay-runtime.ts';", "import { parseMaestroProgram } from '../../../../src/compat/maestro/program-ir-parser.ts';", ].join('\n'), ], ['src/request/progress.ts', 'export function emitRequestProgress() {}'], - ['src/replay/script.ts', 'export function readReplayScriptMetadata() {}'], + [ + 'src/daemon/handlers/session-replay-runtime.ts', + 'export function readReplayScriptMetadata() {}', + ], ['src/compat/maestro/program-ir-parser.ts', 'export function parseMaestroProgram() {}'], ]), ); @@ -136,7 +139,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/replay/script.ts', + 'replay-test must not import src/daemon/handlers/session-replay-runtime.ts', 'replay-test must not import src/compat/maestro/program-ir-parser.ts', ], ); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index dde3550e25..ff9d7d3fca 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -62,7 +62,6 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ 'src/daemon/', 'src/providers/', 'src/request/', - 'src/replay/', 'src/compat/', 'packages/maestro/', 'packages/ad-replay/', @@ -73,7 +72,6 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ const ENGINE_FILE_PREFIXES = [ 'packages/ad-replay/src/', 'packages/maestro/src/', - 'src/replay/', 'src/daemon/handlers/session-replay', 'packages/replay-test/src/', ] as const; @@ -229,8 +227,7 @@ function checkLogicalModuleImports(edges: readonly ResolvedImportEdge[]): Layeri } if (!sourceModule) continue; - // A module's own files are never a forbidden target: `replay-test` sits inside the wider - // `src/replay/` engine root it may not import from. + // A module's own files are never a forbidden target. if (sourceModule.roots.some((root) => edge.target.startsWith(root))) continue; if (!sourceModule.forbiddenTargetRoots.some((root) => edge.target.startsWith(root))) continue; violations.push({ diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index ec55022961..33da026881 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -37,7 +37,6 @@ const TARGET_DAG_RANK = new Map([ ['contracts', 1], ['maestro', 1], ['recording', 1], - ['replay', 1], ['replay-test', 1], ['request', 1], ['screenshot-diff', 1], diff --git a/scripts/layering/replay-ownership.test.ts b/scripts/layering/replay-ownership.test.ts new file mode 100644 index 0000000000..0e7a263544 --- /dev/null +++ b/scripts/layering/replay-ownership.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { REPLAY_OWNERSHIP_RULE, replayOwnershipViolations } from './replay-ownership.ts'; + +test('R71 rejects a planted production file under retired src/replay by name', () => { + const violations = replayOwnershipViolations(['src/replay/planted-production-file.ts']); + + assert.deepEqual(violations, [ + { + rule: REPLAY_OWNERSHIP_RULE, + file: 'src/replay/planted-production-file.ts', + line: 1, + message: + 'src/replay/ is retired; caller source acquisition belongs under src/commands/replay/ ' + + 'and replay-test presentation belongs under src/cli/replay-test/.', + }, + ]); +}); + +test('R71 accepts the current replay owners', () => { + assert.deepEqual( + replayOwnershipViolations([ + 'src/commands/replay/script-source-bundle.ts', + 'src/cli/replay-test/reporting.ts', + 'src/daemon/replay-script-source.ts', + ]), + [], + ); +}); diff --git a/scripts/layering/replay-ownership.ts b/scripts/layering/replay-ownership.ts new file mode 100644 index 0000000000..892e6bce62 --- /dev/null +++ b/scripts/layering/replay-ownership.ts @@ -0,0 +1,17 @@ +import type { LayeringViolation } from './model.ts'; + +const RETIRED_REPLAY_ROOT = 'src/replay/'; +export const REPLAY_OWNERSHIP_RULE = 'R71 replay-ownership'; + +export function replayOwnershipViolations(sourceFiles: readonly string[]): LayeringViolation[] { + return sourceFiles + .filter((file) => file.startsWith(RETIRED_REPLAY_ROOT)) + .map((file) => ({ + rule: REPLAY_OWNERSHIP_RULE, + file, + line: 1, + message: + `${RETIRED_REPLAY_ROOT} is retired; caller source acquisition belongs under ` + + 'src/commands/replay/ and replay-test presentation belongs under src/cli/replay-test/.', + })); +} diff --git a/src/__tests__/cli-startup-import-closure.test.ts b/src/__tests__/cli-startup-import-closure.test.ts index 9eeee5bf9f..416d482fd2 100644 --- a/src/__tests__/cli-startup-import-closure.test.ts +++ b/src/__tests__/cli-startup-import-closure.test.ts @@ -118,6 +118,27 @@ test('the CLI startup import closure never evaluates the Maestro engine', () => ).toEqual([]); }); +test('the replay command source closure keeps Maestro behind flow selection', () => { + const replayEntry = path.join(srcRoot, 'commands/replay/index.ts'); + const replayClosure = eagerClosureOf(replayEntry); + const sourceBundle = path.join(srcRoot, 'commands/replay/script-source-bundle.ts'); + const offenders: string[] = []; + + expect(replayClosure).toContain(sourceBundle); + for (const file of replayClosure) { + if ( + eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8')).includes('@agent-device/maestro') + ) { + offenders.push(path.relative(srcRoot, file)); + } + } + + expect( + offenders, + 'The replay command may evaluate @agent-device/maestro only inside flow source collection.', + ).toEqual([]); +}); + test('the CLI startup import closure is reachable and crosses the package boundary', () => { // Guards the test above from silently passing because the walk found nothing: // a resolver that returned null for everything would leave both the src side diff --git a/src/__tests__/test-utils/property-arbitraries.ts b/src/__tests__/test-utils/property-arbitraries.ts index fa3d521525..fa194bb8be 100644 --- a/src/__tests__/test-utils/property-arbitraries.ts +++ b/src/__tests__/test-utils/property-arbitraries.ts @@ -334,8 +334,7 @@ type ReplayLinePlan = fc.Arbitrary | { waived: string }; * Commands whose `.ad` line is a bare ` …` handled by the * generic parse/print branch (`appendGenericActionScriptArgs`), whose shape the * `wait`/`longpress` templates already exercise. A command that grows its own - * branch in src/replay/script.ts or src/replay/script-formatting.ts must move - * to a template. + * branch in the replay command's script formatting must move to a template. */ const GENERIC_REPLAY_LINE = { waived: 'generic line shape, covered by the wait/longpress templates', diff --git a/src/__tests__/test-utils/replay-script-source.ts b/src/__tests__/test-utils/replay-script-source.ts index b8b5c5847c..eb8bf9254a 100644 --- a/src/__tests__/test-utils/replay-script-source.ts +++ b/src/__tests__/test-utils/replay-script-source.ts @@ -4,8 +4,8 @@ import type { DaemonRequest } from '../../daemon/types.ts'; import { loadReplayScriptSourceBundle, readAdScriptSourceBundle, -} from '../../replay/script-source-bundle.ts'; -import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; +} from '../../commands/replay/script-source-bundle.ts'; +import { discoverReplaySourcePaths } from '../../commands/replay/source-discovery.ts'; /** * A native `.ad` script's bundle, built from a file on disk through the client's own reader diff --git a/src/cli.ts b/src/cli.ts index 62ce545631..13aa87262a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ import { pathToFileURL } from 'node:url'; import { sendToDaemon } from './daemon/client/daemon-client.ts'; import fs from 'node:fs'; import type { BatchStep } from '@agent-device/contracts/client'; -import type { ReplayTestReporterRuntime } from './replay/test/reporting.ts'; +import type { ReplayTestReporterRuntime } from './cli/replay-test/reporting.ts'; import { createAgentDeviceClient, type AgentDeviceClientConfig, @@ -478,7 +478,7 @@ async function createReplayReporterForTest( if (ctx.command !== 'test') return undefined; // Lazy: the replay test reporter is only needed by `test`, and its // static import would put the reporting runtime on every command's path. - const { createReplayTestReporterRuntime } = await import('./replay/test/reporting.ts'); + const { createReplayTestReporterRuntime } = await import('./cli/replay-test/reporting.ts'); return createReplayTestReporterRuntime({ debug: ctx.debugOutputEnabled, verbose: ctx.effectiveFlags.verbose, diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 25bc7f7c9a..fff555c5a4 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -54,7 +54,7 @@ function writeGenericCliOutput( ): Promise | number { if (command === 'test') { // Lazy: keeps the replay test reporting runtime off every other command's path. - return import('../../replay/test/reporting.ts').then(({ renderReplayTestResponse }) => + return import('../replay-test/reporting.ts').then(({ renderReplayTestResponse }) => renderReplayTestResponse({ suite: data as ReplaySuiteResult, debug: options.debug, diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 3a14f82555..5c1bc282fb 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,7 +1,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { CliCommandName } from '../../command-catalog.ts'; -import type { ReplayTestReporterRuntime } from '../../replay/test/reporting.ts'; +import type { ReplayTestReporterRuntime } from '../replay-test/reporting.ts'; export type ClientCommandParams = { positionals: string[]; diff --git a/src/replay/test/__tests__/progress.test.ts b/src/cli/replay-test/__tests__/progress.test.ts similarity index 100% rename from src/replay/test/__tests__/progress.test.ts rename to src/cli/replay-test/__tests__/progress.test.ts diff --git a/src/replay/test/__tests__/reporters-default.test.ts b/src/cli/replay-test/__tests__/reporters-default.test.ts similarity index 100% rename from src/replay/test/__tests__/reporters-default.test.ts rename to src/cli/replay-test/__tests__/reporters-default.test.ts diff --git a/src/replay/test/__tests__/reporters-spec.test.ts b/src/cli/replay-test/__tests__/reporters-spec.test.ts similarity index 100% rename from src/replay/test/__tests__/reporters-spec.test.ts rename to src/cli/replay-test/__tests__/reporters-spec.test.ts diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts similarity index 96% rename from src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts rename to src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts index 4fe8a68ff6..be22a4ea05 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts @@ -39,19 +39,16 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { ReplaySuiteResult } from '@agent-device/contracts/replay'; -import { handleSessionCommands } from './session-command-harness.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { handleSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { SessionStore } from '../../../daemon/session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts'; import { withRequestProgressSink } from '@agent-device/host-kit/request'; import { getReplayTestReporterExitCode, runReplayTestReporterProgress, runReplayTestReporters, -} from '../../../replay/test/reporters/registry.ts'; -import type { - ReplayTestReporter, - ReplayTestReporterContext, -} from '../../../replay/test/reporters/types.ts'; +} from '../reporters/registry.ts'; +import type { ReplayTestReporter, ReplayTestReporterContext } from '../reporters/types.ts'; type RecordedHook = { hook: 'onSuiteStart' | 'onTestStart' | 'onTestStep' | 'onTestResult' | 'onSuiteEnd'; diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts b/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts similarity index 96% rename from src/daemon/handlers/__tests__/session-test-reporter-values.test.ts rename to src/cli/replay-test/__tests__/session-test-reporter-values.test.ts index 43cb366f20..08ea53c5f7 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts +++ b/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts @@ -13,7 +13,7 @@ import { expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../snapshot-interactor-capture.ts', () => ({ +vi.mock('../../../daemon/handlers/snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(async () => { throw new Error('no device runner available in this test'); }), @@ -23,9 +23,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { ReplaySuiteResult } from '@agent-device/contracts/replay'; -import { handleSessionCommands } from './session-command-harness.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { handleSessionCommands } from '../../../daemon/handlers/__tests__/session-command-harness.ts'; +import { SessionStore } from '../../../daemon/session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts'; import { withRequestProgressSink } from '@agent-device/host-kit/request'; import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -33,11 +33,8 @@ import { getReplayTestReporterExitCode, runReplayTestReporterProgress, runReplayTestReporters, -} from '../../../replay/test/reporters/registry.ts'; -import type { - ReplayTestReporter, - ReplayTestReporterContext, -} from '../../../replay/test/reporters/types.ts'; +} from '../reporters/registry.ts'; +import type { ReplayTestReporter, ReplayTestReporterContext } from '../reporters/types.ts'; type RecordedHook = { hook: 'onSuiteStart' | 'onTestStart' | 'onTestStep' | 'onTestResult' | 'onSuiteEnd'; diff --git a/src/replay/test/progress.ts b/src/cli/replay-test/progress.ts similarity index 100% rename from src/replay/test/progress.ts rename to src/cli/replay-test/progress.ts diff --git a/src/replay/test/reporters/__tests__/custom.test.ts b/src/cli/replay-test/reporters/__tests__/custom.test.ts similarity index 100% rename from src/replay/test/reporters/__tests__/custom.test.ts rename to src/cli/replay-test/reporters/__tests__/custom.test.ts diff --git a/src/replay/test/reporters/__tests__/junit.test.ts b/src/cli/replay-test/reporters/__tests__/junit.test.ts similarity index 100% rename from src/replay/test/reporters/__tests__/junit.test.ts rename to src/cli/replay-test/reporters/__tests__/junit.test.ts diff --git a/src/replay/test/reporters/__tests__/progress.test.ts b/src/cli/replay-test/reporters/__tests__/progress.test.ts similarity index 100% rename from src/replay/test/reporters/__tests__/progress.test.ts rename to src/cli/replay-test/reporters/__tests__/progress.test.ts diff --git a/src/replay/test/reporters/__tests__/registry.test.ts b/src/cli/replay-test/reporters/__tests__/registry.test.ts similarity index 100% rename from src/replay/test/reporters/__tests__/registry.test.ts rename to src/cli/replay-test/reporters/__tests__/registry.test.ts diff --git a/src/replay/test/reporters/custom.ts b/src/cli/replay-test/reporters/custom.ts similarity index 100% rename from src/replay/test/reporters/custom.ts rename to src/cli/replay-test/reporters/custom.ts diff --git a/src/replay/test/reporters/default.ts b/src/cli/replay-test/reporters/default.ts similarity index 100% rename from src/replay/test/reporters/default.ts rename to src/cli/replay-test/reporters/default.ts diff --git a/src/replay/test/reporters/format.ts b/src/cli/replay-test/reporters/format.ts similarity index 100% rename from src/replay/test/reporters/format.ts rename to src/cli/replay-test/reporters/format.ts diff --git a/src/replay/test/reporters/junit.ts b/src/cli/replay-test/reporters/junit.ts similarity index 100% rename from src/replay/test/reporters/junit.ts rename to src/cli/replay-test/reporters/junit.ts diff --git a/src/replay/test/reporters/progress.ts b/src/cli/replay-test/reporters/progress.ts similarity index 100% rename from src/replay/test/reporters/progress.ts rename to src/cli/replay-test/reporters/progress.ts diff --git a/src/replay/test/reporters/registry.ts b/src/cli/replay-test/reporters/registry.ts similarity index 100% rename from src/replay/test/reporters/registry.ts rename to src/cli/replay-test/reporters/registry.ts diff --git a/src/replay/test/reporters/spec.ts b/src/cli/replay-test/reporters/spec.ts similarity index 100% rename from src/replay/test/reporters/spec.ts rename to src/cli/replay-test/reporters/spec.ts diff --git a/src/replay/test/reporters/types.ts b/src/cli/replay-test/reporters/types.ts similarity index 100% rename from src/replay/test/reporters/types.ts rename to src/cli/replay-test/reporters/types.ts diff --git a/src/replay/test/reporting.ts b/src/cli/replay-test/reporting.ts similarity index 100% rename from src/replay/test/reporting.ts rename to src/cli/replay-test/reporting.ts diff --git a/src/replay/test/trace.ts b/src/cli/replay-test/trace.ts similarity index 100% rename from src/replay/test/trace.ts rename to src/cli/replay-test/trace.ts diff --git a/src/replay/__tests__/ad-script-round-trip.test.ts b/src/commands/replay/ad-script-round-trip.test.ts similarity index 100% rename from src/replay/__tests__/ad-script-round-trip.test.ts rename to src/commands/replay/ad-script-round-trip.test.ts diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 93868fc45b..62853a403c 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -26,8 +26,8 @@ import { parseReplayCliEnvEntries, readReplayCliEnvEntries, } from '@agent-device/ad-script'; -import { loadReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts'; -import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts'; +import { loadReplayScriptSourceBundle } from './script-source-bundle.ts'; +import { discoverReplaySourcePaths } from './source-discovery.ts'; const REPLAY_COMMAND_NAME = 'replay'; const TEST_COMMAND_NAME = 'test'; diff --git a/src/replay/__tests__/script-source-bundle.test.ts b/src/commands/replay/script-source-bundle.test.ts similarity index 80% rename from src/replay/__tests__/script-source-bundle.test.ts rename to src/commands/replay/script-source-bundle.test.ts index f798ad0bad..1b0444d2ff 100644 --- a/src/replay/__tests__/script-source-bundle.test.ts +++ b/src/commands/replay/script-source-bundle.test.ts @@ -1,12 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; import { loadReplayScriptSourceBundle, - readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, -} from '../script-source-bundle.ts'; +} from './script-source-bundle.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // #1802: the caller reads every script file a replay run needs and sends the text. These pin the @@ -78,17 +76,3 @@ test('an oversized bundle is refused with the limit named', async () => { }), ); }); - -test('reading a file the bundle does not carry names the file and the entry', () => { - const bundle = { entry: '/flows/login.yaml', files: { '/flows/login.yaml': '---\n- back\n' } }; - - try { - readReplayScriptSourceFile(bundle, '/flows/missing.yaml'); - expect.unreachable('expected a missing bundled source to throw'); - } catch (error) { - expect(error).toBeInstanceOf(AppError); - expect((error as AppError).code).toBe('INVALID_ARGS'); - expect((error as AppError).message).toContain('/flows/missing.yaml'); - expect((error as AppError).details).toMatchObject({ entry: '/flows/login.yaml' }); - } -}); diff --git a/src/commands/replay/script-source-bundle.ts b/src/commands/replay/script-source-bundle.ts new file mode 100644 index 0000000000..59ad54eedb --- /dev/null +++ b/src/commands/replay/script-source-bundle.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import { resolveUserPath } from '@agent-device/host-kit/file'; +import { resolveReplayFormat } from '@agent-device/ad-script'; + +export const REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES = 2 * 1024 * 1024; + +export type ReplayScriptSourceRequest = { + inputPath: string; + cwd: string; + replayBackend?: string; + env?: Readonly>; +}; + +export async function loadReplayScriptSourceBundle( + params: ReplayScriptSourceRequest, +): Promise { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + const entrySource = readReplayEntryScript(entry, params.inputPath); + if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { + return finishBundle(entry, { [entry]: entrySource }, params.inputPath); + } + const { collectMaestroFlowSources } = await import('@agent-device/maestro'); + const files = collectMaestroFlowSources({ + entryPath: entry, + entrySource, + env: params.env, + readSource: tryReadScriptFile, + }); + return finishBundle(entry, files, params.inputPath); +} + +export function readAdScriptSourceBundle( + params: Pick, +): ReplayScriptSourceBundle { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + return finishBundle( + entry, + { [entry]: readReplayEntryScript(entry, params.inputPath) }, + params.inputPath, + ); +} + +function finishBundle( + entry: string, + files: Record, + inputPath: string, +): ReplayScriptSourceBundle { + assertBundleWithinLimit(files, inputPath); + return { entry, files }; +} + +function readReplayEntryScript(resolvedPath: string, inputPath: string): string { + try { + return fs.readFileSync(resolvedPath, 'utf8'); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `replay script not found on this machine: ${inputPath}`, + { + path: resolvedPath, + hint: 'Replay scripts are read by the client and sent to the daemon, so the path must resolve where you ran the command.', + }, + error instanceof Error ? error : undefined, + ); + } +} + +function tryReadScriptFile(resolvedPath: string): string | undefined { + try { + return fs.readFileSync(resolvedPath, 'utf8'); + } catch { + return undefined; + } +} + +function assertBundleWithinLimit(files: Record, inputPath: string): void { + const totalBytes = Object.values(files).reduce( + (total, script) => total + Buffer.byteLength(script, 'utf8'), + 0, + ); + if (totalBytes <= REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES) return; + throw new AppError( + 'INVALID_ARGS', + `replay script sources for ${inputPath} total ${totalBytes} bytes, over the ${REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES}-byte limit; split the flow or drop unused runFlow includes.`, + { limitBytes: REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, totalBytes }, + ); +} diff --git a/src/replay/__tests__/source-discovery.test.ts b/src/commands/replay/source-discovery.test.ts similarity index 99% rename from src/replay/__tests__/source-discovery.test.ts rename to src/commands/replay/source-discovery.test.ts index d7d1151fed..a23db4960d 100644 --- a/src/replay/__tests__/source-discovery.test.ts +++ b/src/commands/replay/source-discovery.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { discoverReplaySourcePaths } from '../source-discovery.ts'; +import { discoverReplaySourcePaths } from './source-discovery.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // #1802: path expansion, traversal ordering and file-type routing are CALLER work — `test` diff --git a/src/replay/source-discovery.ts b/src/commands/replay/source-discovery.ts similarity index 100% rename from src/replay/source-discovery.ts rename to src/commands/replay/source-discovery.ts diff --git a/src/daemon/__tests__/replay-script-source.test.ts b/src/daemon/__tests__/replay-script-source.test.ts new file mode 100644 index 0000000000..e11679bfd0 --- /dev/null +++ b/src/daemon/__tests__/replay-script-source.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { readReplayScriptSourceFile } from '../replay-script-source.ts'; + +test('reading a file the bundle does not carry names the file and the entry', () => { + const bundle = { entry: '/flows/login.yaml', files: { '/flows/login.yaml': '---\n- back\n' } }; + + try { + readReplayScriptSourceFile(bundle, '/flows/missing.yaml'); + expect.unreachable('expected a missing bundled source to throw'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).code).toBe('INVALID_ARGS'); + expect((error as AppError).message).toContain('/flows/missing.yaml'); + expect((error as AppError).details).toMatchObject({ entry: '/flows/login.yaml' }); + } +}); diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 545ebdb03e..05e1492b4d 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -18,7 +18,7 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, expect, test } from 'vitest'; import { LeaseRegistry } from '../lease-registry.ts'; -import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay/script-source-bundle.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../replay-script-source.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { SessionStore } from '../session-store.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts index e41d7fdf3c..8c5fb3924a 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -25,7 +25,7 @@ import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; +import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay-script-source.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index 90ac983e33..b4fedcc5e3 100644 --- a/src/daemon/handlers/session-replay-maestro-runtime.ts +++ b/src/daemon/handlers/session-replay-maestro-runtime.ts @@ -34,7 +34,7 @@ import { buildMaestroReplayTargetDeviceResolutionOptions } from '../replay-devic import { readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, -} from '../../replay/script-source-bundle.ts'; +} from '../replay-script-source.ts'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; type TypedMaestroReplayParams = { diff --git a/src/daemon/handlers/session-replay-repair-hint.ts b/src/daemon/handlers/session-replay-repair-hint.ts index bf818efa0e..fd4a216fbf 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/handlers/session-replay-repair-hint.ts @@ -13,8 +13,7 @@ * evidence, or a sparse/unavailable capture — so `repairHint` is always * defined. * - * Lives in the daemon zone (not `src/replay/`, which stays tree-agnostic per - * the replay package contract) because the container-presence test + * Lives in the daemon zone because the container-presence test * below is a genuine structural containment check over `parentIndex` — the * same tree-walking machinery decision 3's own identity-set filter uses * (`buildAncestryChain`/`computeScrollRegionKey`, `session-target-evidence.ts`) diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index da746bdb27..9ef99bd570 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -21,7 +21,7 @@ import { import { readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, -} from '../../replay/script-source-bundle.ts'; +} from '../replay-script-source.ts'; import { prepareReplaySession } from './session-replay-runtime-session.ts'; /** diff --git a/src/daemon/handlers/session-test-source-discovery.ts b/src/daemon/handlers/session-test-source-discovery.ts index f9bda7aa13..35d2af8694 100644 --- a/src/daemon/handlers/session-test-source-discovery.ts +++ b/src/daemon/handlers/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-bundle.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/handlers/session-test-suite-command.ts index db6811e7ac..8b153d8402 100644 --- a/src/daemon/handlers/session-test-suite-command.ts +++ b/src/daemon/handlers/session-test-suite-command.ts @@ -8,7 +8,7 @@ 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-bundle.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'; diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index 8f34d39000..d6760cc286 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -7,7 +7,7 @@ import { } from '@agent-device/ad-script'; import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts'; import { isDeepLinkTarget, type CommandFlags } from '@agent-device/contracts/command'; -import { readReplayScriptSourceFile } from '../replay/script-source-bundle.ts'; +import { readReplayScriptSourceFile } from './replay-script-source.ts'; import { appleSimulatorAppTargetForOpenTarget } from './open-device-selection.ts'; import type { DaemonRequest } from './types.ts'; diff --git a/src/daemon/replay-script-source.ts b/src/daemon/replay-script-source.ts new file mode 100644 index 0000000000..717008434c --- /dev/null +++ b/src/daemon/replay-script-source.ts @@ -0,0 +1,20 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; + +export const REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE = + 'This replay request carries no script sources. Replay scripts are read by the client and sent with the request; upgrade the agent-device client that issued it to a version that sends script sources.'; + +export function readReplayScriptSourceFile( + bundle: ReplayScriptSourceBundle, + resolvedPath: string, +): string { + const script = bundle.files[resolvedPath]; + if (script === undefined) { + throw new AppError( + 'INVALID_ARGS', + `replay script source is missing ${resolvedPath}; it was not part of the script sources sent with this request.`, + { path: resolvedPath, entry: bundle.entry }, + ); + } + return script; +} diff --git a/src/replay/script-source-bundle.ts b/src/replay/script-source-bundle.ts deleted file mode 100644 index 10faf6c113..0000000000 --- a/src/replay/script-source-bundle.ts +++ /dev/null @@ -1,154 +0,0 @@ -import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; -import { resolveUserPath } from '@agent-device/host-kit/file'; -import { resolveReplayFormat } from '@agent-device/ad-script'; - -/** - * The caller-side half of the replay script source bundle (#1802): the CLI, - * the Node client, and the MCP tools all reach the daemon through the replay - * command family's daemon writers, and every one of them builds its bundle - * here — there is no separate remote path, so "works locally, ENOENT in CI" - * cannot come back. - * - * Reading is the caller's job because only the caller has the files. That also - * moves the "no such script" failure to where the path means something: it is - * raised against the caller's own filesystem, naming the path as typed, before - * any daemon round-trip. - * - * `@agent-device/maestro` is loaded ON DEMAND, inside `loadReplayScriptSourceBundle`, and only - * when the entry resolves to a flow. The replay command family is part of the CLI's startup import - * closure (the command registry evaluates it for `--help` and every other invocation), and the - * Maestro engine drags its YAML parser in with it — statically importing it here cost ~28ms on - * every warm command and a 131 kB startup chunk. `src/__tests__/cli-startup-import-closure.test.ts` - * holds that line; ADR 0019 and #1681/#1641 are the same rule applied to `node:http` and the help - * fast path. - */ - -/** - * Total bundled text a single replay request may carry. `.ad` scripts and - * Maestro flows are kilobytes; a bundle this large is a mistake (a directory - * of generated flows, a binary renamed to `.ad`) and is worth refusing with a - * clear message rather than pushing megabytes through the RPC envelope. - */ -export const REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES = 2 * 1024 * 1024; - -/** - * What a daemon says when a request carries only a path. There is no path - * fallback: a pre-#1802 client sending a bare path against a newer daemon is - * told what is missing and what to do, instead of getting an `ENOENT` for a - * file that exists on the machine it typed the command on. - */ -export const REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE = - 'This replay request carries no script sources. Replay scripts are read by the client and sent with the request; upgrade the agent-device client that issued it to a version that sends script sources.'; - -export type ReplayScriptSourceRequest = { - /** The script path as the caller typed it — used verbatim in a not-found error. */ - inputPath: string; - cwd: string; - replayBackend?: string; - /** `${VAR}` values that can resolve a Maestro `runFlow` include path before the run starts. */ - env?: Readonly>; -}; - -/** - * The one bundle constructor every client surface goes through (CLI, Node client, MCP — they all - * reach the daemon via the replay command family's writers). Async because the Maestro branch - * loads its engine on demand; the native `.ad` branch touches nothing beyond the entry file. - */ -export async function loadReplayScriptSourceBundle( - params: ReplayScriptSourceRequest, -): Promise { - const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); - const entrySource = readReplayEntryScript(entry, params.inputPath); - if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { - return finishBundle(entry, { [entry]: entrySource }, params.inputPath); - } - const { collectMaestroFlowSources } = await import('@agent-device/maestro'); - const files = collectMaestroFlowSources({ - entryPath: entry, - entrySource, - env: params.env, - readSource: tryReadScriptFile, - }); - return finishBundle(entry, files, params.inputPath); -} - -/** - * A native `.ad` script IS its own whole bundle — it has no include grammar — so this needs - * neither the Maestro engine nor an await. `loadReplayScriptSourceBundle` is the entry point - * callers use; this is the `.ad` half of it, exported so a test fixture can build the shape the - * client really sends without hand-rolling it. - */ -export function readAdScriptSourceBundle( - params: Pick, -): ReplayScriptSourceBundle { - const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); - return finishBundle( - entry, - { [entry]: readReplayEntryScript(entry, params.inputPath) }, - params.inputPath, - ); -} - -function finishBundle( - entry: string, - files: Record, - inputPath: string, -): ReplayScriptSourceBundle { - assertBundleWithinLimit(files, inputPath); - return { entry, files }; -} - -function readReplayEntryScript(resolvedPath: string, inputPath: string): string { - try { - return fs.readFileSync(resolvedPath, 'utf8'); - } catch (error) { - throw new AppError( - 'INVALID_ARGS', - `replay script not found on this machine: ${inputPath}`, - { - path: resolvedPath, - hint: 'Replay scripts are read by the client and sent to the daemon, so the path must resolve where you ran the command.', - }, - error instanceof Error ? error : undefined, - ); - } -} - -function tryReadScriptFile(resolvedPath: string): string | undefined { - try { - return fs.readFileSync(resolvedPath, 'utf8'); - } catch { - return undefined; - } -} - -function assertBundleWithinLimit(files: Record, inputPath: string): void { - const totalBytes = Object.values(files).reduce( - (total, script) => total + Buffer.byteLength(script, 'utf8'), - 0, - ); - if (totalBytes <= REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES) return; - throw new AppError( - 'INVALID_ARGS', - `replay script sources for ${inputPath} total ${totalBytes} bytes, over the ${REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES}-byte limit; split the flow or drop unused runFlow includes.`, - { limitBytes: REPLAY_SCRIPT_SOURCE_BUNDLE_MAX_BYTES, totalBytes }, - ); -} - -/** Reads one bundled file, failing with the same vocabulary on both sides of the wire. */ -export function readReplayScriptSourceFile( - bundle: ReplayScriptSourceBundle, - resolvedPath: string, -): string { - const script = bundle.files[resolvedPath]; - if (script === undefined) { - throw new AppError( - 'INVALID_ARGS', - `replay script source is missing ${resolvedPath}; it was not part of the script sources sent with this request.`, - { path: resolvedPath, entry: bundle.entry }, - ); - } - return script; -} From caaf7588675504685163a10051a8228a0234cb98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 29 Aug 2026 14:00:19 +0200 Subject: [PATCH 2/3] fix: remove replay test-only export --- .../test-utils/replay-script-source.ts | 15 ++++--- src/commands/replay/script-source-bundle.ts | 45 +++++++++---------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/__tests__/test-utils/replay-script-source.ts b/src/__tests__/test-utils/replay-script-source.ts index eb8bf9254a..a6aa1fba11 100644 --- a/src/__tests__/test-utils/replay-script-source.ts +++ b/src/__tests__/test-utils/replay-script-source.ts @@ -1,20 +1,21 @@ import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import type { MaestroSourceReader } from '@agent-device/maestro'; import type { DaemonRequest } from '../../daemon/types.ts'; -import { - loadReplayScriptSourceBundle, - readAdScriptSourceBundle, -} from '../../commands/replay/script-source-bundle.ts'; +import { loadReplayScriptSourceBundle } from '../../commands/replay/script-source-bundle.ts'; import { discoverReplaySourcePaths } from '../../commands/replay/source-discovery.ts'; /** * A native `.ad` script's bundle, built from a file on disk through the client's own reader - * (#1802) so a test never hand-rolls a shape the CLI would not actually send. Synchronous - * because an `.ad` script has no include grammar and needs no engine; use + * (#1802) so a test never hand-rolls a shape the CLI would not actually send. Native loading is + * synchronous because an `.ad` script has no include grammar and needs no engine; use * `maestroScriptSourceBundleFor` for a flow. */ export function replayScriptSourceBundleFor(filePath: string): ReplayScriptSourceBundle { - return readAdScriptSourceBundle({ inputPath: filePath, cwd: process.cwd() }); + const bundle = loadReplayScriptSourceBundle({ inputPath: filePath, cwd: process.cwd() }); + if (bundle instanceof Promise) { + throw new Error('native replay source bundle unexpectedly loaded asynchronously'); + } + return bundle; } /** A Maestro flow's bundle — async because the engine that walks its includes loads on demand. */ diff --git a/src/commands/replay/script-source-bundle.ts b/src/commands/replay/script-source-bundle.ts index 59ad54eedb..8778828a69 100644 --- a/src/commands/replay/script-source-bundle.ts +++ b/src/commands/replay/script-source-bundle.ts @@ -13,33 +13,28 @@ export type ReplayScriptSourceRequest = { env?: Readonly>; }; -export async function loadReplayScriptSourceBundle( +export function loadReplayScriptSourceBundle( params: ReplayScriptSourceRequest, -): Promise { - const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); - const entrySource = readReplayEntryScript(entry, params.inputPath); - if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { - return finishBundle(entry, { [entry]: entrySource }, params.inputPath); +): ReplayScriptSourceBundle | Promise { + try { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + const entrySource = readReplayEntryScript(entry, params.inputPath); + if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { + return finishBundle(entry, { [entry]: entrySource }, params.inputPath); + } + return (async () => { + const { collectMaestroFlowSources } = await import('@agent-device/maestro'); + const files = collectMaestroFlowSources({ + entryPath: entry, + entrySource, + env: params.env, + readSource: tryReadScriptFile, + }); + return finishBundle(entry, files, params.inputPath); + })(); + } catch (error) { + return Promise.reject(error); } - const { collectMaestroFlowSources } = await import('@agent-device/maestro'); - const files = collectMaestroFlowSources({ - entryPath: entry, - entrySource, - env: params.env, - readSource: tryReadScriptFile, - }); - return finishBundle(entry, files, params.inputPath); -} - -export function readAdScriptSourceBundle( - params: Pick, -): ReplayScriptSourceBundle { - const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); - return finishBundle( - entry, - { [entry]: readReplayEntryScript(entry, params.inputPath) }, - params.inputPath, - ); } function finishBundle( From 129c387c1d1b33d49f509aa9343f0bdd23171d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 29 Aug 2026 14:45:38 +0200 Subject: [PATCH 3/3] fix: restore replay loader promise boundary --- .../test-utils/replay-script-source.ts | 16 +++------ src/commands/replay/script-source-bundle.ts | 34 ++++++++----------- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/__tests__/test-utils/replay-script-source.ts b/src/__tests__/test-utils/replay-script-source.ts index a6aa1fba11..2beeed7edc 100644 --- a/src/__tests__/test-utils/replay-script-source.ts +++ b/src/__tests__/test-utils/replay-script-source.ts @@ -1,21 +1,15 @@ +import fs from 'node:fs'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import type { MaestroSourceReader } from '@agent-device/maestro'; +import { resolveUserPath } from '@agent-device/host-kit/file'; import type { DaemonRequest } from '../../daemon/types.ts'; import { loadReplayScriptSourceBundle } from '../../commands/replay/script-source-bundle.ts'; import { discoverReplaySourcePaths } from '../../commands/replay/source-discovery.ts'; -/** - * A native `.ad` script's bundle, built from a file on disk through the client's own reader - * (#1802) so a test never hand-rolls a shape the CLI would not actually send. Native loading is - * synchronous because an `.ad` script has no include grammar and needs no engine; use - * `maestroScriptSourceBundleFor` for a flow. - */ +/** A native `.ad` script's one-entry source bundle for test requests. */ export function replayScriptSourceBundleFor(filePath: string): ReplayScriptSourceBundle { - const bundle = loadReplayScriptSourceBundle({ inputPath: filePath, cwd: process.cwd() }); - if (bundle instanceof Promise) { - throw new Error('native replay source bundle unexpectedly loaded asynchronously'); - } - return bundle; + const entry = resolveUserPath(filePath, { cwd: process.cwd() }); + return { entry, files: { [entry]: fs.readFileSync(entry, 'utf8') } }; } /** A Maestro flow's bundle — async because the engine that walks its includes loads on demand. */ diff --git a/src/commands/replay/script-source-bundle.ts b/src/commands/replay/script-source-bundle.ts index 8778828a69..a7165f0d4c 100644 --- a/src/commands/replay/script-source-bundle.ts +++ b/src/commands/replay/script-source-bundle.ts @@ -13,28 +13,22 @@ export type ReplayScriptSourceRequest = { env?: Readonly>; }; -export function loadReplayScriptSourceBundle( +export async function loadReplayScriptSourceBundle( params: ReplayScriptSourceRequest, -): ReplayScriptSourceBundle | Promise { - try { - const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); - const entrySource = readReplayEntryScript(entry, params.inputPath); - if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { - return finishBundle(entry, { [entry]: entrySource }, params.inputPath); - } - return (async () => { - const { collectMaestroFlowSources } = await import('@agent-device/maestro'); - const files = collectMaestroFlowSources({ - entryPath: entry, - entrySource, - env: params.env, - readSource: tryReadScriptFile, - }); - return finishBundle(entry, files, params.inputPath); - })(); - } catch (error) { - return Promise.reject(error); +): Promise { + const entry = resolveUserPath(params.inputPath, { cwd: params.cwd }); + const entrySource = readReplayEntryScript(entry, params.inputPath); + if (resolveReplayFormat(entry, params.replayBackend) !== 'maestro') { + return finishBundle(entry, { [entry]: entrySource }, params.inputPath); } + const { collectMaestroFlowSources } = await import('@agent-device/maestro'); + const files = collectMaestroFlowSources({ + entryPath: entry, + entrySource, + env: params.env, + readSource: tryReadScriptFile, + }); + return finishBundle(entry, files, params.inputPath); } function finishBundle(