diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index b87e45597c..852e028a43 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -18,6 +18,14 @@ "count": 1 } }, + "src/__tests__/test-utils/in-memory-replay-selector-port.ts": { + "complexity_moderate": { + "count": 2 + }, + "crap_moderate": { + "count": 2 + } + }, "src/cli-schema/cli-config.ts": { "crap_moderate": { "count": 1 diff --git a/package.json b/package.json index c482f70fe7..8742c1b136 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -245,6 +245,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@agent-device/ad-replay": "workspace:*", "@agent-device/ad-script": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", diff --git a/packages/ad-replay/package.json b/packages/ad-replay/package.json new file mode 100644 index 0000000000..7729d7b817 --- /dev/null +++ b/packages/ad-replay/package.json @@ -0,0 +1,19 @@ +{ + "name": "@agent-device/ad-replay", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "description": "Private native .ad replay engine for agent-device: manifest inspection (inspectAdReplay) and the step-loop execution engine (runAdReplay), plus the neutral AdReplayStepRuntime vocabulary the daemon adapter implements.", + "dependencies": { + "@agent-device/ad-script": "workspace:*", + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts new file mode 100644 index 0000000000..5d87a89a77 --- /dev/null +++ b/packages/ad-replay/src/index.ts @@ -0,0 +1,118 @@ +/** + * The `ad-replay` package façade (#1478 P5 stage D — narrowed; report-action/ + * suggestion-ranking/vars/identity-vocabulary further narrowed by the P5 + * review pass; plan-digest/resume and `classifyTargetBindingMatch` further + * narrowed by the #1555 review pass, "complete the binding façade instead of + * documenting deviations"; the target-verification policy functions further + * narrowed by the #1555 review's R3 pass, "target verification must happen + * INSIDE the engine"). `scripts/layering/package-boundaries.test.ts` asserts + * this file's exact export list — see "the real tree parses, declares, and + * passes R11" — so a stray export (including one this parser cannot + * enumerate a name for, like `export *`) fails that gate, not just a comment + * mismatch. + * + * The binding design (issue comment 5156017698) is two value entrypoints — + * `inspectAdReplay` + `runAdReplay` — plus the neutral vocabulary their + * signatures are built from, exported by name (#1555 structural-quality + * review, "typed façade replaces the zero-type rule"): a package a root + * consumer must integrate against through hand-derived `Parameters<...>`/ + * `ReturnType<...>` gymnastics in a SINGLE allowed root module + * (`src/daemon/ad-replay-facade-types.ts`, since deleted) is a shim tax, not + * an isolation win — every derived name still had to be re-exported from that + * one file for every other root module to use, and every daemon-side type + * that shadowed an engine type by hand (`TargetVerificationEntry`, + * `TargetClassificationOutcome`, `TargetBindingFailureEvidence`, + * `ReplayVerifiedTargetGuard`, plus a `toDaemonEvidence` copy translator + * between mutable and readonly array shapes) was a duplicate definition that + * could silently drift from the type it mirrored. `packages/maestro`'s + * façade (`facade-execution.ts`/`facade-runtime-port.ts`/…) is the precedent: + * a package boundary is enforced by an exact, gate-pinned export LIST, not by + * exporting zero types. The gate below now pins values AND types together, + * so a stray widening — a type accidentally exported, or one accidentally + * dropped that a root file was still deriving by hand — fails loudly either + * way. + * + * `inspectAdReplay` is the read-only `.ad` manifest reader — the plan-digest + * hash (`plan-digest.ts`, `computeReplayPlanDigest`) and the `--from`/ + * `--plan-digest` resume-point math (`resume.ts`, `resolveReplayEntryIndex`) + * are internal-only; the manifest carries the digest as `planDigest` and the + * resume math as a `resolveEntryIndex` closure instead, so + * `session-replay-runtime-plan.ts`'s `prepareReplayPlan` and + * `request-router-repair-expired.test.ts` read them off the manifest rather + * than importing the underlying functions. `AdReplayManifest` is its return + * type and `AdReplayVarSources` is `runAdReplay`'s `${VAR}` scope-input + * shape — both named here since `session-replay-runtime-plan.ts` threads them + * by name across its own helper signatures. + * + * `runAdReplay` is the `.ad` step loop; `AdReplayStepRuntime` is the runtime + * capability bag the daemon adapter + * (`session-replay-runtime-engine-adapter.ts`) implements to thread it, + * including the `ReplaySelectorPort` instance every daemon call site that + * threads a port value names by the SAME type. Two adapters implement the + * port: the production adapter (`src/daemon/replay-selector-port.ts`) and + * the in-memory adapter for this package's own contract suite + * (`src/__tests__/test-utils/in-memory-replay-selector-port.ts` — relocated + * there, #1478 P5 stage D, because package-internal code may not "reach back + * into root `src/`", R11, once its only remaining consumer was a root test). + * + * `./internal/target-verification.ts`'s four policy functions + * (`planPostResolutionTargetVerification`, `planPreDispatchTargetVerification`, + * `deriveReplayTargetGuardMismatchEvidence`, `deriveWaitLandmarkMismatchEvidence`) + * stay engine-private — they are called only from `./internal/verify-dispatch.ts`'s + * `verifyAndDispatchStep`, never the daemon — but the TYPED evidence shapes + * they consume (`AdReplayGuardMismatchEvidence`, `AdReplayLandmarkMismatchEvidence`) + * and the classification/guard/binding-evidence/verification-routing shapes + * `verifyAndDispatchStep` exchanges with the daemon's `AdReplayStepRuntime` + * implementation (`AdReplayVerificationEntry`, `AdReplayTargetClassification`, + * `AdReplayTargetBindingEvidence`, `AdReplayVerifiedTargetGuard`, + * `AdReplayDispatchGuard`, `AdReplayDispatchOutcome`) ARE named here: the + * daemon builds/reads real values of these shapes directly now (routing in + * `session-replay-target-verification.ts`, wire-narrowing in + * `session-replay-runtime-engine-adapter.ts`) rather than re-declaring a + * structurally-identical twin per module. + * + * `${VAR}` scope/planning: the engine builds the `${VAR}` scope (via + * `@agent-device/ad-script`) from the request's `varSources` and resolves + * each action exactly once per step, handing the daemon's `dispatchStep`/ + * `beginTargetVerification` capabilities the RESOLVED action — never a raw + * action plus a scope for the daemon to interpolate itself (#1555 review P1, + * "move variable semantics/planning behind the replay entrypoint"). The + * `${VAR}`-scrub values a divergence report redacts (`AdReplayScrubValue`) + * are threaded the same direction, as an explicit argument on each + * build*Failure/handleActionFailure capability, computed ONCE per run from + * the engine's own live scope — never recomputed daemon-side from a second + * scope object, and never re-collected per call site. + */ + +export { inspectAdReplay } from './internal/inspect.ts'; +export type { AdReplayManifest } from './internal/inspect.ts'; + +export { runAdReplay } from './internal/step-loop.ts'; + +export type { + AdReplayDispatchGuard, + AdReplayDispatchOutcome, + AdReplayScrubValue, + AdReplayStepFailure, + AdReplayStepRuntime, + AdReplayTargetBindingEvidence, + AdReplayTargetClassification, + AdReplayVarSources, + AdReplayVerificationEntry, + AdReplayVerifiedTargetGuard, +} from './internal/runtime-port-types.ts'; + +export type { + AdReplayGuardMismatchEvidence, + AdReplayLandmarkMismatchEvidence, +} from './internal/target-verification.ts'; + +export type { + ReplayRecordedTargetDisambiguation, + ReplayRecordedTargetPolicy, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from './internal/selector-port.ts'; diff --git a/src/replay/__tests__/plan-digest.test.ts b/packages/ad-replay/src/internal/__tests__/plan-digest.test.ts similarity index 100% rename from src/replay/__tests__/plan-digest.test.ts rename to packages/ad-replay/src/internal/__tests__/plan-digest.test.ts diff --git a/packages/ad-replay/src/internal/__tests__/resume.test.ts b/packages/ad-replay/src/internal/__tests__/resume.test.ts new file mode 100644 index 0000000000..9d6a682785 --- /dev/null +++ b/packages/ad-replay/src/internal/__tests__/resume.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + resolveReplayEntryIndex, + type AdReplayEntryIndexParams, + type PendingRecordAndHeal, +} from '../resume.ts'; + +/** + * #1555 structural-quality review ("package-local tests... resume.ts and + * cover its branches; counterfactual per docs/agents/testing.md on at least + * the rejection path"): `resolveReplayEntryIndex` was previously exercised + * only transitively, through the daemon's + * `session-replay-runtime-plan.test.ts` (`resolveReplayPlanEntryIndex` + * wrapping the manifest's `resolveEntryIndex` closure). This suite covers + * the pure resume-point math directly, at package level, cheaper than the + * daemon round trip. + */ + +const PLAN_DIGEST = 'a'.repeat(64); +const OTHER_DIGEST = 'b'.repeat(64); +const ACTION_COUNT = 5; + +function params(overrides: Partial = {}): AdReplayEntryIndexParams { + return { + from: undefined, + digest: undefined, + pendingRecordAndHeal: undefined, + sessionActionsLength: 0, + ...overrides, + }; +} + +test('no --from and no --plan-digest resolves to the plan start (entry index 0)', () => { + const result = resolveReplayEntryIndex(params(), ACTION_COUNT, PLAN_DIGEST); + assert.deepEqual(result, { ok: true, value: 0 }); +}); + +test('--from without --plan-digest is rejected, and the reverse pairing too', () => { + const fromOnly = resolveReplayEntryIndex(params({ from: 2 }), ACTION_COUNT, PLAN_DIGEST); + assert.equal(fromOnly.ok, false); + if (fromOnly.ok) throw new Error('unreachable'); + assert.match(fromOnly.message, /--from requires --plan-digest/); + + const digestOnly = resolveReplayEntryIndex( + params({ digest: PLAN_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(digestOnly.ok, false); + if (digestOnly.ok) throw new Error('unreachable'); + assert.match(digestOnly.message, /--from requires --plan-digest/); +}); + +test('a valid in-range --from resolves to the 0-based entry index (from - 1)', () => { + const result = resolveReplayEntryIndex( + params({ from: 3, digest: PLAN_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.deepEqual(result, { ok: true, value: 2 }); +}); + +// --------------------------------------------------------------------------- +// Rejection: out-of-range --from. +// --------------------------------------------------------------------------- + +test('rejection: --from below 1 or above the plan length (with no matching empty-tail watermark) is out of range', () => { + const zero = resolveReplayEntryIndex( + params({ from: 0, digest: PLAN_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(zero.ok, false); + if (zero.ok) throw new Error('unreachable'); + assert.match(zero.message, /out of range for a 5-step plan/); + + // ACTION_COUNT + 1 (6) is the one legal empty-tail boundary, but ONLY with + // a matching watermark (covered separately below) — absent one, it is out + // of range exactly like anything past it. + const pastEnd = resolveReplayEntryIndex( + params({ from: ACTION_COUNT + 2, digest: PLAN_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(pastEnd.ok, false); + if (pastEnd.ok) throw new Error('unreachable'); + assert.match(pastEnd.message, /out of range for a 5-step plan/); + + const emptyTailNoWatermark = resolveReplayEntryIndex( + params({ from: ACTION_COUNT + 1, digest: PLAN_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(emptyTailNoWatermark.ok, false); + if (emptyTailNoWatermark.ok) throw new Error('unreachable'); + assert.match(emptyTailNoWatermark.message, /out of range for a 5-step plan/); +}); + +// Counterfactual (docs/agents/testing.md): reverting describeOutOfRangeResumeFrom's +// `from <= actionCount` bound to `from <= actionCount + 1` (dropping the +// authorization gate entirely) turns this red — verified by hand, restored +// before commit. Recorded here so the proof does not have to be re-derived: +// `git stash` a local edit changing `from <= actionCount` to +// `from <= actionCount + 1` in resume.ts, re-run this file, observe the +// "rejection: --from below 1..." case fail on its `pastEnd`/`emptyTailNoWatermark` +// assertions, then `git stash pop` to restore. + +test('rejection: --plan-digest that does not match the current plan digest is stale', () => { + const result = resolveReplayEntryIndex( + params({ from: 2, digest: OTHER_DIGEST }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(result.ok, false); + if (result.ok) throw new Error('unreachable'); + assert.match(result.message, /does not match the current plan digest/); +}); + +// --------------------------------------------------------------------------- +// Empty-tail resume: the ONE ordinal beyond the plan's end (actionCount + 1), +// authorized only for the exact session/target that produced the watermark. +// --------------------------------------------------------------------------- + +test('empty-tail: actionCount + 1 resolves when the watermark matches and the session has grown since the divergence', () => { + const pendingRecordAndHeal: PendingRecordAndHeal = { + expectedFrom: ACTION_COUNT + 1, + actionsCountAtDivergence: 3, + }; + const result = resolveReplayEntryIndex( + params({ + from: ACTION_COUNT + 1, + digest: PLAN_DIGEST, + pendingRecordAndHeal, + sessionActionsLength: 4, // grew past actionsCountAtDivergence + }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.deepEqual(result, { ok: true, value: ACTION_COUNT }); +}); + +test('empty-tail: a watermark for a DIFFERENT --from ordinal does not authorize this one', () => { + const pendingRecordAndHeal: PendingRecordAndHeal = { + expectedFrom: ACTION_COUNT, // not ACTION_COUNT + 1 + actionsCountAtDivergence: 3, + }; + const result = resolveReplayEntryIndex( + params({ + from: ACTION_COUNT + 1, + digest: PLAN_DIGEST, + pendingRecordAndHeal, + sessionActionsLength: 4, + }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(result.ok, false); + if (result.ok) throw new Error('unreachable'); + assert.match(result.message, /out of range for a 5-step plan/); +}); + +// --------------------------------------------------------------------------- +// Heal semantics: the watermark alone is not enough — the session's own +// recorded action count must have grown, proving the corrective press (or +// re-recorded read) actually happened in this repair segment. +// --------------------------------------------------------------------------- + +test('heal: a matching watermark with NO session growth is rejected as an unperformed record-and-heal', () => { + const pendingRecordAndHeal: PendingRecordAndHeal = { + expectedFrom: ACTION_COUNT + 1, + actionsCountAtDivergence: 3, + }; + const result = resolveReplayEntryIndex( + params({ + from: ACTION_COUNT + 1, + digest: PLAN_DIGEST, + pendingRecordAndHeal, + sessionActionsLength: 3, // unchanged since the divergence — no corrective action recorded + }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.equal(result.ok, false); + if (result.ok) throw new Error('unreachable'); + assert.match(result.message, /no corrective action was\s+recorded in this repair segment/); + assert.match(result.message, /--record/); +}); + +test('heal: the unperformed-record-and-heal message is scoped to the matching watermark, not a generic in-range --from', () => { + // A mid-plan --from that never matches a pending watermark's expectedFrom + // is not subject to the growth check at all — it is either accepted + // (in-range) or rejected as out-of-range, never as "unperformed". + const pendingRecordAndHeal: PendingRecordAndHeal = { + expectedFrom: ACTION_COUNT + 1, + actionsCountAtDivergence: 3, + }; + const result = resolveReplayEntryIndex( + params({ + from: 2, + digest: PLAN_DIGEST, + pendingRecordAndHeal, + sessionActionsLength: 3, + }), + ACTION_COUNT, + PLAN_DIGEST, + ); + assert.deepEqual(result, { ok: true, value: 1 }); +}); diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts new file mode 100644 index 0000000000..0e01e6fedc --- /dev/null +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { runAdReplay } from '../step-loop.ts'; +import type { AdReplayStepRuntime } from '../runtime-port-types.ts'; +import type { SessionAction } from '@agent-device/contracts/session'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { ReplaySelectorPort } from '../selector-port.ts'; + +/** + * #1554 fold-in: `resolveSuppressedTerminalCloseIndex` (the pure structural + * resolution `runAdReplay` uses for BOTH `--keep-session` and repair-armed + * terminal-close suppression) is engine-private — never re-exported by the + * façade (`packages/ad-replay/src/index.ts`) — so these tests exercise it + * only through `runAdReplay` itself, the same way the daemon's own + * `session-replay-runtime.ts` (`runReplayScriptFile`) does. The equivalent + * daemon-level assertions (full `SessionStore`/`runReplayScriptFile` round + * trip, including the `--keep-session` live-session postcondition) live in + * `src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts` + * (renamed from `session-replay-terminal-lifecycle.test.ts` by the #1555 + * structural-quality review — see that file's own header for the rationale); + * this file covers the SAME suppression decision at the cheaper, + * package-internal level, plus the repair-armed unification that file does + * not exercise directly. + */ + +function action(command: string, overrides: Partial = {}): SessionAction { + return { ts: 0, command, positionals: [], flags: {}, ...overrides }; +} + +/** + * `runAdReplay`'s request, filled in with neutral `${VAR}`-plumbing fields — + * every action in this file is untargeted and carries no `${VAR}` — so each + * test only has to state what it actually varies (`actions`/`entryIndex`/ + * `keepSession`). + */ +function runRequest( + actions: SessionAction[], + overrides: { entryIndex?: number; keepSession: boolean }, +) { + return { + actions, + entryIndex: overrides.entryIndex ?? 0, + keepSession: overrides.keepSession, + actionLines: actions.map(() => 1), + actionSourcePaths: undefined, + resolvedPath: 'fixture.ad', + varSources: {}, + }; +} + +/** + * A minimal `AdReplayStepRuntime` fixture: every action in these tests is + * untargeted (no `targetEvidence`), so `verifyAndDispatchStep` always takes + * the `dispatchNoGuard` path straight to `dispatchStep` — the + * target-verification capabilities are never called and just throw if they + * somehow were. + */ +function createFakeRuntime(params: { isRepairArmed?: () => boolean } = {}): { + runtime: AdReplayStepRuntime; + dispatched: string[]; + armCount: () => number; +} { + const dispatched: string[] = []; + let armCount = 0; + const runtime: AdReplayStepRuntime = { + port: {} as ReplaySelectorPort, + beginTargetVerification: () => ({ kind: 'inactive' }), + captureObservation: async () => { + throw new Error('captureObservation: not used by this fixture (no targetEvidence)'); + }, + classifyTarget: () => { + throw new Error('classifyTarget: not used by this fixture (no targetEvidence)'); + }, + async dispatchStep(dispatchedAction, _resolvedAction, _index, artifactPaths) { + dispatched.push(dispatchedAction.command); + return { status: 'ok', artifactPaths }; + }, + buildRecordedUnverifiableFailure: async () => { + throw new Error('buildRecordedUnverifiableFailure: not used by this fixture'); + }, + buildTargetBindingFailure: async () => { + throw new Error('buildTargetBindingFailure: not used by this fixture'); + }, + buildPostDispatchTargetBindingFailure: async () => { + throw new Error('buildPostDispatchTargetBindingFailure: not used by this fixture'); + }, + handleActionFailure: async () => { + throw new Error('handleActionFailure: not used by this fixture (no failing step)'); + }, + armStep: () => { + armCount += 1; + }, + isRepairArmed: params.isRepairArmed ?? (() => false), + describeStepValue: () => undefined, + diagnosticsMarker: () => 0, + diagnosticsSince: () => [], + }; + return { runtime, dispatched, armCount: () => armCount }; +} + +test('--keep-session suppresses a close that is terminal among executable actions', async () => { + // The trailing `replay "./nested.ad"` line is plan metadata + // (`isExecutableReplayAction` skips it) — the true terminal step is `close` + // at index 1, not the array's physical last index. + const actions = [action('open'), action('close'), action('replay')]; + const { runtime, dispatched } = createFakeRuntime(); + const outcome = await runAdReplay(runRequest(actions, { keepSession: true }), runtime); + assert.deepEqual(dispatched, ['open']); + assert.equal(outcome.status, 'completed'); + if (outcome.status === 'completed') assert.equal(outcome.replayed, 1); +}); + +test('repair-armed suppresses the same terminal-among-executable close (unified decision)', async () => { + const actions = [action('open'), action('close'), action('replay')]; + const { runtime, dispatched } = createFakeRuntime({ isRepairArmed: () => true }); + const outcome = await runAdReplay(runRequest(actions, { keepSession: false }), runtime); + assert.deepEqual(dispatched, ['open']); + assert.equal(outcome.status, 'completed'); + if (outcome.status === 'completed') assert.equal(outcome.replayed, 1); +}); + +test('an interior close is preserved instead of broad command filtering', async () => { + const actions = [action('open'), action('close'), action('open')]; + const { runtime, dispatched } = createFakeRuntime(); + const outcome = await runAdReplay(runRequest(actions, { keepSession: true }), runtime); + assert.deepEqual(dispatched, ['open', 'close', 'open']); + assert.equal(outcome.status, 'completed'); + if (outcome.status === 'completed') assert.equal(outcome.replayed, 3); +}); + +test('a terminal close dispatches normally when neither keepSession nor repair is armed', async () => { + const actions = [action('open'), action('close')]; + const { runtime, dispatched } = createFakeRuntime(); + const outcome = await runAdReplay(runRequest(actions, { keepSession: false }), runtime); + assert.deepEqual(dispatched, ['open', 'close']); + assert.equal(outcome.status, 'completed'); + if (outcome.status === 'completed') assert.equal(outcome.replayed, 2); +}); + +test('a close-less plan suppresses nothing and arms every executable step, including the suppressed one', async () => { + const actions = [action('open'), action('close'), action('replay')]; + const { runtime, armCount } = createFakeRuntime(); + await runAdReplay(runRequest(actions, { keepSession: true }), runtime); + // `armStep` runs before the terminal-close check so `[open, close]` records + // the session `open` created before treating `close` as lifecycle — the + // suppressed `close` is still armed, just never dispatched. + assert.equal(armCount(), 2); +}); + +/** + * #1555 P5 stage R3 invariant: `buildPostDispatchTargetBindingFailure`'s + * `artifactPaths` argument is the run's accumulated PRE-step snapshot — the + * same value `verifyAndDispatchStep`/`dispatchWithGuard` were called with — + * never the artifacts the just-failed dispatch itself produced. A dispatch + * that races a post-resolution refusal (guard-mismatch/landmark-mismatch) + * may have taken its own screenshot as part of resolving (or failing to + * resolve) the action; that capture belongs to the failed attempt, not to + * the divergence report, which describes the screen BEFORE the action ran. + */ +test("a post-dispatch target-binding mismatch reports the pre-step artifact snapshot, not the failed dispatch's own", async () => { + const recorded: TargetAnnotationV1 = { + role: 'button', + ancestry: [], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + }; + const openAction = action('open'); + const waitAction: SessionAction = { + ...action('wait'), + targetEvidence: recorded, + }; + + let receivedArtifactPaths: readonly string[] | undefined; + const runtime: AdReplayStepRuntime = { + port: {} as ReplaySelectorPort, + // Only `waitAction` carries `targetEvidence`, so this is only ever + // called for it — routed to the #1349 deferred-landmark path, which + // dispatches with a guard WITHOUT any capture/classify round trip. + beginTargetVerification: () => ({ kind: 'post-resolution', isSelectorWait: true }), + captureObservation: async () => { + throw new Error( + 'captureObservation: not used — deferred-landmark skips straight to dispatch', + ); + }, + classifyTarget: () => { + throw new Error('classifyTarget: not used — deferred-landmark skips straight to dispatch'); + }, + async dispatchStep(dispatchedAction, _resolvedAction, _index, artifactPaths, _guard) { + if (dispatchedAction.command === 'open') { + return { status: 'ok', artifactPaths: ['open-snapshot.png'] }; + } + // The wait's own dispatch attempt produced a DIFFERENT artifact set + // than the pre-step snapshot it was called with (`artifactPaths`, + // asserted below never to leak into the divergence report). + assert.deepEqual(artifactPaths, ['open-snapshot.png']); + return { + status: 'landmark-mismatch', + evidence: { matchCount: undefined, observed: undefined, observedAncestry: [] }, + plainFailure: { kind: 'REPLAY_DIVERGENCE', message: 'mismatch', artifactPaths: [] }, + artifactPaths: ['open-snapshot.png', 'post-dispatch-only.png'], + }; + }, + buildRecordedUnverifiableFailure: async () => { + throw new Error('buildRecordedUnverifiableFailure: not used by this fixture'); + }, + buildTargetBindingFailure: async () => { + throw new Error( + 'buildTargetBindingFailure: not used by this fixture (this is a POST-dispatch mismatch)', + ); + }, + async buildPostDispatchTargetBindingFailure( + _dispatchedAction, + _index, + _evidence, + artifactPaths, + _scrubVars, + ) { + receivedArtifactPaths = artifactPaths; + return { kind: 'REPLAY_DIVERGENCE', message: 'mismatch', artifactPaths: [] }; + }, + handleActionFailure: async ({ artifactPaths }) => ({ + kind: 'REPLAY_DIVERGENCE', + message: 'mismatch', + artifactPaths: [...artifactPaths], + }), + armStep: () => {}, + isRepairArmed: () => false, + describeStepValue: () => undefined, + diagnosticsMarker: () => 0, + diagnosticsSince: () => [], + }; + + const outcome = await runAdReplay( + runRequest([openAction, waitAction], { keepSession: false }), + runtime, + ); + + assert.equal(outcome.status, 'failed'); + // The divergence reports the snapshot taken BEFORE the wait's dispatch — + // `open`'s own artifact, nothing the failed dispatch itself produced. + assert.deepEqual(receivedArtifactPaths, ['open-snapshot.png']); +}); diff --git a/packages/ad-replay/src/internal/__tests__/target-verification.test.ts b/packages/ad-replay/src/internal/__tests__/target-verification.test.ts new file mode 100644 index 0000000000..3629889713 --- /dev/null +++ b/packages/ad-replay/src/internal/__tests__/target-verification.test.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import { + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, + type AdReplayGuardMismatchEvidence, + type AdReplayLandmarkMismatchEvidence, +} from '../target-verification.ts'; +import type { + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from '../selector-port.ts'; + +/** + * #1555 structural-quality review ("package-local tests... target-verification.ts's + * four policy functions; counterfactual on one"): these four functions + * previously had no direct test coverage at package level — only + * transitively, through `step-loop.test.ts`'s `runAdReplay` fixtures (whose + * fake `AdReplayStepRuntime` never exercises `planPreDispatchTargetVerification`'s + * port call at all) and the daemon's live end-to-end replay suites. This + * file covers each function's decision surface directly. + */ + +function recorded(overrides: Partial = {}): TargetAnnotationV1 { + return { + role: 'button', + label: 'Save', + ancestry: [], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// planPostResolutionTargetVerification +// --------------------------------------------------------------------------- + +test('planPostResolutionTargetVerification: a non-selector wait form is inert (skip), regardless of recorded verification', () => { + assert.deepEqual( + planPostResolutionTargetVerification({ + recorded: recorded({ verification: 'unverifiable' }), + isSelectorWait: false, + }), + { kind: 'skip' }, + ); +}); + +test('planPostResolutionTargetVerification: a selector wait with a recorded-unverifiable annotation refuses up front', () => { + assert.deepEqual( + planPostResolutionTargetVerification({ + recorded: recorded({ verification: 'unverifiable' }), + isSelectorWait: true, + }), + { kind: 'recorded-unverifiable' }, + ); +}); + +test('planPostResolutionTargetVerification: a selector wait with a verifiable annotation defers into the polling loop', () => { + const landmark = recorded({ verification: 'verified' }); + assert.deepEqual( + planPostResolutionTargetVerification({ recorded: landmark, isSelectorWait: true }), + { kind: 'deferred-landmark', landmark }, + ); +}); + +// --------------------------------------------------------------------------- +// planPreDispatchTargetVerification +// --------------------------------------------------------------------------- + +/** A port whose `readSelectorExpression` returns a fixed outcome and records how it was called. */ +function fakePort(outcome: ReplaySelectorExpressionOutcome): { + port: ReplaySelectorPort; + calls: Array<{ grammar: ReplaySelectorGrammar; positionals: readonly string[] }>; +} { + const calls: Array<{ grammar: ReplaySelectorGrammar; positionals: readonly string[] }> = []; + const port: ReplaySelectorPort = { + readSelectorExpression: (grammar, positionals) => { + calls.push({ grammar, positionals }); + return outcome; + }, + resolveRecordedTarget: () => { + throw new Error('resolveRecordedTarget: not used by planPreDispatchTargetVerification'); + }, + buildSelectorCandidates: () => { + throw new Error('buildSelectorCandidates: not used by planPreDispatchTargetVerification'); + }, + }; + return { port, calls }; +} + +test('planPreDispatchTargetVerification: no recorded token means nothing to verify (skip), and the port is never called', () => { + const { port, calls } = fakePort({ kind: 'expression', expression: 'id="x"', rest: [] }); + const plan = planPreDispatchTargetVerification({ recorded: recorded(), token: undefined, port }); + assert.deepEqual(plan, { kind: 'skip' }); + assert.deepEqual(calls, []); +}); + +test('planPreDispatchTargetVerification: a @ref token skips the parse gate entirely', () => { + const { port, calls } = fakePort({ kind: 'invalid' }); + const plan = planPreDispatchTargetVerification({ + recorded: recorded(), + token: '@e1~s0', + port, + }); + assert.deepEqual(plan, { kind: 'verify', token: '@e1~s0' }); + assert.deepEqual(calls, []); +}); + +test("planPreDispatchTargetVerification: a parseable non-@ token proceeds to 'verify' (or 'recorded-unverifiable')", () => { + const { port } = fakePort({ kind: 'expression', expression: 'id="save"', rest: [] }); + const verify = planPreDispatchTargetVerification({ + recorded: recorded({ verification: 'verified' }), + token: 'id="save"', + port, + }); + assert.deepEqual(verify, { kind: 'verify', token: 'id="save"' }); + + const unverifiable = planPreDispatchTargetVerification({ + recorded: recorded({ verification: 'unverifiable' }), + token: 'id="save"', + port, + }); + assert.deepEqual(unverifiable, { kind: 'recorded-unverifiable' }); +}); + +test("planPreDispatchTargetVerification: the port is called with ('ordinary', [token]) exactly", () => { + const { port, calls } = fakePort({ kind: 'expression', expression: 'id="save"', rest: [] }); + planPreDispatchTargetVerification({ recorded: recorded(), token: 'id="save"', port }); + assert.deepEqual(calls, [{ grammar: 'ordinary', positionals: ['id="save"'] }]); +}); + +// #1555 structural-quality review ("fix the engine's parse gate to honor its +// own port contract"): this is the item-2 fix's own decision surface — a +// token that fails to parse must skip pre-dispatch verification, exactly +// like the pre-fix `resolveRecordedTarget`-over-empty-nodes check did for +// `parse-invalid`. Covering BOTH non-'expression' discriminants +// ('not-applicable', the one production's 'ordinary' grammar actually +// reaches from a bare token, and 'invalid', defensively) because the fix's +// whole point is that the mapping does not hinge on which one fires. +test("planPreDispatchTargetVerification: a token that fails to parse ('not-applicable' or 'invalid') skips pre-dispatch verification", () => { + for (const outcome of [{ kind: 'not-applicable' as const }, { kind: 'invalid' as const }]) { + const { port } = fakePort(outcome); + const plan = planPreDispatchTargetVerification({ + recorded: recorded({ verification: 'unverifiable' }), + token: 'id=', + port, + }); + assert.deepEqual(plan, { kind: 'skip' }, `outcome ${outcome.kind} must skip`); + } +}); + +// Counterfactual (docs/agents/testing.md): reverting the `parseCheck.kind !== +// 'expression'` check to `parseCheck.kind === 'invalid'` (the literal, too- +// narrow reading of "'invalid' -> skip") makes the 'not-applicable' case fall +// through to `recorded.verification === 'unverifiable'` and report +// `{ kind: 'recorded-unverifiable' }` instead of `{ kind: 'skip' }` — turning +// the cell above red. Verified by hand (see below), restored before commit. + +// --------------------------------------------------------------------------- +// deriveReplayTargetGuardMismatchEvidence +// --------------------------------------------------------------------------- + +test('deriveReplayTargetGuardMismatchEvidence: identical identity but differing structural position reports a position mismatch line, never an identity one', () => { + const evidence: AdReplayGuardMismatchEvidence = { + observed: { role: 'button', label: 'Save' }, + expectedStructural: { documentOrder: 3, sibling: 0 }, + observedStructural: { documentOrder: 7, sibling: 1 }, + }; + const result = deriveReplayTargetGuardMismatchEvidence( + recorded({ role: 'button', label: 'Save' }), + evidence, + 2, + ); + assert.equal(result.matchCount, 2); + assert.deepEqual(result.observed, evidence.observed); + assert.deepEqual(result.mismatches, ['position: recorded=doc3/sibling0 observed=doc7/sibling1']); +}); + +test('deriveReplayTargetGuardMismatchEvidence: a differing observed identity reports an identity mismatch line', () => { + const evidence: AdReplayGuardMismatchEvidence = { + observed: { role: 'button', label: 'Cancel' }, + expectedStructural: undefined, + observedStructural: undefined, + }; + const result = deriveReplayTargetGuardMismatchEvidence( + recorded({ role: 'button', label: 'Save' }), + evidence, + 1, + ); + assert.equal(result.mismatches.length, 1); + assert.match(result.mismatches[0]!, /label/); +}); + +test('deriveReplayTargetGuardMismatchEvidence: no observed identity reports zero mismatches but still carries matchCount', () => { + const evidence: AdReplayGuardMismatchEvidence = { + observed: undefined, + expectedStructural: undefined, + observedStructural: undefined, + }; + const result = deriveReplayTargetGuardMismatchEvidence(recorded(), evidence, 5); + assert.equal(result.matchCount, 5); + assert.deepEqual(result.mismatches, []); +}); + +// --------------------------------------------------------------------------- +// deriveWaitLandmarkMismatchEvidence +// --------------------------------------------------------------------------- + +test('deriveWaitLandmarkMismatchEvidence: no observed identity reports zero mismatches', () => { + const evidence: AdReplayLandmarkMismatchEvidence = { + matchCount: undefined, + observed: undefined, + observedAncestry: [], + }; + const result = deriveWaitLandmarkMismatchEvidence(recorded(), evidence); + assert.deepEqual(result.mismatches, []); + assert.equal(result.matchCount, undefined); +}); + +test('deriveWaitLandmarkMismatchEvidence: an observed identity combines identity and ancestry mismatches', () => { + const evidence: AdReplayLandmarkMismatchEvidence = { + matchCount: 3, + observed: { role: 'button', label: 'Cancel' }, + observedAncestry: [{ role: 'dialog' }], + }; + const result = deriveWaitLandmarkMismatchEvidence( + recorded({ role: 'button', label: 'Save', ancestry: [{ role: 'sheet' }] }), + evidence, + ); + assert.equal(result.matchCount, 3); + assert.ok(result.mismatches.some((line) => /label/.test(line))); + assert.ok(result.mismatches.some((line) => /sheet/.test(line) || /dialog/.test(line))); +}); diff --git a/src/utils/canonical-json.ts b/packages/ad-replay/src/internal/canonical-json.ts similarity index 100% rename from src/utils/canonical-json.ts rename to packages/ad-replay/src/internal/canonical-json.ts diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts new file mode 100644 index 0000000000..510262f0b9 --- /dev/null +++ b/packages/ad-replay/src/internal/inspect.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SessionAction } from '@agent-device/contracts/session'; +import { + parseReplayScriptDetailed, + readReplayScriptMetadata, + resolveDeclaredScriptPlatform, + type ReplayScriptMetadata, +} from '@agent-device/ad-script'; +import { computeReplayPlanDigest } from './plan-digest.ts'; +import { resolveReplayEntryIndex, type PendingRecordAndHeal } from './resume.ts'; + +/** + * #1478 P5 stage C2b: the read-only `.ad` inspection façade. Moved out of + * `session-replay-runtime.ts`'s old `parseReplayScript` (the fs read + the + * legacy-JSON-payload rejection it guarded) plus the `parseReplayInput` + * composition (`src/compat/replay-input.ts`) it fed into — this is the same + * `parseReplayScriptDetailed` + `readReplayScriptMetadata` pair + * `src/cli/commands/replay.ts` and `session-test-source-discovery.ts` already + * call directly off `@agent-device/ad-script`; nothing beyond the actions, + * line table, and header metadata those call sites read is exposed here. + * + * #1555 review P1 ("digest/resume must also occur behind runAdReplay" — + * satisfied via `inspectAdReplay`'s manifest for these two, since both are + * needed BEFORE any device action and (for resume) before the daemon's own + * session/coordinator preparation runs): `planDigest` and `resolveEntryIndex` + * below are the plan-digest hash and the `--from`/`--plan-digest` resume- + * point math, computed/exposed here instead of the daemon calling + * `computeReplayPlanDigest`/`resolveReplayEntryIndex` directly. Neither is a + * new top-level façade export — they are plain data/a closure hanging off + * the manifest object `inspectAdReplay` already returns. + */ +export type AdReplayManifest = Readonly<{ + actions: SessionAction[]; + actionLines: number[]; + actionSourcePaths: (string | undefined)[] | undefined; + metadata: ReplayScriptMetadata; + /** SHA-256 digest of the canonical plan (`digestFlags` binds the same platform/target the replay invokes with). */ + planDigest: string; + /** The `--from`/`--plan-digest` resume-point math, closed over this manifest's own `actions`/`planDigest`. */ + resolveEntryIndex(params: { + from: number | undefined; + digest: string | undefined; + pendingRecordAndHeal: PendingRecordAndHeal | undefined; + sessionActionsLength: number; + }): { ok: true; value: number } | { ok: false; message: string }; +}>; + +/** The request-level `--platform`/`--target` override the plan digest binds (raw flags, before any metadata merge). */ +export type AdReplayDigestFlags = Readonly<{ platform?: string; target?: string }>; + +/** + * Reads `sourcePath` once and returns its parsed actions/line table, header + * metadata, plan digest, and resume-index resolver. Throws + * `AppError('INVALID_ARGS', …)` for the one source format `.ad` replay no + * longer accepts — a legacy JSON replay payload — matching the daemon's + * prior explicit rejection exactly. Callers do not need to check for this + * case separately: `runReplayScriptFile`'s top-level catch (`asAppError`) + * maps a thrown `AppError` straight to the same `errorResponse` the old + * explicit branch built, so this is not a behavior change, only where the + * check lives. + * + * `digestFlags` is the caller's raw request-level `--platform`/`--target` + * (before any metadata merge) — the SAME precedence the daemon used to apply + * itself: an explicit flag wins outright; absent that, a platform declared + * by the script's own `runtime`/`open` actions before their first real + * `open` wins; absent that, the `context platform=`/`target=` header line. + */ +export function inspectAdReplay( + sourcePath: string, + digestFlags?: AdReplayDigestFlags, +): AdReplayManifest { + const script = fs.readFileSync(sourcePath, 'utf8'); + const firstNonWhitespace = script.trimStart()[0]; + if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { + throw new AppError( + 'INVALID_ARGS', + 'replay accepts .ad script files. JSON replay payloads are no longer supported.', + ); + } + const parsed = parseReplayScriptDetailed(script); + const metadata = readReplayScriptMetadata(script); + const planDigest = computeReplayPlanDigest({ + actions: parsed.actions, + actionLines: parsed.actionLines, + actionSourcePaths: parsed.actionSourcePaths, + metadata: { + platform: + digestFlags?.platform ?? resolveDeclaredScriptPlatform(parsed.actions) ?? metadata.platform, + target: digestFlags?.target ?? metadata.target, + }, + }); + const actionCount = parsed.actions.length; + return { + actions: parsed.actions, + actionLines: parsed.actionLines, + actionSourcePaths: parsed.actionSourcePaths, + metadata, + planDigest, + resolveEntryIndex: (params) => resolveReplayEntryIndex(params, actionCount, planDigest), + }; +} diff --git a/src/replay/plan-digest.ts b/packages/ad-replay/src/internal/plan-digest.ts similarity index 97% rename from src/replay/plan-digest.ts rename to packages/ad-replay/src/internal/plan-digest.ts index f976481579..dc86f02f4b 100644 --- a/src/replay/plan-digest.ts +++ b/packages/ad-replay/src/internal/plan-digest.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import type { SessionAction } from '@agent-device/contracts/session'; -import { canonicalJson } from '../utils/canonical-json.ts'; +import { canonicalJson } from './canonical-json.ts'; /** * ADR 0012 decision 4 / migration step 5: `planDigest` is SHA-256 over the diff --git a/packages/ad-replay/src/internal/resume.ts b/packages/ad-replay/src/internal/resume.ts new file mode 100644 index 0000000000..9e2dcb7789 --- /dev/null +++ b/packages/ad-replay/src/internal/resume.ts @@ -0,0 +1,188 @@ +/** + * #1555 review P1 ("parsing/planning/digest/resume must also occur behind + * runAdReplay"): the `--from`/`--plan-digest` resume-point math, relocated + * verbatim from the daemon's `session-replay-runtime-plan.ts` + * (`resolveReplayEntryIndex`) into the engine. This is pure over plain + * values — it never touches `SessionStore`, the P4b repair coordinator, or a + * `DaemonResponse` — so the only thing that changes by moving it here is + * OWNERSHIP, not behavior or call timing: `inspectAdReplay`'s manifest + * exposes it as `resolveEntryIndex`, and the daemon calls it at exactly the + * point `resolveReplayEntryIndex` used to run (`prepareReplayPlan`, BEFORE + * `prepareReplaySession`'s coordinator-mutating side effects). That ordering + * is load-bearing: an invalid `--from` must be rejected before anything + * about the session or its repair transaction is touched, so this cannot + * move to run any later (e.g. inside `runAdReplay`'s own step loop) without + * either reordering `prepareReplaySession` around it or letting a rejected + * resume request mutate coordinator state first — see the #1555 R2 handoff + * notes for why that reordering was judged out of scope here. + */ + +/** + * The session-side state that gates an EMPTY-TAIL resume (`--from actionCount + * + 1`). Stamped for `record-and-heal`, and per #1262 also for + * `caution`/`manual`'s record-and-heal-shaped alternate repair (their own + * unshifted `resume.from` is unaffected by this watermark). + */ +export type PendingRecordAndHeal = Readonly<{ + expectedFrom: number; + actionsCountAtDivergence: number; +}>; + +export type AdReplayEntryIndexParams = Readonly<{ + from: number | undefined; + digest: string | undefined; + pendingRecordAndHeal: PendingRecordAndHeal | undefined; + sessionActionsLength: number; +}>; + +export type AdReplayEntryIndexResult = + | Readonly<{ readonly ok: true; readonly value: number }> + | Readonly<{ readonly ok: false; readonly message: string }>; + +/** + * Resolves `--from`/`--plan-digest` into a 0-based loop entry index before + * any device action. `--from` is 1-based and matches divergence step indices. + * + * `pendingRecordAndHeal`/`sessionActionsLength` gate the ONE ordinal beyond + * the plan's end (`actionCount + 1`): ADR 0012 decision 6, R2's `record-and-heal` + * repair — and, per #1262, `caution`/`manual`'s record-and-heal-SHAPED + * alternate repair — resumes past the plan's LAST step once the agent + * performs the diverged step's intent as a recorded action, and that resume + * must execute zero device actions before reaching the normal completion + * path. That allowance is scoped to the EXACT session + target that actually + * produced it (the daemon's `ReplayCoordinator`'s `stampCorrectiveWatermark`), + * and only once a new action proves the corrective press happened — never a + * blanket "one past the end is fine" for any session, which would let an + * unrelated or blind `--from actionCount + 1` silently skip the plan's tail + * and commit an unfinished repair. `caution`/`manual`'s OWN `resume.from` + * (the failed step's own index, unshifted) stays legal unconditionally + * regardless of this watermark — it is always `<= actionCount`, never the + * one-past-the-end ordinal this gate concerns. + */ +export function resolveReplayEntryIndex( + params: AdReplayEntryIndexParams, + actionCount: number, + planDigest: string, +): AdReplayEntryIndexResult { + const { from, digest, pendingRecordAndHeal, sessionActionsLength } = params; + if (from === undefined && digest === undefined) return { ok: true, value: 0 }; + if (from === undefined || digest === undefined) { + return { + ok: false, + message: 'replay --from requires --plan-digest (and --plan-digest requires --from).', + }; + } + const message = validateReplayResumeRequest({ + from, + digest, + planDigest, + actionCount, + pendingRecordAndHeal, + sessionActionsLength, + }); + return message ? { ok: false, message } : { ok: true, value: from - 1 }; +} + +/** A single sub-check of a `--from` resume request; `undefined` means "no objection". */ +type ReplayResumeCheck = () => string | undefined; + +function validateReplayResumeRequest(params: { + from: number; + digest: string; + planDigest: string; + actionCount: number; + pendingRecordAndHeal: PendingRecordAndHeal | undefined; + sessionActionsLength: number; +}): string | undefined { + const { from, digest, planDigest, actionCount, pendingRecordAndHeal, sessionActionsLength } = + params; + const checks: ReplayResumeCheck[] = [ + () => describeOutOfRangeResumeFrom({ from, actionCount, pendingRecordAndHeal }), + () => describeUnperformedRecordAndHeal({ from, pendingRecordAndHeal, sessionActionsLength }), + () => describeStaleResumeDigest(digest, planDigest), + ]; + for (const check of checks) { + const message = check(); + if (message) return message; + } + return undefined; +} + +/** + * `actionCount + 1` (one past the plan's end) is a legal EMPTY-TAIL resume + * ONLY when it matches THIS session's own record-and-heal-shaped divergence + * watermark — never a blanket "one past the end is fine" for any session or + * repair kind. Absent a matching watermark, `actionCount + 1` is exactly as + * out-of-range as any other ordinal beyond the plan. + */ +function describeOutOfRangeResumeFrom(params: { + from: number; + actionCount: number; + pendingRecordAndHeal: PendingRecordAndHeal | undefined; +}): string | undefined { + const { from, actionCount, pendingRecordAndHeal } = params; + const isAuthorizedEmptyTail = + from === actionCount + 1 && + pendingRecordAndHeal !== undefined && + pendingRecordAndHeal.expectedFrom === from; + const inRange = + Number.isInteger(from) && from >= 1 && (from <= actionCount || isAuthorizedEmptyTail); + return inRange + ? undefined + : `replay --from ${from} is out of range for a ${actionCount}-step plan.`; +} + +/** + * A `from` matching a pending record-and-heal-shaped watermark — in-range + * (mid-plan, `record-and-heal` only) or the empty-tail boundary the range + * check above authorizes (`record-and-heal`, or per #1262 also + * `caution`/`manual`'s alternate repair, which is ONLY ever stamped at that + * boundary) — requires proof the agent actually performed the diverged step: + * the session's recorded action count must have grown since the divergence. + * Without that proof, this would silently resume past an unrepaired step + * instead of rejecting. `caution`/`manual`'s own `resume.from` stays at the + * failed step unchanged and is never subject to this check (it never + * matches `expectedFrom`, which only ever targets `failedIndex + 1`), so the + * message below is intentionally hint-neutral. + * + * #1271 stage 2 (ADR 0012 amendment): this same growth check is now also the + * repair-segment empty-heal guard. Observation-only actions + * (`snapshot`/`get`/`is`/`find`) are, by default, excluded from + * `session.actions` while repair-armed, so a repair segment containing ONLY + * unrecorded diagnostic reads never grows `sessionActionsLength` either — + * this check refuses it exactly as it already refused "no corrective press + * happened," converting the corrective-read case's one silent-failure mode + * (an excluded read silently missing from the heal) into this same loud + * rejection. The message therefore names `--record` alongside the existing + * `--no-record` mention, since the missing corrective action may have been a + * read rather than a press. + */ +function describeUnperformedRecordAndHeal(params: { + from: number; + pendingRecordAndHeal: PendingRecordAndHeal | undefined; + sessionActionsLength: number; +}): string | undefined { + const { from, pendingRecordAndHeal, sessionActionsLength } = params; + if ( + pendingRecordAndHeal?.expectedFrom !== from || + sessionActionsLength !== pendingRecordAndHeal.actionsCountAtDivergence + ) { + return undefined; + } + return ( + `replay --from ${from} continues a record-and-heal-shaped repair, but no corrective action was ` + + "recorded in this repair segment; press the correct control via a blessed @ref from the divergence's " + + 'screen.refs (recorded, no --no-record) — or, if your corrective action was a read ' + + '(get/is/find/snapshot), re-run it with --record so it lands in the heal — before resuming with ' + + `--from ${from}.` + ); +} + +function describeStaleResumeDigest(digest: string, planDigest: string): string | undefined { + if (digest === planDigest) return undefined; + return ( + 'replay --plan-digest does not match the current plan digest; the script, its includes, or its ' + + 'platform-conditioned expansion changed since the divergence report was generated. Run a fresh full ' + + 'replay to get a new digest.' + ); +} diff --git a/packages/ad-replay/src/internal/runtime-port-types.ts b/packages/ad-replay/src/internal/runtime-port-types.ts new file mode 100644 index 0000000000..edda39fcdc --- /dev/null +++ b/packages/ad-replay/src/internal/runtime-port-types.ts @@ -0,0 +1,374 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; +import type { buildReplayVarScope, LocalIdentity } from '@agent-device/ad-script'; +import type { ReplaySelectorPort } from './selector-port.ts'; +import type { + AdReplayGuardMismatchEvidence, + AdReplayLandmarkMismatchEvidence, + AdReplayTargetStructuralDenotation, +} from './target-verification.ts'; + +/** + * #1478 P5 stage C2b (split out of `step-loop.ts` by the #1555 structural- + * quality review, "split step-loop.ts per the maestro precedent it cites"): + * the boundary vocabulary between the engine and the daemon — + * `AdReplayStepRuntime` (the injected capability bag) and every plain-value + * type its signatures reference. Modeled on `packages/maestro`'s own + * `runtime-port-types.ts` (`MaestroRuntimeOperations` and its neutral + * vocabulary). Nothing here is `DaemonRequest`, `DaemonError`, + * `DaemonResponse`, or `SessionStore` — see `../index.ts`'s header for the + * full boundary rationale. + */ + +/** + * `${VAR}` scope inputs — plain data (builtins/file/shell/cli env) the + * daemon reads from the request/process and passes in; `runAdReplay` builds + * the scope from this. Derived structurally off `buildReplayVarScope` + * (`@agent-device/ad-script` does not export its own `ReplayVarSources` type + * by name) rather than duplicating the shape. + */ +export type AdReplayVarSources = Parameters[0]; + +/** + * A `${VAR}` value eligible for divergence-report redaction — the engine's + * own scrub list (`collectReplayScrubbableVarValues` over its live scope), + * threaded to the daemon's build-failure/`handleActionFailure` capabilities + * as an explicit argument rather than recomputed daemon-side from a second + * scope object. + */ +export type AdReplayScrubValue = Readonly<{ name: string; value: string }>; + +/** Neutral per-step failure: no `DaemonResponse`, no wire shape — just what the engine needs to report. */ +export type AdReplayStepFailure = Readonly<{ + /** The daemon's own error/divergence discriminant (e.g. a `DaemonError.code`), carried opaquely. */ + readonly kind: string; + readonly message: string; + readonly artifactPaths: readonly string[]; +}>; + +/** `verify-dispatch.ts`'s per-dispatch result: pass, or a neutral failure (never a wire response). */ +export type AdReplayStepOutcome = + | Readonly<{ readonly status: 'ok'; readonly artifactPaths: readonly string[] }> + | Readonly<{ readonly status: 'failed'; readonly failure: AdReplayStepFailure }>; + +/** + * A single progress step, structurally mirroring + * `@agent-device/replay-test`'s `ReplayTestAttemptStep` — deliberately not + * imported from that package (engine-to-engine imports are 0 by design). The + * daemon adapter's sink is structurally compatible, so no translation layer + * is needed at the call site. + */ +export type AdReplayProgressStep = Readonly<{ + readonly index: number; + readonly total: number; + readonly command?: string; + readonly value?: string; +}>; + +export type AdReplayProgressSink = (step: AdReplayProgressStep) => void; + +// --------------------------------------------------------------------------- +// Target-verification neutral types: the plain-value shapes that cross the +// engine/daemon boundary for the verify-then-dispatch flow. `SnapshotNode`, +// `LocalIdentity`, and `TargetAnnotationV1` are already shared/neutral types +// (kernel + ad-script + contracts) — never a `DaemonResponse` or a +// daemon-request-shaped value. +// --------------------------------------------------------------------------- + +/** + * The verified member's identity + structural denotation, threaded to + * dispatch as its own pre-action guard (so dispatch's independent resolution + * — occlusion/visibility guards this engine does not replicate — must land + * on the SAME element or refuse). + */ +export type AdReplayVerifiedTargetGuard = Readonly<{ + expected: Readonly<{ + identity: LocalIdentity; + structural: AdReplayTargetStructuralDenotation; + }>; + matchCount: number; +}>; + +/** `captureObservation`'s neutral result: nodes for classification, or why a capture was not available. */ +export type AdReplayObservation = Readonly< + | { readonly state: 'available'; readonly nodes: readonly SnapshotNode[] } + | { readonly state: 'unavailable'; readonly reason: string; readonly hint?: string } +>; + +/** + * `beginTargetVerification`'s per-command routing, only ever called when + * `action.targetEvidence` is present: no active session (skip entirely), the + * post-resolution (`wait`) phase (needs only whether this is a selector + * wait), or the ordinary pre-dispatch gate (needs the resolved-target token + * and the session's platform). + */ +export type AdReplayVerificationEntry = Readonly< + | { readonly kind: 'inactive' } + | { readonly kind: 'post-resolution'; readonly isSelectorWait: boolean } + | { + readonly kind: 'pre-dispatch'; + readonly token: string | undefined; + readonly platform: Platform | PublicPlatform; + } +>; + +/** `classifyTarget`'s result: a verified guard, or the divergence evidence a target-binding failure reports. */ +export type AdReplayTargetClassification = Readonly< + | { readonly verified: true; readonly guard: AdReplayVerifiedTargetGuard } + | Readonly<{ + readonly verified: false; + readonly kind: ReplayDivergenceTargetBindingKind; + readonly matchCount: number | undefined; + readonly observed: LocalIdentity | undefined; + readonly candidateNodes: readonly SnapshotNode[]; + readonly mismatches: readonly string[]; + readonly causeCode: string; + readonly causeMessage: string; + }> +>; + +/** The evidence bag `buildTargetBindingFailure`/`buildPostDispatchTargetBindingFailure` wrap into a wire divergence. */ +export type AdReplayTargetBindingEvidence = Readonly<{ + kind: ReplayDivergenceTargetBindingKind; + matchCount: number | undefined; + observed: LocalIdentity | undefined; + candidateNodes: readonly SnapshotNode[]; + mismatches: readonly string[]; + causeCode: string; + causeMessage: string; + causeHint?: string; +}>; + +/** The pre-action guard `dispatchStep` threads to the interaction layer's own resolution. */ +export type AdReplayDispatchGuard = Readonly< + | { readonly kind: 'target'; readonly guard: AdReplayVerifiedTargetGuard } + | { readonly kind: 'landmark'; readonly landmark: TargetAnnotationV1 } +>; + +/** + * `dispatchStep`'s result: ok, an ordinary failure, or one of the two + * post-resolution identity-refusal markers. The mismatch variants still + * carry a `plainFailure` — the ordinary neutral failure the dispatch itself + * produced — so the orchestrator can fall back to it unconverted on the + * "marker fired without recorded evidence" invariant-violation path, exactly + * like the daemon code this replaces. + * + * #1555 review P1 (second pass, "translate wire failures before the engine + * boundary"): each mismatch variant carries its OWN typed `evidence` — + * `AdReplayGuardMismatchEvidence`/`AdReplayLandmarkMismatchEvidence` — never + * a generic `details: Record` wire-response bag. The daemon + * adapter narrows the wire response into one of these two shapes before + * returning it here, so this outcome never carries an untyped value across + * the engine boundary. + */ +export type AdReplayDispatchOutcome = Readonly< + | { readonly status: 'ok'; readonly artifactPaths: readonly string[] } + | { readonly status: 'failed'; readonly failure: AdReplayStepFailure } + | { + readonly status: 'guard-mismatch'; + readonly evidence: AdReplayGuardMismatchEvidence; + readonly plainFailure: AdReplayStepFailure; + readonly artifactPaths: readonly string[]; + } + | { + readonly status: 'landmark-mismatch'; + readonly evidence: AdReplayLandmarkMismatchEvidence; + readonly plainFailure: AdReplayStepFailure; + readonly artifactPaths: readonly string[]; + } +>; + +/** + * The injected capability bag `runAdReplay` threads the step loop through — + * narrow execute/capture/observe/stamp daemon capabilities, modeled on what + * the loop actually consumes (`MaestroRuntimeOperations`, + * `packages/maestro/src/internal/runtime-port-types.ts`, is the precedent). + * Never `DaemonRequest`, `DaemonError`, `SessionStore`, or a reporter/event + * stream — and, as of the #1555 review pass, never a `DaemonResponse` + * either. + */ +export type AdReplayStepRuntime = Readonly<{ + /** + * The selector-port instance this request threads through classification + * and — as of this pass — the engine's own pre-dispatch verification plan + * (its recorded-selector parse-check). An engine-owned value (the façade + * names `ReplaySelectorPort`), never a daemon/wire shape. + */ + port: ReplaySelectorPort; + /** + * Routes one step's recorded target evidence to its verification phase — + * daemon authority (command-descriptor registry lookup, session read, + * wait-form parse, token extraction). Only ever called when + * `action.targetEvidence` is present. `resolvedAction` is `action` with + * every `${VAR}` already resolved (the engine's own, single resolution for + * this step) — used only to extract the resolved target token/wait form; + * `action` (the recorded original) is what routing decisions and any wire + * report still key on. + */ + beginTargetVerification( + action: SessionAction, + resolvedAction: SessionAction, + index: number, + ): AdReplayVerificationEntry; + /** + * Captures a fresh snapshot for classification or for a divergence's + * `screen` — daemon authority (`SessionStore`, the capture pipeline, the + * #1385 launch-race retry). + */ + captureObservation( + action: SessionAction, + index: number, + options: { retryLaunchRace: boolean }, + ): Promise; + /** + * Resolves the recorded target against `nodes` using the SAME + * lookup/matching a real dispatch would — daemon authority (tree helpers, + * the selector port). + */ + classifyTarget(params: { + action: SessionAction; + index: number; + token: string; + nodes: readonly SnapshotNode[]; + }): AdReplayTargetClassification; + /** + * Dispatches the action, optionally carrying a pre-action identity guard, + * and detects the guard-mismatch / wait-landmark-mismatch post-resolution + * refusal markers on failure — daemon authority (the single `invoke` + * dispatch site). `resolvedAction` (see `beginTargetVerification`) is what + * actually gets sent; `action` is threaded alongside it only for + * daemon-owned, non-interpolation decisions (e.g. a recorded-input + * variable heuristic read off the ORIGINAL fill text). + */ + dispatchStep( + action: SessionAction, + resolvedAction: SessionAction, + index: number, + artifactPaths: readonly string[], + guard: AdReplayDispatchGuard | undefined, + ): Promise; + /** + * Builds the "recorded target evidence itself unverifiable" divergence — + * its own fresh capture — daemon authority (capture, `SessionStore`, + * resume stamping, wire shaping). `artifactPaths` is the pre-step + * snapshot (mirrors `dispatchStep`'s own, never artifacts a just-failed + * dispatch produced — verification never reaches dispatch on this path). + * `scrubVars` is the engine's own live `${VAR}` scrub list, as of this + * point in the run. + */ + buildRecordedUnverifiableFailure( + action: SessionAction, + index: number, + artifactPaths: readonly string[], + scrubVars: readonly AdReplayScrubValue[], + ): Promise; + /** + * Builds a target-binding divergence from `evidence`, reusing the LAST + * `captureObservation` result for its `screen` (the pre-dispatch capture + * and classification/capture-failure evidence share one capture) — + * daemon authority. `artifactPaths` is the pre-step snapshot, as above; + * `scrubVars` as above. + */ + buildTargetBindingFailure( + action: SessionAction, + index: number, + evidence: AdReplayTargetBindingEvidence, + artifactPaths: readonly string[], + scrubVars: readonly AdReplayScrubValue[], + ): Promise; + /** + * Builds a target-binding divergence from `evidence` after a FRESH + * post-dispatch capture (the screen may have changed since dispatch) — + * daemon authority. `artifactPaths` is the PRE-STEP snapshot passed to + * `dispatchStep`, not the just-failed dispatch's own artifacts — mirrors + * the pre-#1555-R3 daemon orchestrator exactly (a target-binding + * divergence's wire `artifactPaths` never included the triggering + * dispatch's own); `scrubVars` as above. + */ + buildPostDispatchTargetBindingFailure( + action: SessionAction, + index: number, + evidence: AdReplayTargetBindingEvidence, + artifactPaths: readonly string[], + scrubVars: readonly AdReplayScrubValue[], + ): Promise; + /** + * Wraps a failed step with replay failure diagnostics and repair-held + * marking — daemon authority (capture, `SessionStore`, the P4b + * coordinator) — and returns the neutral failure the run outcome reports. + * `scrubVars` as above. + */ + handleActionFailure(params: { + action: SessionAction; + index: number; + artifactPaths: readonly string[]; + snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; + scrubVars: readonly AdReplayScrubValue[]; + }): Promise; + /** Arms the save-script transaction for this step; a no-op absent `--save-script`. Repair authority. */ + armStep(): void; + /** Whether the request's session currently carries an armed repair boundary. Repair authority. */ + isRepairArmed(): boolean; + /** The recorded selector's display value for progress reporting — needs the private selector AST, daemon-only. */ + describeStepValue(action: SessionAction): string | undefined; + /** Optional per-attempt progress sink. */ + onStep?: AdReplayProgressSink; + /** The current snapshot-diagnostics sample count, as a resumable marker. */ + diagnosticsMarker(): number; + /** Snapshot-diagnostics samples recorded since `marker`. */ + diagnosticsSince(marker: number): SnapshotTimingSample[]; +}>; + +export type AdReplayRunRequest = Readonly<{ + readonly actions: readonly SessionAction[]; + /** 0-based loop entry index — already resolved from `--from`/`--plan-digest` daemon-side. */ + readonly entryIndex: number; + /** + * #1554: `replay --keep-session` — suppress exactly the plan's terminal + * close among executable actions (see `resolveSuppressedTerminalCloseIndex`, + * `step-loop.ts`) so the session survives completion instead of tearing + * down. Unifies with the pre-existing repair-armed terminal-close + * suppression: both modes share the SAME structural "terminal among + * executable actions" resolution, one OR'd into the single suppression + * check `runAdReplay` makes. + */ + readonly keepSession: boolean; + /** + * Per-action source line, parallel to `actions` — `inspectAdReplay`'s own + * manifest field, threaded back in here since `runAdReplay` is a separate + * call from the manifest inspection that produced it. Used only for + * `${VAR}` interpolation-error location diagnostics (`resolveReplayAction`'s + * `loc`). + */ + readonly actionLines: readonly number[]; + /** Per-action resolved source path when it differs from `resolvedPath` (a `runFlow` include's own file), parallel to `actions`. */ + readonly actionSourcePaths: readonly (string | undefined)[] | undefined; + /** The resolved `.ad` file path — the interpolation-location fallback when an action's own `actionSourcePaths` entry is absent. */ + readonly resolvedPath: string; + /** + * `${VAR}` scope inputs — plain data the daemon reads from the request/ + * process (builtins, file/shell/cli env). `runAdReplay` builds the scope + * from this and performs every `${VAR}` resolution itself (#1555 review + * P1, "move variable semantics/planning behind the replay entrypoint") — + * the daemon never resolves an action or builds a scope of its own. + */ + readonly varSources: AdReplayVarSources; +}>; + +/** Neutral run-level outcome: `runAdReplay` never returns or holds a `DaemonResponse`. */ +export type AdReplayRunOutcome = + | Readonly<{ + readonly status: 'completed'; + readonly replayed: number; + readonly artifactPaths: readonly string[]; + readonly snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; + }> + | Readonly<{ + readonly status: 'failed'; + readonly stepIndex: number; + readonly failure: AdReplayStepFailure; + }>; diff --git a/packages/ad-replay/src/internal/selector-port.ts b/packages/ad-replay/src/internal/selector-port.ts new file mode 100644 index 0000000000..c39af4be23 --- /dev/null +++ b/packages/ad-replay/src/internal/selector-port.ts @@ -0,0 +1,160 @@ +/** + * #1478 P5 stage B: the `ReplaySelectorPort` — the replay-oriented internal + * selector capability approved by the P5 amendment (issue comment + * 5156017698). Exactly three operations, none of which ever trades in + * `Selector`/`SelectorChain`/`SelectorTerm`: those AST types stay private to + * the root `src/selectors` implementation. This port's signatures traffic + * only in strings, kernel snapshot/device types, and the tagged result unions + * below, so `packages/ad-replay` never needs the selector grammar hoisted + * into it (the amendment's explicit rejection of a "seven-function mirror"). + * + * Two adapters implement this port: + * - the production adapter (`src/daemon/replay-selector-port.ts`), which + * delegates to `src/selectors` and composes parse/resolve/list-matches/ + * match exactly as `session-replay-target-classification.ts` does today; + * - a deterministic in-memory adapter + * (`src/__tests__/test-utils/in-memory-replay-selector-port.ts` — root, + * not package-internal: R11 forbids a workspace package from reaching + * back into root `src/`, so once this adapter's only consumer turned out + * to be a root test, it moved alongside its caller) for the package's own + * contract suite (`src/daemon/__tests__/replay-selector-port-contract.test.ts`). + * + * Stage B built the port and both adapters. Handlers now reach it through + * `@agent-device/ad-replay`'s exported `ReplaySelectorPort` type. + */ + +import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { DisambiguationTiebreak } from '@agent-device/contracts/interaction'; + +// --------------------------------------------------------------------------- +// Operation 1: readSelectorExpression — the shared `is`/`wait`/ordinary +// selector-argument grammar. Mirrors today's `splitIsSelectorArgs` (the +// `is`-command call site this stage abstracts, +// `session-replay-target-token.ts`) and the shared `splitSelectorFromArgs` +// primitive `wait`/ordinary target grammar already uses elsewhere in the +// codebase (`src/core/wait-positionals.ts`, `src/core/interaction-positionals.ts`). +// --------------------------------------------------------------------------- + +/** Which positional grammar a command's selector-bearing arguments follow. */ +export type ReplaySelectorGrammar = + /** `is [expected]` — predicate-first or selector-first. */ + | 'is' + /** `wait [timeoutMs]` — caller has already stripped a trailing timeout token. */ + | 'wait' + /** Plain positional selector arguments (`click`/`fill`/`get`-shaped). */ + | 'ordinary'; + +export type ReplaySelectorExpressionOutcome = + /** A selector-shaped expression was extracted and parses. */ + | { + readonly kind: 'expression'; + readonly expression: string; + /** Trailing tokens after the selector (e.g. `is text`'s expected value). */ + readonly rest: readonly string[]; + } + /** + * A selector-shaped prefix was found but it does not parse — callers must + * check this BEFORE ever calling `resolveRecordedTarget`, which assumes a + * well-formed expression (see `selector-port-contract.test.ts` cell 1). + */ + | { readonly kind: 'invalid' } + /** This grammar found no selector-shaped token at all (not an error). */ + | { readonly kind: 'not-applicable' }; + +// --------------------------------------------------------------------------- +// Operation 2: resolveRecordedTarget — selector string + snapshot nodes + +// platform + resolution policy in; tagged winner/domain out. Internally +// composes parse (`tryParseSelectorChain`) / resolve (`resolveSelectorChain`) +// / list-matches (`listSelectorChainMatches`) / match (`matchesSelector`) +// exactly as `session-replay-target-classification.ts`'s +// `resolveSelectorTargetMatches` does today, protecting the "same selector +// alternative" invariant between the winner and the matched-node domain. +// --------------------------------------------------------------------------- + +export type ReplayRecordedTargetPolicy = Readonly<{ + readonly platform: Platform | PublicPlatform; + /** Excludes an otherwise-matching node with no usable rect (cell 4). */ + readonly requireRect: boolean; + /** Lets the deepest/smallest-then-visible heuristic pick among ties (cell 3). */ + readonly allowDisambiguation: boolean; +}>; + +/** Present only when the heuristic picked among N>1 matches for the winning alternative. */ +export type ReplayRecordedTargetDisambiguation = Readonly<{ + readonly tiebreak: DisambiguationTiebreak; + readonly matchCount: number; + /** Every losing matched node from the SAME alternative, document order. */ + readonly alternatives: readonly SnapshotNode[]; +}>; + +export type ReplayRecordedTargetResolved = Readonly<{ + readonly kind: 'resolved'; + readonly winner: SnapshotNode; + /** + * The matched-node domain from the SAME chain alternative the winner was + * resolved through — never a different, earlier-tried alternative (the + * amendment's "same domain as dispatch" invariant; see + * `session-replay-target-classification-port.test.ts` cell 5). + */ + readonly matchedNodes: readonly SnapshotNode[]; + readonly matchCount: number; + readonly disambiguation?: ReplayRecordedTargetDisambiguation; +}>; + +export type ReplayRecordedTargetUnresolved = Readonly<{ + readonly kind: 'unresolved'; + /** + * `parse-invalid`: the expression does not parse at all (no matched-node + * domain exists). `no-match`: it parses, but no alternative matches + * anything. `ambiguous`: it parses and at least one alternative has + * matches, but resolution could not pick a unique/disambiguated winner. + */ + readonly reason: 'parse-invalid' | 'no-match' | 'ambiguous'; + /** Best-available diagnostic domain; always empty for `parse-invalid`. */ + readonly matchedNodes: readonly SnapshotNode[]; +}>; + +export type ReplayRecordedTargetResolution = + | ReplayRecordedTargetResolved + | ReplayRecordedTargetUnresolved; + +// --------------------------------------------------------------------------- +// Operation 3: buildSelectorCandidates — replay repair/divergence suggestions +// via the existing root selector-chain builder (`buildSelectorChainForNode`). +// Mirrors its signature: a resolved node in, a priority-ordered list of +// candidate selector-expression strings out (id, then role+label, then +// label, then value, then text — see +// `session-replay-divergence-suggestion-port.test.ts` cell 8). +// --------------------------------------------------------------------------- + +export type ReplaySelectorCandidateAction = 'click' | 'fill' | 'get'; + +export type ReplaySelectorCandidateOptions = Readonly<{ + readonly action?: ReplaySelectorCandidateAction; + /** The record-time tree `node` came from, for #1269 non-unique-id demotion. */ + readonly nodes?: readonly SnapshotNode[]; +}>; + +// --------------------------------------------------------------------------- +// The port +// --------------------------------------------------------------------------- + +export type ReplaySelectorPort = Readonly<{ + readSelectorExpression( + grammar: ReplaySelectorGrammar, + positionals: readonly string[], + ): ReplaySelectorExpressionOutcome; + + resolveRecordedTarget( + expression: string, + nodes: SnapshotNode[], + policy: ReplayRecordedTargetPolicy, + ): ReplayRecordedTargetResolution; + + buildSelectorCandidates( + node: SnapshotNode, + platform: Platform | PublicPlatform, + options?: ReplaySelectorCandidateOptions, + ): readonly string[]; +}>; diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts new file mode 100644 index 0000000000..3fbd57182e --- /dev/null +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -0,0 +1,237 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import { + buildReplayVarScope, + collectReplayScrubbableVarValues, + resolveReplayAction, +} from '@agent-device/ad-script'; +import { verifyAndDispatchStep } from './verify-dispatch.ts'; +import type { + AdReplayProgressStep, + AdReplayRunOutcome, + AdReplayRunRequest, + AdReplayStepRuntime, +} from './runtime-port-types.ts'; + +/** + * #1478 P5 stage C2b: the `.ad` step-loop ENGINE policy, split out of + * `session-replay-runtime.ts`'s `executeReplayActions` / + * `resolveReplayStepResponse` / `buildReplayActionFailure`. Everything that + * touches a real device, a snapshot, `SessionStore`, or the P4b repair + * coordinator is daemon authority and stays behind the narrow + * `AdReplayStepRuntime` capabilities (`./runtime-port-types.ts`) — this + * module only decides which action to run next, when to skip one, and when + * to stop. + * + * #1555 structural-quality review ("split step-loop.ts per the maestro + * precedent it cites"): this file used to also hold the full boundary + * vocabulary and the verify-then-dispatch orchestrator — both extracted out, + * following `packages/maestro`'s own three-way split + * (`runtime-port-types.ts` for the vocabulary, its engine files for the + * orchestration logic). `./runtime-port-types.ts` now owns every + * `AdReplayStepRuntime`-adjacent type; `./verify-dispatch.ts` owns + * `verifyAndDispatchStep` and its two dispatch helpers. This file is left + * with exactly the loop (`runAdReplay`) and the terminal-close/executable- + * action structural logic it drives. + * + * #1554 fold-in (rebase onto main's `replay --keep-session`): main grew a + * terminal-close-suppression decision independently, daemon-side, as + * `session-replay-terminal-lifecycle.ts`'s `resolveSuppressedTerminalCloseIndex` + * / `countExecutedReplayActions`, generalizing the repair-only physical-last- + * index check this module already had (`isRepairArmedTerminalCloseAction`) to + * "terminal among EXECUTABLE actions" and adding `--keep-session` as a second + * reason to suppress. Per the same "pure policy belongs in the engine" + * boundary this whole module exists to enforce, that generalized resolution + * — `resolveSuppressedTerminalCloseIndex` below — lives here instead, + * unified with (replacing) the old repair-only predicate, and `runAdReplay` + * folds the resulting `replayed` count in directly rather than a separate + * daemon-side post-hoc counter. `requireLiveSessionForKeepSession` — the + * `--keep-session` postcondition that inspects `SessionStore` — stays daemon + * authority and never moved here. + * + * #1555 review P1 (second pass, "move variable semantics/planning behind the + * replay entrypoint"): `runAdReplay` builds the `${VAR}` scope, via + * `@agent-device/ad-script`'s `buildReplayVarScope`, from the request's + * `varSources` — plain data (builtins/file/shell/cli env) the daemon reads + * from the request/process — and resolves each action EXACTLY ONCE per step + * (`resolveReplayAction`), before `verifyAndDispatchStep` does anything else + * with it. The RESOLVED action is what reaches `dispatchStep` and + * `beginTargetVerification`; every other capability still receives the + * ORIGINAL recorded `action` (a target-binding divergence reports the + * recorded selector, never an expanded `${VAR}`). This replaces two + * independent daemon-side interpolation call sites — dispatch's own + * (`session-replay-action-runtime.ts`'s `invokeReplayAction`) and target + * verification's separate one (`session-replay-target-verification.ts`'s + * `resolveTargetVerificationEntry`) — with this one engine-owned resolution. + * The engine's own live scope is also the one source for the `${VAR}` values + * a divergence report may redact (`collectReplayScrubbableVarValues`), + * computed ONCE per run and threaded to each build-failure/`handleActionFailure` + * capability as an explicit `scrubVars` argument rather than recomputed per + * call site — the daemon never holds a `ReplayVarScope` value at all. + */ + +/** + * ADR 0012 step 4's step loop: for every executable action from + * `request.entryIndex` on, arm the save-script transaction, skip a + * repair-armed or `--keep-session` plan's terminal `close` (lifecycle, not a + * script step), report progress, verify-then-dispatch through + * `verifyAndDispatchStep`, and stop at the first failure. Moved verbatim from + * `executeReplayActions`'s composition order — only the daemon capabilities + * it calls through were narrowed into `runtime`. + * + * #1554 fold-in: `terminalCloseIndex` is resolved ONCE, structurally, from + * `actions` alone (independent of which mode wants it suppressed) via + * `resolveSuppressedTerminalCloseIndex`. Whether it actually gets suppressed + * THIS run is decided per-step, at the point the loop reaches it: `keepSession` + * is a static per-run flag, but repair-armed is checked through + * `runtime.isRepairArmed()` right after `runtime.armStep()` — deliberately + * dynamic, because a bare `--save-script` first arms the transaction on this + * very call (`armStep()` mutates the session), so re-reading it here (rather + * than snapshotting it before the loop) is what lets a first-arm run and a + * continuing `--from` leg share one check. The suppressed index is excluded + * from `replayed` exactly like a skipped `replay` pseudo-action — never + * dispatched, never divergence-checked, never counted. + * + * `scrubVars` (the `${VAR}` values a divergence report may redact) is + * computed ONCE PER STEP — right after `resolveReplayAction` (the one call + * that can grow the scope's expanded-builtins set THIS step) — and threaded + * down to `verifyAndDispatchStep`/`handleActionFailure` as an explicit + * argument, never recomputed per call inside the verify/dispatch chain + * (`collectReplayScrubbableVarValues` is otherwise pure over `scope`, so + * every one of those call sites would recompute the identical value). + */ +export async function runAdReplay( + request: AdReplayRunRequest, + runtime: AdReplayStepRuntime, +): Promise { + const { actions, entryIndex, keepSession } = request; + // The one `${VAR}` scope this run builds — see the module header. Mutated + // in place as each step resolves (tracks which builtins actually expanded, + // for `collectReplayScrubbableVarValues`), never rebuilt mid-run. + const scope = buildReplayVarScope(request.varSources); + const artifactPaths = new Set(); + const snapshotDiagnosticSamples: SnapshotTimingSample[] = []; + const terminalCloseIndex = resolveSuppressedTerminalCloseIndex(actions); + let replayed = 0; + for (let index = entryIndex; index < actions.length; index += 1) { + const action = actions[index]; + if (!isExecutableReplayAction(action)) continue; + // Arm before checking terminal close so `[open, close]` records the + // session created by `open` before treating `close` as lifecycle. + runtime.armStep(); + if (index === terminalCloseIndex && (keepSession || runtime.isRepairArmed())) { + continue; + } + replayed += 1; + // `onStep?.(x)` short-circuits evaluating `x` when `onStep` is absent + // (the ordinary `replay` command has no sink) — an explicit guard + // preserves that: `describeStepValue` must not run needlessly. + if (runtime.onStep) { + const value = runtime.describeStepValue(action); + runtime.onStep(buildAdReplayProgressStep(index, actions.length, action, value)); + } + // The engine's one resolution of this step's action — see the module + // header. Every capability below that needs an interpolated value + // receives THIS value; every other capability still receives `action`. + const resolvedAction = resolveReplayAction(action, scope, resolveActionLoc(request, index)); + // This step's one scrub-value computation — see the module header. + // `resolvedAction` above is the only thing that can have just grown + // `scope`'s expanded-builtins set, so this is computed right after it. + const scrubVars = collectReplayScrubbableVarValues(scope); + const sampleStart = runtime.diagnosticsMarker(); + const stepOutcome = await verifyAndDispatchStep( + runtime, + scrubVars, + action, + resolvedAction, + index, + [...artifactPaths], + ); + snapshotDiagnosticSamples.push(...runtime.diagnosticsSince(sampleStart)); + if (stepOutcome.status === 'ok') { + stepOutcome.artifactPaths.forEach((entry) => artifactPaths.add(entry)); + continue; + } + stepOutcome.failure.artifactPaths.forEach((entry) => artifactPaths.add(entry)); + const failure = await runtime.handleActionFailure({ + action, + index, + artifactPaths: [...artifactPaths], + snapshotDiagnosticSamples, + scrubVars, + }); + return { status: 'failed', stepIndex: index, failure }; + } + return { + status: 'completed', + replayed, + artifactPaths: [...artifactPaths], + snapshotDiagnosticSamples, + }; +} + +/** `resolveReplayAction`'s `loc` for one step — `actionSourcePaths[index]` when the step came from a `runFlow` include, else the top-level plan's own resolved path. */ +function resolveActionLoc( + request: AdReplayRunRequest, + index: number, +): { file: string; line: number } { + return { + file: request.actionSourcePaths?.[index] ?? request.resolvedPath, + line: request.actionLines[index] ?? 1, + }; +} + +/** + * ADR 0012 decision 6 (Fix 3): a nested `replay` line in an `.ad` file is + * lifecycle-skipped, never dispatched or expanded (native `.ad` has no + * include grammar). + */ +export function isExecutableReplayAction( + action: SessionAction | undefined, +): action is SessionAction { + return Boolean(action && action.command !== 'replay'); +} + +/** + * ADR 0012 decision 6 (Fix 3) + #1554: resolves the ONE native replay + * lifecycle seam a plan can have — its terminal `close` AMONG EXECUTABLE + * actions, because a trailing `replay "./nested.ad"` line is plan metadata + * (`isExecutableReplayAction` already skips it) and never dispatches, so the + * true terminal step can sit before the array's physical last index. Callers + * (`runAdReplay`) still decide WHETHER this seam is actually suppressed this + * run — repair-armed or `--keep-session` — this function only says WHERE it + * is, structurally, independent of either mode. + * + * Both suppression reasons share this one resolution because they are the + * same decision family: replaying the recorded `close` here would dispatch it + * as an ordinary step — tearing the session down (and, for repair, absent Fix + * 1/2, even publishing or diverging) before the agent/caller gets the chance + * `close --save-script` (repair) or continued interactive use (`--keep-session`) + * depends on. The suppressed close is therefore neither divergence-checked + * nor included in the successful `replayed` count, exactly like the `replay` + * pseudo-command just above it in the loop. + */ +export function resolveSuppressedTerminalCloseIndex( + actions: readonly SessionAction[], +): number | undefined { + for (let index = actions.length - 1; index >= 0; index -= 1) { + const action = actions[index]; + if (!isExecutableReplayAction(action)) continue; + return action.command === 'close' ? index : undefined; + } + return undefined; +} + +function buildAdReplayProgressStep( + actionIndex: number, + actionTotal: number, + action: SessionAction, + value: string | undefined, +): AdReplayProgressStep { + return { + index: actionIndex + 1, + total: actionTotal, + command: action.command, + ...(value !== undefined ? { value } : {}), + }; +} diff --git a/packages/ad-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts new file mode 100644 index 0000000000..bb19764fe5 --- /dev/null +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -0,0 +1,240 @@ +/** + * #1478 P5 stage C2a: the target-verification ENGINE policy — moved verbatim + * out of `src/daemon/handlers/session-replay-target-verification.ts`, which + * keeps the DAEMON-AUTHORITY half (capture, `SessionStore`, resume stamping, + * wire projection into `DaemonResponse`). This module decides, over already- + * available plain values, whether/how a recorded target-binding annotation + * should be verified — never itself touching a snapshot capture, a session, + * or a wire response. + * + * #1555 review R3 ("target verification must happen INSIDE the engine"): the + * four functions below are called ONLY from `./step-loop.ts`'s + * `verifyAndDispatchStep` — the engine's own step loop, never the daemon — + * and are NOT re-exported by the package façade (`../index.ts`). The daemon + * (`session-replay-target-verification.ts`) now implements only the narrow + * `AdReplayStepRuntime` capabilities `verifyAndDispatchStep` drives (routing, + * capture, classification, dispatch, wire-building); it never imports this + * module. + * + * Two pure decisions live here: + * + * - `planPostResolutionTargetVerification` / `planPreDispatchTargetVerification`: + * should the step loop even attempt verification, and with what token — + * mirrors the two branches of the pre-#1555-R3 daemon orchestrator's + * original pre-capture gating exactly (#1349's deferred-landmark `wait` + * case, and the ordinary pre-dispatch token/parse gate). + * - `deriveReplayTargetGuardMismatchEvidence` / `deriveWaitLandmarkMismatchEvidence`: + * given the recorded evidence and a post-dispatch refusal's TYPED evidence + * (`AdReplayGuardMismatchEvidence`/`AdReplayLandmarkMismatchEvidence`), + * compute the observed identity and mismatch lines a target-binding + * divergence reports — the daemon then wraps the result into a + * `DaemonResponse`. + * + * #1555 review P1 (second pass, "translate wire failures before the engine + * boundary"): the two derive functions used to take the wire response's raw + * `details: Record | undefined` bag directly — a daemon + * wire shape crossing into the engine despite carrying no `DaemonResponse` + * itself. The daemon adapter (`session-replay-runtime-engine-adapter.ts`) + * now narrows that bag into the typed `AdReplayGuardMismatchEvidence`/ + * `AdReplayLandmarkMismatchEvidence` shapes below BEFORE constructing the + * `AdReplayDispatchOutcome` the engine sees — the `unknown`-parsing readers + * that used to live here (`readGuardMismatchObservedIdentity`, + * `readAncestryEntries`, an anonymous structural-denotation reader) moved + * there with it, since reading an untyped wire bag is wire-projection work, + * not engine policy. This module now only reads already-typed values. + */ + +import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import { + firstAncestryMismatch, + identityFieldMismatches, + type LocalIdentity, +} from '@agent-device/ad-script'; +import type { ReplaySelectorPort } from './selector-port.ts'; + +/** + * The verified/observed member's structural position within its capture + * (document order + sibling) — moved here (from `./step-loop.ts`, which + * still uses it for `AdReplayVerifiedTargetGuard`) so both this module's + * typed evidence shapes and the step loop can reference ONE definition + * without a cycle: this module has no dependency on `./step-loop.ts`, but + * `./step-loop.ts` already depends on this one. + */ +export type AdReplayTargetStructuralDenotation = Readonly<{ + documentOrder: number; + sibling: number; +}>; + +/** + * The guard-mismatch refusal's typed evidence, already narrowed by the + * daemon adapter from the wire response's `details` bag — the engine never + * sees the untyped bag itself. + */ +export type AdReplayGuardMismatchEvidence = Readonly<{ + observed: LocalIdentity | undefined; + expectedStructural: AdReplayTargetStructuralDenotation | undefined; + observedStructural: AdReplayTargetStructuralDenotation | undefined; +}>; + +/** The wait-landmark-mismatch refusal's typed evidence — same translate-before-crossing rule. */ +export type AdReplayLandmarkMismatchEvidence = Readonly<{ + matchCount: number | undefined; + observed: LocalIdentity | undefined; + observedAncestry: readonly TargetAncestryEntry[]; +}>; + +// --------------------------------------------------------------------------- +// Pre-capture verification gating (`verifyReplayActionTarget`'s two branches). +// --------------------------------------------------------------------------- + +export type ReplayPostResolutionVerificationPlan = + | { kind: 'skip' } + | { kind: 'recorded-unverifiable' } + | { kind: 'deferred-landmark'; landmark: TargetAnnotationV1 }; + +/** + * #1349 post-resolution phase (`wait`): only a selector wait names a + * landmark — an annotation on any other wait form is inert, like an old + * reader. A verifiable landmark defers into the wait's own polling loop + * rather than refusing on the current screen (an absent landmark is a wait's + * expected starting condition). + */ +export function planPostResolutionTargetVerification(params: { + recorded: TargetAnnotationV1; + isSelectorWait: boolean; +}): ReplayPostResolutionVerificationPlan { + const { recorded, isSelectorWait } = params; + if (!isSelectorWait) return { kind: 'skip' }; + if (recorded.verification === 'unverifiable') return { kind: 'recorded-unverifiable' }; + return { kind: 'deferred-landmark', landmark: recorded }; +} + +export type ReplayPreDispatchVerificationPlan = + | { kind: 'skip' } + | { kind: 'recorded-unverifiable' } + | { kind: 'verify'; token: string }; + +/** + * The ordinary pre-dispatch gate: no recorded token means nothing to verify; + * a malformed recorded selector is not this module's concern (the real + * dispatch parses, and fails, it the same way an unannotated action would); + * only past that does a recorded-`unverifiable` annotation refuse pre-action. + * + * #1555 structural-quality review ("fix the engine's parse gate to honor its + * own port contract"): the parse check used to call `resolveRecordedTarget` + * over an EMPTY tree purely to read its `parse-invalid` reason — a resolve + * call standing in for a parse call, and the one call site in this package + * that never used `readSelectorExpression` (operation 1 of the port's own + * three-operation contract) despite existing to answer exactly this + * question. `readSelectorExpression('ordinary', [token])` is the real parse + * check now. + * + * The outcome mapping is NOT `'invalid' -> skip` on the production adapter: + * `readSelectorExpression`'s `'ordinary'`/`'wait'` grammars + * (`splitSelectorFromArgs`) only ever record a prefix boundary once it has + * already parsed, so a single already-whole token that fails to parse can + * only come back `'not-applicable'` (no selector-shaped boundary was ever + * found) — production's `'invalid'` case is structurally unreachable from + * this call site (see `selector-port-contract.test.ts`'s "ordinary bare + * token: production vs. in-memory 'invalid' reachability" cell, which pins + * this precisely and documents where the two adapters legitimately diverge). + * Both non-`'expression'` outcomes are treated identically here — the + * historical behavior this replaces made no distinction either (a single + * `parse-invalid` reason covered both "not selector-shaped at all" and + * "selector-shaped but malformed"). + */ +export function planPreDispatchTargetVerification(params: { + recorded: TargetAnnotationV1; + token: string | undefined; + port: ReplaySelectorPort; +}): ReplayPreDispatchVerificationPlan { + const { recorded, token, port } = params; + if (token === undefined) return { kind: 'skip' }; + if (!token.startsWith('@')) { + const parseCheck = port.readSelectorExpression('ordinary', [token]); + if (parseCheck.kind !== 'expression') { + return { kind: 'skip' }; + } + } + if (recorded.verification === 'unverifiable') return { kind: 'recorded-unverifiable' }; + return { kind: 'verify', token }; +} + +// --------------------------------------------------------------------------- +// Post-dispatch identity-mismatch evidence (the guard mismatch and the wait +// landmark mismatch): both refusal markers arrive as a failed dispatch whose +// TYPED evidence (`AdReplayGuardMismatchEvidence`/`AdReplayLandmarkMismatchEvidence`, +// already narrowed by the daemon adapter from the wire response) carries the +// observed evidence; this derives the SAME bounded identity-mismatch shape +// around it the daemon used to compute inline. +// --------------------------------------------------------------------------- + +export type ReplayPostDispatchMismatchEvidence = { + matchCount: number | undefined; + observed: LocalIdentity | undefined; + mismatches: string[]; + causeMessage: string; +}; + +/** A `position:` mismatch line from the guard's structural denotations, when both are present and differ. */ +export function describeStructuralMismatch( + expected: AdReplayTargetStructuralDenotation | undefined, + observed: AdReplayTargetStructuralDenotation | undefined, +): string | undefined { + if (!expected || !observed) return undefined; + if (expected.documentOrder === observed.documentOrder && expected.sibling === observed.sibling) { + return undefined; + } + return `position: recorded=doc${expected.documentOrder}/sibling${expected.sibling} observed=doc${observed.documentOrder}/sibling${observed.sibling}`; +} + +/** + * Dispatch resolution (with occlusion/visibility guards) resolved a + * different element than pre-action verification isolated. `matchCount` is + * the caller's already-known verified-member match count (verification's + * own recorded-selector match count) — never re-derived from `evidence`. + */ +export function deriveReplayTargetGuardMismatchEvidence( + recorded: TargetAnnotationV1, + evidence: AdReplayGuardMismatchEvidence, + matchCount: number, +): ReplayPostDispatchMismatchEvidence { + const { observed, expectedStructural, observedStructural } = evidence; + // The guard fires even when local identity is identical (a same-identity + // duplicate resolved by structural position) — surface the structural + // difference so `mismatches` is never empty on a real divergence. + const structuralMismatch = describeStructuralMismatch(expectedStructural, observedStructural); + return { + matchCount, + observed, + mismatches: [ + ...(observed ? identityFieldMismatches(recorded, observed) : []), + ...(structuralMismatch ? [structuralMismatch] : []), + ], + causeMessage: + 'Dispatch resolution (with occlusion/visibility guards) resolved a different element than pre-action verification isolated; the action was not sent.', + }; +} + +/** + * Candidates matched the recorded wait selector during polling, but none + * carried the recorded landmark identity before the timeout. + */ +export function deriveWaitLandmarkMismatchEvidence( + recorded: TargetAnnotationV1, + evidence: AdReplayLandmarkMismatchEvidence, +): ReplayPostDispatchMismatchEvidence { + const { matchCount, observed, observedAncestry } = evidence; + return { + matchCount, + observed, + mismatches: observed + ? [ + ...identityFieldMismatches(recorded, observed), + ...firstAncestryMismatch(recorded.ancestry, observedAncestry), + ] + : [], + causeMessage: + 'Candidates matched the recorded wait selector during polling, but none carried the recorded landmark identity before the timeout; the wait did not report success.', + }; +} diff --git a/packages/ad-replay/src/internal/verify-dispatch.ts b/packages/ad-replay/src/internal/verify-dispatch.ts new file mode 100644 index 0000000000..36e6c36d80 --- /dev/null +++ b/packages/ad-replay/src/internal/verify-dispatch.ts @@ -0,0 +1,245 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import { + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, +} from './target-verification.ts'; +import type { + AdReplayDispatchGuard, + AdReplayScrubValue, + AdReplayStepOutcome, + AdReplayStepRuntime, +} from './runtime-port-types.ts'; + +/** + * #1478 P5 stage C2b (split out of `step-loop.ts` by the #1555 structural- + * quality review, "split step-loop.ts per the maestro precedent it cites"): + * `verifyAndDispatchStep` — the verify-then-dispatch orchestrator that used + * to live daemon-side (`session-replay-target-verification.ts`'s + * `verifyReplayActionTarget` / `convertIdentityRefusalResponse`) calling OUT + * to `./target-verification.ts`'s four policy functions. The call sites for + * those four functions live here — engine-private, never re-exported by the + * façade — and the daemon side is the narrow `AdReplayStepRuntime` + * capabilities this function drives: routing (`beginTargetVerification`), + * capture (`captureObservation`), classification (`classifyTarget`), + * dispatch (`dispatchStep`), and wire-building the resulting divergence + * (`buildRecordedUnverifiableFailure`, `buildTargetBindingFailure`, + * `buildPostDispatchTargetBindingFailure`). `./step-loop.ts`'s `runAdReplay` + * is this module's one caller. + * + * #1555 structural-quality review ("scrub values — one name, compute + * collectReplayScrubbableVarValues(scope) once, thread the value, delete + * per-call recomputation"): this module used to take the run's live + * `ReplayVarScope` and call `collectReplayScrubbableVarValues(scope)` fresh + * at each of five separate return points within one step — always the SAME + * result, since nothing in this module's own flow mutates the scope + * (`resolveReplayAction`, the run's one scope-expanding call, already ran + * before `./step-loop.ts` calls in here). `runAdReplay` now computes + * `scrubVars` ONCE per step, right after resolving the step's action, and + * threads it down as a plain value — this module never imports + * `ReplayVarScope` or `collectReplayScrubbableVarValues` at all. + */ +export async function verifyAndDispatchStep( + runtime: AdReplayStepRuntime, + scrubVars: readonly AdReplayScrubValue[], + action: SessionAction, + resolvedAction: SessionAction, + index: number, + artifactPaths: readonly string[], +): Promise { + const recorded = action.targetEvidence; + if (!recorded) return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); + + const entry = runtime.beginTargetVerification(action, resolvedAction, index); + if (entry.kind === 'inactive') { + return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); + } + + // #1349 post-resolution phase (`wait`): NEVER the generic pre-dispatch + // resolution below — an absent landmark is a wait's expected starting + // condition, so refusing on the current screen would break polling. Only + // a recorded-`unverifiable` annotation refuses up front; a verifiable + // landmark is deferred into the wait's own loop. + if (entry.kind === 'post-resolution') { + const plan = planPostResolutionTargetVerification({ + recorded, + isSelectorWait: entry.isSelectorWait, + }); + switch (plan.kind) { + case 'skip': + return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); + case 'recorded-unverifiable': + return { + status: 'failed', + failure: await runtime.buildRecordedUnverifiableFailure( + action, + index, + artifactPaths, + scrubVars, + ), + }; + case 'deferred-landmark': + return dispatchWithGuard(runtime, scrubVars, action, resolvedAction, index, artifactPaths, { + kind: 'landmark', + landmark: plan.landmark, + }); + } + } + + // entry.kind === 'pre-dispatch': the ordinary gate. + const preDispatchPlan = planPreDispatchTargetVerification({ + recorded, + token: entry.token, + port: runtime.port, + }); + if (preDispatchPlan.kind === 'skip') { + return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); + } + if (preDispatchPlan.kind === 'recorded-unverifiable') { + return { + status: 'failed', + failure: await runtime.buildRecordedUnverifiableFailure( + action, + index, + artifactPaths, + scrubVars, + ), + }; + } + const token = preDispatchPlan.token; + + // #1385: this is the pre-dispatch gate a step right after `open --relaunch` + // can race — the app may still be launching/mounting when this capture + // lands. Bounded retry rides out that transition (`retryLaunchRace`). + const observation = await runtime.captureObservation(action, index, { retryLaunchRace: true }); + if (observation.state !== 'available') { + return { + status: 'failed', + failure: await runtime.buildTargetBindingFailure( + action, + index, + { + kind: 'identity-unverifiable', + matchCount: undefined, + observed: undefined, + candidateNodes: [], + mismatches: [], + causeCode: 'IDENTITY_UNVERIFIABLE', + causeMessage: `Could not capture a fresh snapshot to verify the recorded target before acting (${observation.reason}).`, + ...(observation.hint !== undefined ? { causeHint: observation.hint } : {}), + }, + artifactPaths, + scrubVars, + ), + }; + } + + const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); + if (classification.verified) { + return dispatchWithGuard(runtime, scrubVars, action, resolvedAction, index, artifactPaths, { + kind: 'target', + guard: classification.guard, + }); + } + return { + status: 'failed', + failure: await runtime.buildTargetBindingFailure( + action, + index, + { + kind: classification.kind, + matchCount: classification.matchCount, + observed: classification.observed, + candidateNodes: classification.candidateNodes, + mismatches: classification.mismatches, + causeCode: classification.causeCode, + causeMessage: classification.causeMessage, + }, + artifactPaths, + scrubVars, + ), + }; +} + +/** Dispatches with no pre-action guard — nothing to cross-check, so a mismatch marker can never legitimately fire. */ +async function dispatchNoGuard( + runtime: AdReplayStepRuntime, + action: SessionAction, + resolvedAction: SessionAction, + index: number, + artifactPaths: readonly string[], +): Promise { + const outcome = await runtime.dispatchStep( + action, + resolvedAction, + index, + artifactPaths, + undefined, + ); + switch (outcome.status) { + case 'ok': + return { status: 'ok', artifactPaths: outcome.artifactPaths }; + case 'failed': + return { status: 'failed', failure: outcome.failure }; + case 'guard-mismatch': + case 'landmark-mismatch': + // `dispatchStep` never reports a mismatch marker without a matching + // guard to check it against — unreachable in practice; stay total via + // the plain fallback failure. + return { status: 'failed', failure: outcome.plainFailure }; + } +} + +/** + * Dispatches carrying a pre-action guard and converts a matching + * post-resolution refusal marker into its identity-mismatch target-binding + * divergence, deriving the evidence via the (engine-private) derive + * functions this pass moved in from the daemon. + */ +async function dispatchWithGuard( + runtime: AdReplayStepRuntime, + scrubVars: readonly AdReplayScrubValue[], + action: SessionAction, + resolvedAction: SessionAction, + index: number, + artifactPaths: readonly string[], + guard: AdReplayDispatchGuard, +): Promise { + const outcome = await runtime.dispatchStep(action, resolvedAction, index, artifactPaths, guard); + if (outcome.status === 'ok') return { status: 'ok', artifactPaths: outcome.artifactPaths }; + if (outcome.status === 'failed') return { status: 'failed', failure: outcome.failure }; + + // The refusal markers are only ever attached to an annotated action; fall + // back to the plain dispatch failure if the invariant is somehow violated. + const recorded = action.targetEvidence; + if (!recorded) return { status: 'failed', failure: outcome.plainFailure }; + + const evidence = + outcome.status === 'guard-mismatch' + ? deriveReplayTargetGuardMismatchEvidence( + recorded, + outcome.evidence, + guard.kind === 'target' ? guard.guard.matchCount : 0, + ) + : deriveWaitLandmarkMismatchEvidence(recorded, outcome.evidence); + + return { + status: 'failed', + failure: await runtime.buildPostDispatchTargetBindingFailure( + action, + index, + { + kind: 'identity-mismatch', + matchCount: evidence.matchCount, + observed: evidence.observed, + candidateNodes: [], + mismatches: evidence.mismatches, + causeCode: 'IDENTITY_MISMATCH', + causeMessage: evidence.causeMessage, + }, + artifactPaths, + scrubVars, + ), + }; +} diff --git a/packages/ad-replay/tsconfig.json b/packages/ad-replay/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/ad-replay/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index a53bd97c25..13d3059081 100644 --- a/packages/ad-script/src/index.ts +++ b/packages/ad-script/src/index.ts @@ -1,5 +1,7 @@ /** - * The `.ad` script codec façade (#1478 P5 scoping dossier, "the codec seam"). + * The `.ad` script codec façade (#1478 P5 scoping dossier, "the codec seam"; + * widened by the P5 review pass, "keep genuinely shared recording vocabulary + * in its proper shared owner"). * * The canonical `.ad` replay script format — read half (parsing a script into * actions) and write half (formatting actions back into script lines) of one @@ -9,12 +11,34 @@ * CLI's `replay export`, and Maestro's failure-label formatting. * * Also owns the `# agent-device:target-v1` annotation SERDE (wire type, - * canonical field order, normalization, size caps, payload parsing). The - * companion classification core (`classifyTargetBindingMatch`, local-identity - * + ancestry-prefix matching) is NOT part of this codec — it stays in - * `src/replay/target-identity.ts`. The annotation SHAPE is not exported here + * canonical field order, normalization, size caps, payload parsing) and, + * alongside it, the local-identity + ancestry-prefix matching primitives and + * their diagnostic diffs (`target-annotation-identity.ts`) — both record/ + * replay-shared `.ad` vocabulary, not engine policy. The companion + * CLASSIFICATION core (`classifyTargetBindingMatch`, + * `target-annotation-classification.ts`, decision 3's replay-time + * verification paths 2-6) moved here too (#1555 review, "complete the + * binding façade instead of documenting deviations"): its only real + * consumers are the daemon's record-time self-check + * (`src/daemon/session-target-evidence.ts`) and replay-time classification + * wrapper (`src/daemon/handlers/session-replay-target-classification.ts`), + * neither reachable through `@agent-device/ad-replay`'s + * `inspectAdReplay`/`runAdReplay`. The annotation SHAPE is not exported here * either: it lives in `@agent-device/contracts/replay`, which every consumer * (this package included) imports directly. + * + * Also owns `${VAR}` scope/env/resolution (`vars.ts`): the same script- + * language semantics as `env KEY=VALUE` directive parsing, shared by the + * daemon's replay runtime and the Maestro replay path. + * + * Also owns `resolveDeclaredScriptPlatform` (`open-script.ts`, #1555 + * structural-quality review): the platform a script declares before its + * first real `open` (`runtime` actions, then the `open` action's own + * attached hint) — `.ad` script semantics, not engine or daemon policy, and + * needed independently by both `@agent-device/ad-replay`'s plan-digest + * precedence and the daemon's device-selection platform resolution + * (`src/daemon/replay-device-selection.ts`), which is exactly the "shared by + * record/replay AND the daemon" shape this package exists to own. */ export { @@ -24,6 +48,8 @@ export { } from './internal/script.ts'; export type { ParsedReplayScript, ReplayScriptMetadata } from './internal/script.ts'; +export { resolveDeclaredScriptPlatform } from './internal/open-script.ts'; + export { appendScriptSeriesFlags, formatDivergenceActionLabel, @@ -51,3 +77,29 @@ export { TARGET_ANNOTATION_MAX_FIELD_BYTES, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, } from './internal/target-annotation-serde.ts'; + +export { + annotationLocalIdentity, + firstAncestryMismatch, + identityFieldMismatches, + matchesAncestryPrefix, + matchesLocalIdentity, +} from './internal/target-annotation-identity.ts'; +export type { LocalIdentity } from './internal/target-annotation-identity.ts'; + +export { classifyTargetBindingMatch } from './internal/target-annotation-classification.ts'; +export type { + TargetBindingClassification, + TargetBindingClassificationInput, +} from './internal/target-annotation-classification.ts'; + +export { + buildReplayVarScope, + collectReplayScrubbableVarValues, + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + resolveReplayAction, +} from './internal/vars.ts'; +export type { ReplayVarScope } from './internal/vars.ts'; diff --git a/src/replay/__tests__/target-identity-classification.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-classification.test.ts similarity index 97% rename from src/replay/__tests__/target-identity-classification.test.ts rename to packages/ad-script/src/internal/__tests__/target-annotation-classification.test.ts index ba689680a3..78de4e4737 100644 --- a/src/replay/__tests__/target-identity-classification.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-classification.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { classifyTargetBindingMatch } from '../target-identity.ts'; +import { classifyTargetBindingMatch } from '../target-annotation-classification.ts'; // Decision 3's replay-time verification paths 2-6 are shared with the // writer's record-time self-check and stay isolated from parser coverage. diff --git a/src/replay/__tests__/target-identity.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts similarity index 86% rename from src/replay/__tests__/target-identity.test.ts rename to packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts index 18b711df32..e0586ee5c1 100644 --- a/src/replay/__tests__/target-identity.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts @@ -1,12 +1,14 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { matchesAncestryPrefix, matchesLocalIdentity } from '../target-identity.ts'; +import { matchesAncestryPrefix, matchesLocalIdentity } from '../target-annotation-identity.ts'; // The `# agent-device:target-v1` SERDE (serialize/parse, normalization, -// bounds) moved to `@agent-device/ad-script` — see +// bounds) lives alongside this in `target-annotation-serde.ts` — see // `packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts`. -// This file keeps only the record/replay-shared CLASSIFICATION core that -// stays in `src/replay/target-identity.ts` (#1478 P5 scoping dossier). +// This file covers the local-identity + ancestry-prefix matching primitives. +// The record/replay-shared CLASSIFICATION core built on top of them +// (`classifyTargetBindingMatch`) is engine-owned policy and stays in +// `@agent-device/ad-replay`'s `target-identity.ts` (#1478 P5 review). // --------------------------------------------------------------------------- // Leaf-anchored ancestry prefix matching: root-side truncation + inserted diff --git a/src/replay/__tests__/vars.test.ts b/packages/ad-script/src/internal/__tests__/vars.test.ts similarity index 83% rename from src/replay/__tests__/vars.test.ts rename to packages/ad-script/src/internal/__tests__/vars.test.ts index 14990073c7..88226e1f4c 100644 --- a/src/replay/__tests__/vars.test.ts +++ b/packages/ad-script/src/internal/__tests__/vars.test.ts @@ -8,7 +8,7 @@ import { resolveReplayAction, resolveReplayString, } from '../vars.ts'; -import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../script.ts'; import type { SessionAction } from '@agent-device/contracts/session'; const LOC = { file: 'test.ad', line: 1 }; @@ -44,6 +44,33 @@ test('resolveReplayString throws on unresolved variable with file:line', () => { ); }); +test('resolveReplayString passes unclosed and malformed interpolations through verbatim', () => { + const scope = buildReplayVarScope({}); + const loc = { file: 'a.ad', line: 1 }; + assert.equal(resolveReplayString('x${A:-y', scope, loc), 'x${A:-y'); + assert.equal(resolveReplayString('${1bad}', scope, loc), '${1bad}'); + assert.equal(resolveReplayString('${}', scope, loc), '${}'); + assert.equal(resolveReplayString('\\${A}', scope, loc), '${A}'); + // A backslash-newline kills only that candidate; a later one still resolves. + assert.equal(resolveReplayString('${A:-\\\n${B:-ok}', scope, loc), '${A:-\\\nok'); +}); + +test('resolveReplayString stays linear on adversarial unclosed-fallback runs', () => { + // The retired regex form (`(?::-((?:[^}\\]|\\.)*))?`) rescanned to the end of + // the string for every `${A:-` prefix — 1,857 ms measured on this exact + // input. The scanner's abort-and-emit-verbatim path makes it one pass. + const scope = buildReplayVarScope({}); + const loc = { file: 'a.ad', line: 1 }; + const unclosed = '${A:-['.repeat(20_000); + let startedAt = Date.now(); + assert.equal(resolveReplayString(unclosed, scope, loc), unclosed); + assert.ok(Date.now() - startedAt < 1000, 'unclosed-run resolution must be sub-second'); + const newlineAborts = ('${Q:-' + 'x'.repeat(50) + '\\\n').repeat(3000); + startedAt = Date.now(); + resolveReplayString(newlineAborts, scope, loc); + assert.ok(Date.now() - startedAt < 1000, 'newline-abort resolution must be sub-second'); +}); + test('resolveReplayString is case-sensitive', () => { const scope = buildReplayVarScope({ fileEnv: { APP: 'settings' } }); assert.throws(() => resolveReplayString('${app}', scope, LOC), AppError); diff --git a/packages/ad-script/src/internal/open-script.ts b/packages/ad-script/src/internal/open-script.ts index ad26facb6f..f8e2fd1ac3 100644 --- a/packages/ad-script/src/internal/open-script.ts +++ b/packages/ad-script/src/internal/open-script.ts @@ -5,6 +5,37 @@ import { parseReplayRuntimeFlags, } from './script-utils.ts'; +/** + * #1555 structural-quality review ("declaredScriptPlatform... move to + * packages/ad-script, its natural owner"): the platform a script declares + * before its first real `open` — `runtime` actions accumulate a platform, + * and the first `open` action's own attached `runtime.platform` wins over + * (or falls back to) that accumulation. Two independent daemon/package call + * sites needed exactly this scan and, before this move, each carried its own + * copy: `packages/ad-replay/src/internal/inspect.ts`'s plan-digest platform + * precedence, and `src/daemon/replay-device-selection.ts`'s + * `readScriptReplaySelection` (fused into its own single pass alongside an + * app-target scan). A `src/` root file cannot become a façade dependency + * (R11), and a workspace package may not reach back into root `src/` either + * — `ad-script` is the one package both `ad-replay` and the daemon already + * depend on, so it is the correct single owner. Both call sites now import + * this function instead of maintaining their own copy. + */ +export function resolveDeclaredScriptPlatform( + actions: readonly SessionAction[], +): string | undefined { + let platform: string | undefined; + for (const action of actions) { + if (action.command === 'runtime' && typeof action.flags.platform === 'string') { + platform = action.flags.platform; + continue; + } + if (action.command !== 'open') continue; + return action.runtime?.platform ?? platform; + } + return platform; +} + export function appendOpenActionScriptArgs( parts: string[], action: Pick, diff --git a/packages/ad-script/src/internal/script.ts b/packages/ad-script/src/internal/script.ts index b4a16d581c..7186630479 100644 --- a/packages/ad-script/src/internal/script.ts +++ b/packages/ad-script/src/internal/script.ts @@ -17,10 +17,10 @@ import { parseTargetAnnotationCommentLine } from './target-annotation-serde.ts'; /** * The `.ad` script env/var key shape: uppercase letters, digits, and * underscores, leading with a letter or underscore. Canonical here because - * `env KEY=VALUE` directive parsing is script grammar; `src/replay/vars.ts` - * (runtime `${VAR}` resolution, outside this package) and - * `src/replay/recorded-input.ts` import it from this package rather than - * duplicating the rule. + * `env KEY=VALUE` directive parsing is script grammar; the sibling + * `vars.ts` (runtime `${VAR}` resolution) imports it directly, and + * `src/replay/recorded-input.ts` imports it from this package's façade + * rather than duplicating the rule. */ export const REPLAY_VAR_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; diff --git a/src/replay/target-identity.ts b/packages/ad-script/src/internal/target-annotation-classification.ts similarity index 60% rename from src/replay/target-identity.ts rename to packages/ad-script/src/internal/target-annotation-classification.ts index cba03bd749..f16396b16a 100644 --- a/src/replay/target-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-classification.ts @@ -1,69 +1,24 @@ /** * ADR 0012 decision 3: the record/replay-shared CLASSIFICATION core over - * versioned `.ad` target-binding evidence — local-identity + ancestry-prefix - * matching, and `classifyTargetBindingMatch`'s replay-time verification - * paths 2-6. Inert in migration step 3: nothing enforces parsed evidence at - * replay time until step 4. + * versioned `.ad` target-binding evidence — `classifyTargetBindingMatch`'s + * replay-time verification paths 2-6. Inert in migration step 3: nothing + * enforces parsed evidence at replay time until step 4. * - * The comment-line SERDE half (wire type, canonical field order, - * normalization, size caps, payload parsing/validation) moved to - * `@agent-device/ad-script` (#1478 P5 scoping dossier, "the codec seam") — - * this module imports the shared types from there rather than declaring them. + * #1555 review P1 ("complete the binding façade instead of documenting + * deviations"): this used to live in `@agent-device/ad-replay`'s + * `target-identity.ts`, reasoning that it was engine-owned policy rather + * than script vocabulary. In practice its only real consumers were the + * daemon's RECORD-time self-check (`src/daemon/session-target-evidence.ts`) + * and its REPLAY-time classification wrapper + * (`src/daemon/handlers/session-replay-target-classification.ts`) — both + * daemon files, neither reachable through `inspectAdReplay`/`runAdReplay`. + * It interprets `TargetAnnotationV1` evidence semantics shared beyond the + * engine (record-time AND replay-time both need the SAME verdict by + * construction), so it belongs alongside the rest of that shared `.ad` + * target-binding vocabulary in this package rather than behind a façade + * only one of its two callers could reach. */ -import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/contracts/replay'; - -// --------------------------------------------------------------------------- -// Local identity + ancestry-prefix matching (decision 3 "Local identity" / -// "Ancestry"). Pure over the small structural shapes above — no tree -// dependency, so both the writer (over `SnapshotNode`-derived values) and a -// future replay verifier can share it verbatim. -// --------------------------------------------------------------------------- - -export type LocalIdentity = { id?: string; role: string; label?: string }; - -/** The recorded annotation's identity tier as a bare `LocalIdentity` (drop-empty-keys form). */ -export function annotationLocalIdentity( - recorded: Pick, -): LocalIdentity { - return { - ...(recorded.id !== undefined ? { id: recorded.id } : {}), - role: recorded.role, - ...(recorded.label !== undefined ? { label: recorded.label } : {}), - }; -} - -/** - * Decision 3 "Local identity": id match wins outright when the recording - * carries one ("a recorded id never matches a node without that id"); with - * no recorded id, role+label must both match (label absent on both sides - * counts as equal; present on exactly one side is a mismatch). - */ -export function matchesLocalIdentity(candidate: LocalIdentity, recorded: LocalIdentity): boolean { - if (recorded.id !== undefined) return candidate.id === recorded.id; - return candidate.role === recorded.role && candidate.label === recorded.label; -} - -/** - * Decision 3 "Ancestry": leaf-anchored prefix match. `observed` must be at - * least as long as `recorded`; each recorded entry's role must match exactly - * and, when the recorded entry carries a label, so must the observed one (an - * absent recorded label is unconstrained). - */ -export function matchesAncestryPrefix( - observed: readonly TargetAncestryEntry[], - recorded: readonly TargetAncestryEntry[], -): boolean { - if (observed.length < recorded.length) return false; - for (const [index, entry] of recorded.entries()) { - const candidate = observed[index]; - if (!candidate) return false; - if (candidate.role !== entry.role) return false; - if (entry.label !== undefined && candidate.label !== entry.label) return false; - } - return true; -} - // --------------------------------------------------------------------------- // Classification core (decision 3 "Replay-time verification", paths 2-6; // path 1 is the caller's pre-resolution check). Generic over node refs so diff --git a/packages/ad-script/src/internal/target-annotation-identity.ts b/packages/ad-script/src/internal/target-annotation-identity.ts new file mode 100644 index 0000000000..1de5e2a23f --- /dev/null +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -0,0 +1,125 @@ +/** + * ADR 0012 decision 3: the record/replay-shared local-identity + ancestry- + * 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 + * sides compute the SAME identity/ancestry match by construction (#1478 P5 + * review, "genuinely shared recording vocabulary" relocated to its owner). + * + * The classification core built on top of this (`classifyTargetBindingMatch`, + * decision 3's replay-time verification paths 2-6) lives alongside this file + * in `target-annotation-classification.ts` — both daemon-only consumers + * (record-time self-check and replay-time classification) reach it from + * here, not through `@agent-device/ad-replay`'s façade (#1555 review). + */ + +import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/contracts/replay'; + +// --------------------------------------------------------------------------- +// Local identity + ancestry-prefix matching (decision 3 "Local identity" / +// "Ancestry"). Pure over the small structural shapes above — no tree +// dependency, so both the writer (over `SnapshotNode`-derived values) and a +// replay verifier can share it verbatim. +// --------------------------------------------------------------------------- + +export type LocalIdentity = { id?: string; role: string; label?: string }; + +/** The recorded annotation's identity tier as a bare `LocalIdentity` (drop-empty-keys form). */ +export function annotationLocalIdentity( + recorded: Pick, +): LocalIdentity { + return { + ...(recorded.id !== undefined ? { id: recorded.id } : {}), + role: recorded.role, + ...(recorded.label !== undefined ? { label: recorded.label } : {}), + }; +} + +/** + * Decision 3 "Local identity": id match wins outright when the recording + * carries one ("a recorded id never matches a node without that id"); with + * no recorded id, role+label must both match (label absent on both sides + * counts as equal; present on exactly one side is a mismatch). + */ +export function matchesLocalIdentity(candidate: LocalIdentity, recorded: LocalIdentity): boolean { + if (recorded.id !== undefined) return candidate.id === recorded.id; + return candidate.role === recorded.role && candidate.label === recorded.label; +} + +/** + * Decision 3 "Ancestry": leaf-anchored prefix match. `observed` must be at + * least as long as `recorded`; each recorded entry's role must match exactly + * and, when the recorded entry carries a label, so must the observed one (an + * absent recorded label is unconstrained). + */ +export function matchesAncestryPrefix( + observed: readonly TargetAncestryEntry[], + recorded: readonly TargetAncestryEntry[], +): boolean { + if (observed.length < recorded.length) return false; + for (const [index, entry] of recorded.entries()) { + const candidate = observed[index]; + if (!candidate) return false; + if (candidate.role !== entry.role) return false; + if (entry.label !== undefined && candidate.label !== entry.label) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Diagnostic diffs (decision 3): bounded, best-effort mismatch descriptions +// shared by the record-time classification core and replay-time verification +// (#1478 P5 stage C2a) — moved here verbatim from +// `src/daemon/handlers/session-replay-target-classification.ts` so both +// callers depend on one definition instead of two copies. +// --------------------------------------------------------------------------- + +export function identityFieldMismatches( + recorded: TargetAnnotationV1, + observed: LocalIdentity, +): string[] { + const mismatches: string[] = []; + if (recorded.id !== observed.id) { + mismatches.push(`id: recorded=${recorded.id ?? '(none)'} observed=${observed.id ?? '(none)'}`); + } + if (recorded.role !== observed.role) { + mismatches.push(`role: recorded=${recorded.role} observed=${observed.role}`); + } + if (recorded.label !== observed.label) { + mismatches.push( + `label: recorded=${recorded.label ?? '(none)'} observed=${observed.label ?? '(none)'}`, + ); + } + return mismatches; +} + +function describeAncestryEntry(entry: TargetAncestryEntry | undefined): string { + return entry ? `${entry.role}${entry.label ? `/${entry.label}` : ''}` : '(missing)'; +} + +function ancestryEntryMismatches( + expected: TargetAncestryEntry, + actual: TargetAncestryEntry | undefined, +): boolean { + if (!actual) return true; + if (actual.role !== expected.role) return true; + return expected.label !== undefined && actual.label !== expected.label; +} + +/** Leaf-anchored prefix: the first divergence explains everything after it. */ +export function firstAncestryMismatch( + recordedAncestry: readonly TargetAncestryEntry[], + observedAncestry: readonly TargetAncestryEntry[], +): string[] { + for (const [index, expected] of recordedAncestry.entries()) { + const actual = observedAncestry[index]; + if (!ancestryEntryMismatches(expected, actual)) continue; + return [ + `ancestry[${index}]: recorded=${describeAncestryEntry(expected)} observed=${describeAncestryEntry(actual)}`, + ]; + } + return []; +} diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts index 57666ac3de..07360f9e83 100644 --- a/packages/ad-script/src/internal/target-annotation-serde.ts +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -6,11 +6,15 @@ * canonical field order, normalization, size caps, and payload * parsing/validation. * - * The record/replay-shared CLASSIFICATION core (`classifyTargetBindingMatch`, - * local-identity + ancestry-prefix matching) is not part of this codec — it - * stays in `src/replay/target-identity.ts`, which imports the shared shape - * types from `@agent-device/contracts/replay` (#1478 P5 scoping dossier, - * "the codec seam"). + * The local-identity + ancestry-prefix matching primitives and their + * diagnostic diffs live alongside this in the sibling + * `target-annotation-identity.ts`, and the record/replay-shared + * CLASSIFICATION core built on top of them lives in + * `target-annotation-classification.ts` — all shared `.ad` recording + * vocabulary, not engine policy, imported directly by both the daemon and + * `@agent-device/ad-replay` (#1478 P5 scoping dossier, "the codec seam"; + * identity vocabulary relocated by the P5 review pass; classification + * relocated by the #1555 review pass, "complete the binding façade"). */ import { AppError } from '@agent-device/kernel/errors'; diff --git a/src/replay/vars.ts b/packages/ad-script/src/internal/vars.ts similarity index 59% rename from src/replay/vars.ts rename to packages/ad-script/src/internal/vars.ts index 51d34d018e..5c97c315dd 100644 --- a/src/replay/vars.ts +++ b/packages/ad-script/src/internal/vars.ts @@ -1,8 +1,10 @@ import { AppError } from '@agent-device/kernel/errors'; import type { SessionAction } from '@agent-device/contracts/session'; -// The env/var key shape is `.ad` script grammar (env directive parsing lives -// in the codec package). -import { REPLAY_VAR_KEY_RE } from '@agent-device/ad-script'; +// The ${VAR} scope/env/resolution semantics are `.ad` script-language +// semantics, same as `env KEY=VALUE` directive parsing (#1478 P5 review, +// "genuinely shared recording vocabulary" relocated to its owner) — the key +// shape comes from the sibling script grammar module. +import { REPLAY_VAR_KEY_RE } from './script.ts'; export type ReplayVarScope = { values: Readonly>; @@ -17,7 +19,6 @@ export type ReplayVarSources = { cliEnv?: Record; }; -const INTERPOLATION_RE = /(\\\$\{)|\$\{([A-Za-z_][A-Za-z0-9_.]*)(?::-((?:[^}\\]|\\.)*))?\}/g; const SHELL_PREFIX = 'AD_VAR_'; const RESERVED_NAMESPACE_PREFIX = 'AD_'; @@ -112,36 +113,109 @@ export function readReplayShellEnvSource(raw: unknown): NodeJS.ProcessEnv { return process.env; } +// `${NAME}` / `${NAME:-fallback}` / `\${` interpolation, as a single-pass +// scanner rather than a regex: the regex form's fallback group rescans to the +// end of the string for every `${NAME:-` prefix of an unclosed input, which is +// quadratic on adversarial lines (CodeQL js/polynomial-redos). An unclosed +// fallback aborts the whole scan instead — escape pairs align identically from +// every later candidate start, so no later candidate can close either, and the +// regex's per-candidate passthrough collapses to one literal tail. +type ParsedInterpolation = { key: string; fallback: string | undefined; end: number }; + +function isInterpolationNameStart(ch: string): boolean { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_'; +} + +function isInterpolationNameChar(ch: string): boolean { + return isInterpolationNameStart(ch) || (ch >= '0' && ch <= '9') || ch === '.'; +} + +function isLineTerminator(ch: string): boolean { + return ch === '\n' || ch === '\r' || ch === '\u2028' || ch === '\u2029'; +} + +// A failed fallback scan reports how far it got (`abortEnd`) so the caller can +// emit that span verbatim and resume after it instead of re-parsing from the +// next candidate: escape pairs align identically from every candidate start +// inside the span, so none of them can terminate where this scan could not — +// re-scanning them would only repeat the same failure (and turn adversarial +// inputs quadratic, the CodeQL finding this scanner exists to prevent). +type FailedInterpolation = { abortEnd: number }; + +function parseInterpolation( + raw: string, + start: number, +): ParsedInterpolation | FailedInterpolation | null { + let i = start + 2; + if (i >= raw.length || !isInterpolationNameStart(raw[i]!)) return null; + i += 1; + while (i < raw.length && isInterpolationNameChar(raw[i]!)) i += 1; + const key = raw.slice(start + 2, i); + if (raw[i] === '}') return { key, fallback: undefined, end: i + 1 }; + if (raw[i] !== ':' || raw[i + 1] !== '-') return null; + i += 2; + let fallback = ''; + while (i < raw.length) { + const ch = raw[i]!; + if (ch === '}') return { key, fallback, end: i + 1 }; + if (ch === '\\') { + const next = raw[i + 1]; + if (next === undefined) return { abortEnd: raw.length }; + if (isLineTerminator(next)) return { abortEnd: i + 2 }; + fallback += next; + i += 2; + continue; + } + fallback += ch; + i += 1; + } + return { abortEnd: raw.length }; +} + export function resolveReplayString( raw: string, scope: ReplayVarScope, loc: { file: string; line: number }, ): string { - return raw.replace( - INTERPOLATION_RE, - ( - match, - escapedLiteral: string | undefined, - key: string | undefined, - fallback: string | undefined, - ) => { - if (escapedLiteral) return '${'; - if (!key) return match; - if (Object.prototype.hasOwnProperty.call(scope.values, key)) { - if (isReservedNamespaceKey(key)) { - (scope.expandedBuiltinNames ??= new Set()).add(key); - } - return String(scope.values[key]); + let out = ''; + let i = 0; + while (i < raw.length) { + const ch = raw[i]!; + if (ch === '\\' && raw.startsWith('${', i + 1)) { + out += '${'; + i += 3; + continue; + } + if (ch === '$' && raw[i + 1] === '{') { + const parsed = parseInterpolation(raw, i); + if (parsed !== null && 'abortEnd' in parsed) { + out += raw.slice(i, parsed.abortEnd); + i = parsed.abortEnd; + continue; } - if (fallback !== undefined) { - return fallback.replace(/\\(.)/g, '$1'); + if (parsed !== null) { + const { key, fallback } = parsed; + if (Object.prototype.hasOwnProperty.call(scope.values, key)) { + if (isReservedNamespaceKey(key)) { + (scope.expandedBuiltinNames ??= new Set()).add(key); + } + out += String(scope.values[key]); + } else if (fallback !== undefined) { + out += fallback; + } else { + throw new AppError( + 'INVALID_ARGS', + `Unresolved variable \${${key}} at ${loc.file}:${loc.line}.`, + ); + } + i = parsed.end; + continue; } - throw new AppError( - 'INVALID_ARGS', - `Unresolved variable \${${key}} at ${loc.file}:${loc.line}.`, - ); - }, - ); + } + out += ch; + i += 1; + } + return out; } export function resolveReplayAction( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb7a50376b..51903212b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@agent-device/ad-replay': + specifier: workspace:* + version: link:packages/ad-replay '@agent-device/ad-script': specifier: workspace:* version: link:packages/ad-script @@ -94,6 +97,18 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.19.21)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.21)(yaml@2.9.0)) + packages/ad-replay: + dependencies: + '@agent-device/ad-script': + specifier: workspace:* + version: link:../ad-script + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + packages/ad-script: dependencies: '@agent-device/contracts': diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 488dec41d7..bc47d0edd8 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -170,7 +170,7 @@ test('R9 records zone ceilings and keeps engine files outside the largest compon ); const violations = checkDaemonModularityRatchets(baselineEdges(), [ ...commandMembers, - 'src/ad-replay/internal/engine.ts', + 'packages/ad-replay/src/internal/engine.ts', ]); assert.equal(violations.length, 3); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 6f72eaa5b8..e798adbb09 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -52,7 +52,7 @@ type LogicalModulePolicy = { export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ { name: 'ad-replay', - roots: ['src/ad-replay/'], + roots: ['packages/ad-replay/src/'], forbiddenTargetRoots: [ 'src/daemon/', 'src/platforms/', @@ -64,7 +64,12 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ { name: 'maestro', roots: ['packages/maestro/src/'], - forbiddenTargetRoots: ['src/daemon/', 'src/platforms/', 'src/providers/', 'src/ad-replay/'], + forbiddenTargetRoots: [ + 'src/daemon/', + 'src/platforms/', + 'src/providers/', + 'packages/ad-replay/', + ], }, { // Replay-test schedules and reports; it must stay format-neutral. `src/request/` is @@ -81,13 +86,13 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ 'src/replay/', 'src/compat/', 'packages/maestro/', - 'src/ad-replay/', + 'packages/ad-replay/', ], }, ]; const ENGINE_FILE_PREFIXES = [ - 'src/ad-replay/', + 'packages/ad-replay/src/', 'packages/maestro/src/', 'src/replay/', 'src/daemon/handlers/session-replay', diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index d8243aa7d3..2a7d04709e 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -31,6 +31,7 @@ export type BackEdgeMap = Record; // ranked here or listed as unranked — `unclassifiedZones` and `model.test.ts` guard // that no zone is silently unclassified. const TARGET_DAG_RANK = new Map([ + ['ad-replay', 1], ['ad-script', 1], ['contracts', 1], ['maestro', 1], diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index c87255ff26..932833636e 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -3,12 +3,14 @@ // so a rule that stopped matching would look exactly like a rule being obeyed. import assert from 'node:assert/strict'; +import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { checkPackageBoundaries, checkPackageInternalSites, checkRootSites, + readNamedExports, readWorkspacePackages, rootExternalDependencyRanges, rootWorkspaceDependencyNames, @@ -70,6 +72,55 @@ test('specifier sites carry 1-based lines for static and dynamic imports', () => ); }); +test('readNamedExports collects re-export and direct-declaration forms, resolving aliases', () => { + const source = [ + "export { a, b } from './x.ts';", + "export type { C, D } from './y.ts';", + "export { e as f } from './z.ts';", + "export type { g as h } from './z.ts';", + 'export function i() {}', + 'export const j = 1;', + 'export type K = string;', + 'export interface L {}', + "export {\n m,\n n,\n} from './multi.ts';", + ].join('\n'); + assert.deepEqual( + readNamedExports(source), + ['D', 'C', 'K', 'L', 'a', 'b', 'f', 'h', 'i', 'j', 'm', 'n'].sort(), + ); +}); + +test('readNamedExports never reports the original name behind an `as` alias', () => { + const source = "export { internalOnly as publicName } from './x.ts';"; + const names = readNamedExports(source); + assert.deepEqual(names, ['publicName']); + assert.ok(!names.includes('internalOnly')); +}); + +test('readNamedExports resolves `export * as ns` to its one real bound name', () => { + // Unlike bare `export *`, this binds exactly one importable name (`ns`) — + // enumerable, not a widening blind spot. + const source = "export * as ns from './x.ts';"; + assert.deepEqual(readNamedExports(source), ['ns']); +}); + +// #1555 review P1 (second pass, "the gate also ignores export-star +// declarations, so it can miss future widening"): a facade pinned to an +// exact named-export list must not silently accept a form that widens its +// real surface with no enumerable name at all. These two forms throw instead +// of contributing nothing to the list — plant-verified (temporarily reverted +// to a no-op, confirmed both tests failed, restored) rather than merely +// asserted. +test('readNamedExports rejects a bare `export *` re-export', () => { + const source = "export { runAdReplay } from './step-loop.ts';\nexport * from './leak.ts';\n"; + assert.throws(() => readNamedExports(source), /export \* from/); +}); + +test('readNamedExports rejects a default export', () => { + assert.throws(() => readNamedExports('export default function leak() {}'), /export default/); + assert.throws(() => readNamedExports('export default 42;'), /export default/); +}); + test('double-quoted and re-export routes into packages are not invisible to R11', () => { // The scanner is the layering parser, so quote style and statement form // cannot carve out a bypass: a double-quoted import, a re-export, and a @@ -228,6 +279,65 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/contracts', '@agent-device/kernel', ]); + const adReplayPackage = packages.find((pkg) => pkg.name === '@agent-device/ad-replay'); + assert.ok(adReplayPackage, 'ad-replay package must exist'); + // Locks the "exports only `.`" boundary: the stage-A wide façade and the + // `./testing` subpath (the in-memory selector-port adapter, relocated to + // `src/__tests__/test-utils/`) are both gone as of P5 stage D — a future + // `./testing` (or any other) subpath widens this key list and fails the + // assertion. + assert.deepEqual([...adReplayPackage.exportTargets.keys()], ['@agent-device/ad-replay']); + assert.deepEqual([...adReplayPackage.workspaceDependencies].sort(), [ + '@agent-device/ad-script', + '@agent-device/contracts', + '@agent-device/kernel', + ]); + // #1555 review P1 ("add the reviewer-required exact exported-symbol + // gate"; second pass, "enforce the accepted two-entrypoint facade"; the + // structural-quality review, "typed façade replaces the zero-type rule"): + // the exports-subpath assertion above only proves the package exposes one + // `.` entry point — it says nothing about what that entry point actually + // NAMES. This pins the exact symbol list `packages/ad-replay/src/index.ts` + // exports: the binding design's two VALUE entrypoints, `inspectAdReplay` + // and `runAdReplay` (never a third value), plus the neutral vocabulary + // their signatures are built from, named explicitly instead of every root + // consumer hand-deriving `Parameters<...>`/`ReturnType<...>` off them (the + // shim `src/daemon/ad-replay-facade-types.ts` used to centralize — since + // deleted). `formatReplaySuccessMessage` (presentation, not engine policy) + // stays out on purpose — it sits daemon-side beside its one caller. A + // stray export — intentional or not, including a form `readNamedExports` + // cannot enumerate a name for (`export *`, `export default` — see the + // rejection tests below) — must edit this list too, not just slip through + // the exports-subpath check. + assert.deepEqual( + readNamedExports( + fs.readFileSync(path.join(repoRoot, 'packages/ad-replay/src/index.ts'), 'utf8'), + ), + [ + 'AdReplayDispatchGuard', + 'AdReplayDispatchOutcome', + 'AdReplayGuardMismatchEvidence', + 'AdReplayLandmarkMismatchEvidence', + 'AdReplayManifest', + 'AdReplayScrubValue', + 'AdReplayStepFailure', + 'AdReplayStepRuntime', + 'AdReplayTargetBindingEvidence', + 'AdReplayTargetClassification', + 'AdReplayVarSources', + 'AdReplayVerificationEntry', + 'AdReplayVerifiedTargetGuard', + 'ReplayRecordedTargetDisambiguation', + 'ReplayRecordedTargetPolicy', + 'ReplayRecordedTargetResolution', + 'ReplaySelectorCandidateOptions', + 'ReplaySelectorExpressionOutcome', + 'ReplaySelectorGrammar', + 'ReplaySelectorPort', + 'inspectAdReplay', + 'runAdReplay', + ], + ); const providerWebDriverPackage = packages.find( (pkg) => pkg.name === '@agent-device/provider-webdriver', ); @@ -283,6 +393,10 @@ test('the real tree parses, declares, and passes R11', () => { rootWorkspaceDependencyNames(repoRoot).has('@agent-device/ad-script'), 'root must declare the ad-script workspace dependency', ); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/ad-replay'), + 'root must declare the ad-replay workspace dependency', + ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-webdriver'), 'root must declare the provider-webdriver workspace dependency', @@ -320,6 +434,9 @@ test('Node resolution enforces the exports map at runtime', () => { '@agent-device/ad-script/codec', '@agent-device/ad-script/internal/script.ts', '@agent-device/ad-script/src/index.ts', + '@agent-device/ad-replay/testing', + '@agent-device/ad-replay/internal/target-verification.ts', + '@agent-device/ad-replay/src/index.ts', ]) { assert.throws( () => import.meta.resolve(deep), @@ -347,4 +464,6 @@ test('Node resolution enforces the exports map at runtime', () => { assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved); const adScriptResolved = import.meta.resolve('@agent-device/ad-script'); assert.ok(adScriptResolved.endsWith('packages/ad-script/src/index.ts'), adScriptResolved); + const adReplayResolved = import.meta.resolve('@agent-device/ad-replay'); + assert.ok(adReplayResolved.endsWith('packages/ad-replay/src/index.ts'), adReplayResolved); }); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index d7befbb91d..5e060eade8 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -18,6 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { parseSync } from 'oxc-parser'; import { parseImports } from './model.ts'; export type PackageBoundaryViolation = { @@ -55,6 +56,61 @@ export function specifierSites(file: string, source: string): SpecifierSite[] { return parseImports(source).map((edge) => ({ file, line: edge.line, specifier: edge.spec })); } +/** + * Every name a façade module exports, value or type-only, sorted — the exact + * "named-export-list" a package-boundaries gate can pin (#1555 review P1, + * "add the reviewer-required exact exported-symbol gate"). Covers both + * re-export forms (`export { a, b } from './x.ts'`, + * `export type { a, b } from './x.ts'`, with or without `as` aliasing — the + * alias is reported, since that is the name a consumer actually imports), + * `export * as ns from './x.ts'` (one real name, `ns`), and direct + * declarations (`export function`/`const`/`class`/`type`/`interface`, + * including `export const a = 1, b = 2`'s multiple declarators). A stray + * export — intentional or not — changes this list, so a test that pins it + * exactly turns "the façade grew a symbol" into a loud failure instead of a + * silent widening only a PR diff review would catch. + * + * AST-based (`oxc-parser`, already a devDependency — `session-state.ts` is + * the existing precedent for using it in this gate), not a regex, for the + * SAME reason `session-state.ts` gives: a regex has to enumerate every + * export FORM by hand, and the one it forgets is exactly the one that slips + * through. That is precisely what happened here (#1555 review, second pass, + * "the gate also ignores export-star declarations, so it can miss future + * widening"): `export * from './x.ts'` re-exports an unbounded, statically + * unknowable set of names — the old regex scanner had no case for it at all, + * so it silently contributed NOTHING to the list instead of failing loudly. + * `parsed.module.staticExports` is oxc's own resolved export-entry table + * (built for exactly this purpose, not re-derived from a manual AST walk), + * and its `exportName.kind` already draws the line this function needs: + * `'None'` is bare `export *` (unenumerable — thrown), `'Default'` is + * `export default …` (also thrown — a facade pinned to an exact named-export + * list must not carry one), and `'Name'` is every enumerable form above, + * `export * as ns` included (oxc reports its one real bound name, `ns`). + */ +export function readNamedExports(source: string): string[] { + const parsed = parseSync('package-boundaries-export-scan.ts', source); + const names = new Set(); + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + if (entry.exportName.kind === 'None') { + throw new Error( + "readNamedExports cannot enumerate 'export * from …' — it re-exports an unknown set " + + 'of names, exactly the widening an exact-export-list gate exists to catch. Name the ' + + 're-exported symbols explicitly instead of re-exporting the whole module.', + ); + } + if (entry.exportName.kind === 'Default') { + throw new Error( + "readNamedExports cannot enumerate 'export default …' as a named symbol — a facade a " + + 'caller pins to an exact named-export list must not carry a default export.', + ); + } + if (entry.exportName.name) names.add(entry.exportName.name); + } + } + return [...names].sort(); +} + export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { const packagesDir = path.join(repoRoot, 'packages'); if (!fs.existsSync(packagesDir)) return []; diff --git a/src/__tests__/test-utils/in-memory-replay-selector-port.ts b/src/__tests__/test-utils/in-memory-replay-selector-port.ts new file mode 100644 index 0000000000..bd66b1cad5 --- /dev/null +++ b/src/__tests__/test-utils/in-memory-replay-selector-port.ts @@ -0,0 +1,333 @@ +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { + ReplayRecordedTargetDisambiguation, + ReplayRecordedTargetPolicy, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; + +/** + * #1478 P5 stage B: a deterministic, dependency-free `ReplaySelectorPort` + * adapter for `packages/ad-replay`'s own contract suite + * (`replay-selector-port-contract.test.ts`). It honors the SAME contract as + * the production adapter (`src/daemon/replay-selector-port.ts`) — result + * shapes, tagged reasons, and the same-alternative winner+domain invariant — + * over a tiny in-memory matcher instead of the real `src/selectors` grammar + * and resolution engine. Deliberately NOT reproduced: quoting edge cases + * beyond `key="value"`, every selector key, and `options.action`'s + * `editable=true` modifier — the contract is about shapes and invariants, + * not grammar richness. The #1269 shared-id demotion IS reproduced (see + * `buildSelectorCandidates`'s doc below). + * + * #1478 P5 stage D: relocated here from + * `packages/ad-replay/src/internal/testing/in-memory-selector-port.ts`. It + * only ever needed the `ReplaySelectorPort` port type and kernel snapshot + * types — never a package-internal module — so once its only consumer + * (`src/daemon/__tests__/replay-selector-port-contract.test.ts`) turned out + * to be a root test (R11: only root may import the production adapter, since + * a workspace package may never reach back into root `src/`), keeping the + * adapter itself inside `packages/ad-replay` bought nothing: it moved + * alongside its only caller, following the same `src/__tests__/test-utils/` + * convention as `store-factory.ts` and `session-factories.ts`. + * + * #1555 structural-quality review ("typed façade replaces the zero-type + * rule"): the `ReplaySelectorPort` family above is exported by name from + * `@agent-device/ad-replay` directly — no intermediate root-derivation + * module. + * + * Mini expression grammar: `key="value"` terms (space-separated, ANDed), + * alternatives joined by ` || ` (first-match-wins, same as the real chain). + * Supported keys: `id`, `label`, `role`, `value`, `text` (text matches either + * label or value, a stand-in for the real `extractNodeText` fallback). + * + * `buildSelectorCandidates`'s `ReplaySelectorCandidateOptions` coverage: + * `options.nodes` IS honored, for the #1269 shared-id demotion — an `id` + * candidate is dropped (never appended, not merely reordered) when two or + * more nodes in `options.nodes` carry the same `identifier`, mirroring + * `src/selectors/build.ts`'s `selectableId`/`idMatchCountInTree` decision + * (mini-grammar simplification: raw trimmed `node.identifier` equality + * rather than the production NFC+256-byte-cap canonical identity — the two + * agree for every ASCII fixture id this suite uses). `options.action`'s + * `editable=true` modifier is deliberately NOT reproduced, same as the other + * grammar-richness gaps noted above. + */ +export function createInMemoryReplaySelectorPort(): ReplaySelectorPort { + return { + readSelectorExpression, + resolveRecordedTarget, + buildSelectorCandidates, + }; +} + +// --------------------------------------------------------------------------- +// Mini expression grammar +// --------------------------------------------------------------------------- + +type MiniTermKey = 'id' | 'label' | 'role' | 'value' | 'text'; +type MiniTerm = { readonly key: MiniTermKey; readonly value: string }; +type MiniAlternative = readonly MiniTerm[]; + +const TERM_PATTERN = /^(id|label|role|value|text)=(?:"([^"]*)"|(\S+))$/; + +function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let inQuotes = false; + for (const ch of input) { + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === ' ' && !inQuotes) { + if (current) tokens.push(current); + current = ''; + continue; + } + current += ch; + } + if (current) tokens.push(current); + return tokens; +} + +function parseTerm(token: string): MiniTerm | null { + const match = TERM_PATTERN.exec(token); + if (!match) return null; + const key = match[1] as MiniTermKey; + const value = match[2] ?? match[3] ?? ''; + return { key, value }; +} + +function parseAlternative(raw: string): MiniAlternative | null { + const tokens = tokenize(raw.trim()); + if (tokens.length === 0) return null; + const terms: MiniTerm[] = []; + for (const token of tokens) { + const term = parseTerm(token); + if (!term) return null; + terms.push(term); + } + return terms; +} + +/** The in-memory stand-in for `tryParseSelectorChain`: `null` on any malformed alternative. */ +function parseExpression(expression: string): MiniAlternative[] | null { + const rawAlternatives = expression.split('||'); + const alternatives: MiniAlternative[] = []; + for (const raw of rawAlternatives) { + const alt = parseAlternative(raw); + if (!alt) return null; + alternatives.push(alt); + } + return alternatives.length > 0 ? alternatives : null; +} + +function matchesTerm(node: SnapshotNode, term: MiniTerm): boolean { + switch (term.key) { + case 'id': + return node.identifier === term.value; + case 'label': + return node.label === term.value; + case 'role': + return (node.type ?? '').toLowerCase() === term.value.toLowerCase(); + case 'value': + return node.value === term.value; + case 'text': + return node.label === term.value || node.value === term.value; + } +} + +function matchesAlternative(node: SnapshotNode, alt: MiniAlternative): boolean { + return alt.every((term) => matchesTerm(node, term)); +} + +function looksSelectorShaped(candidate: string): boolean { + return /\b(id|label|role|value|text)=/.test(candidate); +} + +// --------------------------------------------------------------------------- +// Operation 1: readSelectorExpression +// --------------------------------------------------------------------------- + +function readSelectorExpression( + grammar: ReplaySelectorGrammar, + positionals: readonly string[], +): ReplaySelectorExpressionOutcome { + if (positionals.length === 0) return { kind: 'not-applicable' }; + // 'is' may be predicate-first ("visible", "text ...") or selector-first; + // a leading token with no '=' is treated as the predicate and dropped, the + // same predicate-first/selector-first duality `splitIsSelectorArgs` covers. + const first = positionals[0]; + const candidateTokens = + grammar === 'is' && first !== undefined && !first.includes('=') + ? positionals.slice(1) + : positionals.slice(); + if (candidateTokens.length === 0) return { kind: 'not-applicable' }; + // Try the longest prefix first, shrinking until something selector-shaped + // is found — mirrors `splitSelectorFromArgs`'s trailing-value handling. + for (let end = candidateTokens.length; end > 0; end -= 1) { + const candidate = candidateTokens.slice(0, end).join(' '); + if (!looksSelectorShaped(candidate)) continue; + if (!parseExpression(candidate)) return { kind: 'invalid' }; + return { kind: 'expression', expression: candidate, rest: candidateTokens.slice(end) }; + } + return { kind: 'not-applicable' }; +} + +// --------------------------------------------------------------------------- +// Operation 2: resolveRecordedTarget +// --------------------------------------------------------------------------- + +function rectOk(node: SnapshotNode, requireRect: boolean): boolean { + return !requireRect || Boolean(node.rect); +} + +function areaOf(node: SnapshotNode): number { + return node.rect ? node.rect.width * node.rect.height : Number.POSITIVE_INFINITY; +} + +/** Deepest-then-smallest-area, mirroring `compareDisambiguationCandidates`; `null` on an exact tie. */ +function pickTiebreak( + candidates: readonly SnapshotNode[], +): { winner: SnapshotNode; tiebreak: 'deepest' | 'smallest-area' } | null { + let best: SnapshotNode | undefined; + let tie = false; + let decidingCriterion: 'deepest' | 'smallest-area' = 'deepest'; + for (const node of candidates) { + if (!best) { + best = node; + continue; + } + const depthBest = best.depth ?? 0; + const depthNode = node.depth ?? 0; + if (depthNode !== depthBest) { + if (depthNode > depthBest) { + best = node; + tie = false; + decidingCriterion = 'deepest'; + } + continue; + } + const areaBest = areaOf(best); + const areaNode = areaOf(node); + if (areaNode !== areaBest) { + if (areaNode < areaBest) { + best = node; + tie = false; + decidingCriterion = 'smallest-area'; + } + continue; + } + tie = true; + } + if (!best || tie) return null; + return { winner: best, tiebreak: decidingCriterion }; +} + +function resolveRecordedTarget( + expression: string, + nodes: SnapshotNode[], + policy: ReplayRecordedTargetPolicy, +): ReplayRecordedTargetResolution { + const chain = parseExpression(expression); + if (!chain) { + return { kind: 'unresolved', reason: 'parse-invalid', matchedNodes: [] }; + } + for (const alt of chain) { + const candidates = nodes.filter( + (node) => rectOk(node, policy.requireRect) && matchesAlternative(node, alt), + ); + if (candidates.length === 0) continue; + if (candidates.length === 1) { + const [winner] = candidates; + if (winner) return { kind: 'resolved', winner, matchedNodes: candidates, matchCount: 1 }; + } + if (policy.allowDisambiguation) { + const picked = pickTiebreak(candidates); + if (picked) { + const disambiguation: ReplayRecordedTargetDisambiguation = { + tiebreak: picked.tiebreak, + matchCount: candidates.length, + alternatives: candidates.filter((node) => node !== picked.winner), + }; + return { + kind: 'resolved', + winner: picked.winner, + matchedNodes: candidates, + matchCount: candidates.length, + disambiguation, + }; + } + } + // Ambiguous and unresolved on this alternative — try the next one, same + // as `resolveSelectorChain`'s per-alternative `continue`. + } + // No alternative produced a winner. Report the diagnostic domain of the + // first alternative with any match at all (mirrors `listSelectorChainMatches`). + for (const alt of chain) { + const candidates = nodes.filter( + (node) => rectOk(node, policy.requireRect) && matchesAlternative(node, alt), + ); + if (candidates.length > 0) { + return { kind: 'unresolved', reason: 'ambiguous', matchedNodes: candidates }; + } + } + return { kind: 'unresolved', reason: 'no-match', matchedNodes: [] }; +} + +// --------------------------------------------------------------------------- +// Operation 3: buildSelectorCandidates +// --------------------------------------------------------------------------- + +function quoted(value: string): string { + return `"${value}"`; +} + +function trimmedOrNull(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** + * #1269 shared-id demotion, mini-grammar form: mirrors + * `src/selectors/build.ts`'s `selectableId` — an id that denotes more than + * one node in the record-time tree is DROPPED (never appended to the + * candidate list), not reordered or kept-but-deprioritized. The production + * decision keys off `idMatchCountInTree`'s canonical (NFC + 256-byte-cap) + * identity id; this mini form counts raw trimmed `node.identifier` equality + * instead, which agrees with the canonical count for every plain-ASCII + * fixture id this suite uses. + */ +function selectableId( + node: SnapshotNode, + nodes: readonly SnapshotNode[] | undefined, +): string | null { + const id = trimmedOrNull(node.identifier); + if (!id || !nodes) return id; + let matchCount = 0; + for (const candidate of nodes) { + if (trimmedOrNull(candidate.identifier) === id) matchCount += 1; + } + return matchCount > 1 ? null : id; +} + +function buildSelectorCandidates( + node: SnapshotNode, + _platform: unknown, + options: ReplaySelectorCandidateOptions = {}, +): readonly string[] { + const id = selectableId(node, options.nodes); + const role = (node.type ?? '').toLowerCase(); + const label = trimmedOrNull(node.label); + const value = trimmedOrNull(node.value); + const candidates: string[] = []; + if (id) candidates.push(`id=${quoted(id)}`); + if (role && label) candidates.push(`role=${quoted(role)} label=${quoted(label)}`); + if (label) candidates.push(`label=${quoted(label)}`); + if (value) candidates.push(`value=${quoted(value)}`); + return Array.from(new Set(candidates)); +} diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index f22a65962d..c32271eafb 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -10,7 +10,7 @@ import { buildIndexMap, filterIdentitySet, } from '../../../replay/target-evidence-tree.ts'; -import { annotationLocalIdentity } from '../../../replay/target-identity.ts'; +import { annotationLocalIdentity } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { PublicPlatform } from '@agent-device/kernel/device'; import { checkWaitText } from '../../../selectors/arguments.ts'; diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts new file mode 100644 index 0000000000..571094bbab --- /dev/null +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -0,0 +1,353 @@ +/** + * #1478 P5 stage B: the `ReplaySelectorPort` contract (issue comment + * 5156017698's amendment), run against BOTH adapters — the production + * adapter (`../replay-selector-port.ts`, delegating to `src/selectors`) and + * the deterministic in-memory adapter + * (`../../__tests__/test-utils/in-memory-replay-selector-port.ts`, relocated + * there in P5 stage D). This suite lives in root, not in `packages/ad-replay`, + * because only root can import the production adapter (R11 package-boundaries: + * a workspace package may never reach back into root `src/`) — the same + * reason the in-memory adapter itself had to move to root once this was its + * only consumer. + * + * Every scenario below is expressed in the in-memory adapter's documented + * mini expression grammar (`key="value"` terms, ` || ` alternatives, keys + * `id`/`label`/`role`/`value`/`text`) — a literal subset of the real + * `src/selectors` grammar, so identical inputs produce identical outcomes on + * both adapters. Fixtures deliberately avoid an `Application`/`Window` + * ancestor (matching `selector-port-contract.test.ts` and + * `session-replay-target-classification-port.test.ts`'s own flat fixtures), + * so on-screen-visibility never enters the deepest/smallest-area tiebreak. + */ +import assert from 'node:assert/strict'; +import { describe, test } from 'vitest'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import { createInMemoryReplaySelectorPort } from '../../__tests__/test-utils/in-memory-replay-selector-port.ts'; +import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; + +const ADAPTERS: readonly (readonly [string, () => ReplaySelectorPort])[] = [ + ['production (src/selectors)', createDaemonReplaySelectorPort], + ['in-memory (test-utils)', createInMemoryReplaySelectorPort], +]; + +const saveNode: SnapshotNode = { + ref: 'e1', + index: 0, + type: 'Button', + label: 'Save', + rect: { x: 0, y: 0, width: 40, height: 20 }, + enabled: true, + hittable: true, +}; + +function twoWayTieNodes(): SnapshotNode[] { + return [ + { + ref: 'e1', + index: 0, + type: 'Button', + label: 'Press me', + rect: { x: 0, y: 0, width: 300, height: 300 }, + depth: 1, + enabled: true, + hittable: true, + }, + { + ref: 'e2', + index: 1, + type: 'Button', + label: 'Press me', + rect: { x: 10, y: 10, width: 100, height: 20 }, + depth: 2, + enabled: true, + hittable: true, + }, + ]; +} + +function decoyAndSaveTree(): SnapshotNode[] { + return [ + { + ref: 'e1', + index: 0, + type: 'Button', + label: 'Decoy', + rect: { x: 0, y: 0, width: 40, height: 20 }, + depth: 1, + }, + { + ref: 'e2', + index: 1, + type: 'Button', + label: 'Decoy', + rect: { x: 60, y: 0, width: 40, height: 20 }, + depth: 1, + }, + { + ref: 'e3', + index: 2, + type: 'Button', + label: 'Decoy', + rect: { x: 120, y: 0, width: 20, height: 10 }, + depth: 3, + }, + { + ref: 'e4', + index: 3, + type: 'Button', + identifier: 'save', + label: 'Save', + rect: { x: 0, y: 40, width: 40, height: 20 }, + depth: 1, + }, + ]; +} + +for (const [name, createPort] of ADAPTERS) { + describe(`ReplaySelectorPort contract: ${name}`, () => { + const port = createPort(); + + // ------------------------------------------------------------------- + // resolveRecordedTarget cell 1: invalid-expression vs valid-no-match + // ------------------------------------------------------------------- + test('cell 1: an unknown selector key is parse-invalid, a well-formed selector with nothing matching is no-match', () => { + const invalid = port.resolveRecordedTarget('foo="bar"', [saveNode], { + platform: 'ios', + requireRect: false, + allowDisambiguation: false, + }); + assert.deepEqual(invalid, { kind: 'unresolved', reason: 'parse-invalid', matchedNodes: [] }); + + const noMatch = port.resolveRecordedTarget('label="Ghost"', [saveNode], { + platform: 'ios', + requireRect: false, + allowDisambiguation: false, + }); + assert.equal(noMatch.kind, 'unresolved'); + if (noMatch.kind !== 'unresolved') throw new Error('unreachable'); + assert.equal(noMatch.reason, 'no-match'); + assert.deepEqual(noMatch.matchedNodes, []); + }); + + // ------------------------------------------------------------------- + // cell 2: fallback-alternative selection + // ------------------------------------------------------------------- + test('cell 2: a later alternative wins when an earlier one has zero matches', () => { + const result = port.resolveRecordedTarget('id="missing" || label="Save"', [saveNode], { + platform: 'ios', + requireRect: false, + allowDisambiguation: false, + }); + assert.equal(result.kind, 'resolved'); + if (result.kind !== 'resolved') throw new Error('unreachable'); + assert.equal(result.winner.ref, 'e1'); + assert.equal(result.matchCount, 1); + }); + + // ------------------------------------------------------------------- + // cell 3: ambiguity with and without a disambiguation tiebreak + // ------------------------------------------------------------------- + test('cell 3: the SAME ambiguous match is unresolved without a tiebreak and a disclosed winner with one', () => { + const nodes = twoWayTieNodes(); + const withoutTiebreak = port.resolveRecordedTarget('label="Press me"', nodes, { + platform: 'ios', + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(withoutTiebreak.kind, 'unresolved'); + if (withoutTiebreak.kind !== 'unresolved') throw new Error('unreachable'); + assert.equal(withoutTiebreak.reason, 'ambiguous'); + assert.equal(withoutTiebreak.matchedNodes.length, 2); + + const withTiebreak = port.resolveRecordedTarget('label="Press me"', nodes, { + platform: 'ios', + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(withTiebreak.kind, 'resolved'); + if (withTiebreak.kind !== 'resolved') throw new Error('unreachable'); + assert.equal(withTiebreak.winner.ref, 'e2'); + assert.equal(withTiebreak.matchCount, 2); + assert.equal(withTiebreak.disambiguation?.tiebreak, 'deepest'); + assert.equal(withTiebreak.disambiguation?.matchCount, 2); + assert.deepEqual( + withTiebreak.disambiguation?.alternatives.map((node) => node.ref), + ['e1'], + ); + }); + + // ------------------------------------------------------------------- + // cell 4: requireRect + // ------------------------------------------------------------------- + test('cell 4: requireRect excludes an otherwise-matching node with no usable rect, consistently on both the winner and the domain', () => { + const rectlessNodes: SnapshotNode[] = [ + { ref: 'e1', index: 0, type: 'Button', label: 'Ghost row', enabled: true }, + ]; + + const withRectRequired = port.resolveRecordedTarget('label="Ghost row"', rectlessNodes, { + platform: 'ios', + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(withRectRequired.kind, 'unresolved'); + if (withRectRequired.kind !== 'unresolved') throw new Error('unreachable'); + assert.equal(withRectRequired.reason, 'no-match'); + assert.deepEqual(withRectRequired.matchedNodes, []); + + const withoutRectRequired = port.resolveRecordedTarget('label="Ghost row"', rectlessNodes, { + platform: 'ios', + requireRect: false, + allowDisambiguation: false, + }); + assert.equal(withoutRectRequired.kind, 'resolved'); + if (withoutRectRequired.kind !== 'resolved') throw new Error('unreachable'); + assert.equal(withoutRectRequired.winner.ref, 'e1'); + }); + + // ------------------------------------------------------------------- + // cell 5: winner and matched-node domain from the SAME alternative + // ------------------------------------------------------------------- + test("cell 5: allowDisambiguation off skips a RESOLVABLE first alternative, using the second alternative's own domain", () => { + const result = port.resolveRecordedTarget('label="Decoy" || id="save"', decoyAndSaveTree(), { + platform: 'ios', + requireRect: true, + allowDisambiguation: false, + }); + assert.equal(result.kind, 'resolved'); + if (result.kind !== 'resolved') throw new Error('unreachable'); + assert.equal(result.winner.ref, 'e4'); + // Load-bearing: 1 (the "id=save" domain), never 3 (the skipped "label=Decoy" domain). + assert.equal(result.matchCount, 1); + }); + + test('cell 5: the SAME fixture with allowDisambiguation on resolves through the first alternative and uses ITS domain instead', () => { + const result = port.resolveRecordedTarget('label="Decoy" || id="save"', decoyAndSaveTree(), { + platform: 'ios', + requireRect: true, + allowDisambiguation: true, + }); + assert.equal(result.kind, 'resolved'); + if (result.kind !== 'resolved') throw new Error('unreachable'); + assert.equal(result.winner.ref, 'e3'); + // Load-bearing: 3 (the "label=Decoy" domain resolution actually used), never 1. + assert.equal(result.matchCount, 3); + assert.equal(result.disambiguation?.tiebreak, 'deepest'); + }); + + // ------------------------------------------------------------------- + // cell 6 (amendment cell 8): repair-suggestion ordering + // ------------------------------------------------------------------- + test('cell 6: a node with id, role+label, label, and value all present suggests them id > role+label > label > value', () => { + const node: SnapshotNode = { + ref: 'e1', + index: 0, + type: 'Button', + identifier: 'save-btn', + label: 'Save Draft', + value: 'Draft', + rect: { x: 0, y: 0, width: 80, height: 30 }, + }; + const candidates = port.buildSelectorCandidates(node, 'ios', { + action: 'get', + nodes: [node], + }); + assert.deepEqual(candidates, [ + 'id="save-btn"', + 'role="button" label="Save Draft"', + 'label="Save Draft"', + 'value="Draft"', + ]); + }); + + // ------------------------------------------------------------------- + // cell 6 shared-id (#1269 binding amendment): id demotion + // ------------------------------------------------------------------- + test('cell 6 shared-id: an id that denotes more than one node in the record-time tree is dropped from the candidate list, not just reordered', () => { + const dup: SnapshotNode = { + ref: 'e1', + index: 0, + type: 'Button', + identifier: 'dup', + label: 'Save Draft', + rect: { x: 0, y: 0, width: 80, height: 30 }, + }; + const otherDup: SnapshotNode = { + ref: 'e2', + index: 1, + type: 'Button', + identifier: 'dup', + label: 'Cancel', + rect: { x: 0, y: 40, width: 80, height: 30 }, + }; + const candidates = port.buildSelectorCandidates(dup, 'ios', { + action: 'get', + nodes: [dup, otherDup], + }); + assert.deepEqual(candidates, ['role="button" label="Save Draft"', 'label="Save Draft"']); + assert.ok( + !candidates.some((candidate) => candidate.startsWith('id=')), + 'a non-unique id must never appear in the candidate list', + ); + }); + + // ------------------------------------------------------------------- + // readSelectorExpression: shape parity for the two reachable outcomes + // ------------------------------------------------------------------- + test('readSelectorExpression: an ordinary selector token round-trips, absent selector is not-applicable', () => { + const found = port.readSelectorExpression('ordinary', ['label=Save']); + assert.deepEqual(found, { kind: 'expression', expression: 'label=Save', rest: [] }); + + const absent = port.readSelectorExpression('ordinary', ['hello']); + assert.deepEqual(absent, { kind: 'not-applicable' }); + + const emptyIs = port.readSelectorExpression('is', ['visible']); + assert.deepEqual(emptyIs, { kind: 'not-applicable' }); + + const isExpression = port.readSelectorExpression('is', ['visible', 'label=Save']); + assert.deepEqual(isExpression, { kind: 'expression', expression: 'label=Save', rest: [] }); + }); + + // ------------------------------------------------------------------- + // readSelectorExpression: 'ordinary' bare-token 'invalid' reachability + // ------------------------------------------------------------------- + // #1555 structural-quality review ("verify both adapters' readSelectorExpression + // handle a bare token identically"): `packages/ad-replay/src/internal/target-verification.ts`'s + // `planPreDispatchTargetVerification` now calls + // `port.readSelectorExpression('ordinary', [token])` as its parse gate + // (replacing an empty-tree `resolveRecordedTarget` call). The two + // adapters do NOT agree on the exact discriminant for a token that LOOKS + // selector-shaped (contains a recognized `key=`) but fails to parse: + // + // - production's 'ordinary' grammar (`splitSelectorFromArgs`) only ever + // records a candidate boundary once `tryParseSelectorChain` has + // already succeeded on it — a single already-whole token that never + // parses at any prefix length contributes NO boundary at all, so the + // call falls through to 'not-applicable'. 'invalid' is structurally + // unreachable from a single-token 'ordinary' call. + // - the in-memory mini-grammar checks "looks selector-shaped" and + // "parses" as two separate steps and reports 'invalid' the moment the + // first shaped candidate fails the second, which a single malformed + // token ('id=' — a recognized key with no value) reaches directly. + // + // This is a real, load-bearing simplification of the mini adapter (its + // own module comment already discloses several grammar-richness gaps), + // not a bug: `planPreDispatchTargetVerification` treats every + // non-'expression' outcome identically (skip pre-dispatch verification), + // so the two adapters still agree on the ONE thing that call site + // observes. This cell pins the exact (diverging) discriminant per + // adapter so a future change to either grammar cannot silently widen the + // gap without failing here first. + test("readSelectorExpression: 'ordinary' bare token that looks selector-shaped but fails to parse", () => { + const outcome = port.readSelectorExpression('ordinary', ['id=']); + if (name.startsWith('production')) { + assert.deepEqual(outcome, { kind: 'not-applicable' }); + } else { + assert.deepEqual(outcome, { kind: 'invalid' }); + } + // Both discriminants are still members of the "not a parseable + // expression" set the one real call site treats identically. + assert.notEqual(outcome.kind, 'expression'); + }); + }); +} diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index f3a1559448..db19f6b862 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -18,9 +18,7 @@ import type { DaemonRequest, SessionState } from '../types.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; -import { parseReplayInput } from '../../compat/replay-input.ts'; -import { computeReplayPlanDigest } from '../../replay/plan-digest.ts'; -import { readEffectiveReplayPlanDigestMetadata } from '../handlers/session-replay-runtime-plan.ts'; +import { inspectAdReplay } from '@agent-device/ad-replay'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); @@ -137,13 +135,7 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO // Compute the plan digest exactly as runReplayScriptFile does (a real agent // takes it from the divergence report's resume.planDigest). const flags = { platform: 'ios' as const }; - const parsed = parseReplayInput(fs.readFileSync(scriptPath, 'utf8'), flags); - const digest = computeReplayPlanDigest({ - actions: parsed.actions, - actionLines: parsed.actionLines, - actionSourcePaths: parsed.actionSourcePaths, - metadata: readEffectiveReplayPlanDigestMetadata(flags), - }); + const digest = inspectAdReplay(scriptPath, { platform: flags.platform }).planDigest; // The repair session was reaped, leaving a tombstone; no live session exists. sessionStore.writeRepairTombstone(tombstonedSession('repair-from')); diff --git a/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts index 953fbb3d29..327786871a 100644 --- a/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-action-runtime.test.ts @@ -3,6 +3,7 @@ import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { recordActionEntry } from '../../session-action-recorder.ts'; import type { DaemonRequest, SessionAction } from '../../types.ts'; import { invokeReplayAction } from '../session-replay-action-runtime.ts'; +import { resolveReplayAction } from '@agent-device/ad-script'; const REPLAY_REQUEST: DaemonRequest = { token: 'token', @@ -22,11 +23,17 @@ test.each(['', ' '])( positionals: ['id="password"', '${PASSWORD}'], flags: {}, }; + // `invokeReplayAction` no longer resolves `${VAR}`s itself (#1555 review + // P1, "move variable semantics/planning behind the replay entrypoint") — + // it receives an already-resolved action, exactly as `runAdReplay` (the + // engine) now produces one per step. + const scope = { values: { PASSWORD: value } }; + const resolved = resolveReplayAction(sourceAction, scope, { file: 'login.ad', line: 1 }); const response = await invokeReplayAction({ req: REPLAY_REQUEST, sessionName: 'default', action: sourceAction, - scope: { values: { PASSWORD: value } }, + resolved, filePath: 'login.ad', line: 1, step: 1, diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts index 2abb1b60bc..83f37803ee 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts @@ -14,11 +14,13 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { buildReplayDivergenceSuggestionForNode } from '../session-replay-divergence.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { toSnapshotNodes } from './session-replay-target-classification-fixtures.ts'; import type { ReplayReportAction } from '../session-replay-report-action.ts'; const identitySanitize = (value: string): string => value; +const port = createDaemonReplaySelectorPort(); test('P5 port cell 8: a node with id, role+label, label, and value all present suggests them in build.ts priority order', () => { const nodes = toSnapshotNodes([ @@ -42,6 +44,7 @@ test('P5 port cell 8: a node with id, role+label, label, and value all present s action, basis: 'id', sanitize: identitySanitize, + port, }); assert.equal( @@ -78,6 +81,7 @@ test('P5 port cell 8: a non-unique id (demoted per #1269) is never suggested, ev action, basis: 'role-label', sanitize: identitySanitize, + port, }); assert.equal( diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 8b5a9c9b4d..0c7b6f656a 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -39,8 +39,10 @@ import { buildReplayFailureDivergence, captureDivergenceObservation, } from '../session-replay-divergence.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const port = createDaemonReplaySelectorPort(); beforeEach(() => { mockDispatchCommand.mockReset(); @@ -90,6 +92,7 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.suggestionCount).toBe(1); @@ -190,6 +193,7 @@ test('buildReplayFailureDivergence excludes keyboard chrome from screen.refs and responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -301,6 +305,7 @@ test('buildReplayFailureDivergence drops unlabeled non-interactive structural no responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -416,6 +421,7 @@ test('buildReplayFailureDivergence keeps an app inputAccessoryView control in sc responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -474,6 +480,7 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -562,6 +569,7 @@ test('buildReplayFailureDivergence: a system-overlay window survives into screen responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -657,6 +665,7 @@ test('buildReplayFailureDivergence: a fully-captured overlay dismiss-target enum responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -756,6 +765,7 @@ test('buildReplayFailureDivergence: when a system overlay mass-covers the app, t responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -834,6 +844,7 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); @@ -888,6 +899,7 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); const screen = divergence.screen as Extract; @@ -993,6 +1005,7 @@ test('buildReplayFailureDivergence: routes through the freshness-retry wrapper a responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); // The freshness wrapper retried past the stale dump (2 on-device captures). @@ -1060,6 +1073,7 @@ test('buildReplayFailureDivergence: divergence capture drops the action snapshot responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(mockDispatchCommand).toHaveBeenCalled(); @@ -1127,6 +1141,7 @@ test.each([ responseLevel: 'default', planActions: [action], planDigest: 'test-plan-digest', + port, }); expect(divergence.screen.state).toBe('available'); diff --git a/src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts similarity index 78% rename from src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts rename to src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts index 2fa1bcef09..c2ad23f6f3 100644 --- a/src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts @@ -1,5 +1,36 @@ import { test, expect, vi } from 'vitest'; +/** + * #1555 structural-quality review ("topology fix... its subject now lives + * in the engine's step-loop/terminal logic — either move it into the + * package tests if it tests engine policy through the façade, or rename to + * match the daemon file it actually exercises"): renamed from + * `session-replay-terminal-lifecycle.test.ts`, a name inherited from a + * production module (`session-replay-terminal-lifecycle.ts`) the #1554 + * fold-in already deleted (`step-loop.ts`'s own header documents the + * deletion — its terminal-close-suppression decision unified into the + * engine's `resolveSuppressedTerminalCloseIndex`). + * + * These six cases stayed daemon-side rather than moving into the package's + * `step-loop.test.ts` because they are NOT a test of engine policy through + * the façade in isolation — every one drives the full + * `runReplayScriptFile` round trip against a REAL `SessionStore`, and two of + * the six (`--keep-session fails explicitly when the completed replay has + * no live session`, `--keep-session rejects Maestro YAML before engine + * dispatch`) exercise daemon-ONLY authority + * (`requireLiveSessionForKeepSession`'s postcondition, `routeMaestroReplay`'s + * routing) that never reaches the engine's step loop at all. The engine's + * OWN terminal-close-suppression decision has its own cheaper, direct + * coverage in `packages/ad-replay/src/internal/__tests__/step-loop.test.ts` + * (see that file's header). This file's real subject is + * `session-replay-runtime.ts`'s `runReplayScriptFile` — specifically its + * `--keep-session` behavior — so it is named and grouped alongside that + * file's other `session-replay-runtime-*.test.ts` siblings + * (`-plan.test.ts`, `-maestro.test.ts`, `-failure.test.ts`, …) rather than + * kept in its own differently-named file or folded into the already-629-line + * `session-replay-runtime.test.ts`. + */ + vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts index ecdd7600d5..b106997ea9 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { runReplayScriptFile } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; +import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { makeAndroidSession, @@ -145,6 +146,66 @@ test('resume rejects an out-of-range --from before any action', async () => { expect(response.error.message).toMatch(/out of range/); }); +/** + * R2: `prepareReplayPlan`'s `--from`/`--plan-digest` validation + * (`resolveReplayPlanEntryIndex`, now in `session-replay-runtime-plan.ts`) + * must run — and reject — before `prepareReplaySession` + * (`session-replay-runtime-session.ts`) performs any coordinator-mutating + * write. Were the order reversed, a rejected `--from` would still clear the + * corrective-resume watermark and demote the armed repair transaction before + * the request failed, silently corrupting the very repair state `--from` + * exists to protect. + */ +test("a rejected --from/--plan-digest resume never reaches prepareReplaySession's coordinator-mutating writes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-resume-no-mutate-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Continue"', 'click "Save"']); + + // Arm a repair transaction and stamp a corrective-resume watermark + // directly, so BOTH of `prepareReplaySession`'s coordinator-mutating + // writes — `consumeReplayResumeState`'s watermark-clear (the `--from 2` + // below matches `expectedFrom`, so it WOULD clear) and + // `prepareSaveScriptSession`'s `demoteForRerunIfArmed` — have something + // real to mutate if this rejected request ever reaches them. + const coordinator = createReplayCoordinator({ sessionStore, sessionName }); + coordinator.armStep({ saveScript: true, force: undefined, sourcePath: filePath, firstArm: true }); + const armedSession = sessionStore.get(sessionName)!; + // `actionsCountAtDivergence: 999` keeps `describeUnperformedRecordAndHeal` + // from firing first (it needs `sessionActionsLength` to equal this), so + // the rejection below is provably the plan-digest check, not a different one. + armedSession.pendingRecordAndHeal = { expectedFrom: 2, actionsCountAtDivergence: 999 }; + sessionStore.set(sessionName, armedSession); + + const beforeView = coordinator.view(); + const beforeActionsLength = sessionStore.get(sessionName)!.actions.length; + + const response = await runReplayScriptFile({ + req: baseReq({ + positionals: [filePath], + flags: { replayFrom: 2, replayPlanDigest: 'not-the-real-digest' }, + }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => { + throw new Error('must not execute a resume the plan-digest preflight rejected'); + }, + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/plan digest/); + + // The armed repair boundary and the corrective watermark are + // byte-for-byte unchanged, and no session action was recorded — proof the + // rejection happened before `prepareReplaySession` ran at all. + expect(coordinator.view()).toEqual(beforeView); + expect(sessionStore.get(sessionName)!.actions.length).toBe(beforeActionsLength); +}); + test('resume rejects a stale --plan-digest after the script changed', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-resume-stale-digest-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 681c46cce1..aa8de623c6 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -432,6 +432,69 @@ test('replay rejects legacy JSON payload files', async () => { expect(response.error.message).toMatch(/\.ad script files/); }); +// #1555 P1: the P5 extraction moved `.ad` inspection to `inspectAdReplay` +// (`packages/ad-replay/src/internal/inspect.ts`), which never receives +// `req.flags` — so the `parseReplayInput` check that used to reject an +// unrecognized `--replay-backend` value (`src/compat/replay-input.ts`) no +// longer ran on this path. `buildReplayTargetDeviceResolution` +// (`src/daemon/replay-device-selection.ts`) still calls `parseReplayInput` +// for advisory device-lock binding, but its `catch` deliberately swallows +// any thrown error ("Parsing and validation stay in the replay handler."), +// so a raw `.ad` replay with `replayBackend: 'unknown'` executed instead of +// being rejected. `prepareReplayPlan` now restores the identical check +// before `inspectAdReplay` runs. +test('replay rejects an unknown --replay-backend value before any step dispatch (#1555 P1)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-unknown-backend-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayBackend: 'unknown' } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.code).toBe('INVALID_ARGS'); + // Byte-identical to `parseReplayInput`'s message on main + // (`src/compat/replay-input.ts`), so the CLI/client-facing text is unchanged. + expect(response.error.message).toBe('Unsupported replay backend "unknown".'); + expect(invoke).not.toHaveBeenCalled(); +}); + +// Sibling to the rejection test above: `replayBackend: 'maestro'` is the one +// non-empty value main's `parseReplayInput` accepted, and it stays valid even +// against a plain `.ad` file (the format resolver only routes to the Maestro +// engine for a `.yaml`/`.yml` source — see `resolveReplayFormat`). Pins that +// the restored check does not overreject the accepted value. +test('replay still dispatches a plain .ad script with replayBackend: "maestro"', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-ad-maestro-backend-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayBackend: 'maestro' } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect((response.data as { replayed: number }).replayed).toBe(2); + expect(invoke).toHaveBeenCalledTimes(2); +}); + test('replay rejects malformed .ad lines with unclosed quotes', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-invalid-ad-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification-port.test.ts b/src/daemon/handlers/__tests__/session-replay-target-classification-port.test.ts index 9d0e17eb4e..b23fe01aef 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification-port.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-classification-port.test.ts @@ -27,9 +27,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; import { toSnapshotNodes } from './session-replay-target-classification-fixtures.ts'; const PLATFORM = 'ios' as const; +const port = createDaemonReplaySelectorPort(); // Three "Decoy" buttons: the first two exactly tie (same depth/area), the // third is uniquely deepest and smallest, so the deepest-then-smallest @@ -95,6 +97,7 @@ test("P5 port cell 5: allowDisambiguation off skips a RESOLVABLE (not just tied) // must skip the first alternative even though it is resolvable in // principle (see the next test, same fixture, flag flipped). allowDisambiguation: false, + port, }); assert.equal(result.verified, true); @@ -122,6 +125,7 @@ test('P5 port cell 5: the SAME fixture with allowDisambiguation on resolves thro refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assert.equal(result.verified, true); diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts index 0a4ae661dc..eb05be610b 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts @@ -7,12 +7,15 @@ import { buildSelectorChainForNode } from '../../../selectors/build.ts'; import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts'; import { resolvePressRecordingTarget } from '../../../core/press-retarget.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; import { bottomTabsRealCaptureFixture, recordArticleEvidence, toSnapshotNodes, } from './session-replay-target-classification-fixtures.ts'; +const port = createDaemonReplaySelectorPort(); + /** Verified outcomes carry the verified member + matchCount (for the post-resolution guard). */ function assertVerified( result: ReturnType, @@ -39,6 +42,7 @@ test('classifyReplayTarget: real-capture fixture verifies by @ref when the tree refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: winner.ref, matchCount: 1 }); }); @@ -60,6 +64,7 @@ test('classifyReplayTarget: real-capture fixture — a relabeled node is identit refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -122,6 +127,7 @@ test('classifyReplayTarget path 2: selector-miss when the recorded target is gon refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -141,6 +147,7 @@ test('classifyReplayTarget path 4: verified via @ref on an unchanged tree', () = refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: 'e3', matchCount: 1 }); }); @@ -179,6 +186,7 @@ test('classifyReplayTarget uses the later chain alternative that resolution sele refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); // The first alternative ties, so `resolveSelectorChain` skips it and @@ -199,6 +207,7 @@ test('classifyReplayTarget path 4: verified by ref-label fallback when the ref i refLabel: 'Save', requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: 'e3', matchCount: 1 }); }); @@ -214,6 +223,7 @@ test('classifyReplayTarget: an unparseable-but-@-ref token with no fallback labe refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -259,6 +269,7 @@ test('classifyReplayTarget path 5: a unique-but-wrong rebind is caught even when refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -348,6 +359,7 @@ test('classifyReplayTarget path 6: same sibling ordinal recurring under a differ // genuine (non-tied) winner here — exercising path 6's compare-with-W // step, not just the identity-set/region math in isolation. allowDisambiguation: true, + port, }); // Sibling ordinal 0 recurs under both anonymous sections (e5 and e7): the // sibling signal alone cannot isolate. Region-scoped viewportOrder (all @@ -367,6 +379,7 @@ test('classifyReplayTarget path 6: viewport order resolves a lower row via docum refLabel: undefined, requireRect: true, allowDisambiguation: false, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -393,6 +406,7 @@ test('classifyReplayTarget path 6: a recorded scroll region that no longer exist refLabel: undefined, requireRect: true, allowDisambiguation: false, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -417,6 +431,7 @@ test('classifyReplayTarget path 6: an out-of-range recorded viewportOrder falls refLabel: undefined, requireRect: true, allowDisambiguation: false, + port, }); assert.equal(result.verified, false); if (result.verified) throw new Error('unreachable'); @@ -463,6 +478,7 @@ test('classifyReplayTarget: document-order determinism for equal rect centers', refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: 'e4', matchCount: 2 }); }); @@ -542,6 +558,7 @@ test('#1269 e2e: a demoted shared-id row rebinds by role+label after the shared- refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: expected.ref, matchCount: 1 }); @@ -624,6 +641,7 @@ test('#1280 e2e: a retargeted press on a row container rebinds its labeled desce refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); assertVerified(result, { winnerRef: expected.ref, matchCount: 1 }); diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts index 64fb8bf2ef..c56a97b199 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts @@ -19,6 +19,9 @@ import { } from '../../../commands/interaction/runtime/selector-read-utils.ts'; import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; + +const port = createDaemonReplaySelectorPort(); /** The verified-member guard denotation the replay loop mints (identity + structural position). */ function guardFor(node: SnapshotNode, nodes: SnapshotNode[]): ReplayTargetGuardDenotation { @@ -103,6 +106,7 @@ test('split resolver: verification verifies the covered deeper node while dispat refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); // Verification's unfiltered domain: both buttons match; the identity set // isolates A; the unfiltered disambiguation winner is also A (deepest) — @@ -274,6 +278,7 @@ test('same-identity duplicates: verification denotes the covered member A among refLabel: undefined, requireRect: true, allowDisambiguation: true, + port, }); // matchCount 2 (both match); path 6 sibling ordinal isolates A, which is // also the unfiltered disambiguation winner → verified on A. diff --git a/src/daemon/handlers/__tests__/session-replay-target-token.test.ts b/src/daemon/handlers/__tests__/session-replay-target-token.test.ts index 772907e5b8..042be7a9b0 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-token.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-token.test.ts @@ -4,9 +4,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { SessionAction } from '../../types.ts'; import { extractReplayTargetToken, readRefLabel } from '../session-replay-target-token.ts'; +import { createDaemonReplaySelectorPort } from '../../replay-selector-port.ts'; // --------------------------------------------------------------------------- +const port = createDaemonReplaySelectorPort(); + function action(overrides: Partial): SessionAction { return { ts: 0, command: 'click', positionals: [], flags: {}, ...overrides }; } @@ -14,7 +17,7 @@ function action(overrides: Partial): SessionAction { test('extractReplayTargetToken: click/press/longpress/fill take positional 0', () => { for (const command of ['click', 'press', 'longpress', 'fill']) { assert.equal( - extractReplayTargetToken(action({ command, positionals: ['id="save"', 'text'] })), + extractReplayTargetToken(action({ command, positionals: ['id="save"', 'text'] }), port), 'id="save"', ); } @@ -22,14 +25,14 @@ test('extractReplayTargetToken: click/press/longpress/fill take positional 0', ( test('extractReplayTargetToken: get takes positional 1 (after the text/attrs subcommand)', () => { assert.equal( - extractReplayTargetToken(action({ command: 'get', positionals: ['text', 'id="save"'] })), + extractReplayTargetToken(action({ command: 'get', positionals: ['text', 'id="save"'] }), port), 'id="save"', ); }); test('extractReplayTargetToken: a two-numeric-positional point target is not eligible', () => { assert.equal( - extractReplayTargetToken(action({ command: 'click', positionals: ['100', '200'] })), + extractReplayTargetToken(action({ command: 'click', positionals: ['100', '200'] }), port), undefined, ); }); @@ -37,7 +40,7 @@ test('extractReplayTargetToken: a two-numeric-positional point target is not eli test('extractReplayTargetToken: an ineligible command (find/is/wait/scroll) returns undefined', () => { for (const command of ['find', 'is', 'wait', 'scroll', 'swipe']) { assert.equal( - extractReplayTargetToken(action({ command, positionals: ['id="save"'] })), + extractReplayTargetToken(action({ command, positionals: ['id="save"'] }), port), undefined, ); } diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index 6feacc0c8f..71b3ac92d2 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/handlers/session-replay-action-runtime.ts @@ -1,5 +1,4 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import { resolveReplayAction, type ReplayVarScope } from '../../replay/vars.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { mergeParentFlags } from '../../core/batch.ts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; @@ -15,11 +14,21 @@ import { resolveImplicitSessionScope } from '../session-routing.ts'; type ReplayBaseRequest = Omit; +/** + * #1555 review P1 (second pass, "move variable semantics/planning behind the + * replay entrypoint"): `resolved` arrives already `${VAR}`-interpolated — + * the engine's own ONE resolution of this step (`runAdReplay`) — rather than + * this function resolving `action` itself over a `scope` it used to hold. + * `action` (the recorded original) is still threaded alongside it for the + * one daemon-owned, non-interpolation decision that reads it: + * `readRecordedInputVariableName`'s heuristic below, over the ORIGINAL fill + * text. + */ export async function invokeReplayAction(params: { req: DaemonRequest; sessionName: string; action: SessionAction; - scope: ReplayVarScope; + resolved: SessionAction; filePath: string; line: number; step: number; @@ -28,9 +37,18 @@ export async function invokeReplayAction(params: { tracePath?: string; invoke: DaemonInvokeFn; }): Promise { - const { req, sessionName, action, scope, filePath, line, step, sourcePath, tracePath, invoke } = - params; - const resolved = resolveReplayAction(action, scope, { file: sourcePath ?? filePath, line }); + const { + req, + sessionName, + action, + resolved, + filePath, + line, + step, + sourcePath, + tracePath, + invoke, + } = params; const startedAt = Date.now(); appendReplayTraceEvent(tracePath, { type: 'replay_action_start', @@ -54,9 +72,6 @@ export async function invokeReplayAction(params: { sessionName, resolved, sourceAction: action, - scope, - line, - step, invoke, }); } catch (dispatchErr) { @@ -112,9 +127,6 @@ async function invokeResolvedReplayAction(params: { sessionName: string; resolved: SessionAction; sourceAction: SessionAction; - scope: ReplayVarScope; - line: number; - step: number; invoke: DaemonInvokeFn; }): Promise { const { req, sessionName, resolved, sourceAction, invoke } = params; diff --git a/src/daemon/handlers/session-replay-dispatch-narrowing.ts b/src/daemon/handlers/session-replay-dispatch-narrowing.ts new file mode 100644 index 0000000000..00e1008a58 --- /dev/null +++ b/src/daemon/handlers/session-replay-dispatch-narrowing.ts @@ -0,0 +1,148 @@ +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { + AdReplayDispatchGuard, + AdReplayDispatchOutcome, + AdReplayGuardMismatchEvidence, + AdReplayLandmarkMismatchEvidence, + AdReplayStepFailure, +} from '@agent-device/ad-replay'; +import type { LocalIdentity } from '@agent-device/ad-script'; +import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; +import { + isReplayTargetGuardMismatchResponse, + isWaitLandmarkMismatchResponse, +} from './session-replay-target-verification.ts'; + +/** + * #1555 structural-quality review ("split step-loop.ts per the maestro + * precedent it cites... shrink the runtime adapter toward the plan's <300 + * LOC metric"): extracted out of `session-replay-runtime-engine-adapter.ts` + * — the cohesive "given a failed wire `DaemonResponse` and the pre-action + * guard `dispatchStep` was threaded, decide whether it is an ordinary + * failure or one of the two post-resolution identity-refusal markers, and + * narrow the wire response's `details: Record | undefined` + * bag into the engine's typed evidence shapes" concern — separable from + * `createAdReplayStepRuntime`'s runtime-bag construction itself. The + * adapter's `dispatchStep` capability is this module's one caller. + * + * #1555 review P1 (second pass, "translate wire failures before the engine + * boundary"): the wire response's `details` bag is read HERE, at the + * daemon/wire boundary — the one place a real `DaemonResponse` exists — and + * narrowed into the engine's typed evidence shapes before + * `classifyReplayDispatchFailure` returns. `deriveReplayTargetGuardMismatchEvidence`/ + * `deriveWaitLandmarkMismatchEvidence` (`@agent-device/ad-replay`'s engine- + * private `target-verification.ts`) consume only these typed values now — + * the `unknown`-parsing readers below (reading `details.observed`/ + * `details.expectedStructural`/`details.observedStructural`/ + * `details.observedAncestry`/`details.matchCount` defensively) are + * wire-reading responsibility, not engine policy. + */ + +/** Threads a pre-action identity guard into the request's `internal` block the interaction layer reads for its own resolution — a no-op when no guard applies. */ +export function applyReplayDispatchGuard( + replayReq: DaemonRequest, + guard: AdReplayDispatchGuard | undefined, +): DaemonRequest { + const guardInternal = + guard?.kind === 'target' + ? { replayTargetGuard: guard.guard.expected } + : guard?.kind === 'landmark' + ? { replayLandmarkGuard: guard.landmark } + : undefined; + return guardInternal + ? { ...replayReq, internal: { ...replayReq.internal, ...guardInternal } } + : replayReq; +} + +function readGuardMismatchObservedIdentity(value: unknown): LocalIdentity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.role !== 'string') return undefined; + return { + ...(typeof record.id === 'string' ? { id: record.id } : {}), + role: record.role, + ...(typeof record.label === 'string' ? { label: record.label } : {}), + }; +} + +/** The wait refusal's `observedAncestry` entries, defensively re-read off error details. */ +function readAncestryEntries(value: unknown): TargetAncestryEntry[] { + if (!Array.isArray(value)) return []; + const entries: TargetAncestryEntry[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; + const record = entry as Record; + if (typeof record.role !== 'string') return []; + entries.push({ + role: record.role, + ...(typeof record.label === 'string' ? { label: record.label } : {}), + }); + } + return entries; +} + +/** A structural denotation (`{documentOrder, sibling}`), defensively re-read off error details. */ +function readTargetStructuralDenotation( + value: unknown, +): { documentOrder: number; sibling: number } | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.documentOrder !== 'number' || typeof record.sibling !== 'number') { + return undefined; + } + return { documentOrder: record.documentOrder, sibling: record.sibling }; +} + +function readGuardMismatchEvidence( + details: Record | undefined, +): AdReplayGuardMismatchEvidence { + return { + observed: readGuardMismatchObservedIdentity(details?.observed), + expectedStructural: readTargetStructuralDenotation(details?.expectedStructural), + observedStructural: readTargetStructuralDenotation(details?.observedStructural), + }; +} + +function readLandmarkMismatchEvidence( + details: Record | undefined, +): AdReplayLandmarkMismatchEvidence { + return { + matchCount: typeof details?.matchCount === 'number' ? details.matchCount : undefined, + observed: readGuardMismatchObservedIdentity(details?.observed), + observedAncestry: readAncestryEntries(details?.observedAncestry), + }; +} + +/** Projects a wire response down to the neutral shape the engine's outcome carries. */ +export function toAdReplayStepFailure( + response: Extract, + artifactPaths: readonly string[], +): AdReplayStepFailure { + return { kind: response.error.code, message: response.error.message, artifactPaths }; +} + +/** Classifies a failed dispatch response into an ordinary failure or one of the two post-resolution identity-refusal markers (`guard-mismatch`/`landmark-mismatch`) `dispatchStep` detects. */ +export function classifyReplayDispatchFailure( + response: Extract, + guard: AdReplayDispatchGuard | undefined, + entries: readonly string[], +): AdReplayDispatchOutcome { + const plainFailure = toAdReplayStepFailure(response, entries); + if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { + return { + status: 'guard-mismatch', + evidence: readGuardMismatchEvidence(response.error.details), + plainFailure, + artifactPaths: entries, + }; + } + if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { + return { + status: 'landmark-mismatch', + evidence: readLandmarkMismatchEvidence(response.error.details), + plainFailure, + artifactPaths: entries, + }; + } + return { status: 'failed', failure: plainFailure }; +} diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index ffe5363c8a..2dab6763aa 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -9,16 +9,10 @@ import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { captureSnapshot } from './snapshot-capture.ts'; -import { - buildSelectorChainForNode, - resolveSelectorChain, - tryParseSelectorChain, - type Selector, -} from '../../selectors/index.ts'; import { collectReplaySelectorCandidates } from './session-replay-heal.ts'; +import { resolveReplaySuggestionCandidate } from '../replay-selector-port.ts'; import { collectSettleChromeRefs } from '../../core/snapshot-chrome.ts'; import { buildAndPersistReplayDivergenceResume } from './session-replay-resume.ts'; -import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; import { computeReplayRepairHint, @@ -31,7 +25,9 @@ import { type InternalObservationEvidence, } from '../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import type { ReplayReportAction } from './session-replay-report-action.ts'; +import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import type { SessionAction, SessionState } from '../types.ts'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, @@ -67,12 +63,13 @@ export async function buildReplayFailureDivergence(params: { logPath: string; responseLevel: ResponseLevel | undefined; /** Replay-scope values scrubbed from every divergence string (ADR 0012: expanded variables are never serialized). */ - scrubVars?: ReplayVarScrubEntry[]; + scrubVars?: readonly ReplayVarScrubEntry[]; /** ADR 0012 migration step 5: the full top-level plan, used to compute `resume.allowed`. */ planActions: SessionAction[]; /** SHA-256 digest of the canonical plan `planActions` came from (`computeReplayPlanDigest`). */ planDigest: string; signal?: AbortSignal; + port: ReplaySelectorPort; }): Promise { const { error, @@ -90,6 +87,7 @@ export async function buildReplayFailureDivergence(params: { planActions, planDigest, signal, + port, } = params; const sanitize = createReplayDivergenceSanitizer(scrubVars); @@ -115,6 +113,7 @@ export async function buildReplayFailureDivergence(params: { session, nodes: observation.nodes, sanitize, + port, }) : []; @@ -515,16 +514,6 @@ function buildReplayDivergenceScreenRefs( return { refs, truncated }; } -function classifySuggestionBasis(selector: Selector): ReplayDivergenceSuggestionBasis { - const keys = new Set(selector.terms.map((term) => term.key)); - if (keys.has('id')) return 'id'; - const hasRole = keys.has('role'); - const hasLabelLike = keys.has('label') || keys.has('text'); - if (hasRole && hasLabelLike) return 'role-label'; - if (hasLabelLike || keys.has('value')) return 'label'; - return 'other'; -} - /** * Decision 1's candidate machinery reused READ-ONLY over the shared capture. * Ranking: identity-component strength (id > role+label > label > other), @@ -536,13 +525,14 @@ function collectReplayDivergenceSuggestions(params: { session: SessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; + port: ReplaySelectorPort; }): ReplayDivergenceSuggestion[] { - const { action, session, nodes, sanitize } = params; + const { action, session, nodes, sanitize, port } = params; if (!isSuggestionEligibleCommand(action.command)) return []; - const candidates = collectReplaySelectorCandidates(action); + const candidates = collectReplaySelectorCandidates(action, port); if (candidates.length === 0) return []; const matching = resolveSuggestionMatchingConfig(action); - return rankSuggestionCandidates({ candidates, nodes, session, action, matching, sanitize }); + return rankSuggestionCandidates({ candidates, nodes, session, action, matching, sanitize, port }); } function isSuggestionEligibleCommand(command: string): boolean { @@ -577,8 +567,9 @@ function rankSuggestionCandidates(params: { action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; + port: ReplaySelectorPort; }): ReplayDivergenceSuggestion[] { - const { candidates, nodes, session, action, matching, sanitize } = params; + const { candidates, nodes, session, action, matching, sanitize, port } = params; // Dedupe by node (its unique tree index), keeping the STRONGEST match basis // per the ADR: a node reachable through several recorded selector terms // appears once, tagged with its strongest basis — not whichever candidate @@ -592,6 +583,7 @@ function rankSuggestionCandidates(params: { action, matching, sanitize, + port, }); if (!entry) continue; entries.push(entry); @@ -606,29 +598,27 @@ function resolveSuggestionCandidate(params: { action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; + port: ReplaySelectorPort; }): RankedSuggestion | undefined { - const { candidate, nodes, session, action, matching, sanitize } = params; - const chain = tryParseSelectorChain(candidate); - if (!chain) return undefined; - const resolved = resolveSelectorChain(nodes, chain, { + const { candidate, nodes, session, action, matching, sanitize, port } = params; + const match = resolveReplaySuggestionCandidate(candidate, nodes, { platform: session.device.platform, requireRect: matching.requiresRect, - requireUnique: true, - disambiguateAmbiguous: matching.allowDisambiguation, + allowDisambiguation: matching.allowDisambiguation, }); - if (!resolved) return undefined; - const basis = classifySuggestionBasis(resolved.selector); + if (!match) return undefined; return { suggestion: buildReplayDivergenceSuggestionForNode({ - node: resolved.node, + node: match.node, nodes, session, action, - basis, + basis: match.basis, sanitize, + port, }), - basis, - nodeIndex: resolved.node.index, + basis: match.basis, + nodeIndex: match.node.index, }; } @@ -640,9 +630,10 @@ export function buildReplayDivergenceSuggestionForNode(params: { action: ReplayReportAction; basis: ReplayDivergenceSuggestionBasis; sanitize: DivergenceFieldSanitizer; + port: ReplaySelectorPort; }): ReplayDivergenceSuggestion { - const { node, nodes, session, action, basis, sanitize } = params; - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { + const { node, nodes, session, action, basis, sanitize, port } = params; + const selectorChain = port.buildSelectorCandidates(node, session.device.platform, { action: action.command === 'fill' ? 'fill' : isTouchTargetCommand(action.command) ? 'click' : 'get', nodes, diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index 23cdf5111d..106b3e4253 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,7 +1,7 @@ -import { splitIsSelectorArgs, splitSelectorFromArgs } from '../../selectors/index.ts'; import { uniqueStrings } from '@agent-device/kernel/collections'; -import type { ReplayReportAction } from './session-replay-report-action.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import { isTouchTargetCommand } from '@agent-device/ad-script'; +import type { ReplayReportAction } from './session-replay-report-action.ts'; /** * ADR 0012 decision 1 / migration step 6: `--update` retired as an actor — @@ -13,7 +13,10 @@ import { isTouchTargetCommand } from '@agent-device/ad-script'; * (`session-replay-divergence.ts`'s `collectReplayDivergenceSuggestions`). */ -function parseSelectorWaitPositionals(positionals: string[]): { +function parseSelectorWaitPositionals( + positionals: string[], + port: ReplaySelectorPort, +): { selectorExpression: string | null; selectorTimeout: string | null; } { @@ -23,18 +26,21 @@ function parseSelectorWaitPositionals(positionals: string[]): { maybeTimeout !== undefined && /^\d+$/.test(maybeTimeout) ? maybeTimeout : null; const hasTimeout = selectorTimeout !== null; const selectorTokens = hasTimeout ? positionals.slice(0, -1) : positionals.slice(); - const split = splitSelectorFromArgs(selectorTokens); - if (!split || split.rest.length > 0) { + const outcome = port.readSelectorExpression('wait', selectorTokens); + if (outcome.kind !== 'expression' || outcome.rest.length > 0) { return { selectorExpression: null, selectorTimeout: null }; } return { - selectorExpression: split.selectorExpression, + selectorExpression: outcome.expression, selectorTimeout, }; } // fallow-ignore-next-line complexity -export function collectReplaySelectorCandidates(action: ReplayReportAction): string[] { +export function collectReplaySelectorCandidates( + action: ReplayReportAction, + port: ReplaySelectorPort, +): string[] { const result: string[] = []; const explicitChain = Array.isArray(action.result?.selectorChain) && @@ -63,13 +69,13 @@ export function collectReplaySelectorCandidates(action: ReplayReportAction): str } } if (action.command === 'is') { - const { split } = splitIsSelectorArgs([...action.positionals]); - if (split) { - result.push(split.selectorExpression); + const outcome = port.readSelectorExpression('is', [...action.positionals]); + if (outcome.kind === 'expression') { + result.push(outcome.expression); } } if (action.command === 'wait') { - const { selectorExpression } = parseSelectorWaitPositionals([...action.positionals]); + const { selectorExpression } = parseSelectorWaitPositionals([...action.positionals], port); if (selectorExpression) { result.push(selectorExpression); } diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index c47e2363e5..a505a7e9e3 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -13,6 +13,7 @@ import { getRequestSignal } from '../../request/cancel.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; +import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { buildReplayDivergenceSuggestionForNode, buildDivergenceScreen, @@ -20,9 +21,9 @@ import { toReplayRepairHintCapture, type DivergenceFieldSanitizer, } from './session-replay-divergence.ts'; +import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; import { computeReplayRepairHint } from './session-replay-repair-hint.ts'; -import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { buildReplayDivergenceFailureResponseFromDescriptor, hoistReplayFailureCauseDiagnosticMeta, @@ -164,6 +165,14 @@ function collectTypedMaestroSuggestions(params: { nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; }) { + // #1478 P5 stage C: a locally-constructed port instance is fine here — the + // adapter is stateless (no session/request state captured), so this is + // functionally identical to the SAME single instance the native `.ad` + // replay path threads from `session-replay-runtime.ts`, just without + // rippling that threading through the separate typed-Maestro call chain + // (`session-replay-maestro-runtime.ts` / `-response.ts`), which never + // touches `src/selectors` on its own. + const port = createDaemonReplaySelectorPort(); const snapshot = { createdAt: Date.now(), nodes: params.nodes }; return rankAndDedupeReplaySuggestions( adaptMaestroFailureSnapshot(params.failure, snapshot).map(({ node, basis }) => ({ @@ -179,6 +188,7 @@ function collectTypedMaestroSuggestions(params: { action: params.action, basis, sanitize: params.sanitize, + port, }), ); } diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index fc100b2d0e..cfae6dcb75 100644 --- a/src/daemon/handlers/session-replay-maestro-runtime.ts +++ b/src/daemon/handlers/session-replay-maestro-runtime.ts @@ -19,7 +19,7 @@ import { parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, -} from '../../replay/vars.ts'; +} from '@agent-device/ad-script'; import { createDaemonMaestroRuntimePort } from '../adapters/maestro/daemon-runtime-port.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; diff --git a/src/daemon/handlers/session-replay-repair-hint.ts b/src/daemon/handlers/session-replay-repair-hint.ts index faf1f2686e..9cf90d6797 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/handlers/session-replay-repair-hint.ts @@ -14,7 +14,7 @@ * defined. * * Lives in the daemon zone (not `src/replay/`, which stays tree-agnostic per - * `target-identity.ts`'s own contract) because the container-presence test + * `target-identity-node.ts`'s own contract) 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`) @@ -23,7 +23,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; -import { matchesAncestryPrefix } from '../../replay/target-identity.ts'; +import { matchesAncestryPrefix } from '@agent-device/ad-script'; import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/contracts/replay'; import { buildAncestryChain, buildIndexMap } from '../../replay/target-evidence-tree.ts'; import { computeScrollRegionKey, scrollRegionKeysEqual } from '../session-target-evidence.ts'; diff --git a/src/daemon/handlers/session-replay-report-action.ts b/src/daemon/handlers/session-replay-report-action.ts index 672ca3294c..8c9a6391f5 100644 --- a/src/daemon/handlers/session-replay-report-action.ts +++ b/src/daemon/handlers/session-replay-report-action.ts @@ -1,4 +1,4 @@ -import type { SessionAction } from '../types.ts'; +import type { SessionAction } from '@agent-device/contracts/session'; export type ReplayReportAction = { readonly command: string; diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts new file mode 100644 index 0000000000..34b9250aff --- /dev/null +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -0,0 +1,444 @@ +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import { errorResponse } from './response.ts'; +import { readReplaySelectorDisplayValue } from '../replay-selector-port.ts'; +import type { ResponseLevel } from '@agent-device/kernel/contracts'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; +import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; +import { invokeReplayAction } from './session-replay-action-runtime.ts'; +import type { + AdReplayStepFailure, + AdReplayStepRuntime, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; +import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; +import { + applyReplayDispatchGuard, + classifyReplayDispatchFailure, + toAdReplayStepFailure, +} from './session-replay-dispatch-narrowing.ts'; +import { + captureDivergenceObservation, + type DivergenceObservation, +} from './session-replay-divergence.ts'; +import { + buildPostDispatchTargetBindingFailureResponse, + buildRecordedUnverifiableFailureResponse, + buildTargetBindingFailureResponse, + classifyPreDispatchTarget, + resolveTargetVerificationEntry, + type TargetBindingDivergenceContext, +} from './session-replay-target-verification.ts'; +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; + +/** + * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out + * of `session-replay-runtime.ts`, which now only constructs a `ReplayStepContext` and calls + * `createAdReplayStepRuntime`. See that file's `runReplayScriptFile` for the request-level + * orchestration this adapter plugs into. + * + * The wire-narrowing concern (guard threading, `details` bag -> typed + * evidence, dispatch-failure classification) lives in + * `session-replay-dispatch-narrowing.ts` — a real seam with one nameable job. + * Everything else the factory's capabilities delegate to (the step context + * shape, failure wrapping, progress display, diagnostics sampling) lives + * below in this file: a briefly-extracted `-step-support` module was folded + * back after review judged it a size-target fragment, not a concern boundary + * — this adapter's honest size is ~430 lines, renegotiated from the plan's + * <300 metric on #1478. + */ + +/** + * #1478 P5 stage C2b (narrowed further by the #1555 review's neutral-outcomes + * pass, then again by the R3 pass that moved verify-then-dispatch into the + * engine): the daemon's `AdReplayStepRuntime` adapter — the narrow + * routing/capture/classify/dispatch/build-failure capability bag + * `runAdReplay`'s step loop threads through. Every member closes over this + * one request's `ReplayStepContext` (or the outer accumulators it needs to + * keep in sync); none of it is reachable from the engine except through these + * functions — the engine drives WHEN each one is called and, for the four + * target-verification policy decisions, WHAT it means; this adapter only + * knows HOW to do each daemon-owned piece. + * + * `lastResponse` is the side-map the neutral-outcomes design relies on: the + * ONLY place a real `DaemonResponse` is built or held. Every capability that + * can end a step (`dispatchStep`, the three `build*Failure` capabilities, and + * `handleActionFailure`) records the wire response it just built here before + * projecting it down to the neutral `AdReplayStepOutcome`/`AdReplayStepFailure` + * the engine actually sees; `readLastResponse` lets `runReplayScriptFile` + * recover the exact final response once `runAdReplay` reports which step + * failed, so the client-visible wire output never changes even though the + * engine itself never touches it. + * + * `lastObservation` is the analogous side-map for `buildTargetBindingFailure` + * — it reuses the SAME capture `captureObservation` just took (for its + * `screen`), mirroring the pre-R3 code's single-capture-serves-both-paths + * invariant instead of taking a second, possibly-different snapshot. + * + * #1555 structural-quality review ("fix lastObservation to be genuinely + * per-step"): both this closure and `armStep` live for the whole RUN (one + * `createAdReplayStepRuntime` call covers every step), so an un-reset + * `lastObservation` would silently carry a PREVIOUS step's capture into a + * step that somehow reached `buildTargetBindingFailure` without its own + * `captureObservation` call first — the `?? { reason: 'observation-missing' + * }` fallback below exists to name that condition, but could never actually + * fire for it; it would instead attach a stale, wrong-step screen. `armStep` + * runs exactly once per step, before any of this step's capabilities do — + * clearing `lastObservation` there makes the fallback message correct for + * ANY future call ordering, not just the current one where every + * `buildTargetBindingFailure` call site happens to be preceded by this same + * step's own `captureObservation`. + */ +export function createAdReplayStepRuntime(params: { + ctx: ReplayStepContext; + req: DaemonRequest; + /** The outer exception-reporting mirror (see `runReplayScriptFile`'s catch block). */ + artifactPaths: Set; + onStep: ReplayTestAttemptStepSink | undefined; + armSaveScript: () => void; +}): { runtime: AdReplayStepRuntime; readLastResponse: () => DaemonResponse | undefined } { + const { ctx, req, artifactPaths, onStep, armSaveScript } = params; + let lastResponse: DaemonResponse | undefined; + let lastObservation: DivergenceObservation | undefined; + + /** + * The `TargetBindingDivergenceContext` every wire-builder needs — built + * fresh per call from `action`/`index`/its own `artifactPaths` snapshot. + * `scrubVars` is the engine's own live `${VAR}` scrub list as of this + * point in the run, threaded in by the caller rather than recomputed here + * from a scope this adapter no longer holds. + */ + const buildDivergenceContext = ( + action: SessionAction, + index: number, + stepArtifactPaths: readonly string[], + scrubVars: TargetBindingDivergenceContext['scrubVars'], + ): TargetBindingDivergenceContext => ({ + // Only ever called on a path that confirmed `action.targetEvidence` is + // present (the engine checks that before calling anything else). + recorded: action.targetEvidence!, + action, + step: index + 1, + sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, + sourceLine: ctx.actionLines[index] ?? 1, + replayPath: ctx.resolved, + artifactPaths: [...stepArtifactPaths], + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + resumeStamper: ctx.coordinator.resumeStamper, + responseLevel: ctx.responseLevel, + scrubVars, + planActions: ctx.actions, + planDigest: ctx.planDigest, + signal: ctx.signal, + }); + + /** Records `response` in the side-map and projects it down to the neutral failure shape. */ + const recordFailure = (response: DaemonResponse): AdReplayStepFailure => { + lastResponse = response; + return toAdReplayStepFailure( + asFailedReplayStepResponse(response), + collectReplayActionArtifactPaths(response), + ); + }; + + const runtime: AdReplayStepRuntime = { + port: ctx.port, + + beginTargetVerification(action, resolvedAction, _index) { + return resolveTargetVerificationEntry({ + action, + resolvedAction, + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + port: ctx.port, + }); + }, + + async captureObservation(action, _index, options) { + const session = ctx.sessionStore.get(ctx.sessionName); + // #1385: this is the pre-dispatch gate a step right after `open + // --relaunch` can race — the app may still be launching/mounting when + // this capture lands, producing a transient `capture-failed` / + // `sparse-snapshot` verdict that is not a real divergence. Bounded + // retry (`retryLaunchRace`, engine-driven) rides out that transition + // instead of failing closed on the first unlucky capture. + const observation: DivergenceObservation = session + ? await captureDivergenceObservation({ + session, + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + logPath: ctx.logPath, + action, + retryLaunchRace: options.retryLaunchRace, + }) + : { + state: 'unavailable', + reason: 'no-session', + hint: 'The session closed before a screen could be captured to verify the recorded target.', + }; + lastObservation = observation; + return observation.state === 'available' + ? { state: 'available', nodes: observation.nodes } + : { state: 'unavailable', reason: observation.reason, hint: observation.hint }; + }, + + classifyTarget({ action, token, nodes }) { + const session = ctx.sessionStore.get(ctx.sessionName); + return classifyPreDispatchTarget({ + // Only ever called right after a successful `captureObservation`, + // which itself only reaches `state: 'available'` when a session is + // active — `action.targetEvidence`/`session` are always defined here + // in practice. + recorded: action.targetEvidence!, + token, + action, + nodes: [...nodes], + platform: session!.device.platform, + port: ctx.port, + }); + }, + + // `_stepArtifactPaths` (the pre-step snapshot) is unused here — dispatch + // never fed it to `invokeReplayAction`, even before this split; it only + // ever reached the target-binding wire builders (`build*Failure` below). + async dispatchStep(action, resolvedAction, index, _stepArtifactPaths, guard) { + const sourceLine = ctx.actionLines[index] ?? 1; + const response = await invokeReplayAction({ + req: applyReplayDispatchGuard(ctx.replayReq, guard), + sessionName: ctx.sessionName, + action, + resolved: resolvedAction, + filePath: ctx.resolved, + line: sourceLine, + sourcePath: ctx.actionSourcePaths?.[index], + step: index + 1, + tracePath: ctx.actionTracePath, + invoke: ctx.invoke, + }); + lastResponse = response; + const entries = collectReplayActionArtifactPaths(response); + entries.forEach((entry) => artifactPaths.add(entry)); + if (response.ok) return { status: 'ok', artifactPaths: entries }; + return classifyReplayDispatchFailure(response, guard, entries); + }, + + async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths, scrubVars) { + const response = await buildRecordedUnverifiableFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), + { + session: ctx.sessionStore.get(ctx.sessionName), + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + logPath: ctx.logPath, + action, + }, + ); + return recordFailure(response); + }, + + async buildTargetBindingFailure(action, index, evidence, stepArtifactPaths, scrubVars) { + const observation: DivergenceObservation = lastObservation ?? { + state: 'unavailable', + reason: 'observation-missing', + hint: 'No capture was recorded before this target-binding failure.', + }; + const response = buildTargetBindingFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), + evidence, + observation, + ); + return recordFailure(response); + }, + + async buildPostDispatchTargetBindingFailure( + action, + index, + evidence, + stepArtifactPaths, + scrubVars, + ) { + const response = await buildPostDispatchTargetBindingFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), + evidence, + { + session: ctx.sessionStore.get(ctx.sessionName), + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + logPath: ctx.logPath, + action, + }, + ); + return recordFailure(response); + }, + + async handleActionFailure({ + action, + index, + artifactPaths: failureArtifactPaths, + snapshotDiagnosticSamples, + scrubVars, + }) { + const failedResponse = asFailedReplayStepResponse(lastResponse); + const finalResponse = await buildReplayActionFailure( + ctx, + req, + action, + index, + failedResponse, + [...failureArtifactPaths], + [...snapshotDiagnosticSamples], + scrubVars, + ); + // `buildReplayActionFailure` is typed `Promise` (it + // shares its return type with the ordinary success path elsewhere in + // this module) but always produces a failed response on this call + // path — it exists to WRAP a failure with diagnostics/repair-hold + // marking, never to turn one into a success. + return recordFailure(finalResponse); + }, + armStep: () => { + // Runs exactly once per step, before any of this step's other + // capabilities — the natural per-step boundary to clear the previous + // step's capture (see this factory's own header). + lastObservation = undefined; + armSaveScript(); + }, + isRepairArmed: () => ctx.coordinator.view()?.repairBoundary !== undefined, + describeStepValue: (action) => describeReplayStepValue(action), + onStep, + diagnosticsMarker: () => readSessionSnapshotSampleCount(ctx.sessionStore, ctx.sessionName), + diagnosticsSince: (marker) => + readSessionSnapshotSamplesSince(ctx.sessionStore, ctx.sessionName, marker), + }; + return { runtime, readLastResponse: () => lastResponse }; +} + +/** + * Per-run invariants for a single replay step (ADR 0012 step 4 verify + + * dispatch + guard). No `${VAR}` scope here (#1555 review P1, "move variable + * semantics/planning behind the replay entrypoint") — the engine + * (`runAdReplay`) builds and owns it; the adapter never resolves an action + * or reads a scope value itself. + */ +export type ReplayStepContext = { + replayReq: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + resolved: string; + actions: SessionAction[]; + actionLines: number[]; + actionSourcePaths: (string | undefined)[] | undefined; + planDigest: string; + actionTracePath: string | undefined; + responseLevel: ResponseLevel | undefined; + invoke: DaemonInvokeFn; + signal: AbortSignal | undefined; + /** #1478 P4b: the one locked gateway to this request's repair transaction. */ + coordinator: ReplayCoordinator; + /** #1478 P5 stage C: the one selector-port instance this request threads through the divergence-report chain. */ + port: ReplaySelectorPort; +}; + +/** + * `runAdReplay` only ever calls `handleActionFailure` right after a step's + * dispatch/build-failure capability reported `status: 'failed'`, and every + * one of those capabilities records its response in the adapter's + * `lastResponse` side-map before returning — so this narrowing cannot + * actually fail in practice. The `COMMAND_FAILED` fallback exists only so + * `buildReplayActionFailure` (which needs a real failed response to wrap) + * stays total if that invariant is ever violated. + */ +function asFailedReplayStepResponse( + response: DaemonResponse | undefined, +): Extract { + if (response && !response.ok) return response; + return errorResponse( + 'COMMAND_FAILED', + 'replay step reported failure with no recorded response', + ) as Extract; +} + +async function buildReplayActionFailure( + ctx: ReplayStepContext, + req: DaemonRequest, + action: SessionAction, + index: number, + response: Extract, + artifactPaths: string[], + snapshotDiagnosticSamples: SnapshotTimingSample[], + scrubVars: TargetBindingDivergenceContext['scrubVars'], +): Promise { + const heldResponse = (failure: DaemonResponse): DaemonResponse => + ctx.coordinator.markSessionHeldIfArmed(failure); + if (isCompleteTargetBindingDivergenceResponse(response)) return heldResponse(response); + return heldResponse( + await withReplayFailureDiagnostics({ + response, + action, + index, + replayPath: ctx.resolved, + sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, + sourceLine: ctx.actionLines[index] ?? 1, + artifactPaths, + snapshotDiagnosticSamples, + scrubVars, + req, + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + resumeStamper: ctx.coordinator.resumeStamper, + logPath: ctx.logPath, + planActions: ctx.actions, + planDigest: ctx.planDigest, + port: ctx.port, + }), + ); +} + +/** + * A replay-test progress step's display value: the recorded selector's + * label/text/id term value when every alternative agrees on ONE value, else + * `undefined`. Needs `readReplaySelectorDisplayValue`'s private selector AST + * (`replay-selector-port.ts` deliberately keeps it daemon-only — see that + * file's own comment), so this stays daemon-side and is handed to the engine + * loop as the narrow `describeStepValue` capability. + */ +function describeReplayStepValue(action: SessionAction): string | undefined { + const positionals = action.positionals ?? []; + const selectorValue = readReplaySelectorDisplayValue(positionals[0]); + if (selectorValue) return selectorValue; + if (positionals.length === 0) return undefined; + return positionals.join(' '); +} + +// ADR 0012 step 4: a target-binding divergence is already a complete, final +// REPLAY_DIVERGENCE built from its own pre-action capture — distinguished from +// an action-failure divergence by its non-`action-failure` kind. Pinned +// daemon-side: it re-inspects the already-projected `DaemonResponse` wire +// shape to decide whether the wire-level diagnostics-augmentation step +// applies, which is daemon/wire authority, not target-binding classification +// itself (that already happened, in `session-replay-target-classification.ts`'s +// `classifyReplayTarget`, called from `classifyPreDispatchTarget`). +function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): boolean { + if (response.ok || response.error.code !== 'REPLAY_DIVERGENCE') return false; + const divergence = response.error.details?.divergence; + const kind = + divergence && typeof divergence === 'object' + ? (divergence as Record).kind + : undefined; + return typeof kind === 'string' && kind !== 'action-failure'; +} + +function readSessionSnapshotSampleCount(sessionStore: SessionStore, sessionName: string): number { + return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; +} + +function readSessionSnapshotSamplesSince( + sessionStore: SessionStore, + sessionName: string, + start: number, +): SnapshotTimingSample[] { + return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(start) ?? []; +} diff --git a/src/daemon/handlers/session-replay-runtime-failure-response.ts b/src/daemon/handlers/session-replay-runtime-failure-response.ts index 478913e58a..496c2ae67c 100644 --- a/src/daemon/handlers/session-replay-runtime-failure-response.ts +++ b/src/daemon/handlers/session-replay-runtime-failure-response.ts @@ -25,7 +25,7 @@ export function buildReplayDivergenceFailureResponse(params: { artifactPaths: string[]; snapshotDiagnostics?: SnapshotDiagnosticsSummary; divergence: unknown; - scrubVars: ReplayVarScrubEntry[]; + scrubVars: readonly ReplayVarScrubEntry[]; }): DaemonResponse { const { error, @@ -61,7 +61,7 @@ export function buildReplayDivergenceFailureResponseFromDescriptor(params: { artifactPaths: string[]; snapshotDiagnostics?: SnapshotDiagnosticsSummary; divergence: unknown; - scrubVars: ReplayVarScrubEntry[]; + scrubVars: readonly ReplayVarScrubEntry[]; }): DaemonResponse { const { error, diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 6c9163e883..541612cf72 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,4 +1,4 @@ -import { collectReplayScrubbableVarValues, type ReplayVarScope } from '../../replay/vars.ts'; +import type { AdReplayScrubValue, ReplaySelectorPort } from '@agent-device/ad-replay'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, @@ -23,7 +23,8 @@ export async function withReplayFailureDiagnostics(params: { sourceLine: number; artifactPaths: string[]; snapshotDiagnosticSamples: SnapshotTimingSample[]; - scope: ReplayVarScope; + /** The engine's own live `${VAR}` scrub list, as of this point in the run — never recomputed here from a second scope object. */ + scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -32,6 +33,7 @@ export async function withReplayFailureDiagnostics(params: { logPath: string; planActions: SessionAction[]; planDigest: string; + port: ReplaySelectorPort; }): Promise { return await withReplayFailureContext({ ...params, @@ -48,7 +50,8 @@ async function withReplayFailureContext(params: { sourceLine: number; artifactPaths?: string[]; snapshotDiagnostics?: SnapshotDiagnosticsSummary; - scope: ReplayVarScope; + /** The engine's own live `${VAR}` scrub list, as of this point in the run — never recomputed here from a second scope object. */ + scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -57,6 +60,7 @@ async function withReplayFailureContext(params: { logPath: string; planActions: SessionAction[]; planDigest: string; + port: ReplaySelectorPort; }): Promise { const { response, @@ -67,7 +71,7 @@ async function withReplayFailureContext(params: { sourceLine, artifactPaths = [], snapshotDiagnostics, - scope, + scrubVars, req, sessionName, sessionStore, @@ -75,10 +79,10 @@ async function withReplayFailureContext(params: { logPath, planActions, planDigest, + port, } = params; if (response.ok) return response; const failureSource = readReplayFailureSource(response.error.details?.replaySource); - const scrubVars = collectReplayScrubbableVarValues(scope); const cause = hoistReplayFailureCauseDiagnosticMeta(response.error); const divergence = await buildReplayFailureDivergence({ error: cause, @@ -96,6 +100,7 @@ async function withReplayFailureContext(params: { planActions, planDigest, signal: getRequestSignal(req.meta?.requestId), + port, }); return buildReplayDivergenceFailureResponse({ error: cause, diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index d9cf4c5953..e8751c9e15 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -1,198 +1,283 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import type { ReplayPlanDigestMetadata } from '../../replay/plan-digest.ts'; -import type { ReplayScriptMetadata } from '@agent-device/ad-script'; -import type { DaemonResponse } from '../types.ts'; +import type { + DaemonInvokeFn, + DaemonRequest, + DaemonResponse, + SessionAction, + SessionState, +} from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; import { errorResponse } from './response.ts'; +import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; +import { + inspectAdReplay, + type AdReplayManifest, + type AdReplayVarSources, +} from '@agent-device/ad-replay'; +import { + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + type ReplayScriptMetadata, +} from '@agent-device/ad-script'; +import { resolveReplayFormat } from '../../replay/format.ts'; +import { buildReplayBuiltinVars } from './session-replay-vars.ts'; +import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -export function buildReplayMetadataFlags( - flags: CommandFlags | undefined, - metadata: ReplayScriptMetadata, -): CommandFlags { - return { - ...(flags ?? {}), - ...(metadata.platform !== undefined && flags?.platform === undefined - ? { platform: metadata.platform } - : {}), - ...(metadata.target !== undefined && flags?.target === undefined - ? { target: metadata.target } - : {}), - }; +/** + * #1555 P5 (decomposition): `runReplayScriptFile`'s (`session-replay-runtime.ts`) plan-side + * helpers — everything that inspects the script, resolves its `--from`/`--plan-digest` entry + * point, and routes a Maestro-format request, before any session-mutating work begins. Extracted + * verbatim; `buildReplayMetadataFlags` (below) was already here from the #1555 review pass — see + * its own comment for why it, alone among the digest/resume math, stayed daemon-side. + */ + +/** + * `runReplayScriptFile`'s own parameter shape, named here (rather than derived at the call site + * via `Parameters`) so `routeMaestroReplay` below can reference it + * without importing back from `session-replay-runtime.ts` — that direction would be a cycle now + * that the Maestro routing decision lives in this module instead of alongside the function it + * routes for. `session-replay-runtime.ts` imports this type instead of restating the params. + */ +export type ReplayScriptFileParams = { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + tracePath?: string; + /** + * Per-attempt step sink supplied by the replay-test scheduler through its host (#1478 P3). + * Threaded alongside `tracePath` rather than read from request-global storage, so a direct + * `replay` simply has no sink and emits nothing. + */ + onStep?: ReplayTestAttemptStepSink; + invoke: DaemonInvokeFn; +}; + +/** + * Routes a Maestro-format request to the typed Maestro engine, rejecting + * `--keep-session` (native-`.ad`-only lifecycle) and an active `.ad` + * `--save-script` repair boundary first. Returns `undefined` for a non-Maestro + * request so `runReplayScriptFile` continues down the native `.ad` path — + * extracted from `runReplayScriptFile` itself (fallow complexity) rather than + * split further, since every branch here is this one routing decision. + */ +export async function routeMaestroReplay(params: { + resolved: string; + req: DaemonRequest; + keepSession: boolean; + coordinator: ReplayCoordinator; + maestroParams: ReplayScriptFileParams; +}): Promise { + const { resolved, req, keepSession, coordinator, maestroParams } = params; + if (resolveReplayFormat(resolved, req.flags?.replayBackend) !== 'maestro') return undefined; + if (keepSession) { + return errorResponse( + 'INVALID_ARGS', + '--keep-session is supported only for native .ad replay; Maestro YAML owns its lifecycle.', + ); + } + if (coordinator.view()?.repairBoundary !== undefined) { + return errorResponse( + 'INVALID_ARGS', + 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', + ); + } + return await runTypedMaestroReplayFile(maestroParams); } -/** The digest binds the same platform/target values the replay invokes with. */ -export function readEffectiveReplayPlanDigestMetadata( - flags: CommandFlags | undefined, -): ReplayPlanDigestMetadata { +export type PreparedReplayPlan = { + replayReq: DaemonRequest; + actions: SessionAction[]; + actionLines: number[]; + actionSourcePaths: (string | undefined)[] | undefined; + planDigest: string; + preEntrySession: SessionState | undefined; + entryIndex: number; + /** + * `${VAR}` scope INPUTS — plain data, never a built `ReplayVarScope` + * (#1555 review P1, "move variable semantics/planning behind the replay + * entrypoint"): `runAdReplay` builds the scope and performs every + * interpolation itself now. + */ + varSources: AdReplayVarSources; + actionTracePath: string | undefined; +}; + +export function prepareReplayPlan(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + tracePath: string | undefined; + resolved: string; + coordinator: ReplayCoordinator; +}): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { + const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; + const backendRejection = validateReplayBackendFlag(req); + if (backendRejection) return { ok: false, response: backendRejection }; + + const { manifest, replayReq } = inspectReplayPlanManifest(req, resolved); + const { metadata, actions, actionLines, actionSourcePaths, planDigest } = manifest; + const preEntrySession = sessionStore.get(sessionName); + const entryIndexResult = resolveReplayPlanEntryIndex({ + req, + coordinator, + manifest, + preEntrySession, + }); + if (!entryIndexResult.ok) return { ok: false, response: entryIndexResult.response }; + return { - platform: typeof flags?.platform === 'string' ? flags.platform : undefined, - target: typeof flags?.target === 'string' ? flags.target : undefined, + ok: true, + value: { + replayReq, + actions, + actionLines, + actionSourcePaths, + planDigest, + preEntrySession, + entryIndex: entryIndexResult.value, + varSources: buildPreparedReplayVarSources({ + req, + replayReq, + sessionName, + resolved, + metadata, + }), + actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, + }, }; } -type ReplayEntryIndexResult = { ok: true; value: number } | { ok: false; response: DaemonResponse }; - /** - * The session-side state that gates an EMPTY-TAIL resume (`--from actionCount - * + 1`). Stamped for `record-and-heal`, and per #1262 also for - * `caution`/`manual`'s record-and-heal-shaped alternate repair (their own - * unshifted `resume.from` is unaffected by this watermark). + * #1555 P1: the authoritative rejection for an unrecognized --replay-backend + * value. Extraction moved `.ad` inspection to `inspectAdReplay`, which never + * receives flags — restoring the check here (the one caller of + * `inspectAdReplay` that reaches this point with a non-Maestro request) + * matches `src/compat/replay-input.ts`'s `parseReplayInput` exactly, byte for + * byte, before any plan/session work begins. `replayBackend: 'maestro'` still + * passes here because `runReplayScriptFile` has already routed a real + * Maestro-format request to `runTypedMaestroReplayFile` above; only a + * stray/unknown value reaches this branch. */ -export type PendingRecordAndHeal = { expectedFrom: number; actionsCountAtDivergence: number }; +function validateReplayBackendFlag(req: DaemonRequest): DaemonResponse | undefined { + if (req.flags?.replayBackend && req.flags.replayBackend !== 'maestro') { + return errorResponse( + 'INVALID_ARGS', + `Unsupported replay backend "${req.flags.replayBackend}".`, + ); + } + return undefined; +} /** - * Resolves `--from`/`--plan-digest` into a 0-based loop entry index before - * any device action. `--from` is 1-based and matches divergence step indices. - * - * `pendingRecordAndHeal`/`sessionActionsLength` gate the ONE ordinal beyond - * the plan's end (`actionCount + 1`): ADR 0012 decision 6, R2's `record-and-heal` - * repair — and, per #1262, `caution`/`manual`'s record-and-heal-SHAPED - * alternate repair — resumes past the plan's LAST step once the agent - * performs the diverged step's intent as a recorded action, and that resume - * must execute zero device actions before reaching the normal completion - * path. That allowance is scoped to the EXACT session + target that actually - * produced it (the `ReplayCoordinator`'s `stampCorrectiveWatermark`, `session-replay-coordinator.ts`, #1478 P4b), - * and only once a new action proves the corrective press happened — never a - * blanket "one past the end is fine" for any session, which would let an - * unrelated or blind `--from actionCount + 1` silently skip the plan's tail - * and commit an unfinished repair. `caution`/`manual`'s OWN `resume.from` - * (the failed step's own index, unshifted) stays legal unconditionally - * regardless of this watermark — it is always `<= actionCount`, never the - * one-past-the-end ordinal this gate concerns. + * #1555 P1 (digest/resume behind runAdReplay): `digestFlags` is the raw + * request-level platform/target override — `inspectAdReplay` applies the + * SAME precedence (flag, then a script-declared platform, then the `context` + * header) internally that this call site used to apply itself via + * `readEffectiveReplayPlanDigestMetadata(replayReq.flags)`. */ -export function resolveReplayEntryIndex( - flags: CommandFlags | undefined, - actionCount: number, - planDigest: string, - pendingRecordAndHeal: PendingRecordAndHeal | undefined, - sessionActionsLength: number, -): ReplayEntryIndexResult { - const from = flags?.replayFrom; - const digest = flags?.replayPlanDigest; - if (from === undefined && digest === undefined) return { ok: true, value: 0 }; - if (from === undefined || digest === undefined) { - return invalidReplayEntryIndex( - 'replay --from requires --plan-digest (and --plan-digest requires --from).', - ); - } - const message = validateReplayResumeRequest({ - from, - digest, - planDigest, - actionCount, - pendingRecordAndHeal, - sessionActionsLength, +function inspectReplayPlanManifest( + req: DaemonRequest, + resolved: string, +): { manifest: AdReplayManifest; replayReq: DaemonRequest } { + const manifest = inspectAdReplay(resolved, { + platform: req.flags?.platform, + target: req.flags?.target, }); - return message ? invalidReplayEntryIndex(message) : { ok: true, value: from - 1 }; + const replayReq = applyReplayMetadata( + { ...req, flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions) }, + manifest.metadata, + ); + return { manifest, replayReq }; } -function invalidReplayEntryIndex(message: string): ReplayEntryIndexResult { - return { ok: false, response: errorResponse('INVALID_ARGS', message) }; +function resolveReplayPlanEntryIndex(params: { + req: DaemonRequest; + coordinator: ReplayCoordinator; + manifest: AdReplayManifest; + preEntrySession: SessionState | undefined; +}): { ok: true; value: number } | { ok: false; response: DaemonResponse } { + const { req, coordinator, manifest, preEntrySession } = params; + const entryIndex = manifest.resolveEntryIndex({ + from: req.flags?.replayFrom, + digest: req.flags?.replayPlanDigest, + pendingRecordAndHeal: coordinator.view()?.pendingRecordAndHeal, + sessionActionsLength: preEntrySession?.actions.length ?? 0, + }); + if (!entryIndex.ok) { + return { ok: false, response: errorResponse('INVALID_ARGS', entryIndex.message) }; + } + return { ok: true, value: entryIndex.value }; } -/** A single sub-check of a `--from` resume request; `undefined` means "no objection". */ -type ReplayResumeCheck = () => string | undefined; - -function validateReplayResumeRequest(params: { - from: number; - digest: string; - planDigest: string; - actionCount: number; - pendingRecordAndHeal: PendingRecordAndHeal | undefined; - sessionActionsLength: number; -}): string | undefined { - const { from, digest, planDigest, actionCount, pendingRecordAndHeal, sessionActionsLength } = - params; - const checks: ReplayResumeCheck[] = [ - () => describeOutOfRangeResumeFrom({ from, actionCount, pendingRecordAndHeal }), - () => describeUnperformedRecordAndHeal({ from, pendingRecordAndHeal, sessionActionsLength }), - () => describeStaleResumeDigest(digest, planDigest), - ]; - for (const check of checks) { - const message = check(); - if (message) return message; - } - return undefined; +function applyReplayMetadata( + req: DaemonRequest, + metadata: AdReplayManifest['metadata'], +): DaemonRequest { + if (!metadata.platform && !metadata.target) return req; + return { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) }; } /** - * `actionCount + 1` (one past the plan's end) is a legal EMPTY-TAIL resume - * ONLY when it matches THIS session's own record-and-heal-shaped divergence - * watermark (the `ReplayCoordinator`'s `stampCorrectiveWatermark`, - * `session-replay-coordinator.ts`, #1478 P4b — stamped for `record-and-heal`, and per #1262 also for `caution`/`manual`'s - * recorded-action alternate) — never a blanket "one past the end is fine" for - * any session or repair kind. Absent a matching watermark, `actionCount + 1` - * is exactly as out-of-range as any other ordinal beyond the plan. + * The `${VAR}` scope's raw INPUTS — builtins (this request's session/ + * platform/target/device/artifacts-dir), the script's own `env` header, the + * shell's `AD_VAR_*` entries, and `-e KEY=VALUE` CLI entries — read here, + * once, from the request/process. `runAdReplay` is the one place these are + * merged into an actual scope and used to resolve an action (#1555 review + * P1); this function stops at collecting the plain data. */ -function describeOutOfRangeResumeFrom(params: { - from: number; - actionCount: number; - pendingRecordAndHeal: PendingRecordAndHeal | undefined; -}): string | undefined { - const { from, actionCount, pendingRecordAndHeal } = params; - const isAuthorizedEmptyTail = - from === actionCount + 1 && - pendingRecordAndHeal !== undefined && - pendingRecordAndHeal.expectedFrom === from; - const inRange = - Number.isInteger(from) && from >= 1 && (from <= actionCount || isAuthorizedEmptyTail); - return inRange - ? undefined - : `replay --from ${from} is out of range for a ${actionCount}-step plan.`; +function buildPreparedReplayVarSources(params: { + req: DaemonRequest; + replayReq: DaemonRequest; + sessionName: string; + resolved: string; + metadata: AdReplayManifest['metadata']; +}): AdReplayVarSources { + const { req, replayReq, sessionName, resolved, metadata } = params; + return { + builtins: buildReplayBuiltinVars({ + req: replayReq, + sessionName, + metadata, + resolvedPath: resolved, + }), + fileEnv: metadata.env, + shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), + cliEnv: parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), + }; } /** - * A `from` matching a pending record-and-heal-shaped watermark — in-range - * (mid-plan, `record-and-heal` only) or the empty-tail boundary the range - * check above authorizes (`record-and-heal`, or per #1262 also - * `caution`/`manual`'s alternate repair, which is ONLY ever stamped at that - * boundary — see the `ReplayCoordinator`'s `stampCorrectiveWatermark`, - * `session-replay-coordinator.ts`) — requires proof the agent actually performed - * the diverged step: the session's recorded action count must have grown - * since the divergence. Without that proof, this would silently resume past - * an unrepaired step instead of rejecting. `caution`/`manual`'s own - * `resume.from` stays at the failed step unchanged and is never subject to - * this check (it never matches `expectedFrom`, which only ever targets - * `failedIndex + 1`), so the message below is intentionally hint-neutral. + * #1555 review P1 ("digest/resume must also occur behind runAdReplay"): the + * `--from`/`--plan-digest` resume-point math (`resolveReplayEntryIndex`) and + * the digest-metadata reader that fed it (`readEffectiveReplayPlanDigestMetadata`, + * `PendingRecordAndHeal`) moved into `@agent-device/ad-replay` — + * `inspectAdReplay`'s manifest now exposes the digest as `planDigest` and the + * resume math as a `resolveEntryIndex` closure, both computed from the SAME + * effective platform/target precedence this file used to apply itself. Only + * `buildReplayMetadataFlags` stays here: it builds the REQUEST's flags (used + * throughout `runReplayScriptFile`, not just for the digest), which is a + * daemon/wire concern the manifest has no reason to own. * - * #1271 stage 2 (ADR 0012 amendment): this same growth check is now also the - * repair-segment empty-heal guard. Observation-only actions - * (`snapshot`/`get`/`is`/`find`) are, by default, excluded from - * `session.actions` while repair-armed (`isExcludedRepairSegmentObservation`, - * `session-action-recorder.ts`), so a repair segment containing ONLY - * unrecorded diagnostic reads never grows `sessionActionsLength` either — - * this check refuses it exactly as it already refused "no corrective press - * happened," converting the corrective-read case's one silent-failure mode - * (an excluded read silently missing from the heal) into this same loud - * rejection. The message therefore names `--record` alongside the existing - * `--no-record` mention, since the missing corrective action may have been a - * read rather than a press. + * Module-private as of the #1555 P5 decomposition: its one caller, + * `applyReplayMetadata`, now lives in this same file (it used to live in + * `session-replay-runtime.ts`). */ -function describeUnperformedRecordAndHeal(params: { - from: number; - pendingRecordAndHeal: PendingRecordAndHeal | undefined; - sessionActionsLength: number; -}): string | undefined { - const { from, pendingRecordAndHeal, sessionActionsLength } = params; - if ( - pendingRecordAndHeal?.expectedFrom !== from || - sessionActionsLength !== pendingRecordAndHeal.actionsCountAtDivergence - ) { - return undefined; - } - return ( - `replay --from ${from} continues a record-and-heal-shaped repair, but no corrective action was ` + - "recorded in this repair segment; press the correct control via a blessed @ref from the divergence's " + - 'screen.refs (recorded, no --no-record) — or, if your corrective action was a read ' + - '(get/is/find/snapshot), re-run it with --record so it lands in the heal — before resuming with ' + - `--from ${from}.` - ); -} - -function describeStaleResumeDigest(digest: string, planDigest: string): string | undefined { - if (digest === planDigest) return undefined; - return ( - 'replay --plan-digest does not match the current plan digest; the script, its includes, or its ' + - 'platform-conditioned expansion changed since the divergence report was generated. Run a fresh full ' + - 'replay to get a new digest.' - ); +function buildReplayMetadataFlags( + flags: CommandFlags | undefined, + metadata: ReplayScriptMetadata, +): CommandFlags { + return { + ...(flags ?? {}), + ...(metadata.platform !== undefined && flags?.platform === undefined + ? { platform: metadata.platform } + : {}), + ...(metadata.target !== undefined && flags?.target === undefined + ? { target: metadata.target } + : {}), + }; } diff --git a/src/daemon/handlers/session-replay-runtime-session.ts b/src/daemon/handlers/session-replay-runtime-session.ts new file mode 100644 index 0000000000..5a2c61c68d --- /dev/null +++ b/src/daemon/handlers/session-replay-runtime-session.ts @@ -0,0 +1,219 @@ +import fs from 'node:fs'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import { expandSessionPath } from '../session-paths.ts'; +import { errorResponse, noActiveSessionError } from './response.ts'; +import { + NO_SCRIPT_PUBLICATION, + scriptTargetForce, + scriptTargetPath, + type SessionScriptPublicationState, +} from '../session-script-publication-state.ts'; +import { healedScriptSiblingPath, type ReplayCoordinator } from '../session-replay-coordinator.ts'; + +/** + * #1555 P5 (decomposition): `runReplayScriptFile`'s (`session-replay-runtime.ts`) session + * preparation — the repair-preflight/resume-consumption/save-script-arming work that runs after + * `prepareReplayPlan` (`session-replay-runtime-plan.ts`) accepts a plan but before the engine step + * loop dispatches step 1. Extracted verbatim. `prepareReplaySession` is the one entry point; + * everything else here is its own private decomposition (R2's repair-preflight, R6's arm-time + * EEXIST preflight, and the actual arming closure). + */ + +export function prepareReplaySession(params: { + req: DaemonRequest; + entryIndex: number; + sessionStore: SessionStore; + sessionName: string; + sourcePath: string; + coordinator: ReplayCoordinator; +}): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { + const { req, entryIndex, sessionStore, sessionName, sourcePath, coordinator } = params; + const sessionPreflight = validateReplaySessionEntry({ + entryIndex, + sessionStore, + sessionName, + coordinator, + }); + if (sessionPreflight) return { ok: false, response: sessionPreflight }; + + consumeReplayResumeState({ req, coordinator }); + return prepareSaveScriptSession({ req, sessionStore, sessionName, sourcePath, coordinator }); +} + +function validateReplaySessionEntry(params: { + entryIndex: number; + sessionStore: SessionStore; + sessionName: string; + coordinator: ReplayCoordinator; +}): DaemonResponse | undefined { + const repairPreflight = preflightReplayAgainstActiveRepair(params); + if (repairPreflight) return repairPreflight; + if (params.entryIndex > 0 && !params.sessionStore.get(params.sessionName)) { + return noActiveSessionError(); + } + return undefined; +} + +/** + * Rejects arming a repair over an ordinary authoring recording (R2's disjointness) and runs the + * arm-time EEXIST preflight against the target this request resolves to. + */ +function rejectSaveScriptArming(params: { + saveScript: boolean | string | undefined; + force: boolean | undefined; + preRunState: SessionScriptPublicationState; + sourcePath: string; +}): DaemonResponse | undefined { + const { saveScript, force, preRunState, sourcePath } = params; + if (saveScript && preRunState.kind === 'authoring') { + return errorResponse( + 'INVALID_ARGS', + `replay --save-script cannot re-arm an ordinary recording in terminal/active state ${preRunState.status}. Close this session and use a fresh one for repair authoring.`, + ); + } + return preflightSaveScriptTarget({ + saveScript, + liveForce: force, + persistedForce: scriptTargetForce(preRunState) || undefined, + sourcePath, + existingSaveScriptPath: scriptTargetPath(preRunState), + }); +} + +function prepareSaveScriptSession(params: { + req: DaemonRequest; + sessionStore: SessionStore; + sessionName: string; + sourcePath: string; + coordinator: ReplayCoordinator; +}): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { + const { req, sessionStore, sessionName, sourcePath, coordinator } = params; + const preRunSession = sessionStore.get(sessionName); + const { saveScript, force } = req.flags ?? {}; + const rejection = rejectSaveScriptArming({ + saveScript, + force, + preRunState: preRunSession?.scriptPublication ?? NO_SCRIPT_PUBLICATION, + sourcePath, + }); + if (rejection) return { ok: false, response: rejection }; + + coordinator.demoteForRerunIfArmed(); + return { + ok: true, + armSaveScript: createReplaySaveScriptArmer({ + saveScript, + force, + coordinator, + sourcePath, + }), + }; +} + +function consumeReplayResumeState(params: { + req: DaemonRequest; + coordinator: ReplayCoordinator; +}): void { + const { req, coordinator } = params; + coordinator.clearCorrectiveWatermarkIfExpected(req.flags?.replayFrom); + if (req.flags?.saveScript) coordinator.clearTombstone(); +} + +/** + * ADR 0012 decision 6, R2: reject a fresh FULL replay on a session that + * already carries a repair-run boundary — the session stays repair-armed + * (`recordSession` remains true), so ANY full re-run re-appends the + * already-recorded prefix (`session-action-recorder.ts` pushes + * unconditionally), duplicating it in the healed slice. This fires REGARDLESS + * of whether `--save-script` is passed this invocation (omitting the flag + * does not disarm the session). A `--from` resume (`entryIndex > 0`) + * legitimately continues the same armed run and is allowed. + */ +function preflightReplayAgainstActiveRepair(params: { + entryIndex: number; + coordinator: ReplayCoordinator; +}): DaemonResponse | undefined { + const { entryIndex, coordinator } = params; + if (entryIndex > 0) return undefined; + if (coordinator.view()?.repairBoundary === undefined) return undefined; + return errorResponse( + 'INVALID_ARGS', + 'This session has an active --save-script repair run; continue it with replay --from --plan-digest , or finish with close, before starting a fresh full replay.', + ); +} + +/** + * #1258: arm-time EEXIST preflight. Absent this, a repair-armed run's target + * is only checked at PUBLISH time (`publishHealedScriptAtomically`, on + * `close`/completion) — by then the ENTIRE repair (agent's corrective steps + * included) may already have executed against the device, only to fail on a + * pre-existing target at the very end. Resolves the SAME target + * the coordinator's `armStep` would (explicit `--save-script=` always + * wins; otherwise an already-armed session's existing path if this is a + * `--from` continuation leg reusing it, else the default `.healed.ad` + * sibling) WITHOUT needing the session to exist yet, so it runs before step 1 + * dispatches even when that step is the `open` that creates the session. + * READ-ONLY: it never mutates the session (it runs before + * `resolveScriptTarget`). + * + * The effective-force decision MATCHES `resolveScriptTarget`'s per-target + * contract, computed against the target THIS request resolves to: a live + * `--force`/`--overwrite` always bypasses; a PERSISTED per-target grant + * bypasses ONLY when this request writes to the SAME target it was granted for + * (`targetPath === existingSaveScriptPath`). An explicit RETARGET to a + * different path without a live force does NOT bypass here — because + * `resolveScriptTarget` will CLEAR that persisted force for the new target + * before publication anyway, so letting the run execute (mutating the session + * mid-flight) only to refuse the existing target at the end is exactly what + * this preflight exists to prevent. A no-op when `--save-script` was not passed. + */ +function preflightSaveScriptTarget(params: { + saveScript: boolean | string | undefined; + liveForce: boolean | undefined; + persistedForce: boolean | undefined; + sourcePath: string; + existingSaveScriptPath: string | undefined; +}): DaemonResponse | undefined { + const { saveScript, liveForce, persistedForce, sourcePath, existingSaveScriptPath } = params; + if (!saveScript) return undefined; + const targetPath = + typeof saveScript === 'string' + ? expandSessionPath(saveScript) + : (existingSaveScriptPath ?? healedScriptSiblingPath(sourcePath)); + const effectiveForce = + Boolean(liveForce) || (Boolean(persistedForce) && targetPath === existingSaveScriptPath); + if (effectiveForce) return undefined; + if (!fs.existsSync(targetPath)) return undefined; + return errorResponse( + 'COMMAND_FAILED', + `A file already exists at ${targetPath}; remove it, pass replay --save-script=, or pass --force/--overwrite to replace it.`, + ); +} + +/** + * ADR 0012 decision 6 (Fix 3): the source plan's own terminal `close` is + * lifecycle, not a script step to replay, while a repair is armed — the agent + * finalizes the transaction with `close --save-script` instead + * (`session-close.ts`). Replaying the recorded `close` here would dispatch it + * as an ordinary step: it tears the session down (and, absent Fix 1/2, could + * even publish or diverge) before the agent gets that chance. The pure + * decision (`resolveSuppressedTerminalCloseIndex`, unified with #1554's + * `--keep-session` suppression) now lives in `@agent-device/ad-replay`'s step + * loop; this daemon-only preflight — the arm-time EEXIST check above — is + * unrelated repair authority that stays here. + */ +function createReplaySaveScriptArmer(params: { + saveScript: boolean | string | undefined; + force: boolean | undefined; + coordinator: ReplayCoordinator; + sourcePath: string; +}): () => void { + const { saveScript, force, coordinator, sourcePath } = params; + if (!saveScript) return () => {}; + let firstArm = true; + return () => { + coordinator.armStep({ saveScript, force, sourcePath, firstArm }); + firstArm = false; + }; +} diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 9b484ce6e2..c2947561fd 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -1,224 +1,50 @@ -import fs from 'node:fs'; -import { parseReplayInput } from '../../compat/replay-input.ts'; import { asAppError } from '@agent-device/kernel/errors'; -import type { - DaemonInvokeFn, - DaemonRequest, - DaemonResponse, - SessionAction, - SessionState, -} from '../types.ts'; +import type { DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { expandSessionPath } from '../session-paths.ts'; -import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; -import { computeReplayPlanDigest } from '../../replay/plan-digest.ts'; -import { errorResponse, noActiveSessionError } from './response.ts'; -import { invokeReplayAction } from './session-replay-action-runtime.ts'; -import { tryParseSelectorChain } from '../../selectors/index.ts'; -import type { ResponseLevel } from '@agent-device/kernel/contracts'; -import { - buildReplayVarScope, - collectReplayShellEnv, - parseReplayCliEnvEntries, - readReplayCliEnvEntries, - readReplayShellEnvSource, - type ReplayVarScope, -} from '../../replay/vars.ts'; -import { - summarizeSnapshotTimingSamples, - type SnapshotTimingSample, -} from '@agent-device/contracts/capture'; -import type { ReplayCommandResult, TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { - isMaestroYamlPath, - maestroBackendRequiredMessage, - resolveReplayFormat, -} from '../../replay/format.ts'; -import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; -import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; -import { - buildReplayMetadataFlags, - readEffectiveReplayPlanDigestMetadata, - resolveReplayEntryIndex, -} from './session-replay-runtime-plan.ts'; -import { - buildReplayTargetGuardMismatchResponse, - buildWaitLandmarkMismatchResponse, - isReplayTargetGuardMismatchResponse, - isWaitLandmarkMismatchResponse, - verifyReplayActionTarget, - type ReplayVerifiedTargetGuard, -} from './session-replay-target-verification.ts'; -import { buildReplayBuiltinVars } from './session-replay-vars.ts'; -import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; -import type { ReplayTestAttemptStep, ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import { errorResponse } from './response.ts'; +import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; +import { runAdReplay } from '@agent-device/ad-replay'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; +import type { ReplayCommandResult } from '@agent-device/contracts/replay'; +import { isMaestroYamlPath, maestroBackendRequiredMessage } from '../../replay/format.ts'; import { getRequestSignal } from '../../request/cancel.ts'; +import { createReplayCoordinator, type ReplayCoordinator } from '../session-replay-coordinator.ts'; import { - NO_SCRIPT_PUBLICATION, - scriptTargetForce, - scriptTargetPath, - type SessionScriptPublicationState, -} from '../session-script-publication-state.ts'; + createAdReplayStepRuntime, + type ReplayStepContext, +} from './session-replay-runtime-engine-adapter.ts'; import { - createReplayCoordinator, - healedScriptSiblingPath, - type ReplayCoordinator, -} from '../session-replay-coordinator.ts'; -import { - countExecutedReplayActions, - isExecutableReplayAction, - requireLiveSessionForKeepSession, - resolveSuppressedTerminalCloseIndex, -} from './session-replay-terminal-lifecycle.ts'; - -/** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */ -type ReplayStepContext = { - scope: ReplayVarScope; - replayReq: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - logPath: string; - resolved: string; - actions: SessionAction[]; - actionLines: number[]; - actionSourcePaths: (string | undefined)[] | undefined; - planDigest: string; - actionTracePath: string | undefined; - responseLevel: ResponseLevel | undefined; - invoke: DaemonInvokeFn; - signal: AbortSignal | undefined; - /** #1478 P4b: the one locked gateway to this request's repair transaction. */ - coordinator: ReplayCoordinator; -}; - -/** - * ADR 0012 migration step 4: verify the recorded target BEFORE sending the - * device action. A non-verified outcome is a complete target-binding - * REPLAY_DIVERGENCE (built from its own pre-action capture); only a verified - * outcome dispatches, carrying the verified member's identity as a - * post-resolution guard so dispatch's own resolution (occlusion/visibility - * guards verification does not replicate) must land on the SAME element or - * refuse pre-action. - */ -async function resolveReplayStepResponse( - ctx: ReplayStepContext, - action: SessionAction, - index: number, - artifactPaths: string[], -): Promise { - const sourcePath = ctx.actionSourcePaths?.[index] ?? ctx.resolved; - const sourceLine = ctx.actionLines[index] ?? 1; - const verification = await verifyReplayActionTarget({ - action, - scope: ctx.scope, - sourcePath, - sourceLine, - replayPath: ctx.resolved, - step: index + 1, - sessionName: ctx.sessionName, - sessionStore: ctx.sessionStore, - resumeStamper: ctx.coordinator.resumeStamper, - logPath: ctx.logPath, - artifactPaths, - responseLevel: ctx.responseLevel, - planActions: ctx.actions, - planDigest: ctx.planDigest, - signal: ctx.signal, - }); - if (!verification.verified) return verification.response; - const guard = verification.guard; - const deferredLandmark = verification.deferredLandmark; - const guardInternal = guard - ? { replayTargetGuard: guard.expected } - : deferredLandmark - ? { replayLandmarkGuard: deferredLandmark } - : undefined; - const guardedReq = guardInternal - ? { ...ctx.replayReq, internal: { ...ctx.replayReq.internal, ...guardInternal } } - : ctx.replayReq; - const response = await invokeReplayAction({ - req: guardedReq, - sessionName: ctx.sessionName, - action, - scope: ctx.scope, - filePath: ctx.resolved, - line: sourceLine, - sourcePath: ctx.actionSourcePaths?.[index], - step: index + 1, - tracePath: ctx.actionTracePath, - invoke: ctx.invoke, - }); - return await convertIdentityRefusalResponse({ - ctx, - action, - index, - artifactPaths, - sourcePath, - sourceLine, - response, - guard, - deferredLandmark, - }); -} + prepareReplayPlan, + routeMaestroReplay, + type ReplayScriptFileParams, +} from './session-replay-runtime-plan.ts'; +import { prepareReplaySession } from './session-replay-runtime-session.ts'; /** - * Converts a dispatch-time identity refusal — the post-resolution guard - * mismatch, or wait's landmark timeout — into its identity-mismatch - * divergence; every other response passes through unchanged. + * #1555 P5 (decomposition): the replay request's own orchestration — routing, plan resolution, + * session preparation, the engine step loop, and run completion — kept thin by extracting the + * three cohesive pieces it drives into their own modules: + * - the plan-side helpers (`validateReplayBackendFlag`, `inspectReplayPlanManifest`, + * `resolveReplayPlanEntryIndex`, `routeMaestroReplay`, and `prepareReplayPlan` itself) live in + * `session-replay-runtime-plan.ts`, alongside the digest/resume metadata helper that was + * already there. + * - session preparation (the R2 repair preflight, resume-state consumption, and save-script + * arming) lives in `session-replay-runtime-session.ts`. + * - the `AdReplayStepRuntime` engine adapter (`createAdReplayStepRuntime`, its `build*Failure` + * capability implementations, and the `lastResponse`/`lastObservation` side-map mechanics) + * lives in `session-replay-runtime-engine-adapter.ts`. + * This file is what remains: the one place `runReplayScriptFile` composes them, and the run's + * completion (`completeReplayRun`/`requireLiveSessionForKeepSession`), which runs after the engine + * loop returns and never touches the step runtime itself. + * + * Coordinator ownership is unchanged by this split: `createReplayCoordinator` is still called + * here, and only here — see `src/daemon/__tests__/replay-coordinator-ownership.test.ts` — every + * extracted module receives the already-constructed `ReplayCoordinator` as a parameter instead of + * constructing its own. */ -async function convertIdentityRefusalResponse(params: { - ctx: ReplayStepContext; - action: SessionAction; - index: number; - artifactPaths: string[]; - sourcePath: string; - sourceLine: number; - response: DaemonResponse; - guard: ReplayVerifiedTargetGuard | undefined; - deferredLandmark: TargetAnnotationV1 | undefined; -}): Promise { - const { ctx, action, index, artifactPaths, sourcePath, sourceLine, response } = params; - const mismatchParams = { - action, - scope: ctx.scope, - failedResponse: response, - sourcePath, - sourceLine, - replayPath: ctx.resolved, - step: index + 1, - sessionName: ctx.sessionName, - sessionStore: ctx.sessionStore, - resumeStamper: ctx.coordinator.resumeStamper, - logPath: ctx.logPath, - artifactPaths, - responseLevel: ctx.responseLevel, - planActions: ctx.actions, - planDigest: ctx.planDigest, - signal: ctx.signal, - }; - if (params.guard && isReplayTargetGuardMismatchResponse(response)) { - return await buildReplayTargetGuardMismatchResponse({ ...mismatchParams, guard: params.guard }); - } - if (params.deferredLandmark && isWaitLandmarkMismatchResponse(response)) { - return await buildWaitLandmarkMismatchResponse(mismatchParams); - } - return response; -} -export async function runReplayScriptFile(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - tracePath?: string; - /** - * Per-attempt step sink supplied by the replay-test scheduler through its host (#1478 P3). - * Threaded alongside `tracePath` rather than read from request-global storage, so a direct - * `replay` simply has no sink and emits nothing. - */ - onStep?: ReplayTestAttemptStepSink; - invoke: DaemonInvokeFn; -}): Promise { +export async function runReplayScriptFile(params: ReplayScriptFileParams): Promise { const { req, sessionName, logPath, sessionStore, tracePath, onStep, invoke } = params; const filePath = req.positionals?.[0]; if (!filePath) { @@ -228,30 +54,31 @@ export async function runReplayScriptFile(params: { const startedAt = Date.now(); const keepSession = req.flags?.replayKeepSession === true; let resolved = ''; + // The one accumulator `createAdReplayStepRuntime`'s adapter mutates as it + // dispatches/builds each step's failure (via `collectReplayActionArtifactPaths`), + // so a mid-loop exception still reports the artifacts collected up to that + // point. const artifactPaths = new Set(); // #1478 P4b: the one locked coordinator this request reaches the repair // transaction and resume watermark through. const coordinator = createReplayCoordinator({ sessionStore, sessionName }); + // #1478 P5 stage C: the one selector-port instance this request threads + // through the divergence-report chain (verification, classification, + // suggestion building) — never a second-constructed adapter. + const port = createDaemonReplaySelectorPort(); try { resolved = SessionStore.expandHome(filePath, req.meta?.cwd); if (isMaestroYamlPath(resolved) && req.flags?.replayBackend !== 'maestro') { return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', filePath)); } - if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { - if (keepSession) { - return errorResponse( - 'INVALID_ARGS', - '--keep-session is supported only for native .ad replay; Maestro YAML owns its lifecycle.', - ); - } - if (coordinator.view()?.repairBoundary !== undefined) { - return errorResponse( - 'INVALID_ARGS', - 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', - ); - } - return await runTypedMaestroReplayFile(params); - } + const maestroResponse = await routeMaestroReplay({ + resolved, + req, + keepSession, + coordinator, + maestroParams: params, + }); + if (maestroResponse) return maestroResponse; const planPreparation = prepareReplayPlan({ req, sessionName, @@ -259,7 +86,6 @@ export async function runReplayScriptFile(params: { tracePath, resolved, coordinator, - keepSession, }); if (!planPreparation.ok) return planPreparation.response; const { @@ -269,10 +95,8 @@ export async function runReplayScriptFile(params: { actionSourcePaths, planDigest, entryIndex, - scope, + varSources, actionTracePath, - snapshotDiagnosticSamples, - suppressedTerminalCloseIndex, } = planPreparation.value; const sessionPreparation = prepareReplaySession({ req, @@ -284,7 +108,6 @@ export async function runReplayScriptFile(params: { }); if (!sessionPreparation.ok) return sessionPreparation.response; const stepContext: ReplayStepContext = { - scope, replayReq, sessionName, sessionStore, @@ -299,39 +122,53 @@ export async function runReplayScriptFile(params: { invoke, signal: getRequestSignal(req.meta?.requestId), coordinator, + port, }; - const failure = await executeReplayActions({ + const { runtime, readLastResponse } = createAdReplayStepRuntime({ + ctx: stepContext, req, - sessionName, - sessionStore, - logPath, - resolved, - actions, - actionLines, - actionSourcePaths, - planDigest, - entryIndex, - scope, - stepContext, artifactPaths, - snapshotDiagnosticSamples, onStep, armSaveScript: sessionPreparation.armSaveScript, - suppressedTerminalCloseIndex, }); - if (failure) return failure; + const outcome = await runAdReplay( + { + actions, + entryIndex, + keepSession, + actionLines, + actionSourcePaths, + resolvedPath: resolved, + varSources, + }, + runtime, + ); + if (outcome.status === 'failed') { + // #1555 P1 (neutral outcomes): `runAdReplay` never holds or returns a + // `DaemonResponse` — it only reports WHICH step failed. The real wire + // response was built (and wrapped with diagnostics/repair-hold marking) + // by this adapter's own dispatch/build-failure capabilities, which + // stashed it in `readLastResponse`'s closure as it went; reading it + // back here is what makes the final response byte-identical to the + // pre-split code that threaded it straight through the engine's return + // value. The fallback below is unreachable in practice (a response is + // always recorded before any failure can be reported) and exists only + // so this stays total. + return ( + readLastResponse() ?? + errorResponse('COMMAND_FAILED', 'replay step failed with no recorded response') + ); + } return completeReplayRun({ startedAt, sessionName, sessionStore, - actions, - entryIndex, - artifactPaths, - snapshotDiagnosticSamples, + replayed: outcome.replayed, + artifactPaths: outcome.artifactPaths, + snapshotDiagnosticSamples: outcome.snapshotDiagnosticSamples, armSaveScript: sessionPreparation.armSaveScript, coordinator, keepSession, - suppressedTerminalCloseIndex, }); } catch (err) { const appErr = asAppError(err); @@ -343,119 +180,27 @@ export async function runReplayScriptFile(params: { } } -type ReplayActionExecution = { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - logPath: string; - resolved: string; - actions: SessionAction[]; - actionLines: number[]; - actionSourcePaths: (string | undefined)[] | undefined; - planDigest: string; - entryIndex: number; - scope: ReplayVarScope; - stepContext: ReplayStepContext; - artifactPaths: Set; - snapshotDiagnosticSamples: SnapshotTimingSample[]; - onStep: ReplayTestAttemptStepSink | undefined; - armSaveScript: () => void; - suppressedTerminalCloseIndex: number | undefined; -}; - -async function executeReplayActions( - params: ReplayActionExecution, -): Promise { - const { - sessionName, - sessionStore, - actions, - entryIndex, - stepContext, - artifactPaths, - snapshotDiagnosticSamples, - onStep, - armSaveScript, - suppressedTerminalCloseIndex, - } = params; - for (let index = entryIndex; index < actions.length; index += 1) { - const action = actions[index]; - if (!isExecutableReplayAction(action)) continue; - // Arm before checking terminal close so `[open, close]` records the - // session created by `open` before treating `close` as lifecycle. - armSaveScript(); - if (index === suppressedTerminalCloseIndex) continue; - onStep?.(replayActionStep(index, actions.length, action)); - const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName); - const response = await resolveReplayStepResponse(stepContext, action, index, [ - ...artifactPaths, - ]); - snapshotDiagnosticSamples.push( - ...readSessionSnapshotSamplesSince(sessionStore, sessionName, sampleStart), - ); - collectReplayActionArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); - if (response.ok) continue; - return await buildReplayActionFailure(params, action, index, response); - } - return undefined; -} - -async function buildReplayActionFailure( - params: ReplayActionExecution, - action: SessionAction, - index: number, - response: Extract, -): Promise { - const heldResponse = (failure: DaemonResponse): DaemonResponse => - params.stepContext.coordinator.markSessionHeldIfArmed(failure); - if (isCompleteTargetBindingDivergenceResponse(response)) return heldResponse(response); - return heldResponse( - await withReplayFailureDiagnostics({ - response, - action, - index, - replayPath: params.resolved, - sourcePath: params.actionSourcePaths?.[index] ?? params.resolved, - sourceLine: params.actionLines[index] ?? 1, - artifactPaths: [...params.artifactPaths], - snapshotDiagnosticSamples: params.snapshotDiagnosticSamples, - scope: params.scope, - req: params.req, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - resumeStamper: params.stepContext.coordinator.resumeStamper, - logPath: params.logPath, - planActions: params.actions, - planDigest: params.planDigest, - }), - ); -} - function completeReplayRun(params: { startedAt: number; sessionName: string; sessionStore: SessionStore; - actions: SessionAction[]; - entryIndex: number; - artifactPaths: Set; - snapshotDiagnosticSamples: SnapshotTimingSample[]; + replayed: number; + artifactPaths: readonly string[]; + snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; armSaveScript: () => void; coordinator: ReplayCoordinator; keepSession: boolean; - suppressedTerminalCloseIndex: number | undefined; }): DaemonResponse { const { startedAt, sessionName, sessionStore, - actions, - entryIndex, + replayed, artifactPaths, snapshotDiagnosticSamples, armSaveScript, coordinator, keepSession, - suppressedTerminalCloseIndex, } = params; armSaveScript(); coordinator.markCompleteIfArmed(); @@ -467,403 +212,55 @@ function completeReplayRun(params: { artifactPaths, }); if (keepSessionFailure) return keepSessionFailure; - const replayedCount = countExecutedReplayActions({ - actions, - entryIndex, - suppressedTerminalCloseIndex, - }); - const snapshotDiagnosticsSummary = summarizeSnapshotTimingSamples(snapshotDiagnosticSamples); + const snapshotDiagnosticsSummary = summarizeSnapshotTimingSamples([...snapshotDiagnosticSamples]); return { ok: true, data: { - replayed: replayedCount, + replayed, healed: 0, session: sessionName, sessionActive: completedSession !== undefined, artifactPaths: [...artifactPaths], ...(snapshotDiagnosticsSummary ? { snapshotDiagnostics: snapshotDiagnosticsSummary } : {}), - message: formatReplaySuccessMessage(replayedCount, Date.now() - startedAt), + message: formatReplaySuccessMessage(replayed, Date.now() - startedAt), } satisfies ReplayCommandResult, }; } -function replayActionStep( - actionIndex: number, - actionTotal: number, - action: SessionAction, -): ReplayTestAttemptStep { - return { - index: actionIndex + 1, - total: actionTotal, - command: action.command, - ...replayActionStepValue(action), - }; -} - -function replayActionStepValue(action: SessionAction): Pick { - const positionals = action.positionals ?? []; - const selectorValue = readSelectorDisplayValue(positionals[0]); - if (selectorValue) return { value: selectorValue }; - if (positionals.length === 0) return {}; - return { value: positionals.join(' ') }; -} - -function readSelectorDisplayValue(selector: string | undefined): string | undefined { - if (!selector) return undefined; - const parsed = tryParseSelectorChain(selector); - if (!parsed) return undefined; - const values = parsed.selectors.flatMap((entry) => - entry.terms.flatMap((term) => - (term.key === 'label' || term.key === 'text' || term.key === 'id') && - typeof term.value === 'string' - ? [term.value] - : [], - ), - ); - if (values.length === 0) return undefined; - const first = values[0]; - return first && values.every((value) => value === first) ? first : undefined; -} - -type PreparedReplayPlan = { - replayReq: DaemonRequest; - actions: SessionAction[]; - actionLines: number[]; - actionSourcePaths: (string | undefined)[] | undefined; - planDigest: string; - preEntrySession: SessionState | undefined; - entryIndex: number; - scope: ReplayVarScope; - actionTracePath: string | undefined; - snapshotDiagnosticSamples: SnapshotTimingSample[]; - suppressedTerminalCloseIndex: number | undefined; -}; - -type ParsedReplayInput = ReturnType; - -function prepareReplayPlan(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - tracePath: string | undefined; - resolved: string; - coordinator: ReplayCoordinator; - keepSession: boolean; -}): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { - const { req, sessionName, sessionStore, tracePath, resolved, coordinator, keepSession } = params; - const parsedResult = parseReplayScript(resolved, req); - if (!parsedResult.ok) return parsedResult; - const parsed = parsedResult.value; - const { metadata, actions, actionLines, actionSourcePaths } = parsed; - const replayReq = applyReplayMetadata( - { ...req, flags: buildReplayScriptPlatformFlags(req.flags, actions) }, - metadata, - ); - const planDigest = computeReplayPlanDigest({ - actions, - actionLines, - actionSourcePaths, - metadata: readEffectiveReplayPlanDigestMetadata(replayReq.flags), - }); - const preEntrySession = sessionStore.get(sessionName); - const entryIndex = resolveReplayEntryIndex( - req.flags, - actions.length, - planDigest, - coordinator.view()?.pendingRecordAndHeal, - preEntrySession?.actions.length ?? 0, - ); - if (!entryIndex.ok) return entryIndex; - - return { - ok: true, - value: { - replayReq, - actions, - actionLines, - actionSourcePaths, - planDigest, - preEntrySession, - entryIndex: entryIndex.value, - scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), - actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, - snapshotDiagnosticSamples: [], - suppressedTerminalCloseIndex: resolveSuppressedTerminalCloseIndex({ - actions, - keepSession, - saveScript: req.flags?.saveScript, - repairActive: coordinator.view()?.repairBoundary !== undefined, - }), - }, - }; -} - -function parseReplayScript( - resolved: string, - req: DaemonRequest, -): { ok: true; value: ParsedReplayInput } | { ok: false; response: DaemonResponse } { - const script = fs.readFileSync(resolved, 'utf8'); - const firstNonWhitespace = script.trimStart()[0]; - if (firstNonWhitespace !== '{' && firstNonWhitespace !== '[') { - return { ok: true, value: parseReplayInput(script, req.flags) }; - } - return { - ok: false, - response: errorResponse( - 'INVALID_ARGS', - 'replay accepts .ad script files. JSON replay payloads are no longer supported.', - ), - }; -} - -function applyReplayMetadata( - req: DaemonRequest, - metadata: ParsedReplayInput['metadata'], -): DaemonRequest { - if (!metadata.platform && !metadata.target) return req; - return { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) }; -} - -function buildPreparedReplayScope(params: { - req: DaemonRequest; - replayReq: DaemonRequest; - sessionName: string; - resolved: string; - metadata: ParsedReplayInput['metadata']; -}): ReplayVarScope { - const { req, replayReq, sessionName, resolved, metadata } = params; - return buildReplayVarScope({ - builtins: buildReplayBuiltinVars({ - req: replayReq, - sessionName, - metadata, - resolvedPath: resolved, - }), - fileEnv: metadata.env, - shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), - cliEnv: parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), - }); -} - -function prepareReplaySession(params: { - req: DaemonRequest; - entryIndex: number; - sessionStore: SessionStore; - sessionName: string; - sourcePath: string; - coordinator: ReplayCoordinator; -}): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, entryIndex, sessionStore, sessionName, sourcePath, coordinator } = params; - const sessionPreflight = validateReplaySessionEntry({ - entryIndex, - sessionStore, - sessionName, - coordinator, - }); - if (sessionPreflight) return { ok: false, response: sessionPreflight }; - - consumeReplayResumeState({ req, coordinator }); - return prepareSaveScriptSession({ req, sessionStore, sessionName, sourcePath, coordinator }); -} - -function validateReplaySessionEntry(params: { - entryIndex: number; - sessionStore: SessionStore; - sessionName: string; - coordinator: ReplayCoordinator; -}): DaemonResponse | undefined { - const repairPreflight = preflightReplayAgainstActiveRepair(params); - if (repairPreflight) return repairPreflight; - if (params.entryIndex > 0 && !params.sessionStore.get(params.sessionName)) { - return noActiveSessionError(); - } - return undefined; -} - /** - * Rejects arming a repair over an ordinary authoring recording (R2's disjointness) and runs the - * arm-time EEXIST preflight against the target this request resolves to. + * `--keep-session`'s postcondition (#1554): a suppressed terminal close only + * ever promises a live session, so a session that is gone by completion + * anyway (some other action closed or otherwise removed it) must fail loudly + * rather than silently report `sessionActive: false` as if `--keep-session` + * had never been requested. Stays daemon-side, unlike the terminal-close + * suppression itself (`resolveSuppressedTerminalCloseIndex`, + * `@agent-device/ad-replay`'s step loop): it inspects `SessionState`, which + * the engine never sees. */ -function rejectSaveScriptArming(params: { - saveScript: boolean | string | undefined; - force: boolean | undefined; - preRunState: SessionScriptPublicationState; - sourcePath: string; -}): DaemonResponse | undefined { - const { saveScript, force, preRunState, sourcePath } = params; - if (saveScript && preRunState.kind === 'authoring') { - return errorResponse( - 'INVALID_ARGS', - `replay --save-script cannot re-arm an ordinary recording in terminal/active state ${preRunState.status}. Close this session and use a fresh one for repair authoring.`, - ); - } - return preflightSaveScriptTarget({ - saveScript, - liveForce: force, - persistedForce: scriptTargetForce(preRunState) || undefined, - sourcePath, - existingSaveScriptPath: scriptTargetPath(preRunState), - }); -} - -function prepareSaveScriptSession(params: { - req: DaemonRequest; - sessionStore: SessionStore; - sessionName: string; - sourcePath: string; - coordinator: ReplayCoordinator; -}): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, sessionStore, sessionName, sourcePath, coordinator } = params; - const preRunSession = sessionStore.get(sessionName); - const { saveScript, force } = req.flags ?? {}; - const rejection = rejectSaveScriptArming({ - saveScript, - force, - preRunState: preRunSession?.scriptPublication ?? NO_SCRIPT_PUBLICATION, - sourcePath, - }); - if (rejection) return { ok: false, response: rejection }; - - coordinator.demoteForRerunIfArmed(); - return { - ok: true, - armSaveScript: createReplaySaveScriptArmer({ - saveScript, - force, - coordinator, - sourcePath, - }), - }; -} - -function consumeReplayResumeState(params: { - req: DaemonRequest; - coordinator: ReplayCoordinator; -}): void { - const { req, coordinator } = params; - coordinator.clearCorrectiveWatermarkIfExpected(req.flags?.replayFrom); - if (req.flags?.saveScript) coordinator.clearTombstone(); -} - /** - * ADR 0012 decision 6, R2: reject a fresh FULL replay on a session that - * already carries a repair-run boundary — the session stays repair-armed - * (`recordSession` remains true), so ANY full re-run re-appends the - * already-recorded prefix (`session-action-recorder.ts` pushes - * unconditionally), duplicating it in the healed slice. This fires REGARDLESS - * of whether `--save-script` is passed this invocation (omitting the flag - * does not disarm the session). A `--from` resume (`entryIndex > 0`) - * legitimately continues the same armed run and is allowed. - */ -function preflightReplayAgainstActiveRepair(params: { - entryIndex: number; - coordinator: ReplayCoordinator; -}): DaemonResponse | undefined { - const { entryIndex, coordinator } = params; - if (entryIndex > 0) return undefined; - if (coordinator.view()?.repairBoundary === undefined) return undefined; - return errorResponse( - 'INVALID_ARGS', - 'This session has an active --save-script repair run; continue it with replay --from --plan-digest , or finish with close, before starting a fresh full replay.', - ); -} - -/** - * #1258: arm-time EEXIST preflight. Absent this, a repair-armed run's target - * is only checked at PUBLISH time (`publishHealedScriptAtomically`, on - * `close`/completion) — by then the ENTIRE repair (agent's corrective steps - * included) may already have executed against the device, only to fail on a - * pre-existing target at the very end. Resolves the SAME target - * the coordinator's `armStep` would (explicit `--save-script=` always - * wins; otherwise an already-armed session's existing path if this is a - * `--from` continuation leg reusing it, else the default `.healed.ad` - * sibling) WITHOUT needing the session to exist yet, so it runs before step 1 - * dispatches even when that step is the `open` that creates the session. - * READ-ONLY: it never mutates the session (it runs before - * `resolveScriptTarget`). - * - * The effective-force decision MATCHES `resolveScriptTarget`'s per-target - * contract, computed against the target THIS request resolves to: a live - * `--force`/`--overwrite` always bypasses; a PERSISTED per-target grant - * bypasses ONLY when this request writes to the SAME target it was granted for - * (`targetPath === existingSaveScriptPath`). An explicit RETARGET to a - * different path without a live force does NOT bypass here — because - * `resolveScriptTarget` will CLEAR that persisted force for the new target - * before publication anyway, so letting the run execute (mutating the session - * mid-flight) only to refuse the existing target at the end is exactly what - * this preflight exists to prevent. A no-op when `--save-script` was not passed. + * #1555 review P1 (second pass, "keep success formatting daemon-side"): + * moved verbatim from `@agent-device/ad-replay`'s `step-loop.ts` — pure + * presentation of the run's own `replayed` count/wall-clock duration, not + * engine policy, so it sits beside its one caller (`completeReplayRun` + * above) instead of behind the façade. */ -function preflightSaveScriptTarget(params: { - saveScript: boolean | string | undefined; - liveForce: boolean | undefined; - persistedForce: boolean | undefined; - sourcePath: string; - existingSaveScriptPath: string | undefined; -}): DaemonResponse | undefined { - const { saveScript, liveForce, persistedForce, sourcePath, existingSaveScriptPath } = params; - if (!saveScript) return undefined; - const targetPath = - typeof saveScript === 'string' - ? expandSessionPath(saveScript) - : (existingSaveScriptPath ?? healedScriptSiblingPath(sourcePath)); - const effectiveForce = - Boolean(liveForce) || (Boolean(persistedForce) && targetPath === existingSaveScriptPath); - if (effectiveForce) return undefined; - if (!fs.existsSync(targetPath)) return undefined; - return errorResponse( - 'COMMAND_FAILED', - `A file already exists at ${targetPath}; remove it, pass replay --save-script=, or pass --force/--overwrite to replace it.`, - ); -} - -/** - * ADR 0012 decision 6, R1/R6: returns a per-step armer that sets - * `recordSession` and stamps the repair-run boundary watermark ONCE, through - * the request's `ReplayCoordinator` (#1478 P4b). Absent `--save-script` it is - * a no-op, so replay is byte-identical to today. - */ -function createReplaySaveScriptArmer(params: { - saveScript: boolean | string | undefined; - force: boolean | undefined; - coordinator: ReplayCoordinator; - sourcePath: string; -}): () => void { - const { saveScript, force, coordinator, sourcePath } = params; - if (!saveScript) return () => {}; - let firstArm = true; - return () => { - coordinator.armStep({ saveScript, force, sourcePath, firstArm }); - firstArm = false; - }; -} - function formatReplaySuccessMessage(replayed: number, wallClockMs: number): string { const seconds = (wallClockMs / 1000).toFixed(1); const noun = replayed === 1 ? 'step' : 'steps'; return `Replayed ${replayed} ${noun} in ${seconds}s`; } -// ADR 0012 step 4: a target-binding divergence is already a complete, final -// REPLAY_DIVERGENCE built from its own pre-action capture — distinguished from -// an action-failure divergence by its non-`action-failure` kind. -function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): boolean { - if (response.ok || response.error.code !== 'REPLAY_DIVERGENCE') return false; - const divergence = response.error.details?.divergence; - const kind = - divergence && typeof divergence === 'object' - ? (divergence as Record).kind - : undefined; - return typeof kind === 'string' && kind !== 'action-failure'; -} - -function readSessionSnapshotSampleCount(sessionStore: SessionStore, sessionName: string): number { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; -} - -function readSessionSnapshotSamplesSince( - sessionStore: SessionStore, - sessionName: string, - start: number, -): SnapshotTimingSample[] { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(start) ?? []; +function requireLiveSessionForKeepSession(params: { + keepSession: boolean; + sessionName: string; + completedSession: SessionState | undefined; + artifactPaths: readonly string[]; +}): DaemonResponse | undefined { + const { keepSession, sessionName, completedSession, artifactPaths } = params; + if (!keepSession || completedSession) return undefined; + return errorResponse( + 'COMMAND_FAILED', + `Replay completed but --keep-session could not preserve session "${sessionName}". Run the script again after checking which action closed the session.`, + artifactPaths.length > 0 ? { artifactPaths: [...artifactPaths] } : undefined, + ); } diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index ca50cea6f4..79b03384df 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -3,9 +3,8 @@ * enforcement. * * For every replay/test step whose action carries `target-v1` evidence - * (`action.targetEvidence`, parsed by `src/replay/script.ts` / - * `src/replay/target-identity.ts`), this resolves the SAME recorded - * selector/ref the action's own dispatch would use against a fresh + * (`action.targetEvidence`, parsed by `@agent-device/ad-script`), this + * resolves the SAME recorded selector/ref the action's own dispatch would use against a fresh * pre-action snapshot, classifies the match via decision 3's six-path * algorithm (`classifyTargetBindingMatch`), and — on any non-verified * outcome — builds a complete `REPLAY_DIVERGENCE` response carrying the @@ -34,12 +33,6 @@ import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import { findNodeByRef, normalizeRef, type SnapshotNode } from '@agent-device/kernel/snapshot'; import { findNodeByLabel } from '../../snapshot/snapshot-processing.ts'; -import { matchesSelector } from '../../selectors/match.ts'; -import { - listSelectorChainMatches, - resolveSelectorChain, - tryParseSelectorChain, -} from '../../selectors/index.ts'; import { buildAncestryChain, buildIndexMap, @@ -52,11 +45,13 @@ import { scrollRegionKeysEqual, orderByViewportPosition, } from '../session-target-evidence.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import { annotationLocalIdentity, classifyTargetBindingMatch, - type LocalIdentity, -} from '../../replay/target-identity.ts'; + firstAncestryMismatch, + identityFieldMismatches, +} from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; @@ -98,8 +93,10 @@ export function classifyReplayTarget(params: { refLabel: string | undefined; requireRect: boolean; allowDisambiguation: boolean; + port: ReplaySelectorPort; }): ReplayTargetClassification { - const { recorded, token, nodes, platform, refLabel, requireRect, allowDisambiguation } = params; + const { recorded, token, nodes, platform, refLabel, requireRect, allowDisambiguation, port } = + params; const matching = resolveTargetMatches({ token, @@ -108,6 +105,7 @@ export function classifyReplayTarget(params: { refLabel, requireRect, allowDisambiguation, + port, }); const byIndex = buildIndexMap(nodes); @@ -184,11 +182,12 @@ function resolveTargetMatches(params: { refLabel: string | undefined; requireRect: boolean; allowDisambiguation: boolean; + port: ReplaySelectorPort; }): TargetMatchResolution { - const { token, nodes, platform, refLabel, requireRect, allowDisambiguation } = params; + const { token, nodes, platform, refLabel, requireRect, allowDisambiguation, port } = params; return token.startsWith('@') ? resolveRefTargetMatches(nodes, token, refLabel, requireRect) - : resolveSelectorTargetMatches(nodes, token, platform, requireRect, allowDisambiguation); + : resolveSelectorTargetMatches(nodes, token, platform, requireRect, allowDisambiguation, port); } function resolveRefTargetMatches( @@ -208,37 +207,30 @@ function resolveRefTargetMatches( : { matchedNodes: [], winnerRef: '' }; } +/** + * `port.resolveRecordedTarget` composes parse/resolve/list-matches/match + * exactly as this function used to (its production adapter, + * `src/daemon/replay-selector-port.ts`, is that composition lifted verbatim — + * #1478 P5 stage B), protecting the SAME "matched-node domain comes from the + * winning chain alternative" invariant this module relied on directly before. + */ function resolveSelectorTargetMatches( nodes: SnapshotNode[], token: string, platform: Platform | PublicPlatform, requireRect: boolean, allowDisambiguation: boolean, + port: ReplaySelectorPort, ): TargetMatchResolution { - const chain = tryParseSelectorChain(token); - if (!chain) return { matchedNodes: [], winnerRef: '' }; - const resolved = resolveSelectorChain(nodes, chain, { + const resolution = port.resolveRecordedTarget(token, nodes, { platform, requireRect, - requireUnique: true, - disambiguateAmbiguous: allowDisambiguation, + allowDisambiguation, }); - if (!resolved) { - // No alternative produced a dispatch winner (for example, ambiguity with - // disambiguation disabled). Keep the established diagnostic domain so - // classification can report that ambiguity, but do not invent a winner. - const matchList = listSelectorChainMatches(nodes, chain, { platform, requireRect }); - return { matchedNodes: matchList?.matchedNodes ?? [], winnerRef: '' }; + if (resolution.kind === 'resolved') { + return { matchedNodes: [...resolution.matchedNodes], winnerRef: resolution.winner.ref }; } - // `resolved.selector` is the selected chain alternative. The verification - // domain must use that same alternative, not the first one with any match: - // an earlier ambiguous/tied alternative can be skipped in favor of a later - // resolvable alternative. - const matchedNodes = nodes.filter((node) => { - if (requireRect && !node.rect) return false; - return matchesSelector(node, resolved.selector, platform); - }); - return { matchedNodes, winnerRef: resolved.node.ref }; + return { matchedNodes: [...resolution.matchedNodes], winnerRef: '' }; } type MappedVerificationFailure = Omit; @@ -332,50 +324,3 @@ function computeIdentityMismatches( ...firstAncestryMismatch(recorded.ancestry, observedAncestry), ].slice(0, 5); } - -export function identityFieldMismatches( - recorded: TargetAnnotationV1, - observed: LocalIdentity, -): string[] { - const mismatches: string[] = []; - if (recorded.id !== observed.id) { - mismatches.push(`id: recorded=${recorded.id ?? '(none)'} observed=${observed.id ?? '(none)'}`); - } - if (recorded.role !== observed.role) { - mismatches.push(`role: recorded=${recorded.role} observed=${observed.role}`); - } - if (recorded.label !== observed.label) { - mismatches.push( - `label: recorded=${recorded.label ?? '(none)'} observed=${observed.label ?? '(none)'}`, - ); - } - return mismatches; -} - -function describeAncestryEntry(entry: { role: string; label?: string } | undefined): string { - return entry ? `${entry.role}${entry.label ? `/${entry.label}` : ''}` : '(missing)'; -} - -function ancestryEntryMismatches( - expected: { role: string; label?: string }, - actual: { role: string; label?: string } | undefined, -): boolean { - if (!actual) return true; - if (actual.role !== expected.role) return true; - return expected.label !== undefined && actual.label !== expected.label; -} - -/** Leaf-anchored prefix: the first divergence explains everything after it. */ -export function firstAncestryMismatch( - recordedAncestry: readonly { role: string; label?: string }[], - observedAncestry: readonly { role: string; label?: string }[], -): string[] { - for (const [index, expected] of recordedAncestry.entries()) { - const actual = observedAncestry[index]; - if (!ancestryEntryMismatches(expected, actual)) continue; - return [ - `ancestry[${index}]: recorded=${describeAncestryEntry(expected)} observed=${describeAncestryEntry(actual)}`, - ]; - } - return []; -} diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/handlers/session-replay-target-token.ts index c0294783bd..b754766066 100644 --- a/src/daemon/handlers/session-replay-target-token.ts +++ b/src/daemon/handlers/session-replay-target-token.ts @@ -1,15 +1,21 @@ import { isTouchTargetCommand } from '@agent-device/ad-script'; -import { splitIsSelectorArgs } from '../../selectors/index.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import type { SessionAction } from '../types.ts'; /** Returns the resolved-target token carried by an eligible replay action. */ -export function extractReplayTargetToken(action: SessionAction): string | undefined { +export function extractReplayTargetToken( + action: SessionAction, + port: ReplaySelectorPort, +): string | undefined { const positionals = action.positionals ?? []; if (action.command === 'get') return positionals[1]; // #1349: `is [expected]` — the selector expression is // the target token (an `is exists` step is never annotated, so this only // runs for unique-resolving predicates). - if (action.command === 'is') return splitIsSelectorArgs(positionals).split?.selectorExpression; + if (action.command === 'is') { + const outcome = port.readSelectorExpression('is', positionals); + return outcome.kind === 'expression' ? outcome.expression : undefined; + } if (!isTouchTargetCommand(action.command) && action.command !== 'fill') return undefined; const first = positionals[0]; if (first === undefined) return undefined; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index ed1121ecf9..c03febfddf 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -1,15 +1,21 @@ import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { DaemonError } from '@agent-device/kernel/errors'; +import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; -import { formatDivergenceActionLabel } from '@agent-device/ad-script'; -import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { - collectReplayScrubbableVarValues, - resolveReplayAction, - type ReplayVarScope, -} from '../../replay/vars.ts'; -import { annotationLocalIdentity, type LocalIdentity } from '../../replay/target-identity.ts'; + annotationLocalIdentity, + formatDivergenceActionLabel, + type LocalIdentity, +} from '@agent-device/ad-script'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { + AdReplayScrubValue, + AdReplayTargetBindingEvidence, + AdReplayTargetClassification, + AdReplayVerificationEntry, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; import { createReplayDivergenceSanitizer, type ReplayDivergence, @@ -21,21 +27,20 @@ import { readNodeStructuralDenotation, REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON, - type ReplayTargetGuardDenotation, } from '../../replay/target-identity-node.ts'; import { resolveTargetIdentityVerification } from '../../core/command-descriptor/registry.ts'; import { parseWaitPositionals } from '../../core/wait-positionals.ts'; -import type { DaemonResponse, SessionAction } from '../types.ts'; +import type { DaemonResponse, SessionAction, SessionState } from '../types.ts'; import type { SessionStore } from '../session-store.ts'; import type { ReplayResumeStamper } from '../session-replay-coordinator.ts'; import type { InternalObservationEvidence } from '../internal-observation.ts'; import { boundedLocalIdentity } from '../session-target-evidence.ts'; -import { tryParseSelectorChain } from '../../selectors/index.ts'; import { buildDivergenceScreen, captureDivergenceObservation, resolveSuggestionMatchingConfig, toReplayRepairHintCapture, + type DivergenceObservation, } from './session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; import { @@ -44,48 +49,58 @@ import { } from './session-replay-repair-hint.ts'; import { buildReplayDivergenceFailureResponse } from './session-replay-runtime-failure-response.ts'; import { buildAndPersistReplayDivergenceResume } from './session-replay-resume.ts'; -import { - classifyReplayTarget, - firstAncestryMismatch, - identityFieldMismatches, -} from './session-replay-target-classification.ts'; +import { classifyReplayTarget } from './session-replay-target-classification.ts'; import { extractReplayTargetToken, readRefLabel } from './session-replay-target-token.ts'; // --------------------------------------------------------------------------- -// Daemon-level orchestration: capture, session, wire shaping. +// #1555 review R3 ("target verification must happen INSIDE the engine"): the +// verify-then-dispatch DECISION flow (the four `@agent-device/ad-replay` +// policy functions, `planPostResolutionTargetVerification` / +// `planPreDispatchTargetVerification` / `deriveReplayTargetGuardMismatchEvidence` +// / `deriveWaitLandmarkMismatchEvidence`) lives entirely inside the engine's +// step loop (`packages/ad-replay/src/internal/verify-dispatch.ts`, +// `verifyAndDispatchStep`) — this module never imports those four DECISION +// functions. It does import the package's neutral VOCABULARY types +// (`AdReplayVerificationEntry`/`AdReplayTargetClassification`/ +// `AdReplayTargetBindingEvidence`, #1555 structural-quality review, "typed +// façade replaces the zero-type rule") so the values it builds/routes are the +// engine's own shapes, not a hand-shadowed daemon twin. This module +// implements only the narrow `AdReplayStepRuntime` capabilities the engine +// loop drives: +// +// - `resolveTargetVerificationEntry` — routing (registry lookup, session +// read, wait-form parse, token extraction) for `beginTargetVerification`. +// - `classifyPreDispatchTarget` — tree matching for `classifyTarget`. +// - `buildRecordedUnverifiableFailureResponse` / +// `buildTargetBindingFailureResponse` / +// `buildPostDispatchTargetBindingFailureResponse` — capture + wire-shaping +// for the `build*Failure` capabilities. +// - `isReplayTargetGuardMismatchResponse` / `isWaitLandmarkMismatchResponse` +// — post-dispatch refusal-marker detection for `dispatchStep`. +// +// `session-replay-runtime-engine-adapter.ts`'s `createAdReplayStepRuntime` is +// the thin adapter that wires these into the `AdReplayStepRuntime` object and +// supplies the per-request context (resume stamper, artifact accumulator, +// side-map response holder) these functions need but do not own — as of the +// #1555 review's second pass, that context no longer includes a +// `ReplayVarScope`: the engine builds and owns the scope itself. // --------------------------------------------------------------------------- /** - * Post-resolution guard payload for a verified action: dispatch re-resolves - * with its own occlusion/visibility guards, and its winner must carry - * `expected` (the verified member's identity) or the interaction layer - * refuses pre-action (`assertExpectedResolvedTarget`, resolution.ts). - * `matchCount` is verification's recorded-selector match count, carried so - * the resulting identity-mismatch divergence satisfies decision 3's - * matchCount presence rule. + * #1555 structural-quality review ("unify on the engine's types"): this + * module used to declare its own `ReplayVerifiedTargetGuard` — structurally + * identical to (but a separate nominal declaration from) + * `@agent-device/ad-replay`'s `AdReplayVerifiedTargetGuard` — so it now + * imports the engine's type directly instead of maintaining a shadow copy + * that could silently drift. `ReplayTargetGuardDenotation` + * (`target-identity-node.ts`) stays the concrete producer type for + * `expected`; it is structurally assignable to `AdReplayVerifiedTargetGuard['expected']` + * (both `{ identity: LocalIdentity; structural: { documentOrder: number; + * sibling: number } }`) without a name-level dependency between the two + * files. */ -export type ReplayVerifiedTargetGuard = { - expected: ReplayTargetGuardDenotation; - matchCount: number; -}; - -export type ReplayTargetVerificationOutcome = - | { - verified: true; - guard?: ReplayVerifiedTargetGuard; - /** - * #1349 post-resolution phase (`wait`): the recorded landmark to thread - * into the command's own resolution (`internal.replayLandmarkGuard`). - * `verified: true` here means only "nothing to refuse pre-dispatch" — - * the identity check runs inside the wait's polling loop, and the step - * loop converts its timeout refusal into an identity-mismatch - * divergence (`buildWaitLandmarkMismatchResponse`). - */ - deferredLandmark?: TargetAnnotationV1; - } - | { verified: false; response: DaemonResponse }; -type TargetBindingDivergenceContext = { +export type TargetBindingDivergenceContext = { recorded: TargetAnnotationV1; action: SessionAction; step: number; @@ -98,7 +113,15 @@ type TargetBindingDivergenceContext = { /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ resumeStamper: ReplayResumeStamper; responseLevel: ResponseLevel | undefined; - scrubVars: ReturnType; + /** + * #1555 structural-quality review ("scrub values — one name"): the + * engine's own `AdReplayScrubValue` shape — never a second, separately- + * named `ReturnType` derivation + * for the identical concept. Readonly-compatible with the engine's + * `readonly AdReplayScrubValue[]` capability parameters, so no daemon call + * site needs a `[...scrubVars]` copy to satisfy this field. + */ + scrubVars: readonly AdReplayScrubValue[]; /** ADR 0012 step 5: the full top-level plan + its digest, for `resume`. */ planActions: SessionAction[]; planDigest: string; @@ -109,8 +132,8 @@ type TargetBindingDivergenceBuilt = { kind: ReplayDivergenceTargetBindingKind; matchCount: number | undefined; observed: LocalIdentity | undefined; - candidateNodes: SnapshotNode[]; - mismatches: string[]; + candidateNodes: readonly SnapshotNode[]; + mismatches: readonly string[]; causeCode: string; causeMessage: string; causeHint?: string; @@ -210,88 +233,79 @@ function buildTargetBindingDivergenceResponse( }); } -type ReplayTargetDivergenceParams = { - action: SessionAction; - scope: ReplayVarScope; - sourcePath: string; - sourceLine: number; - replayPath: string; - step: number; +/** + * #1555 structural-quality review ("make the engine evidence types + * readonly-compatible with daemon consumers so no copy translator is + * needed"): this module used to declare its own `TargetBindingFailureEvidence` + * — structurally identical to `@agent-device/ad-replay`'s + * `AdReplayTargetBindingEvidence` except for mutable vs. readonly array + * fields — so a `toDaemonEvidence` translator in + * `session-replay-runtime-engine-adapter.ts` had to copy every call. Every + * builder below now accepts the engine's own (readonly) evidence type + * directly; `TargetBindingDivergenceBuilt` above is readonly-compatible too, + * so the adapter passes the engine's value straight through. + */ + +/** Assembles a target-binding divergence from already-computed `evidence` and a capture `observation`. */ +export function buildTargetBindingFailureResponse( + context: TargetBindingDivergenceContext, + evidence: AdReplayTargetBindingEvidence, + observation: DivergenceObservation, +): DaemonResponse { + const sanitize = createReplayDivergenceSanitizer(context.scrubVars); + return buildTargetBindingDivergenceResponse(context, { + kind: evidence.kind, + matchCount: evidence.matchCount, + observed: evidence.observed, + candidateNodes: evidence.candidateNodes, + mismatches: evidence.mismatches, + causeCode: evidence.causeCode, + causeMessage: evidence.causeMessage, + ...(evidence.causeHint !== undefined ? { causeHint: evidence.causeHint } : {}), + screen: buildDivergenceScreen(observation, sanitize), + publicationEvidence: publicationEvidenceFrom(observation), + repairCapture: toReplayRepairHintCapture(observation), + }); +} + +async function captureFreshObservation(params: { + session: SessionState | undefined; sessionName: string; sessionStore: SessionStore; - /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ - resumeStamper: ReplayResumeStamper; logPath: string; - artifactPaths: string[]; - responseLevel: ResponseLevel | undefined; - planActions: SessionAction[]; - planDigest: string; - signal?: AbortSignal; -}; - -export async function verifyReplayActionTarget( - params: ReplayTargetDivergenceParams, -): Promise { - const { - action, - scope, - sourcePath, - sourceLine, - replayPath, - step, - sessionName, - sessionStore, - resumeStamper, - logPath, - artifactPaths, - responseLevel, - planActions, - planDigest, - signal, - } = params; - - const recorded = action.targetEvidence; - if (!recorded) return { verified: true }; - - const session = sessionStore.get(sessionName); - if (!session) return { verified: true }; - - // Resolved ONLY to extract the match token below — never serialized onto - // the wire (the response is always built from the ORIGINAL `action`, like - // every other replay divergence, so an expanded `${VAR}` never leaks - // through an un-scrubbed positional). - const resolvedAction = resolveReplayAction(action, scope, { file: sourcePath, line: sourceLine }); + action: SessionAction; + unavailableHint: string; +}): Promise { + const { session, sessionName, sessionStore, logPath, action, unavailableHint } = params; + return session + ? await captureDivergenceObservation({ session, sessionName, sessionStore, logPath, action }) + : { state: 'unavailable', reason: 'no-session', hint: unavailableHint }; +} - const scrubVars = collectReplayScrubbableVarValues(scope); - const sanitize = createReplayDivergenceSanitizer(scrubVars); - const context: TargetBindingDivergenceContext = { - recorded, - action, - step, - sourcePath, - sourceLine, - replayPath, - artifactPaths, - sessionName, - sessionStore, - resumeStamper, - responseLevel, - scrubVars, - planActions, - planDigest, - signal, - }; - const buildRecordedUnverifiableResponse = async (): Promise => { - // Decision 3 path 1: a recorded-`unverifiable` annotation fires before - // any resolution — matchCount is omitted (never computed). - const observation = await captureDivergenceObservation({ - session, - sessionName, - sessionStore, - logPath, - action, - }); - return buildTargetBindingDivergenceResponse(context, { +/** + * Decision 3 path 1: a recorded-`unverifiable` annotation fires before any + * resolution — matchCount is omitted (never computed). Its own fresh capture, + * independent of any earlier pre-dispatch capture (this path never reaches + * one). + */ +export async function buildRecordedUnverifiableFailureResponse( + context: TargetBindingDivergenceContext, + params: { + session: SessionState | undefined; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + action: SessionAction; + }, +): Promise { + const observation = await captureFreshObservation({ + ...params, + unavailableHint: + 'The session closed before a screen could be captured to verify the recorded target evidence.', + }); + return buildTargetBindingFailureResponse( + context, + { kind: 'identity-unverifiable', matchCount: undefined, observed: undefined, @@ -300,83 +314,126 @@ export async function verifyReplayActionTarget( causeCode: 'IDENTITY_UNVERIFIABLE', causeMessage: 'The recorded target evidence could not verify itself when it was captured (a structural capture anomaly), so replay cannot trust it before acting.', - screen: buildDivergenceScreen(observation, sanitize), - publicationEvidence: publicationEvidenceFrom(observation), - repairCapture: toReplayRepairHintCapture(observation), - }); - }; + }, + observation, + ); +} + +/** + * Post-dispatch identity-mismatch shaping (the guard mismatch and wait's + * landmark mismatch): its own FRESH capture — the screen may have changed + * since dispatch, so this never reuses the pre-dispatch capture. + */ +export async function buildPostDispatchTargetBindingFailureResponse( + context: TargetBindingDivergenceContext, + evidence: AdReplayTargetBindingEvidence, + params: { + session: SessionState | undefined; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + action: SessionAction; + }, +): Promise { + const observation = await captureFreshObservation({ + ...params, + unavailableHint: 'The session closed before a post-failure screen could be captured.', + }); + return buildTargetBindingFailureResponse(context, evidence, observation); +} + +function publicationEvidenceFrom( + observation: DivergenceObservation, +): InternalObservationEvidence | undefined { + return observation.state === 'available' ? observation.evidence : undefined; +} + +// --------------------------------------------------------------------------- +// `beginTargetVerification` routing: which verification phase (if any) one +// step's recorded target evidence enters. Mirrors the pre-#1555-R3 daemon +// orchestrator's own routing exactly — only called when +// `action.targetEvidence` is present (the engine checks that itself). +// +// #1555 structural-quality review ("unify on the engine's types"): returns +// `@agent-device/ad-replay`'s own `AdReplayVerificationEntry` directly — this +// module used to declare a separate, structurally-identical +// `TargetVerificationEntry`. +// --------------------------------------------------------------------------- - // #1349 post-resolution phase (`wait`): NEVER the generic pre-dispatch - // resolution below — an absent landmark is a wait's expected starting - // condition, so refusing on the current screen would break polling. Only - // path 1 (recorded-`unverifiable`, no resolution involved) refuses up - // front; a verifiable landmark is deferred into the wait's own loop. +/** + * #1349 post-resolution phase (`wait`): NEVER the generic pre-dispatch + * resolution — an absent landmark is a wait's expected starting condition. + * Otherwise the ordinary pre-dispatch gate: the resolved-target token (scope + * var-substituted, matching what the real dispatch would resolve) and the + * session's platform. + * + * #1555 review P1 (second pass, "move variable semantics/planning behind the + * replay entrypoint"): `resolvedAction` arrives already interpolated — the + * engine's own ONE resolution of this step (`runAdReplay`), never a second, + * daemon-side `resolveReplayAction` call over a `scope` this module used to + * hold. `action` (the recorded original) is used only for the command-kind + * check below; `resolvedAction` is used only to extract the match + * token/wait-form below, never serialized onto the wire (a target-binding + * response is always built from the ORIGINAL `action`, like every other + * replay divergence, so an expanded `${VAR}` never leaks through an + * un-scrubbed positional). + */ +export function resolveTargetVerificationEntry(params: { + action: SessionAction; + resolvedAction: SessionAction; + sessionName: string; + sessionStore: SessionStore; + port: ReplaySelectorPort; +}): AdReplayVerificationEntry { + const { action, resolvedAction, sessionName, sessionStore, port } = params; + const session = sessionStore.get(sessionName); + if (!session) return { kind: 'inactive' }; if (resolveTargetIdentityVerification(action.command) === 'post-resolution') { const parsed = parseWaitPositionals(resolvedAction.positionals ?? []); - // Only a selector wait names a landmark; an annotation on any other wait - // form is inert, like an old reader. - if (parsed?.kind !== 'selector') return { verified: true }; - if (recorded.verification === 'unverifiable') { - return { verified: false, response: await buildRecordedUnverifiableResponse() }; - } - return { verified: true, deferredLandmark: recorded }; + return { kind: 'post-resolution', isSelectorWait: parsed?.kind === 'selector' }; } - - const token = extractReplayTargetToken(resolvedAction); - if (token === undefined) return { verified: true }; - if (!token.startsWith('@') && !tryParseSelectorChain(token)) { + return { + kind: 'pre-dispatch', // A malformed recorded selector is not this module's concern — the real // dispatch will parse (and fail) it the same way an unannotated action - // would. - return { verified: true }; - } - - if (recorded.verification === 'unverifiable') { - return { verified: false, response: await buildRecordedUnverifiableResponse() }; - } + // would; `extractReplayTargetToken` returning a token here is not proof + // it parses (the engine's pre-dispatch plan runs that check itself). + token: extractReplayTargetToken(resolvedAction, port), + platform: session.device.platform, + }; +} - // #1385: this is the pre-dispatch gate a step right after `open --relaunch` - // can race — the app may still be launching/mounting when this capture - // lands, producing a transient `capture-failed` / `sparse-snapshot` - // verdict that is not a real divergence. Bounded retry rides out that - // transition instead of failing closed on the first unlucky capture. - const observation = await captureDivergenceObservation({ - session, - sessionName, - sessionStore, - logPath, - action, - retryLaunchRace: true, - }); - if (observation.state !== 'available') { - return { - verified: false, - response: buildTargetBindingDivergenceResponse(context, { - kind: 'identity-unverifiable', - matchCount: undefined, - observed: undefined, - candidateNodes: [], - mismatches: [], - causeCode: 'IDENTITY_UNVERIFIABLE', - causeMessage: `Could not capture a fresh snapshot to verify the recorded target before acting (${observation.reason}).`, - causeHint: observation.hint, - screen: buildDivergenceScreen(observation, sanitize), - repairCapture: toReplayRepairHintCapture(observation), - }), - }; - } +// --------------------------------------------------------------------------- +// `classifyTarget`: resolves the recorded target against an already-captured +// tree using the SAME lookup/matching a real dispatch would. +// +// #1555 structural-quality review ("unify on the engine's types"): returns +// `@agent-device/ad-replay`'s own `AdReplayTargetClassification` directly — +// this module used to declare a separate, structurally-identical +// `TargetClassificationOutcome` (with its own `ReplayVerifiedTargetGuard` +// for the verified branch). +// --------------------------------------------------------------------------- +export function classifyPreDispatchTarget(params: { + recorded: TargetAnnotationV1; + token: string; + action: SessionAction; + nodes: SnapshotNode[]; + platform: Platform | PublicPlatform; + port: ReplaySelectorPort; +}): AdReplayTargetClassification { + const { recorded, token, action, nodes, platform, port } = params; const config = resolveSuggestionMatchingConfig(action); const classification = classifyReplayTarget({ recorded, token, - nodes: observation.nodes, - platform: session.device.platform, + nodes, + platform, refLabel: readRefLabel(action), requireRect: config.requiresRect, allowDisambiguation: config.allowDisambiguation, + port, }); - if (classification.verified) { return { verified: true, @@ -386,29 +443,23 @@ export async function verifyReplayActionTarget( // different duplicate that shares the same {id, role, label}. expected: { identity: boundedLocalIdentity(classification.winnerNode), - structural: readNodeStructuralDenotation(classification.winnerNode, observation.nodes), + structural: readNodeStructuralDenotation(classification.winnerNode, nodes), }, matchCount: classification.matchCount, }, }; } - return { verified: false, - response: buildTargetBindingDivergenceResponse(context, { - kind: classification.kind, - matchCount: classification.matchCount, - observed: classification.observedNode - ? boundedLocalIdentity(classification.observedNode) - : undefined, - candidateNodes: classification.candidateNodes, - mismatches: classification.mismatches, - causeCode: classification.causeCode, - causeMessage: classification.causeMessage, - screen: buildDivergenceScreen(observation, sanitize), - publicationEvidence: observation.evidence, - repairCapture: toReplayRepairHintCapture(observation), - }), + kind: classification.kind, + matchCount: classification.matchCount, + observed: classification.observedNode + ? boundedLocalIdentity(classification.observedNode) + : undefined, + candidateNodes: classification.candidateNodes, + mismatches: classification.mismatches, + causeCode: classification.causeCode, + causeMessage: classification.causeMessage, }; } @@ -419,204 +470,24 @@ export async function verifyReplayActionTarget( // from the verified member even after verification passed. The interaction // layer cross-checks the two identities pre-action // (`assertExpectedResolvedTarget`, resolution.ts) and refuses with the -// marker below; the replay loop converts that refusal into an -// identity-mismatch target-binding divergence here. +// marker below; `dispatchStep` detects the refusal and reports it to the +// engine as a neutral `guard-mismatch`/`landmark-mismatch` outcome. // --------------------------------------------------------------------------- export function isReplayTargetGuardMismatchResponse(response: DaemonResponse): boolean { return !response.ok && response.error.details?.reason === REPLAY_TARGET_GUARD_MISMATCH_REASON; } -type PostDispatchMismatchParams = ReplayTargetDivergenceParams & { - failedResponse: DaemonResponse; -}; - -type PostDispatchMismatchEvidence = { - matchCount: number | undefined; - observed: LocalIdentity | undefined; - mismatches: string[]; - causeMessage: string; -}; - /** - * The shared post-dispatch identity-mismatch shaping: both refusal markers — - * the guard mismatch and wait's landmark refusal — arrive as a failed dispatch - * response whose details carry the observed evidence, and both become the same - * bounded identity-mismatch divergence around their marker-specific evidence. + * #1349: `wait`'s post-resolution landmark timeout refusal — candidates + * matched the recorded selector during polling, but none carried the + * recorded landmark identity. `dispatchStep` detects this the same way as + * the guard-mismatch marker above. */ -async function buildPostDispatchIdentityMismatchResponse( - params: PostDispatchMismatchParams, - deriveEvidence: ( - recorded: TargetAnnotationV1, - details: Record | undefined, - ) => PostDispatchMismatchEvidence, -): Promise { - const { action, scope, failedResponse, sessionName, sessionStore, logPath } = params; - // The refusal markers are only ever attached to an annotated action; fall - // back to the original failure if the invariant is somehow violated. - const recorded = action.targetEvidence; - if (!recorded) return failedResponse; - - const scrubVars = collectReplayScrubbableVarValues(scope); - const sanitize = createReplayDivergenceSanitizer(scrubVars); - const details = failedResponse.ok ? undefined : failedResponse.error.details; - const evidence = deriveEvidence(recorded, details); - - const session = sessionStore.get(sessionName); - const observation = session - ? await captureDivergenceObservation({ session, sessionName, sessionStore, logPath, action }) - : ({ - state: 'unavailable', - reason: 'no-session', - hint: 'The session closed before a post-failure screen could be captured.', - } as const); - - return buildTargetBindingDivergenceResponse( - { - recorded, - action, - step: params.step, - sourcePath: params.sourcePath, - sourceLine: params.sourceLine, - replayPath: params.replayPath, - artifactPaths: params.artifactPaths, - sessionName, - sessionStore, - resumeStamper: params.resumeStamper, - responseLevel: params.responseLevel, - scrubVars, - planActions: params.planActions, - planDigest: params.planDigest, - signal: params.signal, - }, - { - kind: 'identity-mismatch', - matchCount: evidence.matchCount, - observed: evidence.observed, - candidateNodes: [], - mismatches: evidence.mismatches, - causeCode: 'IDENTITY_MISMATCH', - causeMessage: evidence.causeMessage, - screen: buildDivergenceScreen(observation, sanitize), - publicationEvidence: publicationEvidenceFrom(observation), - repairCapture: toReplayRepairHintCapture(observation), - }, - ); -} - -function publicationEvidenceFrom( - observation: Awaited>, -): InternalObservationEvidence | undefined { - return observation.state === 'available' ? observation.evidence : undefined; -} - -export async function buildReplayTargetGuardMismatchResponse( - params: PostDispatchMismatchParams & { guard: ReplayVerifiedTargetGuard }, -): Promise { - return await buildPostDispatchIdentityMismatchResponse(params, (recorded, details) => { - const observed = readGuardMismatchObservedIdentity(details?.observed); - // The guard fires even when local identity is identical (a same-identity - // duplicate resolved by structural position) — surface the structural - // difference so `mismatches` is never empty on a real divergence. - const structuralMismatch = describeStructuralMismatch( - details?.expectedStructural, - details?.observedStructural, - ); - return { - matchCount: params.guard.matchCount, - observed, - mismatches: [ - ...(observed ? identityFieldMismatches(recorded, observed) : []), - ...(structuralMismatch ? [structuralMismatch] : []), - ], - causeMessage: - 'Dispatch resolution (with occlusion/visibility guards) resolved a different element than pre-action verification isolated; the action was not sent.', - }; - }); -} - -// --------------------------------------------------------------------------- -// #1349 deferred (post-resolution) landmark verification for `wait`: the -// polling loop refuses at its deadline when selector candidates appeared but -// none carried the recorded landmark identity; the replay loop converts that -// refusal into an identity-mismatch target-binding divergence here. A plain -// wait timeout (the selector never matched at all) is NOT this marker — it -// stays an ordinary action-failure divergence, because "the landmark never -// appeared" needs a state repair, not an identity repair. -// --------------------------------------------------------------------------- - export function isWaitLandmarkMismatchResponse(response: DaemonResponse): boolean { return !response.ok && response.error.details?.reason === WAIT_LANDMARK_MISMATCH_REASON; } -export async function buildWaitLandmarkMismatchResponse( - params: PostDispatchMismatchParams, -): Promise { - return await buildPostDispatchIdentityMismatchResponse(params, (recorded, details) => { - const observed = readGuardMismatchObservedIdentity(details?.observed); - const observedAncestry = readAncestryEntries(details?.observedAncestry); - return { - matchCount: typeof details?.matchCount === 'number' ? details.matchCount : undefined, - observed, - mismatches: observed - ? [ - ...identityFieldMismatches(recorded, observed), - ...firstAncestryMismatch(recorded.ancestry, observedAncestry), - ] - : [], - causeMessage: - 'Candidates matched the recorded wait selector during polling, but none carried the recorded landmark identity before the timeout; the wait did not report success.', - }; - }); -} - -/** The wait refusal's `observedAncestry` entries, defensively re-read off error details. */ -function readAncestryEntries(value: unknown): { role: string; label?: string }[] { - if (!Array.isArray(value)) return []; - const entries: { role: string; label?: string }[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []; - const record = entry as Record; - if (typeof record.role !== 'string') return []; - entries.push({ - role: record.role, - ...(typeof record.label === 'string' ? { label: record.label } : {}), - }); - } - return entries; -} - -function readGuardMismatchObservedIdentity(value: unknown): LocalIdentity | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - if (typeof record.role !== 'string') return undefined; - return { - ...(typeof record.id === 'string' ? { id: record.id } : {}), - role: record.role, - ...(typeof record.label === 'string' ? { label: record.label } : {}), - }; -} - -/** A `position:` mismatch line from the guard's structural denotations, when both are present and differ. */ -function describeStructuralMismatch(expected: unknown, observed: unknown): string | undefined { - const e = readStructuralDenotation(expected); - const o = readStructuralDenotation(observed); - if (!e || !o) return undefined; - if (e.documentOrder === o.documentOrder && e.sibling === o.sibling) return undefined; - return `position: recorded=doc${e.documentOrder}/sibling${e.sibling} observed=doc${o.documentOrder}/sibling${o.sibling}`; -} - -function readStructuralDenotation( - value: unknown, -): { documentOrder: number; sibling: number } | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - if (typeof record.documentOrder !== 'number' || typeof record.sibling !== 'number') { - return undefined; - } - return { documentOrder: record.documentOrder, sibling: record.sibling }; -} - function sanitizeIdentity( identity: ReplayDivergenceTargetIdentity, sanitize: (value: string, limit?: number) => string, diff --git a/src/daemon/handlers/session-replay-terminal-lifecycle.ts b/src/daemon/handlers/session-replay-terminal-lifecycle.ts deleted file mode 100644 index 580c4ce7d4..0000000000 --- a/src/daemon/handlers/session-replay-terminal-lifecycle.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { DaemonResponse, SessionAction, SessionState } from '../types.ts'; -import { errorResponse } from './response.ts'; - -/** A dispatchable step: nested `replay` markers are plan metadata and never dispatch. */ -export function isExecutableReplayAction( - action: SessionAction | undefined, -): action is SessionAction { - return Boolean(action && action.command !== 'replay'); -} - -/** - * Resolves the one native replay lifecycle seam once per plan. Terminal means - * the last executable action, because nested `replay` markers are plan - * metadata and never dispatch. The suppressed close is therefore neither - * divergence-checked nor included in the successful `replayed` count. - */ -export function resolveSuppressedTerminalCloseIndex(params: { - actions: SessionAction[]; - keepSession: boolean; - saveScript: boolean | string | undefined; - repairActive: boolean; -}): number | undefined { - if (!params.keepSession && !params.saveScript && !params.repairActive) return undefined; - for (let index = params.actions.length - 1; index >= 0; index -= 1) { - const action = params.actions[index]; - if (!isExecutableReplayAction(action)) continue; - return action.command === 'close' ? index : undefined; - } - return undefined; -} - -export function countExecutedReplayActions(params: { - actions: SessionAction[]; - entryIndex: number; - suppressedTerminalCloseIndex: number | undefined; -}): number { - let count = 0; - for (let index = params.entryIndex; index < params.actions.length; index += 1) { - if (index === params.suppressedTerminalCloseIndex) continue; - if (isExecutableReplayAction(params.actions[index])) count += 1; - } - return count; -} - -/** - * `--keep-session`'s postcondition: a suppressed terminal close only ever - * promises a live session, so a session that is gone by completion anyway - * (some other action closed or otherwise removed it) must fail loudly rather - * than silently report `sessionActive: false` as if `--keep-session` had - * never been requested. - */ -export function requireLiveSessionForKeepSession(params: { - keepSession: boolean; - sessionName: string; - completedSession: SessionState | undefined; - artifactPaths: Set; -}): DaemonResponse | undefined { - const { keepSession, sessionName, completedSession, artifactPaths } = params; - if (!keepSession || completedSession) return undefined; - return errorResponse( - 'COMMAND_FAILED', - `Replay completed but --keep-session could not preserve session "${sessionName}". Run the script again after checking which action closed the session.`, - artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, - ); -} diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index c0330f28a2..c277eade90 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { inspectMaestroFlow } from '@agent-device/maestro'; +import { resolveDeclaredScriptPlatform } from '@agent-device/ad-script'; import { parseReplayInput } from '../compat/replay-input.ts'; import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; @@ -63,18 +64,31 @@ export function buildMaestroReplayTargetDeviceResolutionOptions( return appTargetResolutionOptions(appTarget) ?? {}; } +/** + * #1555 structural-quality review ("declaredScriptPlatform... move to + * packages/ad-script"): the platform half of this selection is + * `resolveDeclaredScriptPlatform` (`@agent-device/ad-script`) — a single + * shared scan, no longer a second copy kept in sync by hand with + * `packages/ad-replay/src/internal/inspect.ts`'s own plan-digest precedence. + * The app-target half stays its own pass here (never fused back into one + * loop with the platform scan): `resolveDeclaredScriptPlatform` stops at the + * first `open`, exactly where this function's own app-target search needs + * to look too, so a second, separate pass over the (typically tiny) actions + * array costs nothing observable and keeps the shared function free of a + * daemon-only concern. + */ function readScriptReplaySelection(actions: SessionAction[]): { appTarget: string | undefined; platform: CommandFlags['platform'] | undefined; } { - let platform: CommandFlags['platform'] | undefined; + // `resolveDeclaredScriptPlatform` returns a plain `string` — narrowed back + // to `CommandFlags['platform']` here because both callers only ever feed + // it a value already typed that way at the source (`runtime`/`open` + // actions' own recorded flags), so this is a representation return trip, + // never an unvalidated external string. + const platform = resolveDeclaredScriptPlatform(actions) as CommandFlags['platform'] | undefined; for (const action of actions) { - if (action.command === 'runtime' && action.flags.platform) { - platform = action.flags.platform; - continue; - } if (action.command !== 'open') continue; - platform = action.runtime?.platform ?? platform; const target = action.positionals?.[0]; if (isStaticAppTarget(target)) return { appTarget: target, platform }; return { appTarget: undefined, platform }; diff --git a/src/daemon/replay-selector-port.ts b/src/daemon/replay-selector-port.ts new file mode 100644 index 0000000000..ac138f784d --- /dev/null +++ b/src/daemon/replay-selector-port.ts @@ -0,0 +1,199 @@ +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { + ReplayRecordedTargetPolicy, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; +import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; +import { matchesSelector } from '../selectors/match.ts'; +import { + buildSelectorChainForNode, + listSelectorChainMatches, + resolveSelectorChain, + splitIsSelectorArgs, + splitSelectorFromArgs, + tryParseSelectorChain, + type Selector, +} from '../selectors/index.ts'; + +/** + * #1478 P5 stage B: the production `ReplaySelectorPort` adapter. Delegates to + * `src/selectors` internals, composing parse/resolve/list-matches/match + * exactly as `session-replay-target-classification.ts`'s + * `resolveSelectorTargetMatches` does today — that composition (and the + * "same selector alternative" winner+domain invariant it protects) lives + * HERE now; handlers keep calling their existing direct imports until stage C + * migrates them onto this port. + */ +export function createDaemonReplaySelectorPort(): ReplaySelectorPort { + return { + readSelectorExpression: readSelectorExpression, + resolveRecordedTarget: resolveRecordedTarget, + buildSelectorCandidates: buildSelectorCandidates, + }; +} + +/** + * `is`'s grammar goes through `splitIsSelectorArgs` (predicate-first or + * selector-first, per `session-replay-target-token.ts`'s eligible-token + * extraction); `wait`/`ordinary` share the same underlying + * `splitSelectorFromArgs` primitive `src/core/wait-positionals.ts` and + * `src/core/interaction-positionals.ts` already use for their own + * selector-bearing positional forms. Either way, a syntactically-found + * expression is validated with `tryParseSelectorChain` before being handed + * back — callers must not need a second parse-validity check before calling + * `resolveRecordedTarget` (`selector-port-contract.test.ts` cell 1). + */ +function readSelectorExpression( + grammar: ReplaySelectorGrammar, + positionals: readonly string[], +): ReplaySelectorExpressionOutcome { + const split = + grammar === 'is' + ? splitIsSelectorArgs([...positionals]).split + : splitSelectorFromArgs([...positionals]); + if (!split) return { kind: 'not-applicable' }; + if (!tryParseSelectorChain(split.selectorExpression)) return { kind: 'invalid' }; + return { kind: 'expression', expression: split.selectorExpression, rest: split.rest }; +} + +function resolveRecordedTarget( + expression: string, + nodes: SnapshotNode[], + policy: ReplayRecordedTargetPolicy, +): ReplayRecordedTargetResolution { + const chain = tryParseSelectorChain(expression); + if (!chain) { + return { kind: 'unresolved', reason: 'parse-invalid', matchedNodes: [] }; + } + const resolved = resolveSelectorChain(nodes, chain, { + platform: policy.platform, + requireRect: policy.requireRect, + requireUnique: true, + disambiguateAmbiguous: policy.allowDisambiguation, + }); + if (resolved) { + // The matched-node domain must come from the SAME chain alternative the + // winner resolved through, not the first alternative with any match at + // all — an earlier ambiguous/tied alternative can be skipped in favor of + // a later resolvable one (the amendment's same-alternative invariant). + const matchedNodes = nodes.filter((node) => { + if (policy.requireRect && !node.rect) return false; + return matchesSelector(node, resolved.selector, policy.platform); + }); + return { + kind: 'resolved', + winner: resolved.node, + matchedNodes, + matchCount: matchedNodes.length, + ...(resolved.disambiguation + ? { + disambiguation: { + tiebreak: resolved.disambiguation.tiebreak, + matchCount: resolved.disambiguation.matchCount, + alternatives: resolved.disambiguation.alternatives, + }, + } + : {}), + }; + } + // No alternative produced a dispatch winner (e.g. ambiguity without + // disambiguation). Keep the established diagnostic domain — the first + // alternative with any match — so callers can still report a matchCount, + // without inventing a winner. + const matchList = listSelectorChainMatches(nodes, chain, { + platform: policy.platform, + requireRect: policy.requireRect, + }); + const matchedNodes = matchList?.matchedNodes ?? []; + return { + kind: 'unresolved', + reason: matchedNodes.length > 0 ? 'ambiguous' : 'no-match', + matchedNodes, + }; +} + +function buildSelectorCandidates( + node: SnapshotNode, + platform: ReplayRecordedTargetPolicy['platform'], + options: ReplaySelectorCandidateOptions = {}, +): readonly string[] { + return buildSelectorChainForNode(node, platform, options); +} + +// --------------------------------------------------------------------------- +// #1478 P5 stage C: daemon-only siblings to `ReplaySelectorPort`. Both need +// the private `Selector` AST of a resolved chain alternative — term keys for +// `resolveReplaySuggestionCandidate`'s basis classification, term values for +// `readReplaySelectorDisplayValue`'s progress-step label — which the port +// deliberately never exposes (the amendment's Selector/SelectorChain/ +// SelectorTerm rejection). Neither is part of the swappable 3-operation +// contract (packages/ad-replay's in-memory adapter has no need for them), so +// they stay here as plain functions the daemon handlers import directly, +// rather than being threaded through a `ReplaySelectorPort` instance. +// --------------------------------------------------------------------------- + +export type ReplaySuggestionCandidateMatch = Readonly<{ + readonly node: SnapshotNode; + readonly basis: ReplayDivergenceSuggestionBasis; +}>; + +/** + * Resolves ONE divergence-suggestion candidate string against the current + * tree and classifies which selector fields (id / role+label / label / other) + * the WINNING chain alternative used — lifted verbatim from + * `session-replay-divergence.ts`'s old `resolveSuggestionCandidate` + + * `classifySuggestionBasis` composition. + */ +export function resolveReplaySuggestionCandidate( + candidate: string, + nodes: SnapshotNode[], + policy: ReplayRecordedTargetPolicy, +): ReplaySuggestionCandidateMatch | undefined { + const chain = tryParseSelectorChain(candidate); + if (!chain) return undefined; + const resolved = resolveSelectorChain(nodes, chain, { + platform: policy.platform, + requireRect: policy.requireRect, + requireUnique: true, + disambiguateAmbiguous: policy.allowDisambiguation, + }); + if (!resolved) return undefined; + return { node: resolved.node, basis: classifySuggestionBasis(resolved.selector) }; +} + +function classifySuggestionBasis(selector: Selector): ReplayDivergenceSuggestionBasis { + const keys = new Set(selector.terms.map((term) => term.key)); + if (keys.has('id')) return 'id'; + const hasRole = keys.has('role'); + const hasLabelLike = keys.has('label') || keys.has('text'); + if (hasRole && hasLabelLike) return 'role-label'; + if (hasLabelLike || keys.has('value')) return 'label'; + return 'other'; +} + +/** + * A replay-test progress step's display `value`: the recorded selector's + * label/text/id term value when every alternative agrees on ONE value, else + * `undefined` — lifted verbatim from `session-replay-runtime.ts`'s old + * `readSelectorDisplayValue`. + */ +export function readReplaySelectorDisplayValue(selector: string | undefined): string | undefined { + if (!selector) return undefined; + const parsed = tryParseSelectorChain(selector); + if (!parsed) return undefined; + const values = parsed.selectors.flatMap((entry) => + entry.terms.flatMap((term) => + (term.key === 'label' || term.key === 'text' || term.key === 'id') && + typeof term.value === 'string' + ? [term.value] + : [], + ), + ); + if (values.length === 0) return undefined; + const first = values[0]; + return first && values.every((value) => value === first) ? first : undefined; +} diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index ffed7f4b47..5b4b068c3c 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -5,7 +5,13 @@ * `computeTargetEvidence` runs decision 3's "Record-time write" steps 1-5 * against the tree the resolver already captured; it never captures, and * callers gate it on `session.recordSession`. Tree-agnostic spec pieces live - * in `src/replay/target-identity.ts`, shared with the parser. + * in `@agent-device/ad-script`: local-identity + ancestry-prefix matching + * (`packages/ad-script/src/internal/target-annotation-identity.ts`) and the + * classification core (`target-annotation-classification.ts`, relocated + * there from `@agent-device/ad-replay` by the #1555 review — this writer's + * self-check and replay-time verification were its only two real callers, + * and neither reaches it through the engine façade), shared with the + * parser/replay-time verification. * * The structural helpers below (identity/ancestry/sibling/scroll-region/ * viewport-order) are exported so migration step 4's replay-time enforcement @@ -30,11 +36,9 @@ import { import { classifyTargetBindingMatch, matchesLocalIdentity, - type LocalIdentity, -} from '../replay/target-identity.ts'; -import { serializeTargetAnnotationV1, utf8ByteLength, + type LocalIdentity, TARGET_ANNOTATION_MAX_ANCESTRY, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, } from '@agent-device/ad-script'; diff --git a/src/replay/target-evidence-tree.ts b/src/replay/target-evidence-tree.ts index 7befbd167f..87b12381b5 100644 --- a/src/replay/target-evidence-tree.ts +++ b/src/replay/target-evidence-tree.ts @@ -15,7 +15,7 @@ import { matchesAncestryPrefix, matchesLocalIdentity, type LocalIdentity, -} from './target-identity.ts'; +} from '@agent-device/ad-script'; import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; export function buildIndexMap(nodes: readonly SnapshotNode[]): Map { diff --git a/src/replay/target-identity-node.ts b/src/replay/target-identity-node.ts index cf422dd60f..f3b5fbbd81 100644 --- a/src/replay/target-identity-node.ts +++ b/src/replay/target-identity-node.ts @@ -17,9 +17,9 @@ import { normalizeLabelField, normalizeRoleField, truncateToUtf8Bytes, + type LocalIdentity, TARGET_ANNOTATION_MAX_FIELD_BYTES, } from '@agent-device/ad-script'; -import type { LocalIdentity } from './target-identity.ts'; type IdentityTreeNode = Pick;