From 524c9197d0314e8fd58eb253999161ae0874df1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 19:49:32 +0200 Subject: [PATCH 1/5] refactor(maestro): shape the replay backend as a create(host) plugin --- packages/contracts/src/replay-backend.ts | 65 +++++++- ...on-replay-maestro-error-projection.test.ts | 6 +- .../session-replay-maestro-failure.test.ts | 144 +++++------------- ...ion-replay-maestro-session-address.test.ts | 6 +- packages/maestro/src/index.ts | 3 - .../maestro/src/internal/facade-support.ts | 3 - .../__tests__/replay-maestro-backend.test.ts | 23 ++- .../session-replay-maestro-failure.test.ts | 109 +++++++++++++ .../session-replay-maestro-observer.test.ts | 33 ++-- .../session-replay-maestro-request.test.ts | 0 .../replay-backend/replay-maestro-backend.ts | 51 +++++++ .../replay-maestro-declaration.ts | 22 +++ .../session-replay-maestro-failure.ts | 27 ++-- .../session-replay-maestro-observer.ts | 38 +++-- .../session-replay-maestro-request.ts | 4 +- .../session-replay-maestro-response.ts | 4 +- .../session-replay-maestro-runtime.ts | 64 ++++---- packages/maestro/tsconfig.json | 1 + .../__tests__/replay-backend-registry.test.ts | 20 ++- ...on-replay-runtime-failure-response.test.ts | 38 +++++ .../replay-backend-declarations.ts | 21 +++ .../src/daemon-port/replay-backend-host.ts | 31 ++++ .../daemon-port/replay-backend-registry.ts | 32 ++-- .../src/daemon-port/replay-maestro-backend.ts | 46 ------ pnpm-lock.yaml | 3 + .../__tests__/maestro-support-matrix.test.ts | 16 +- src/commands/schema/cli-help.ts | 18 +-- 27 files changed, 549 insertions(+), 279 deletions(-) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/__tests__/replay-maestro-backend.test.ts (65%) create mode 100644 packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/__tests__/session-replay-maestro-observer.test.ts (57%) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/__tests__/session-replay-maestro-request.test.ts (100%) create mode 100644 packages/maestro/src/replay-backend/replay-maestro-backend.ts create mode 100644 packages/maestro/src/replay-backend/replay-maestro-declaration.ts rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/session-replay-maestro-failure.ts (84%) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/session-replay-maestro-observer.ts (80%) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/session-replay-maestro-request.ts (97%) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/session-replay-maestro-response.ts (96%) rename packages/{replay-port/src/daemon-port => maestro/src/replay-backend}/session-replay-maestro-runtime.ts (86%) create mode 100644 packages/replay-port/src/daemon-port/replay-backend-declarations.ts create mode 100644 packages/replay-port/src/daemon-port/replay-backend-host.ts delete mode 100644 packages/replay-port/src/daemon-port/replay-maestro-backend.ts diff --git a/packages/contracts/src/replay-backend.ts b/packages/contracts/src/replay-backend.ts index 1b8a0e35a8..bea4cc61f8 100644 --- a/packages/contracts/src/replay-backend.ts +++ b/packages/contracts/src/replay-backend.ts @@ -2,12 +2,17 @@ import type { DaemonError } from '@agent-device/kernel/errors'; import type { DaemonResponse, SessionRuntimeHints } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; +import type { CommandFlags } from './command-flags.ts'; import type { DaemonWireRequest } from './daemon-wire-request.ts'; import type { ReplayDivergenceResume, ReplayDivergenceSuggestionBasis, } from './replay-divergence.ts'; -import type { ReplayDispatchOptions, ReplayObservationAuthorityBinder } from './replay.ts'; +import type { + ReplayDispatchOptions, + ReplayObservationAuthorityBinder, + ReplayScriptSourceBundle, +} from './replay.ts'; import type { SessionAction } from './session-action.ts'; import type { SnapshotDiagnosticsState, @@ -15,9 +20,12 @@ import type { } from './snapshot-diagnostics.ts'; /** - * The replay backend contract (#3377): the session and command shapes a non-native replay engine - * is handed, the capabilities it implements, and the failure descriptor the daemon reports for a - * failed step. Everything here is type-only, so an engine can depend on this module without + * The replay backend plugin contract (#3377): what a non-native replay engine implements + * (`ReplayBackendPlugin`) and the host services the daemon lends it (`ReplayBackendHost`). + * + * The replay port owns the registry and builds the host; an engine package owns its plugin and + * reaches process state, session policy, and daemon dispatch only through `host` and the command + * it is handed. Everything here is type-only, so an engine can depend on this module without * evaluating any host code. */ @@ -205,3 +213,52 @@ export type ReplayBackendActionFailure = Readonly<{ snapshot: Readonly<{ createdAt: number; nodes: SnapshotNode[] }>, ): readonly Readonly<{ node: SnapshotNode; basis: ReplayDivergenceSuggestionBasis }>[]; }>; + +/** The device-binding facts a backend resolves a target device from. */ +export type ReplayBackendDeviceBinding = Readonly<{ + /** The flow's static app target, when it declares one. */ + appTarget: string | undefined; + platform: CommandFlags['platform'] | undefined; +}>; + +/** + * The host services a backend plugin reaches. Everything holding process state or daemon + * session policy is a member; stateless workspace packages stay direct imports of the plugin. + */ +export type ReplayBackendHost = Readonly<{ + /** Appends one record to the run's trace file, if any; failures are diagnostics, never thrown. */ + appendTraceEvent(tracePath: string | undefined, event: Record): void; + /** The `AD_*` variables every replay run is seeded with. */ + buildBuiltinVars(params: { + req: DaemonWireRequest; + sessionName: string; + resolvedPath: string; + }): Record; + /** Reads one file out of the request's script source bundle; throws when it was not sent. */ + readScriptSourceFile(bundle: ReplayScriptSourceBundle, resolvedPath: string): string; + scriptSourceRequiredMessage: string; + /** Selects the device a run binds when no session or explicit platform already names one. */ + resolveTargetDevice( + flags: CommandFlags, + binding: ReplayBackendDeviceBinding, + ): Promise; + /** The abort signal of the in-flight daemon request. */ + requestSignal(requestId: string | undefined): AbortSignal | undefined; + /** Builds the REPLAY_DIVERGENCE failure response: screen capture, ranking, bounding, resume. */ + buildActionFailureResponse(failure: ReplayBackendActionFailure): Promise; +}>; + +/** + * Versioned like `apiVersion` in the provider plugin manifest. A declaration is engine-free + * metadata, importable without evaluating the engine, so help and registry listings stay light. + */ +export type ReplayBackendDeclaration = Readonly<{ + apiVersion: 1; + id: TId; + help: Readonly<{ summary: string; body: string }>; +}>; + +export type ReplayBackendPlugin = Readonly<{ + declaration: ReplayBackendDeclaration; + create(host: ReplayBackendHost): ReplayBackend; +}>; diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts index fa0175a7b1..e761698892 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; import { stringify } from 'yaml'; -import { runTypedMaestroReplay } from '@agent-device/replay-port/session-replay-maestro-runtime'; +import { getReplayBackend } from '@agent-device/replay-port/replay-backend-registry'; import { SessionStore } from '../../../core/session-store.ts'; import { replayCommandForTest } from '../replay-runtime/replay-command-fixture.ts'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; @@ -35,7 +35,9 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { meta: { requestId: 'req-maestro-hint' }, } as unknown as DaemonRequest; - const response = await runTypedMaestroReplay( + const response = await ( + await getReplayBackend('maestro') + ).runReplay( replayCommandForTest({ req, sessionName: 'default', diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts index a5c92b3ae2..1888123fcb 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts @@ -1,4 +1,3 @@ -import { noMaestroIncludeSources } from '@agent-device/replay-port/replay-script-source-fixtures'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; @@ -13,23 +12,12 @@ vi.mock('../../../core/snapshot-interactor-capture.ts', () => ({ import fs from 'node:fs'; import path from 'node:path'; import { stringify } from 'yaml'; -import { - executeMaestroFlow, - inspectMaestroFlow, - type MaestroFailedAction, - type MaestroRuntimeCommand, - type MaestroRuntimePort, -} from '@agent-device/maestro'; -import { - buildTypedMaestroFailureReportProjection, - buildTypedMaestroFailureResponse, -} from '@agent-device/replay-port/session-replay-maestro-failure'; +import type { MaestroRuntimeCommand } from '@agent-device/maestro'; import { runReplayForTest } from '../replay-runtime/replay-command-fixture.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { captureSnapshotWithInteractor } from '../../../core/snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; import { baseReplayRequest as baseReq } from '../replay-runtime/session-replay-runtime.fixtures.ts'; -import { replaySessionForTest } from '../replay-divergence/replay-session-fixture.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { captureSnapshotThroughLegacyDispatchFixture, @@ -47,30 +35,35 @@ beforeEach(() => { mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); +/** + * Runs a one-step flow whose action fails, so the divergence report is built the way the daemon + * builds it: `nodes` is the screen captured after the failure, `engineNodes` what the flow's own + * target lookup sees before it. + */ async function buildFailureScenario( command: MaestroRuntimeCommand, nodes: SnapshotNode[], - options: { warnings?: readonly string[] } = {}, -): Promise<{ - response: Extract>, { ok: false }>; - sessionStore: SessionStore; - sessionName: string; -}> { + options: { engineNodes?: SnapshotNode[] } = {}, +) { const root = mkdtempForTestSync('agent-device-maestro-suggestions-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); sessionStore.publish(sessionName, makeIosSession(sessionName)); - const replaySession = replaySessionForTest(sessionStore, sessionName); + const flowPath = path.join(root, 'flow.yaml'); + fs.writeFileSync(flowPath, maestroFlowForCommand(command)); mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); - const failure = await captureMaestroFailure(command, path.join(root, 'flow.yaml')); - const response = await buildTypedMaestroFailureResponse({ - error: { code: 'COMMAND_FAILED', message: 'typed Maestro action failed' }, - failure: options.warnings ? { ...failure, warnings: options.warnings } : failure, - replayPath: path.join(root, 'flow.yaml'), - req: baseReq({ flags: { replayBackend: 'maestro', platform: 'ios' } }), - sessionStore: replaySession.store, - observationStore: replaySession.observationStore, + const response = await runReplayForTest({ + req: baseReq({ + positionals: [flowPath], + flags: { replayBackend: 'maestro', platform: 'ios' }, + }), + sessionName, logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => + req.command === 'snapshot' + ? { ok: true, data: { nodes: options.engineNodes ?? nodes } } + : { ok: false, error: { code: 'COMMAND_FAILED', message: 'typed Maestro action failed' } }, }); if (response.ok) throw new Error('expected typed Maestro failure response'); return { response, sessionStore, sessionName }; @@ -79,85 +72,11 @@ async function buildFailureScenario( async function buildFailureResponse( command: MaestroRuntimeCommand, nodes: SnapshotNode[], - options: { warnings?: readonly string[] } = {}, -): Promise>, { ok: false }>> { + options: { engineNodes?: SnapshotNode[] } = {}, +) { return (await buildFailureScenario(command, nodes, options)).response; } -test('typed Maestro failure response carries warnings accumulated before the failing step (#2560)', async () => { - const response = await buildFailureResponse( - { - kind: 'tapOn' as const, - source: { path: '/flows/login.yaml', line: 4 }, - target: { space: 'target' as const, selector: { id: 'save' } }, - }, - [], - { warnings: ['Optional Maestro assertVisible skipped at line 2: no match'] }, - ); - - if (!response.error.details) throw new Error('expected error details on the failure response'); - const details = response.error.details; - expect(details.warnings).toEqual(['Optional Maestro assertVisible skipped at line 2: no match']); - expect((details.divergence as { kind: string }).kind).toBe('action-failure'); -}); - -test('typed Maestro failure response omits an empty warnings channel', async () => { - const response = await buildFailureResponse( - { - kind: 'tapOn' as const, - source: { path: '/flows/login.yaml', line: 4 }, - target: { space: 'target' as const, selector: { id: 'save' } }, - }, - [], - { warnings: [] }, - ); - - expect('warnings' in (response.error.details ?? {})).toBe(false); -}); - -test('typed Maestro failure projection keeps action and source provenance', async () => { - const command = { - kind: 'tapOn' as const, - source: { path: '/flows/login.yaml', line: 4 }, - target: { space: 'target' as const, selector: { id: 'save' } }, - }; - const request = baseReq({ flags: { replayBackend: 'maestro' } }); - const failure = await captureMaestroFailure(command, command.source.path); - const projection = buildTypedMaestroFailureReportProjection(failure, request); - - expect(projection.command).toEqual({ kind: 'tapOn' }); - expect(projection.source).toEqual({ path: command.source.path, line: 3 }); - expect(projection.progress).toEqual({ command: 'tapOn', value: 'save' }); - expect(projection.action).toEqual({ - command: 'click', - positionals: ['save'], - flags: request.flags, - }); - expect(Object.keys(projection.action)).toEqual(['command', 'positionals', 'flags']); -}); - -async function captureMaestroFailure( - command: MaestroRuntimeCommand, - sourcePath: string, -): Promise { - const flow = inspectMaestroFlow(maestroFlowForCommand(command), sourcePath); - const error = new Error('typed Maestro action failed'); - const port: MaestroRuntimePort = { - execute: async () => { - throw error; - }, - observe: async () => { - throw error; - }, - }; - const outcome = await executeMaestroFlow(flow, port, { - platform: 'ios', - readSource: noMaestroIncludeSources, - }); - if (outcome.ok || !outcome.failure) throw new Error('expected typed Maestro failure'); - return outcome.failure; -} - function maestroFlowForCommand(command: MaestroRuntimeCommand): string { const authored = command.kind === 'inputText' @@ -378,7 +297,7 @@ test('typed Maestro failure publishes exactly the refs exposed by its divergence source: { path: '/flows/actions.yaml', line: 4 }, target: { space: 'target' as const, selector: { text: 'Missing' } }, } satisfies Extract; - const scenario = await buildFailureScenario(command, [ + const screen: SnapshotNode[] = [ { ref: 'e1', index: 0, @@ -394,7 +313,20 @@ test('typed Maestro failure publishes exactly the refs exposed by its divergence rect: { x: 16, y: 40, width: 140, height: 44 }, hittable: true, }, - ]); + ]; + const scenario = await buildFailureScenario(command, screen, { + engineNodes: [ + ...screen, + { + index: 2, + parentIndex: 0, + label: 'Missing', + type: 'Button', + rect: { x: 16, y: 96, width: 140, height: 44 }, + hittable: true, + }, + ], + }); const divergence = scenario.response.error.details?.divergence as { screen: { state: string; refs: Array<{ ref: string }> }; }; diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts index f2db04abcb..c863bbf792 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; import { stringify } from 'yaml'; -import { runTypedMaestroReplay } from '@agent-device/replay-port/session-replay-maestro-runtime'; +import { getReplayBackend } from '@agent-device/replay-port/replay-backend-registry'; import { SessionStore } from '../../../core/session-store.ts'; import { replayCommandForTest } from '../replay-runtime/replay-command-fixture.ts'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; @@ -37,7 +37,9 @@ test('a typed Maestro selector conflict names the session store key, not "defaul meta: { requestId: 'req-maestro-address' }, } as unknown as DaemonRequest; - const response = await runTypedMaestroReplay( + const response = await ( + await getReplayBackend('maestro') + ).runReplay( replayCommandForTest({ req, sessionName: SCOPED_ADDRESS, diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts index e49ac229a8..94d51c0fa4 100644 --- a/packages/maestro/src/index.ts +++ b/packages/maestro/src/index.ts @@ -29,9 +29,6 @@ export { export { MAESTRO_SELECTOR_PROJECTION } from './internal/selector-vocabulary.ts'; export { - formatMaestroCompatibilityReference, - MAESTRO_COMPATIBILITY_ADR_URL, - MAESTRO_COMPATIBILITY_ISSUE_URL, MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, } from './internal/facade-support.ts'; diff --git a/packages/maestro/src/internal/facade-support.ts b/packages/maestro/src/internal/facade-support.ts index b89db5495e..ae1d71f7dd 100644 --- a/packages/maestro/src/internal/facade-support.ts +++ b/packages/maestro/src/internal/facade-support.ts @@ -1,7 +1,4 @@ export { - formatMaestroCompatibilityReference, - MAESTRO_COMPATIBILITY_ADR_URL, - MAESTRO_COMPATIBILITY_ISSUE_URL, MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, } from './support-matrix.ts'; diff --git a/packages/replay-port/src/daemon-port/__tests__/replay-maestro-backend.test.ts b/packages/maestro/src/replay-backend/__tests__/replay-maestro-backend.test.ts similarity index 65% rename from packages/replay-port/src/daemon-port/__tests__/replay-maestro-backend.test.ts rename to packages/maestro/src/replay-backend/__tests__/replay-maestro-backend.test.ts index 8417c4d926..68e4237f19 100644 --- a/packages/replay-port/src/daemon-port/__tests__/replay-maestro-backend.test.ts +++ b/packages/maestro/src/replay-backend/__tests__/replay-maestro-backend.test.ts @@ -1,10 +1,27 @@ import { expect, test } from 'vitest'; -import { maestroBackend } from '../replay-maestro-backend.ts'; +import type { ReplayBackendHost } from '@agent-device/contracts/replay-backend'; +import { maestroReplayBackendPlugin } from '../replay-maestro-backend.ts'; // The backend adapter maps the host's capability vocabulary onto the engine's façade. The hosts // that consume it (source discovery, request device binding, `replay export`, the replay command) -// each test the behavior through the registry; what is pinned here is the adapter's own mapping, -// which is the contract the 0.22 plugin (#3377) relocates unchanged. +// each test the behavior through the registry; what is pinned here is the adapter's own mapping. +// None of these capabilities reach host services, so the host is never called. +const unusedHost: ReplayBackendHost = { + appendTraceEvent: () => { + throw new Error('host unused'); + }, + buildBuiltinVars: () => { + throw new Error('host unused'); + }, + readScriptSourceFile: () => { + throw new Error('host unused'); + }, + scriptSourceRequiredMessage: '', + resolveTargetDevice: () => Promise.reject(new Error('host unused')), + requestSignal: () => undefined, + buildActionFailureResponse: () => Promise.reject(new Error('host unused')), +}; +const maestroBackend = maestroReplayBackendPlugin.create(unusedHost); test('inspectSource reports the flow title and static app target', () => { const inspection = maestroBackend.inspectSource( diff --git a/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts new file mode 100644 index 0000000000..52d442ba5d --- /dev/null +++ b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts @@ -0,0 +1,109 @@ +import { expect, test, vi } from 'vitest'; +import type { + ReplayBackendHost, + ReplaySessionObservation, + ReplaySessionStore, +} from '@agent-device/contracts/replay-backend'; +import { + executeMaestroFlow, + inspectMaestroFlow, + type MaestroFailedAction, +} from '@agent-device/maestro'; +import { + buildTypedMaestroFailureReportProjection, + buildTypedMaestroFailureResponse, +} from '../session-replay-maestro-failure.ts'; + +const request = { token: 'test-token', session: 'default', command: 'replay', positionals: [] }; +const TAP_FLOW = ['appId: com.example.app', '---', '- tapOn:', ' id: save', ''].join('\n'); + +async function captureMaestroFailure(sourcePath: string): Promise { + const error = new Error('typed Maestro action failed'); + const outcome = await executeMaestroFlow( + inspectMaestroFlow(TAP_FLOW, sourcePath), + { + execute: async () => { + throw error; + }, + observe: async () => { + throw error; + }, + }, + { + platform: 'ios', + readSource: () => { + throw new Error('no includes'); + }, + }, + ); + if (outcome.ok || !outcome.failure) throw new Error('expected typed Maestro failure'); + return outcome.failure; +} + +test('typed Maestro failure projection keeps action and source provenance', async () => { + const failure = await captureMaestroFailure('/flows/login.yaml'); + const flags = { replayBackend: 'maestro' as const }; + const projection = buildTypedMaestroFailureReportProjection(failure, { ...request, flags }); + + expect(projection.command).toEqual({ kind: 'tapOn' }); + expect(projection.source).toEqual({ path: '/flows/login.yaml', line: 3 }); + expect(projection.progress).toEqual({ command: 'tapOn', value: 'save' }); + expect(projection.action).toEqual({ command: 'click', positionals: ['save'], flags }); + expect(Object.keys(projection.action)).toEqual(['command', 'positionals', 'flags']); +}); + +test('the failure response hands the host the step, its warnings, and its suggestion source', async () => { + const failure = { + ...(await captureMaestroFailure('/flows/login.yaml')), + warnings: ['Optional Maestro assertVisible skipped at line 2: no match'], + }; + const buildActionFailureResponse = vi.fn( + async () => ({ ok: false, error: { code: 'REPLAY_DIVERGENCE', message: 'failed' } }), + ); + const sessionStore: ReplaySessionStore = { + get: () => undefined, + lookup: () => undefined, + getRuntimeHints: () => undefined, + ensureSessionDir: () => '/tmp/session', + assertSelectorMatches: () => {}, + resolveOpenRuntimeHints: () => undefined, + }; + const observationStore: ReplaySessionObservation = { + get: () => undefined, + bindAuthority: () => { + throw new Error('the host owns observation'); + }, + }; + + await buildTypedMaestroFailureResponse( + { + appendTraceEvent: () => {}, + buildBuiltinVars: () => ({}), + readScriptSourceFile: () => '', + scriptSourceRequiredMessage: '', + resolveTargetDevice: () => Promise.reject(new Error('unused')), + requestSignal: () => undefined, + buildActionFailureResponse, + }, + { + error: { code: 'COMMAND_FAILED', message: 'typed Maestro action failed' }, + failure, + replayPath: '/flows/login.yaml', + req: request, + sessionStore, + observationStore, + logPath: '/tmp/daemon.log', + }, + ); + + expect(buildActionFailureResponse).toHaveBeenCalledWith( + expect.objectContaining({ + stepIndex: failure.stepIndex, + action: 'tapOn', + actionLabel: 'tapOn "save"', + isControl: false, + warnings: ['Optional Maestro assertVisible skipped at line 2: no match'], + captureAction: expect.objectContaining({ command: 'click', positionals: ['save'] }), + }), + ); +}); diff --git a/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-observer.test.ts b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts similarity index 57% rename from packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-observer.test.ts rename to packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts index 09e346aed9..89cdaa844c 100644 --- a/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-observer.test.ts +++ b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts @@ -1,15 +1,12 @@ -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 '../../tmp-dir.fixtures.ts'; import { createMaestroReplayObserver } from '../session-replay-maestro-observer.ts'; test('forwards command labels to progress and replay trace projection', () => { - const root = mkdtempForTestSync('agent-device-maestro-label-trace-'); - const tracePath = path.join(root, 'replay-timing.ndjson'); - const replayPath = path.join(root, 'flow.yaml'); + const tracePath = '/trace/replay-timing.ndjson'; + const replayPath = '/flows/flow.yaml'; const onStep = vi.fn(); + const appendTraceEvent = vi.fn(); const event: MaestroActionEvent = { action: 'tapOn', value: 'save action', @@ -19,7 +16,12 @@ test('forwards command labels to progress and replay trace projection', () => { stepTotal: 1, }; - const observer = createMaestroReplayObserver({ tracePath, filePath: replayPath, onStep }); + const observer = createMaestroReplayObserver({ + appendTraceEvent, + tracePath, + filePath: replayPath, + onStep, + }); observer.actionStarted?.(event); observer.actionCompleted?.({ ...event, @@ -39,13 +41,12 @@ test('forwards command labels to progress and replay trace projection', () => { command: 'tapOn', value: 'save action', }); - const traceStart = JSON.parse(fs.readFileSync(tracePath, 'utf8').split('\n')[0]!) as Record< - string, - unknown - >; - expect(traceStart).toMatchObject({ - type: 'replay_action_start', - command: 'tapOn', - positionals: ['save action'], - }); + expect(appendTraceEvent).toHaveBeenCalledWith( + tracePath, + expect.objectContaining({ + type: 'replay_action_start', + command: 'tapOn', + positionals: ['save action'], + }), + ); }); diff --git a/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-request.test.ts similarity index 100% rename from packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts rename to packages/maestro/src/replay-backend/__tests__/session-replay-maestro-request.test.ts diff --git a/packages/maestro/src/replay-backend/replay-maestro-backend.ts b/packages/maestro/src/replay-backend/replay-maestro-backend.ts new file mode 100644 index 0000000000..4e75c8d24f --- /dev/null +++ b/packages/maestro/src/replay-backend/replay-maestro-backend.ts @@ -0,0 +1,51 @@ +import type { + ReplayBackend, + ReplayBackendHost, + ReplayBackendPlugin, +} from '@agent-device/contracts/replay-backend'; +import { projectSelectorExpression } from '@agent-device/selectors'; +import { + collectMaestroFlowSources, + exportReplayActionsToMaestro, + inspectMaestroFlow, + MAESTRO_SELECTOR_PROJECTION, +} from '@agent-device/maestro'; +import { maestroReplayBackendDeclaration } from './replay-maestro-declaration.ts'; +import { runTypedMaestroReplay } from './session-replay-maestro-runtime.ts'; + +/** + * The Maestro replay backend plugin (#3377): the engine behind `replay --maestro` and + * `test --maestro`, handed to the daemon as `create(host)`. Every run goes through the command's + * `invoke` onto ordinary public commands (ADR 0015), so the engine's own request folding stays in + * `session-replay-maestro-request.ts` against the daemon's dispatch contract. + */ +function createMaestroReplayBackend(host: ReplayBackendHost): ReplayBackend<'maestro'> { + return { + id: 'maestro', + collectSourceFiles(params) { + return collectMaestroFlowSources(params); + }, + inspectSource(source, sourcePath) { + const flow = inspectMaestroFlow(source, sourcePath); + return { title: flow.name, appTarget: flow.appTarget }; + }, + async runReplay(command) { + return await runTypedMaestroReplay(host, command); + }, + exportReplayScript(actions, options) { + // The selector vocabulary a Maestro flow understands is this backend's knowledge, so the + // projection lives here rather than at the CLI call site, which would keep a second copy of + // the key lists that nothing keeps in step. + return exportReplayActionsToMaestro(actions, { + ...options, + resolveSelector: (expression) => + projectSelectorExpression(expression, MAESTRO_SELECTOR_PROJECTION), + }); + }, + }; +} + +export const maestroReplayBackendPlugin: ReplayBackendPlugin<'maestro'> = { + declaration: maestroReplayBackendDeclaration, + create: createMaestroReplayBackend, +}; diff --git a/packages/maestro/src/replay-backend/replay-maestro-declaration.ts b/packages/maestro/src/replay-backend/replay-maestro-declaration.ts new file mode 100644 index 0000000000..fb82bbde1f --- /dev/null +++ b/packages/maestro/src/replay-backend/replay-maestro-declaration.ts @@ -0,0 +1,22 @@ +import type { ReplayBackendDeclaration } from '@agent-device/contracts/replay-backend'; +import { + formatMaestroCompatibilityReference, + MAESTRO_COMPATIBILITY_ADR_URL, + MAESTRO_COMPATIBILITY_ISSUE_URL, +} from '../internal/support-matrix.ts'; + +export const maestroReplayBackendDeclaration: ReplayBackendDeclaration<'maestro'> = { + apiVersion: 1, + id: 'maestro', + help: { + summary: 'Supported Maestro YAML commands, grammar, and runtime boundaries', + body: `agent-device help maestro + +Run Maestro compatibility flows with replay --maestro or test --maestro. Bind an iOS or Android target with --platform or an existing session. + +${formatMaestroCompatibilityReference()} + +Unsupported syntax fails loudly rather than being skipped. Architecture, performance tradeoffs, and declared conformance divergences: ${MAESTRO_COMPATIBILITY_ADR_URL} +Focused compatibility request: ${MAESTRO_COMPATIBILITY_ISSUE_URL}`, + }, +}; diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-failure.ts b/packages/maestro/src/replay-backend/session-replay-maestro-failure.ts similarity index 84% rename from packages/replay-port/src/daemon-port/session-replay-maestro-failure.ts rename to packages/maestro/src/replay-backend/session-replay-maestro-failure.ts index fd0c3902c0..87d6ffa3ff 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-failure.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-failure.ts @@ -5,11 +5,11 @@ import { formatScriptArg } from '@agent-device/ad-script'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { ReplayBackendActionFailure, + ReplayBackendHost, ReplaySessionObservation, ReplaySessionStore, } from '@agent-device/contracts/replay-backend'; import { type DaemonResponse } from '@agent-device/kernel/contracts'; -import { buildReplayBackendActionFailureResponse } from './replay-backend-failure.ts'; export type MaestroFailureReportAction = ReplayBackendActionFailure['captureAction']; @@ -40,19 +40,22 @@ export function buildTypedMaestroFailureReportProjection( }; } -export async function buildTypedMaestroFailureResponse(params: { - readonly error: DaemonError; - readonly failure: MaestroFailedAction; - readonly replayPath: string; - readonly req: DaemonWireRequest; - readonly sessionStore: ReplaySessionStore; - readonly observationStore: ReplaySessionObservation; - readonly logPath: string; - readonly snapshotDiagnostics?: SnapshotDiagnosticsSummary; -}): Promise { +export async function buildTypedMaestroFailureResponse( + host: ReplayBackendHost, + params: { + readonly error: DaemonError; + readonly failure: MaestroFailedAction; + readonly replayPath: string; + readonly req: DaemonWireRequest; + readonly sessionStore: ReplaySessionStore; + readonly observationStore: ReplaySessionObservation; + readonly logPath: string; + readonly snapshotDiagnostics?: SnapshotDiagnosticsSummary; + }, +): Promise { const { failure, req } = params; const report = buildTypedMaestroFailureReportProjection(failure, req); - return await buildReplayBackendActionFailureResponse({ + return await host.buildActionFailureResponse({ error: params.error, req, sessionStore: params.sessionStore, diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-observer.ts b/packages/maestro/src/replay-backend/session-replay-maestro-observer.ts similarity index 80% rename from packages/replay-port/src/daemon-port/session-replay-maestro-observer.ts rename to packages/maestro/src/replay-backend/session-replay-maestro-observer.ts index 7acabf65e2..5ae1a01e2f 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-observer.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-observer.ts @@ -5,33 +5,50 @@ import type { MaestroFailedAction, } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; -import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { + ReplayBackendCommand, + ReplayBackendHost, +} from '@agent-device/contracts/replay-backend'; import { stripUndefined } from '@agent-device/kernel/record'; -import { appendReplayTraceEvent } from '@agent-device/replay-port/session-replay-trace'; + +type AppendTraceEvent = ReplayBackendHost['appendTraceEvent']; export function createMaestroReplayObserver(params: { + appendTraceEvent: AppendTraceEvent; filePath: string; tracePath: string | undefined; - onStep?: ReplayTestAttemptStepSink; + onStep?: ReplayBackendCommand['onStep']; }): MaestroExecutionObserver { - const { filePath, tracePath, onStep } = params; + const { appendTraceEvent, filePath, tracePath, onStep } = params; const traceStarts = new Map(); return { actionStarted: (event) => { traceStarts.set(event.stepIndex, event); runTelemetrySink(() => emitMaestroStep(onStep, event)); - runTelemetrySink(() => appendMaestroTraceStart(tracePath, filePath, event)); + runTelemetrySink(() => appendMaestroTraceStart(appendTraceEvent, tracePath, filePath, event)); }, actionCompleted: (event) => { const traceEvent = takeTraceStart(traceStarts, event.stepIndex); runTelemetrySink(() => - appendMaestroTraceStop(tracePath, filePath, traceStopEvent(traceEvent, event), true), + appendMaestroTraceStop( + appendTraceEvent, + tracePath, + filePath, + traceStopEvent(traceEvent, event), + true, + ), ); }, actionFailed: (event) => { const traceEvent = takeTraceStart(traceStarts, event.stepIndex); runTelemetrySink(() => - appendMaestroTraceStop(tracePath, filePath, traceStopEvent(traceEvent, event), false), + appendMaestroTraceStop( + appendTraceEvent, + tracePath, + filePath, + traceStopEvent(traceEvent, event), + false, + ), ); }, }; @@ -65,10 +82,7 @@ function traceStopEvent( }); } -function emitMaestroStep( - onStep: ReplayTestAttemptStepSink | undefined, - event: MaestroActionEvent, -): void { +function emitMaestroStep(onStep: ReplayBackendCommand['onStep'], event: MaestroActionEvent): void { onStep?.({ index: event.stepIndex, total: event.stepTotal, @@ -78,6 +92,7 @@ function emitMaestroStep( } function appendMaestroTraceStart( + appendReplayTraceEvent: AppendTraceEvent, tracePath: string | undefined, replayPath: string, event: MaestroActionEvent, @@ -97,6 +112,7 @@ function appendMaestroTraceStart( } function appendMaestroTraceStop( + appendReplayTraceEvent: AppendTraceEvent, tracePath: string | undefined, replayPath: string, event: MaestroCompletedActionEvent | MaestroFailedAction, diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts b/packages/maestro/src/replay-backend/session-replay-maestro-request.ts similarity index 97% rename from packages/replay-port/src/daemon-port/session-replay-maestro-request.ts rename to packages/maestro/src/replay-backend/session-replay-maestro-request.ts index 122ef12d37..44e2e0bf8b 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-request.ts @@ -1,12 +1,12 @@ import type { MaestroDaemonDispatchOptions, MaestroDaemonOperationRequest, -} from '@agent-device/maestro/daemon-runtime-port'; -import { stripUndefined } from '@agent-device/kernel/record'; +} from '../daemon-port/daemon-runtime-port.ts'; import type { ReplayBackendDispatchOptions, ReplayDispatchRequest, } from '@agent-device/contracts/replay-backend'; +import { stripUndefined } from '@agent-device/kernel/record'; /** * The daemon half of the Maestro runtime port. The port projects a flow step onto one public diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-response.ts b/packages/maestro/src/replay-backend/session-replay-maestro-response.ts similarity index 96% rename from packages/replay-port/src/daemon-port/session-replay-maestro-response.ts rename to packages/maestro/src/replay-backend/session-replay-maestro-response.ts index 4df917c03a..a4de8acf87 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-response.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-response.ts @@ -5,6 +5,7 @@ import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture' import { buildTypedMaestroFailureResponse } from './session-replay-maestro-failure.ts'; import type { ReplayBackendCommand, + ReplayBackendHost, ReplaySessionStore, } from '@agent-device/contracts/replay-backend'; import { type DaemonResponse } from '@agent-device/kernel/contracts'; @@ -51,6 +52,7 @@ type TypedMaestroErrorResponseParams = Readonly<{ }>; export async function buildTypedMaestroReplayErrorResponse( + host: ReplayBackendHost, params: TypedMaestroErrorResponseParams, ): Promise { const { command, replayPath, state, outcome } = params; @@ -61,7 +63,7 @@ export async function buildTypedMaestroReplayErrorResponse( const { failure } = outcome; const normalizedError = normalizeError(outcome.error); if (failure) { - return await buildTypedMaestroFailureResponse({ + return await buildTypedMaestroFailureResponse(host, { error: normalizedError, failure, replayPath, diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-runtime.ts b/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts similarity index 86% rename from packages/replay-port/src/daemon-port/session-replay-maestro-runtime.ts rename to packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts index c3eae4070d..b0635f8ec1 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-runtime.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts @@ -7,31 +7,24 @@ import { type MaestroPlatform, } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; -import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; -import { getRequestSignal } from '@agent-device/host-kit/request'; import { collectReplayShellEnv, parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, } from '@agent-device/ad-script'; -import { createDaemonMaestroRuntimePort } from '@agent-device/maestro/daemon-runtime-port'; +import { createDaemonMaestroRuntimePort } from '../daemon-port/daemon-runtime-port.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { buildReplayBuiltinVars } from '@agent-device/replay-port/session-replay-vars'; -import { createMaestroReplayObserver } from '@agent-device/replay-port/session-replay-maestro-observer'; -import { maestroOperationDispatchRequest } from '@agent-device/replay-port/session-replay-maestro-request'; +import { createMaestroReplayObserver } from './session-replay-maestro-observer.ts'; +import { maestroOperationDispatchRequest } from './session-replay-maestro-request.ts'; import { buildTypedMaestroReplayErrorResponse, buildTypedMaestroSuccessResponse, } from './session-replay-maestro-response.ts'; -import { buildMaestroReplayTargetDeviceResolutionOptions } from '@agent-device/replay-port/replay-script-selection'; -import { - readReplayScriptSourceFile, - REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, -} from '@agent-device/replay-port/script-source'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; import type { ReplayBackendCommand, + ReplayBackendHost, ReplayDispatchRequest, ReplaySessionStore, } from '@agent-device/contracts/replay-backend'; @@ -46,6 +39,7 @@ type TypedMaestroReplayState = { }; type TypedMaestroReplayPreparation = Readonly<{ + host: ReplayBackendHost; command: ReplayBackendCommand; bundle: ReplayScriptSourceBundle; }>; @@ -74,11 +68,12 @@ type MaestroReplayBinding = Pick< >; export async function runTypedMaestroReplay( + host: ReplayBackendHost, command: ReplayBackendCommand, ): Promise { const { request: req } = command; const bundle = req.flags?.replayScriptSource; - if (!bundle) return errorResponse('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); + if (!bundle) return errorResponse('INVALID_ARGS', host.scriptSourceRequiredMessage); if (req.flags?.saveScript !== undefined) { return errorResponse( 'INVALID_ARGS', @@ -89,13 +84,14 @@ export async function runTypedMaestroReplay( const state: TypedMaestroReplayState = { snapshotStart: 0 }; try { return await executeTypedMaestroReplay({ + host, command, bundle, startedAt, state, }); } catch (error) { - return await buildTypedMaestroReplayErrorResponse({ + return await buildTypedMaestroReplayErrorResponse(host, { command, replayPath: bundle.entry, state, @@ -107,14 +103,14 @@ export async function runTypedMaestroReplay( async function executeTypedMaestroReplay( params: TypedMaestroReplayExecution, ): Promise { - const { command, bundle, startedAt, state } = params; + const { host, command, bundle, startedAt, state } = params; const { request: req, session: { store: sessionStore }, tracePath, onStep, } = command; - const context = await prepareTypedMaestroReplay({ command, bundle }); + const context = await prepareTypedMaestroReplay({ host, command, bundle }); // evalScript runs via node:vm, which is not a security sandbox; only trust it // for flows that did not arrive over the daemon's remote HTTP surface. const publicNetworkOnly = command.publicNetworkOnly === true; @@ -139,15 +135,16 @@ async function executeTypedMaestroReplay( trustedScripts: !publicNetworkOnly, // #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(bundle, includePath), + readSource: (includePath) => host.readScriptSourceFile(bundle, includePath), observer: createMaestroReplayObserver({ + appendTraceEvent: host.appendTraceEvent, filePath: context.filePath, tracePath, onStep, }), }); if (!outcome.ok) { - return await buildTypedMaestroReplayErrorResponse({ + return await buildTypedMaestroReplayErrorResponse(host, { command, replayPath: bundle.entry, state, @@ -165,19 +162,20 @@ async function executeTypedMaestroReplay( async function prepareTypedMaestroReplay( params: TypedMaestroReplayPreparation, ): Promise { - const { command, bundle } = params; + const { host, command, bundle } = params; const { request: req, session: { name: sessionName, store: sessionStore }, } = command; const filePath = bundle.entry; - const flow = inspectMaestroFlow(readReplayScriptSourceFile(bundle, filePath), filePath); + const flow = inspectMaestroFlow(host.readScriptSourceFile(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(); const session = sessionRef?.session; if (sessionRef) sessionStore.assertSelectorMatches(req.flags); const binding = await resolveMaestroReplayBinding({ + host, req, sessionStore, sessionName, @@ -189,6 +187,7 @@ async function prepareTypedMaestroReplay( flow, ...binding, defaults: buildTypedMaestroDefaults({ + host, req, sessionName, filePath, @@ -196,27 +195,28 @@ async function prepareTypedMaestroReplay( target: binding.target, }), env: buildTypedMaestroEnv(req), - signal: getRequestSignal(req.meta?.requestId), + signal: host.requestSignal(req.meta?.requestId), }; } async function resolveMaestroReplayBinding(params: { + host: ReplayBackendHost; req: DaemonWireRequest; sessionStore: ReplaySessionStore; sessionName: string; session: ReturnType; flow: MaestroFlow; }): Promise { - const { req, sessionStore, sessionName, session, flow } = params; + const { host, req, sessionStore, sessionName, session, flow } = params; const requestedPlatform = req.flags?.platform; const device = session?.device ?? (requestedPlatform === 'android' || requestedPlatform === 'ios' ? undefined - : await resolveTargetDevice( - req.flags ?? {}, - buildMaestroReplayTargetDeviceResolutionOptions(flow.appTarget, requestedPlatform), - )); + : await host.resolveTargetDevice(req.flags ?? {}, { + appTarget: flow.appTarget, + platform: requestedPlatform, + })); const platform = resolveMaestroPlatform(req, device); const runtimeHints = resolveReplayRuntimeHints({ req, @@ -226,6 +226,7 @@ async function resolveMaestroReplayBinding(params: { platform, }); return await completeMaestroRuntimeBinding({ + host, req, sessionStore, sessionName, @@ -239,6 +240,7 @@ async function resolveMaestroReplayBinding(params: { async function completeMaestroRuntimeBinding( params: { + host: ReplayBackendHost; req: DaemonWireRequest; sessionStore: ReplaySessionStore; sessionName: string; @@ -246,10 +248,10 @@ async function completeMaestroRuntimeBinding( } & MaestroReplayBinding, ): Promise { if (params.device || !requiresDeviceRuntimeDefaults(params.runtimeHints)) return params; - const device = await resolveTargetDevice( - params.req.flags ?? {}, - buildMaestroReplayTargetDeviceResolutionOptions(params.flow.appTarget, params.platform), - ); + const device = await params.host.resolveTargetDevice(params.req.flags ?? {}, { + appTarget: params.flow.appTarget, + platform: params.platform, + }); return { device, platform: params.platform, @@ -287,6 +289,7 @@ function requiresDeviceRuntimeDefaults(runtimeHints: SessionRuntimeHints | undef } function buildTypedMaestroDefaults(params: { + host: ReplayBackendHost; req: DaemonWireRequest; sessionName: string; filePath: string; @@ -294,10 +297,9 @@ function buildTypedMaestroDefaults(params: { target: string; }): Record { return { - ...buildReplayBuiltinVars({ + ...params.host.buildBuiltinVars({ req: params.req, sessionName: params.sessionName, - metadata: {}, resolvedPath: params.filePath, }), AD_PLATFORM: params.platform, diff --git a/packages/maestro/tsconfig.json b/packages/maestro/tsconfig.json index eb2efbbb9d..6614d7d44e 100644 --- a/packages/maestro/tsconfig.json +++ b/packages/maestro/tsconfig.json @@ -11,6 +11,7 @@ }, "include": ["src"], "references": [ + { "path": "../ad-script" }, { "path": "../capture-kit" }, { "path": "../contracts" }, { "path": "../host-kit" }, diff --git a/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts b/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts index 6ed80d2876..471d35bd3f 100644 --- a/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts @@ -1,13 +1,14 @@ import { expect, test, vi } from 'vitest'; +import { maestroReplayBackendPlugin as maestroPlugin } from '@agent-device/maestro/replay-backend'; +import { getReplayBackendDeclaration } from '../replay-backend-declarations.ts'; import { getReplayBackend } from '../replay-backend-registry.ts'; -import { maestroBackend } from '../replay-maestro-backend.ts'; // The #3377 extraction seam: the registry is the only host-side door to a non-native engine, so // what it promises is exactly what the plugin swap must keep — the load-once contract and the // capability shape every host call site routes through. -test('resolves the Maestro backend through its registration', async () => { - await expect(getReplayBackend('maestro')).resolves.toBe(maestroBackend); +test('lists the declaration the Maestro plugin carries', () => { + expect(getReplayBackendDeclaration('maestro')).toBe(maestroPlugin.declaration); }); test('memoizes the backend so a process evaluates the engine once', async () => { @@ -32,18 +33,21 @@ test('a failed load is not cached, so the next call retries it', async () => { // the memo map free of the successes above. vi.resetModules(); let loadAttempts = 0; - vi.doMock('../replay-maestro-backend.ts', () => ({ - // The thunk reads `.maestroBackend` right after the `import()`, so failing the first read + vi.doMock('@agent-device/maestro/replay-backend', () => ({ + // The loader reads the plugin export right after the `import()`, so failing the first read // fails the load exactly like a rejecting module evaluation would. - get maestroBackend() { + get maestroReplayBackendPlugin() { loadAttempts += 1; if (loadAttempts === 1) throw new Error('transient load failure'); - return { id: 'maestro' }; + return { + declaration: maestroPlugin.declaration, + create: () => ({ id: 'maestro' }), + }; }, })); const { getReplayBackend: freshGet } = await import('../replay-backend-registry.ts'); await expect(freshGet('maestro')).rejects.toThrow('transient load failure'); await expect(freshGet('maestro')).resolves.toMatchObject({ id: 'maestro' }); expect(loadAttempts).toBe(2); - vi.doUnmock('../replay-maestro-backend.ts'); + vi.doUnmock('@agent-device/maestro/replay-backend'); }); diff --git a/packages/replay-port/src/daemon-port/__tests__/session-replay-runtime-failure-response.test.ts b/packages/replay-port/src/daemon-port/__tests__/session-replay-runtime-failure-response.test.ts index ad9f943ca2..9a8edededd 100644 --- a/packages/replay-port/src/daemon-port/__tests__/session-replay-runtime-failure-response.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/session-replay-runtime-failure-response.test.ts @@ -108,3 +108,41 @@ test('a cause without the Android shell-text reason keeps its own hoisted hint', expect(cause.hint).toBe('Inspect the latest snapshot.'); }); + +test('a failure response carries warnings accumulated before the failing step (#2560)', () => { + const response = buildReplayDivergenceFailureResponseFromDescriptor({ + error: { code: 'COMMAND_FAILED', message: 'typed action failed' }, + actionLabel: 'tapOn save', + action: 'tapOn', + positionals: ['save'], + step: 3, + replayPath: '/tmp/flows/login.yaml', + artifactPaths: [], + divergence: { kind: 'action-failure' }, + scrubVars: [], + warnings: ['Optional Maestro assertVisible skipped at line 2: no match'], + }); + + if (response.ok) throw new Error('expected a failure response'); + expect(response.error.details?.warnings).toEqual([ + 'Optional Maestro assertVisible skipped at line 2: no match', + ]); +}); + +test('a failure response omits an empty warnings channel', () => { + const response = buildReplayDivergenceFailureResponseFromDescriptor({ + error: { code: 'COMMAND_FAILED', message: 'typed action failed' }, + actionLabel: 'tapOn save', + action: 'tapOn', + positionals: ['save'], + step: 3, + replayPath: '/tmp/flows/login.yaml', + artifactPaths: [], + divergence: {}, + scrubVars: [], + warnings: [], + }); + + if (response.ok) throw new Error('expected a failure response'); + expect('warnings' in (response.error.details ?? {})).toBe(false); +}); diff --git a/packages/replay-port/src/daemon-port/replay-backend-declarations.ts b/packages/replay-port/src/daemon-port/replay-backend-declarations.ts new file mode 100644 index 0000000000..374523b149 --- /dev/null +++ b/packages/replay-port/src/daemon-port/replay-backend-declarations.ts @@ -0,0 +1,21 @@ +import type { ReplayBackendId } from '@agent-device/ad-script'; +import type { ReplayBackendDeclaration } from '@agent-device/contracts/replay-backend'; +import { maestroReplayBackendDeclaration } from '@agent-device/maestro/replay-backend-declaration'; + +/** + * The engine-free half of every registered replay backend (#3377), as `help` lists it. Kept apart + * from the registry so reading a declaration adds nothing to the closure of the daemon routes + * that only load backends. Keys come from the format grammar's vocabulary, so the table and the + * `--maestro` flag's accepted values cannot drift apart. + */ +const REPLAY_BACKEND_DECLARATIONS: Readonly< + Record> +> = { + maestro: maestroReplayBackendDeclaration, +}; + +export function getReplayBackendDeclaration( + id: ReplayBackendId, +): ReplayBackendDeclaration { + return REPLAY_BACKEND_DECLARATIONS[id]; +} diff --git a/packages/replay-port/src/daemon-port/replay-backend-host.ts b/packages/replay-port/src/daemon-port/replay-backend-host.ts new file mode 100644 index 0000000000..09afb61af3 --- /dev/null +++ b/packages/replay-port/src/daemon-port/replay-backend-host.ts @@ -0,0 +1,31 @@ +import type { ReplayBackendHost } from '@agent-device/contracts/replay-backend'; +import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; +import { getRequestSignal } from '@agent-device/host-kit/request'; +import { buildReplayBuiltinVars } from '../session-replay-vars.ts'; +import { buildReplayBackendActionFailureResponse } from './replay-backend-failure.ts'; +import { buildMaestroReplayTargetDeviceResolutionOptions } from './replay-script-selection.ts'; +import { + readReplayScriptSourceFile, + REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, +} from './script-source.ts'; +import { appendReplayTraceEvent } from './session-replay-trace.ts'; + +/** + * The host services a replay backend plugin reaches (#3377): the process state and daemon + * session policy it may not import. Built once per backend load by the registry. + */ +export function createReplayBackendHost(): ReplayBackendHost { + return { + appendTraceEvent: appendReplayTraceEvent, + buildBuiltinVars: (params) => buildReplayBuiltinVars({ ...params, metadata: {} }), + readScriptSourceFile: readReplayScriptSourceFile, + scriptSourceRequiredMessage: REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, + requestSignal: getRequestSignal, + resolveTargetDevice: async (flags, binding) => + await resolveTargetDevice( + flags, + buildMaestroReplayTargetDeviceResolutionOptions(binding.appTarget, binding.platform), + ), + buildActionFailureResponse: buildReplayBackendActionFailureResponse, + }; +} diff --git a/packages/replay-port/src/daemon-port/replay-backend-registry.ts b/packages/replay-port/src/daemon-port/replay-backend-registry.ts index cf84fc9151..db459810bd 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-registry.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-registry.ts @@ -1,14 +1,17 @@ import type { ReplayBackendId } from '@agent-device/ad-script'; -import type { ReplayBackend } from '@agent-device/contracts/replay-backend'; +import type { ReplayBackend, ReplayBackendPlugin } from '@agent-device/contracts/replay-backend'; /** * The replay backend registry (#3377). * * The host (the replay command, `test` discovery, request device binding, and the client-side * replay surface) reaches a non-native engine only through this module. A backend id resolves to - * one registration thunk whose loading is memoized per process; nothing outside the backend's own - * adapter module names an engine package. The Maestro plugin extraction (#3377) replaces the - * builtin table with an installed-plugin lookup and changes no caller. + * one plugin (`ReplayBackendPlugin`: an engine-free declaration plus `create(host)`) whose + * loading is memoized per process; this module and `replay-backend-declarations.ts` are the only + * host code that names an engine package. Backend ids come from the format grammar's vocabulary + * (`@agent-device/ad-script`), so the registry's keys and the `--maestro` flag's accepted values + * can never drift apart. The Maestro plugin extraction (#3377) swaps the builtin + * table for an installed-plugin lookup and changes no caller. * * A backend is a capability bundle over scripts: it expands a source closure, inspects a flow for * scheduling metadata, runs a replay, and converts a native `.ad` script into its own format. @@ -19,23 +22,30 @@ import type { ReplayBackend } from '@agent-device/contracts/replay-backend'; * A hub eager-closure rule of this repository (`scripts/__tests__/eager-closure-budgets.ts`) * holds `src/cli.ts` and `src/daemon.ts` to "no new module evaluated at startup", so the host's * value imports of this module are function-scoped `await import()` calls at the few call sites; - * the adapter and engine behind it are reached the same way. Type imports are free. + * the plugin, its declaration, and the host behind it are reached the same way. Type imports are + * free. */ /** - * Builtin registrations: the ONE extraction seam. Each thunk is the only place its backend's - * package is named outside the backend's own adapter, and stays a function-scoped `import()` so + * Builtin registrations: the ONE extraction seam. Each loader is a function-scoped `import()` so * no host entry evaluates an engine eagerly (the `cli-startup-import-closure` pins). Extraction * swaps this table for the installed-plugin lookup; the host call sites never change. */ -const BUILTIN_REPLAY_BACKENDS: Readonly< - Record Promise>> +const BUILTIN_REPLAY_BACKEND_PLUGINS: Readonly< + Record Promise>> > = { - maestro: async () => (await import('./replay-maestro-backend.ts')).maestroBackend, + maestro: async () => + (await import('@agent-device/maestro/replay-backend')).maestroReplayBackendPlugin, }; const loadedBackends = new Map>>(); +async function createReplayBackend(id: ReplayBackendId): Promise> { + const plugin = await BUILTIN_REPLAY_BACKEND_PLUGINS[id](); + const { createReplayBackendHost } = await import('./replay-backend-host.ts'); + return plugin.create(createReplayBackendHost()); +} + /** * Resolves a backend once per process, memoizing the engine's single evaluation. A load failure * is NOT cached — a failed `import()` stays retryable, as it was at the pre-registry call sites @@ -46,7 +56,7 @@ const loadedBackends = new Map> { const cached = loadedBackends.get(id); if (cached) return cached; - const loading = BUILTIN_REPLAY_BACKENDS[id]().catch((error: unknown) => { + const loading = createReplayBackend(id).catch((error: unknown) => { loadedBackends.delete(id); throw error; }); diff --git a/packages/replay-port/src/daemon-port/replay-maestro-backend.ts b/packages/replay-port/src/daemon-port/replay-maestro-backend.ts deleted file mode 100644 index 3a00164b4f..0000000000 --- a/packages/replay-port/src/daemon-port/replay-maestro-backend.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - collectMaestroFlowSources, - exportReplayActionsToMaestro, - inspectMaestroFlow, - MAESTRO_SELECTOR_PROJECTION, -} from '@agent-device/maestro'; -import { projectSelectorExpression } from '@agent-device/selectors'; -import type { ReplayBackend } from '@agent-device/contracts/replay-backend'; -import { runTypedMaestroReplay } from './session-replay-maestro-runtime.ts'; - -/** - * The Maestro backend registration (#3377). - * - * This module and the `session-replay-maestro-*` adapters it wires are the Maestro plugin's - * future home: they are the only production code outside `packages/maestro` that names the engine - * package, and the registry reaches them through one thunk. The 0.22 extraction relocates this - * cluster into the plugin and registers it there — the registry, the daemon's replay command, and - * the client-side replay surface keep their shape. - * - * What the backend may not do is own dispatch: every run goes through the command's `invoke` onto - * ordinary public commands (ADR 0015), so the engine's own request folding stays in - * `session-replay-maestro-request.ts` against the daemon's `ReplayDispatchOptions` contract. - */ -export const maestroBackend: ReplayBackend<'maestro'> = { - id: 'maestro', - collectSourceFiles(params) { - return collectMaestroFlowSources(params); - }, - inspectSource(source, sourcePath) { - const flow = inspectMaestroFlow(source, sourcePath); - return { title: flow.name, appTarget: flow.appTarget }; - }, - async runReplay(command) { - return await runTypedMaestroReplay(command); - }, - exportReplayScript(actions, options) { - // The selector vocabulary a Maestro flow understands is this backend's knowledge, so the - // projection lives here rather than at the CLI call site, which would keep a second copy of - // the key lists that nothing keeps in step. - return exportReplayActionsToMaestro(actions, { - ...options, - resolveSelector: (expression) => - projectSelectorExpression(expression, MAESTRO_SELECTOR_PROJECTION), - }); - }, -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53f44ec2c5..72a443b3a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,9 @@ importers: packages/maestro: dependencies: + '@agent-device/ad-script': + specifier: workspace:* + version: link:../ad-script '@agent-device/capture-kit': specifier: workspace:* version: link:../capture-kit diff --git a/src/cli/parser/__tests__/maestro-support-matrix.test.ts b/src/cli/parser/__tests__/maestro-support-matrix.test.ts index b0b5e5b5b9..93321bd864 100644 --- a/src/cli/parser/__tests__/maestro-support-matrix.test.ts +++ b/src/cli/parser/__tests__/maestro-support-matrix.test.ts @@ -3,11 +3,23 @@ import { expect, test } from 'vitest'; import { usageForCommand } from '../args.ts'; import { getFlagDefinitions } from '@agent-device/command-registry/flag-registry'; import { - MAESTRO_COMPATIBILITY_ADR_URL, - MAESTRO_COMPATIBILITY_ISSUE_URL, MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, } from '@agent-device/maestro'; +import { maestroReplayBackendDeclaration } from '@agent-device/maestro/replay-backend-declaration'; + +const MAESTRO_COMPATIBILITY_ADR_URL = declaredUrl('declared conformance divergences: '); +const MAESTRO_COMPATIBILITY_ISSUE_URL = declaredUrl('Focused compatibility request: '); + +function declaredUrl(prefix: string): string { + const line = maestroReplayBackendDeclaration.help.body + .split('\n') + .find((candidate) => candidate.includes(prefix)); + const url = line?.slice(line.indexOf(prefix) + prefix.length).trim(); + if (!url?.startsWith('https://')) + throw new Error(`declaration help has no URL after "${prefix}"`); + return url; +} test('Maestro flag routes to the versioned compatibility help topic', () => { const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro'); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index b95c8a5d35..93645b3a32 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -1,9 +1,5 @@ import { listCliCommandNames } from '@agent-device/command-registry/catalog'; -import { - formatMaestroCompatibilityReference, - MAESTRO_COMPATIBILITY_ADR_URL, - MAESTRO_COMPATIBILITY_ISSUE_URL, -} from '@agent-device/maestro'; +import { getReplayBackendDeclaration } from '@agent-device/replay-port/replay-backend-declarations'; import { normalizeCliCommandAlias } from '@agent-device/command-registry/cli-command-aliases'; import { helpBody } from '@agent-device/command-registry/command-text'; import { @@ -67,17 +63,7 @@ const HELP_TOPICS = { body: 'agent-device help commands', }, ...manualQaHelpTopics, - maestro: { - summary: 'Supported Maestro YAML commands, grammar, and runtime boundaries', - body: `agent-device help maestro - -Run Maestro compatibility flows with replay --maestro or test --maestro. Bind an iOS or Android target with --platform or an existing session. - -${formatMaestroCompatibilityReference()} - -Unsupported syntax fails loudly rather than being skipped. Architecture, performance tradeoffs, and declared conformance divergences: ${MAESTRO_COMPATIBILITY_ADR_URL} -Focused compatibility request: ${MAESTRO_COMPATIBILITY_ISSUE_URL}`, - }, + maestro: getReplayBackendDeclaration('maestro').help, workflow: { summary: 'Normal agent-device bootstrap, exploration, and validation loop', body: `agent-device help workflow From 199876648b40f865530d1f92aeefc184e75c8f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 19:49:32 +0200 Subject: [PATCH 2/5] chore(gates): pin the maestro replay backend plugin surface and closure approval --- packages/maestro/package.json | 11 ++++++++--- packages/maestro/src/index.test.ts | 13 ++++++++----- packages/replay-port/package.json | 20 ++++---------------- scripts/__tests__/eager-closure-budgets.ts | 6 ++++++ scripts/layering/package-boundaries.test.ts | 10 ++++++---- 5 files changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/maestro/package.json b/packages/maestro/package.json index ca09cc3e5a..280734aa4f 100644 --- a/packages/maestro/package.json +++ b/packages/maestro/package.json @@ -6,6 +6,7 @@ "type": "module", "description": "Private Maestro compatibility engine for agent-device.", "dependencies": { + "@agent-device/ad-script": "workspace:*", "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/host-kit": "workspace:*", @@ -18,9 +19,13 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./daemon-runtime-port": { - "types": "./src/daemon-port/daemon-runtime-port.ts", - "default": "./src/daemon-port/daemon-runtime-port.ts" + "./replay-backend": { + "types": "./src/replay-backend/replay-maestro-backend.ts", + "default": "./src/replay-backend/replay-maestro-backend.ts" + }, + "./replay-backend-declaration": { + "types": "./src/replay-backend/replay-maestro-declaration.ts", + "default": "./src/replay-backend/replay-maestro-declaration.ts" }, "./run-script-http": { "types": "./src/daemon-port/run-script-http.ts", diff --git a/packages/maestro/src/index.test.ts b/packages/maestro/src/index.test.ts index 6e053503d6..4a7ec925e8 100644 --- a/packages/maestro/src/index.test.ts +++ b/packages/maestro/src/index.test.ts @@ -19,16 +19,19 @@ describe('@agent-device/maestro facade', () => { expect(exports).not.toContain('rankMaestroFailureCandidates'); }); - test('keeps the daemon-side port off the engine entry and declares pure modules', () => { + test('keeps the plugin and helper off the engine entry and declares pure modules', () => { const manifest = JSON.parse( fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'), ) as { exports: Record; sideEffects?: boolean }; - // The daemon-side runtime port (#2544) and its runScript HTTP helper are subpaths, not part - // of the engine entry. This pins the surface; scripts/__tests__/eager-closure-budgets.test.ts - // is what keeps `.` from starting to evaluate host-kit, capture-kit, or provision-kit. + // The runScript HTTP helper is a subpath, not part of the engine entry, and the replay + // backend plugin (#3377) is the only other way in: its engine-free declaration lets help list + // the backend without evaluating the engine. This pins the surface; + // scripts/__tests__/eager-closure-budgets.test.ts is what keeps `.` from starting to evaluate + // host-kit, capture-kit, or provision-kit. expect(Object.keys(manifest.exports)).toEqual([ '.', - './daemon-runtime-port', + './replay-backend', + './replay-backend-declaration', './run-script-http', ]); expect(manifest.sideEffects).toBe(false); diff --git a/packages/replay-port/package.json b/packages/replay-port/package.json index 88eb85c27f..a2066fced2 100644 --- a/packages/replay-port/package.json +++ b/packages/replay-port/package.json @@ -40,6 +40,10 @@ "types": "./src/daemon-port/replay-backend-registry.ts", "default": "./src/daemon-port/replay-backend-registry.ts" }, + "./replay-backend-declarations": { + "types": "./src/daemon-port/replay-backend-declarations.ts", + "default": "./src/daemon-port/replay-backend-declarations.ts" + }, "./replay-script-selection": { "types": "./src/daemon-port/replay-script-selection.ts", "default": "./src/daemon-port/replay-script-selection.ts" @@ -72,22 +76,6 @@ "types": "./src/daemon-port/session-replay-heal.ts", "default": "./src/daemon-port/session-replay-heal.ts" }, - "./session-replay-maestro-failure": { - "types": "./src/daemon-port/session-replay-maestro-failure.ts", - "default": "./src/daemon-port/session-replay-maestro-failure.ts" - }, - "./session-replay-maestro-observer": { - "types": "./src/daemon-port/session-replay-maestro-observer.ts", - "default": "./src/daemon-port/session-replay-maestro-observer.ts" - }, - "./session-replay-maestro-request": { - "types": "./src/daemon-port/session-replay-maestro-request.ts", - "default": "./src/daemon-port/session-replay-maestro-request.ts" - }, - "./session-replay-maestro-runtime": { - "types": "./src/daemon-port/session-replay-maestro-runtime.ts", - "default": "./src/daemon-port/session-replay-maestro-runtime.ts" - }, "./session-replay-report-action": { "types": "./src/daemon-port/session-replay-report-action.ts", "default": "./src/daemon-port/session-replay-report-action.ts" diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 2caa42e08c..11325bad5d 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -151,6 +151,12 @@ export const APPROVED_OVER_CEILING: Readonly< 'Moved verbatim out of the daemon so the CLI release path and the daemon share one reconciler without the CLI importing the daemon; only the daemon and the lazily loaded stale-release path import it.', owner: 'daemon single-entry stack (L1b)', }, + 'packages/maestro/src/replay-backend/replay-maestro-backend.ts': { + issue: '#3377', + reason: + 'The Maestro replay backend plugin IS the engine behind `replay --maestro`; the registry reaches it only through a function-scoped import(), and no startup hub evaluates it (cli-startup-import-closure pins that).', + owner: 'thymikee', + }, }); /** The category is a function of the path, never a hand-written column. */ diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 8bb4bd5376..b5cb6f5a1c 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -669,15 +669,17 @@ test('the real tree parses, declares, and passes R11', () => { ]); const maestroPackage = packages.find((pkg) => pkg.name === '@agent-device/maestro'); assert.ok(maestroPackage, 'maestro package must exist'); - // Locks the export surface: `.` (the engine) plus the two daemon-side port entries #2544 - // moved out of `src/daemon/adapters/maestro`. The eager-closure budget gate - // (scripts/__tests__/eager-closure-budgets.test.ts) is what keeps the port off `.`. + // Locks the export surface: `.` (the engine), the replay backend plugin (#3377) with its + // engine-free declaration, and the runScript HTTP helper. The eager-closure budget gate + // (scripts/__tests__/eager-closure-budgets.test.ts) is what keeps the plugin off `.`. assert.deepEqual([...maestroPackage.exportTargets.keys()].sort(), [ '@agent-device/maestro', - '@agent-device/maestro/daemon-runtime-port', + '@agent-device/maestro/replay-backend', + '@agent-device/maestro/replay-backend-declaration', '@agent-device/maestro/run-script-http', ]); assert.deepEqual([...maestroPackage.workspaceDependencies].sort(), [ + '@agent-device/ad-script', '@agent-device/capture-kit', '@agent-device/contracts', '@agent-device/host-kit', From ae6af60365cb3e362478013f8aea260081cee2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 20:27:18 +0200 Subject: [PATCH 3/5] fix(maestro): assert the suggestion source, first trace write, and pinned help URLs --- packages/contracts/src/replay-backend.ts | 7 ++++- .../session-replay-maestro-failure.test.ts | 8 ++++++ .../session-replay-maestro-observer.test.ts | 3 ++- .../cli-startup-import-closure.test.ts | 26 ++++++++++++++----- .../__tests__/maestro-support-matrix.test.ts | 16 +++--------- 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/contracts/src/replay-backend.ts b/packages/contracts/src/replay-backend.ts index bea4cc61f8..4d46b4aacc 100644 --- a/packages/contracts/src/replay-backend.ts +++ b/packages/contracts/src/replay-backend.ts @@ -237,7 +237,12 @@ export type ReplayBackendHost = Readonly<{ /** Reads one file out of the request's script source bundle; throws when it was not sent. */ readScriptSourceFile(bundle: ReplayScriptSourceBundle, resolvedPath: string): string; scriptSourceRequiredMessage: string; - /** Selects the device a run binds when no session or explicit platform already names one. */ + /** + * Selects the device a run binds when no session or explicit platform already names one. It + * applies the Maestro device-binding rule (the flow's app target steers selection on iOS only), + * the same rule `packages/daemon/src/server/replay-device-selection.ts` applies when it + * pre-binds a device for the request. + */ resolveTargetDevice( flags: CommandFlags, binding: ReplayBackendDeviceBinding, diff --git a/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts index 52d442ba5d..6617455911 100644 --- a/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts +++ b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-failure.test.ts @@ -5,6 +5,7 @@ import type { ReplaySessionStore, } from '@agent-device/contracts/replay-backend'; import { + collectMaestroFailureSuggestions, executeMaestroFlow, inspectMaestroFlow, type MaestroFailedAction, @@ -96,8 +97,15 @@ test('the failure response hands the host the step, its warnings, and its sugges }, ); + const [handed] = buildActionFailureResponse.mock.calls[0] ?? []; + const node = { index: 0, type: 'Button', label: 'save', identifier: 'save', hittable: true }; + expect(handed?.suggestNodes({ createdAt: 0, nodes: [node] })).toEqual( + collectMaestroFailureSuggestions(failure, { createdAt: 0, nodes: [node] }), + ); + expect(handed?.suggestNodes({ createdAt: 0, nodes: [node] })).not.toEqual([]); expect(buildActionFailureResponse).toHaveBeenCalledWith( expect.objectContaining({ + source: failure.source, stepIndex: failure.stepIndex, action: 'tapOn', actionLabel: 'tapOn "save"', diff --git a/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts index 89cdaa844c..b58d5feb38 100644 --- a/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts +++ b/packages/maestro/src/replay-backend/__tests__/session-replay-maestro-observer.test.ts @@ -41,7 +41,8 @@ test('forwards command labels to progress and replay trace projection', () => { command: 'tapOn', value: 'save action', }); - expect(appendTraceEvent).toHaveBeenCalledWith( + expect(appendTraceEvent).toHaveBeenNthCalledWith( + 1, tracePath, expect.objectContaining({ type: 'replay_action_start', diff --git a/src/__tests__/cli-startup-import-closure.test.ts b/src/__tests__/cli-startup-import-closure.test.ts index 598c5263b5..d4c00f9bcc 100644 --- a/src/__tests__/cli-startup-import-closure.test.ts +++ b/src/__tests__/cli-startup-import-closure.test.ts @@ -21,8 +21,24 @@ import { eagerClosureOf, eagerlyEvaluatedModules } from './eager-import-closure. const srcRoot = path.resolve(import.meta.dirname, '..'); const LAZY_HTTP_MODULES = new Set(['node:http', 'node:https']); -/** Engines heavy enough that evaluating them on startup is a measurable regression. */ -const LAZY_ENGINE_MODULES = new Set(['@agent-device/maestro']); +/** + * Engines heavy enough that evaluating them on startup is a measurable regression: the engine + * package and every subpath of it except the replay backend's engine-free declaration. + */ +function isLazyEngineModule(specifier: string): boolean { + return ( + specifier === '@agent-device/maestro' || + (specifier.startsWith('@agent-device/maestro/') && + specifier !== '@agent-device/maestro/replay-backend-declaration') + ); +} + +test('the lazy engine set covers the plugin subpath but not its engine-free declaration', () => { + expect(isLazyEngineModule('@agent-device/maestro')).toBe(true); + expect(isLazyEngineModule('@agent-device/maestro/replay-backend')).toBe(true); + expect(isLazyEngineModule('@agent-device/maestro/replay-backend-declaration')).toBe(false); + expect(isLazyEngineModule('@agent-device/maestroish')).toBe(false); +}); test.for([ // --- static forms --- @@ -104,7 +120,7 @@ test('the CLI startup import closure never evaluates the Maestro engine', () => const offenders: string[] = []; for (const file of eagerClosureOf(path.join(srcRoot, 'cli.ts'))) { for (const specifier of eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8'))) { - if (LAZY_ENGINE_MODULES.has(specifier)) { + if (isLazyEngineModule(specifier)) { offenders.push(`${path.relative(srcRoot, file)} -> ${specifier}`); } } @@ -126,9 +142,7 @@ test('the replay command source closure keeps Maestro behind flow selection', () expect(replayClosure).toContain(sourceBundle); for (const file of replayClosure) { - if ( - eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8')).includes('@agent-device/maestro') - ) { + if (eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8')).some(isLazyEngineModule)) { offenders.push(path.relative(srcRoot, file)); } } diff --git a/src/cli/parser/__tests__/maestro-support-matrix.test.ts b/src/cli/parser/__tests__/maestro-support-matrix.test.ts index 93321bd864..c84c5b0ec2 100644 --- a/src/cli/parser/__tests__/maestro-support-matrix.test.ts +++ b/src/cli/parser/__tests__/maestro-support-matrix.test.ts @@ -6,20 +6,10 @@ import { MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, } from '@agent-device/maestro'; -import { maestroReplayBackendDeclaration } from '@agent-device/maestro/replay-backend-declaration'; -const MAESTRO_COMPATIBILITY_ADR_URL = declaredUrl('declared conformance divergences: '); -const MAESTRO_COMPATIBILITY_ISSUE_URL = declaredUrl('Focused compatibility request: '); - -function declaredUrl(prefix: string): string { - const line = maestroReplayBackendDeclaration.help.body - .split('\n') - .find((candidate) => candidate.includes(prefix)); - const url = line?.slice(line.indexOf(prefix) + prefix.length).trim(); - if (!url?.startsWith('https://')) - throw new Error(`declaration help has no URL after "${prefix}"`); - return url; -} +const MAESTRO_COMPATIBILITY_ADR_URL = + 'https://github.com/callstack/agent-device/blob/main/docs/adr/0015-direct-maestro-engine.md'; +const MAESTRO_COMPATIBILITY_ISSUE_URL = 'https://github.com/callstack/agent-device/issues/new'; test('Maestro flag routes to the versioned compatibility help topic', () => { const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro'); From a0be641cc5762b98eb36e902b3dc69b2093a505d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 10:23:29 +0200 Subject: [PATCH 4/5] refactor(replay-port): tie each backend's declaration and plugin to its id, drop unread fields Both id-keyed tables are mapped over ReplayBackendId, so a declaration or plugin registered under another backend's id fails to type-check. The tables stay apart so the daemon routes that load backends do not evaluate help text. ReplayBackendPlugin.declaration and ReplayBackendDeclaration.apiVersion had no reader; the installed-plugin loader that would check them is not part of #3377 yet. --- packages/contracts/src/replay-backend.ts | 5 +---- .../src/replay-backend/replay-maestro-backend.ts | 2 -- .../src/replay-backend/replay-maestro-declaration.ts | 1 - .../__tests__/replay-backend-registry.test.ts | 11 ++++------- .../src/daemon-port/replay-backend-declarations.ts | 11 ++++++----- .../src/daemon-port/replay-backend-registry.ts | 11 +++++------ 6 files changed, 16 insertions(+), 25 deletions(-) diff --git a/packages/contracts/src/replay-backend.ts b/packages/contracts/src/replay-backend.ts index 4d46b4aacc..a87999f5c9 100644 --- a/packages/contracts/src/replay-backend.ts +++ b/packages/contracts/src/replay-backend.ts @@ -254,16 +254,13 @@ export type ReplayBackendHost = Readonly<{ }>; /** - * Versioned like `apiVersion` in the provider plugin manifest. A declaration is engine-free - * metadata, importable without evaluating the engine, so help and registry listings stay light. + * Engine-free metadata, importable without evaluating the engine, so help stays light. */ export type ReplayBackendDeclaration = Readonly<{ - apiVersion: 1; id: TId; help: Readonly<{ summary: string; body: string }>; }>; export type ReplayBackendPlugin = Readonly<{ - declaration: ReplayBackendDeclaration; create(host: ReplayBackendHost): ReplayBackend; }>; diff --git a/packages/maestro/src/replay-backend/replay-maestro-backend.ts b/packages/maestro/src/replay-backend/replay-maestro-backend.ts index 4e75c8d24f..f0bc099c1d 100644 --- a/packages/maestro/src/replay-backend/replay-maestro-backend.ts +++ b/packages/maestro/src/replay-backend/replay-maestro-backend.ts @@ -10,7 +10,6 @@ import { inspectMaestroFlow, MAESTRO_SELECTOR_PROJECTION, } from '@agent-device/maestro'; -import { maestroReplayBackendDeclaration } from './replay-maestro-declaration.ts'; import { runTypedMaestroReplay } from './session-replay-maestro-runtime.ts'; /** @@ -46,6 +45,5 @@ function createMaestroReplayBackend(host: ReplayBackendHost): ReplayBackend<'mae } export const maestroReplayBackendPlugin: ReplayBackendPlugin<'maestro'> = { - declaration: maestroReplayBackendDeclaration, create: createMaestroReplayBackend, }; diff --git a/packages/maestro/src/replay-backend/replay-maestro-declaration.ts b/packages/maestro/src/replay-backend/replay-maestro-declaration.ts index fb82bbde1f..e169ef4a08 100644 --- a/packages/maestro/src/replay-backend/replay-maestro-declaration.ts +++ b/packages/maestro/src/replay-backend/replay-maestro-declaration.ts @@ -6,7 +6,6 @@ import { } from '../internal/support-matrix.ts'; export const maestroReplayBackendDeclaration: ReplayBackendDeclaration<'maestro'> = { - apiVersion: 1, id: 'maestro', help: { summary: 'Supported Maestro YAML commands, grammar, and runtime boundaries', diff --git a/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts b/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts index 471d35bd3f..a907928087 100644 --- a/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/replay-backend-registry.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vitest'; -import { maestroReplayBackendPlugin as maestroPlugin } from '@agent-device/maestro/replay-backend'; +import { maestroReplayBackendDeclaration } from '@agent-device/maestro/replay-backend-declaration'; import { getReplayBackendDeclaration } from '../replay-backend-declarations.ts'; import { getReplayBackend } from '../replay-backend-registry.ts'; @@ -7,8 +7,8 @@ import { getReplayBackend } from '../replay-backend-registry.ts'; // what it promises is exactly what the plugin swap must keep — the load-once contract and the // capability shape every host call site routes through. -test('lists the declaration the Maestro plugin carries', () => { - expect(getReplayBackendDeclaration('maestro')).toBe(maestroPlugin.declaration); +test('lists the Maestro declaration for the maestro id', () => { + expect(getReplayBackendDeclaration('maestro')).toBe(maestroReplayBackendDeclaration); }); test('memoizes the backend so a process evaluates the engine once', async () => { @@ -39,10 +39,7 @@ test('a failed load is not cached, so the next call retries it', async () => { get maestroReplayBackendPlugin() { loadAttempts += 1; if (loadAttempts === 1) throw new Error('transient load failure'); - return { - declaration: maestroPlugin.declaration, - create: () => ({ id: 'maestro' }), - }; + return { create: () => ({ id: 'maestro' }) }; }, })); const { getReplayBackend: freshGet } = await import('../replay-backend-registry.ts'); diff --git a/packages/replay-port/src/daemon-port/replay-backend-declarations.ts b/packages/replay-port/src/daemon-port/replay-backend-declarations.ts index 374523b149..d49fddf315 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-declarations.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-declarations.ts @@ -5,12 +5,13 @@ import { maestroReplayBackendDeclaration } from '@agent-device/maestro/replay-ba /** * The engine-free half of every registered replay backend (#3377), as `help` lists it. Kept apart * from the registry so reading a declaration adds nothing to the closure of the daemon routes - * that only load backends. Keys come from the format grammar's vocabulary, so the table and the - * `--maestro` flag's accepted values cannot drift apart. + * that only load backends. Keys come from the format grammar's vocabulary, and each declaration's + * `id` is typed to its key, as each plugin's is in the registry, so help cannot describe a backend + * other than the one its id loads. */ -const REPLAY_BACKEND_DECLARATIONS: Readonly< - Record> -> = { +const REPLAY_BACKEND_DECLARATIONS: { + readonly [TId in ReplayBackendId]: ReplayBackendDeclaration; +} = { maestro: maestroReplayBackendDeclaration, }; diff --git a/packages/replay-port/src/daemon-port/replay-backend-registry.ts b/packages/replay-port/src/daemon-port/replay-backend-registry.ts index db459810bd..e5d2cc3d42 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-registry.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-registry.ts @@ -6,9 +6,8 @@ import type { ReplayBackend, ReplayBackendPlugin } from '@agent-device/contracts * * The host (the replay command, `test` discovery, request device binding, and the client-side * replay surface) reaches a non-native engine only through this module. A backend id resolves to - * one plugin (`ReplayBackendPlugin`: an engine-free declaration plus `create(host)`) whose - * loading is memoized per process; this module and `replay-backend-declarations.ts` are the only - * host code that names an engine package. Backend ids come from the format grammar's vocabulary + * one plugin (`ReplayBackendPlugin`, `create(host)`) whose loading is memoized per process; this + * module and `replay-backend-declarations.ts` are the only host code that names an engine package. Backend ids come from the format grammar's vocabulary * (`@agent-device/ad-script`), so the registry's keys and the `--maestro` flag's accepted values * can never drift apart. The Maestro plugin extraction (#3377) swaps the builtin * table for an installed-plugin lookup and changes no caller. @@ -31,9 +30,9 @@ import type { ReplayBackend, ReplayBackendPlugin } from '@agent-device/contracts * no host entry evaluates an engine eagerly (the `cli-startup-import-closure` pins). Extraction * swaps this table for the installed-plugin lookup; the host call sites never change. */ -const BUILTIN_REPLAY_BACKEND_PLUGINS: Readonly< - Record Promise>> -> = { +const BUILTIN_REPLAY_BACKEND_PLUGINS: { + readonly [TId in ReplayBackendId]: () => Promise>; +} = { maestro: async () => (await import('@agent-device/maestro/replay-backend')).maestroReplayBackendPlugin, }; From 36d06fa8dc88740690dc84db3a4afc5b3e29bc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 10:23:32 +0200 Subject: [PATCH 5/5] chore(gates): drop the #3616 over-ceiling approvals the merge-base now carries --- scripts/__tests__/eager-closure-budgets.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 11325bad5d..ae16f53cbf 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -133,24 +133,6 @@ export const NEW_ENTRY_CEILINGS: Readonly> = Objec export const APPROVED_OVER_CEILING: Readonly< Record > = Object.freeze({ - 'packages/capture-kit/src/capture-admission/app-log-durable-resource.ts': { - issue: '#3616', - reason: - 'Moved verbatim out of the daemon app-log session resource so the claim reconciler no longer imports the daemon; the closure is the durable-capture machinery every capture resource carries, and no consumer gains modules.', - owner: 'daemon single-entry stack (L1b)', - }, - 'packages/daemon-contracts/src/device-claims.fixtures.ts': { - issue: '#3616', - reason: - 'Test-only entry for daemon tests that drive the claim reconciler directly; it loads the same closure as device-claim-recovery.ts and no production module imports it.', - owner: 'daemon single-entry stack (L1b)', - }, - 'packages/daemon-contracts/src/device-claim-recovery.ts': { - issue: '#3616', - reason: - 'Moved verbatim out of the daemon so the CLI release path and the daemon share one reconciler without the CLI importing the daemon; only the daemon and the lazily loaded stale-release path import it.', - owner: 'daemon single-entry stack (L1b)', - }, 'packages/maestro/src/replay-backend/replay-maestro-backend.ts': { issue: '#3377', reason: