From e9705627c658987c8705abf84a6c86a797f86505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 13:08:39 +0200 Subject: [PATCH 01/31] refactor(replay): move the dependency-free engine leaves into packages/ad-replay Stage A of the #1478 P5 extraction: vars, plan-digest (+canonical-json, sole consumer), the target-identity classification core, report-action, and suggestion-ranking move verbatim; imports updated. The package facade temporarily re-exports the moved symbols so root consumers keep compiling; a later stage narrows it to inspectAdReplay/runAdReplay only. --- packages/ad-replay/package.json | 19 +++++++++ packages/ad-replay/src/index.ts | 41 +++++++++++++++++++ .../internal}/__tests__/plan-digest.test.ts | 0 .../target-identity-classification.test.ts | 0 .../__tests__/target-identity.test.ts | 0 .../src/internal}/__tests__/vars.test.ts | 0 .../ad-replay/src/internal}/canonical-json.ts | 0 .../ad-replay/src/internal}/plan-digest.ts | 2 +- .../internal}/session-replay-report-action.ts | 2 +- .../session-replay-suggestion-ranking.ts | 0 .../src/internal}/target-identity.ts | 0 .../ad-replay/src/internal}/vars.ts | 0 packages/ad-replay/tsconfig.json | 12 ++++++ .../interaction/runtime/selector-wait.ts | 2 +- .../request-router-repair-expired.test.ts | 2 +- ...-replay-divergence-suggestion-port.test.ts | 2 +- .../handlers/session-replay-action-runtime.ts | 2 +- .../handlers/session-replay-divergence.ts | 3 +- src/daemon/handlers/session-replay-heal.ts | 2 +- .../session-replay-maestro-failure.ts | 3 +- .../session-replay-maestro-runtime.ts | 2 +- .../handlers/session-replay-repair-hint.ts | 2 +- .../session-replay-runtime-failure.ts | 2 +- .../handlers/session-replay-runtime-plan.ts | 2 +- src/daemon/handlers/session-replay-runtime.ts | 4 +- .../session-replay-target-classification.ts | 6 +-- .../session-replay-target-verification.ts | 5 ++- src/daemon/session-target-evidence.ts | 5 ++- src/replay/target-evidence-tree.ts | 2 +- src/replay/target-identity-node.ts | 2 +- 30 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 packages/ad-replay/package.json create mode 100644 packages/ad-replay/src/index.ts rename {src/replay => packages/ad-replay/src/internal}/__tests__/plan-digest.test.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/__tests__/target-identity-classification.test.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/__tests__/target-identity.test.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/__tests__/vars.test.ts (100%) rename {src/utils => packages/ad-replay/src/internal}/canonical-json.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/plan-digest.ts (97%) rename {src/daemon/handlers => packages/ad-replay/src/internal}/session-replay-report-action.ts (78%) rename {src/daemon/handlers => packages/ad-replay/src/internal}/session-replay-suggestion-ranking.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/target-identity.ts (100%) rename {src/replay => packages/ad-replay/src/internal}/vars.ts (100%) create mode 100644 packages/ad-replay/tsconfig.json diff --git a/packages/ad-replay/package.json b/packages/ad-replay/package.json new file mode 100644 index 0000000000..8a8b8f6736 --- /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 replay target-identity, variable substitution, plan-digest, and report primitives for agent-device.", + "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..5fce1bd2ba --- /dev/null +++ b/packages/ad-replay/src/index.ts @@ -0,0 +1,41 @@ +/** + * The `ad-replay` package façade (#1478 P5 stage A). + * + * STAGE-A WIDE FAÇADE — TEMPORARY. This re-exports every symbol carried over + * by the mechanical move so root consumers keep working unchanged apart from + * their import specifier. It is not the intended final shape: a later stage + * narrows this façade to `inspectAdReplay`/`runAdReplay` once the daemon + * handlers and runtime wiring move into this package too. Do not treat the + * current export list as a design decision — it is scaffolding. + */ + +export { + buildReplayVarScope, + collectReplayScrubbableVarValues, + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + resolveReplayAction, + resolveReplayString, +} from './internal/vars.ts'; +export type { ReplayVarScope, ReplayVarSources } from './internal/vars.ts'; + +export { computeReplayPlanDigest } from './internal/plan-digest.ts'; +export type { ReplayPlanDigestMetadata } from './internal/plan-digest.ts'; + +export { + annotationLocalIdentity, + classifyTargetBindingMatch, + matchesAncestryPrefix, + matchesLocalIdentity, +} from './internal/target-identity.ts'; +export type { + LocalIdentity, + TargetBindingClassification, + TargetBindingClassificationInput, +} from './internal/target-identity.ts'; + +export type { ReplayReportAction } from './internal/session-replay-report-action.ts'; + +export { rankAndDedupeReplaySuggestions } from './internal/session-replay-suggestion-ranking.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/src/replay/__tests__/target-identity-classification.test.ts b/packages/ad-replay/src/internal/__tests__/target-identity-classification.test.ts similarity index 100% rename from src/replay/__tests__/target-identity-classification.test.ts rename to packages/ad-replay/src/internal/__tests__/target-identity-classification.test.ts diff --git a/src/replay/__tests__/target-identity.test.ts b/packages/ad-replay/src/internal/__tests__/target-identity.test.ts similarity index 100% rename from src/replay/__tests__/target-identity.test.ts rename to packages/ad-replay/src/internal/__tests__/target-identity.test.ts diff --git a/src/replay/__tests__/vars.test.ts b/packages/ad-replay/src/internal/__tests__/vars.test.ts similarity index 100% rename from src/replay/__tests__/vars.test.ts rename to packages/ad-replay/src/internal/__tests__/vars.test.ts 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/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/src/daemon/handlers/session-replay-report-action.ts b/packages/ad-replay/src/internal/session-replay-report-action.ts similarity index 78% rename from src/daemon/handlers/session-replay-report-action.ts rename to packages/ad-replay/src/internal/session-replay-report-action.ts index 672ca3294c..8c9a6391f5 100644 --- a/src/daemon/handlers/session-replay-report-action.ts +++ b/packages/ad-replay/src/internal/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-suggestion-ranking.ts b/packages/ad-replay/src/internal/session-replay-suggestion-ranking.ts similarity index 100% rename from src/daemon/handlers/session-replay-suggestion-ranking.ts rename to packages/ad-replay/src/internal/session-replay-suggestion-ranking.ts diff --git a/src/replay/target-identity.ts b/packages/ad-replay/src/internal/target-identity.ts similarity index 100% rename from src/replay/target-identity.ts rename to packages/ad-replay/src/internal/target-identity.ts diff --git a/src/replay/vars.ts b/packages/ad-replay/src/internal/vars.ts similarity index 100% rename from src/replay/vars.ts rename to packages/ad-replay/src/internal/vars.ts 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/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index f22a65962d..c1703c127f 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-replay'; 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__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index f3a1559448..7deeae7917 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -19,7 +19,7 @@ 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 { computeReplayPlanDigest } from '@agent-device/ad-replay'; import { readEffectiveReplayPlanDigestMetadata } from '../handlers/session-replay-runtime-plan.ts'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); 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..b5f3e22f46 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 @@ -16,7 +16,7 @@ import { test } from 'vitest'; import { buildReplayDivergenceSuggestionForNode } from '../session-replay-divergence.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'; +import type { ReplayReportAction } from '@agent-device/ad-replay'; const identitySanitize = (value: string): string => value; diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index 6feacc0c8f..ef2e362fec 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/handlers/session-replay-action-runtime.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import { resolveReplayAction, type ReplayVarScope } from '../../replay/vars.ts'; +import { resolveReplayAction, type ReplayVarScope } from '@agent-device/ad-replay'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { mergeParentFlags } from '../../core/batch.ts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index ffe5363c8a..d1a8a0b584 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -18,7 +18,6 @@ import { import { collectReplaySelectorCandidates } from './session-replay-heal.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 +30,7 @@ import { type InternalObservationEvidence, } from '../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import type { ReplayReportAction } from './session-replay-report-action.ts'; +import { rankAndDedupeReplaySuggestions, type ReplayReportAction } from '@agent-device/ad-replay'; import type { SessionAction, SessionState } from '../types.ts'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index 23cdf5111d..db2e852c8d 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,6 +1,6 @@ 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 { ReplayReportAction } from '@agent-device/ad-replay'; import { isTouchTargetCommand } from '@agent-device/ad-script'; /** diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index c47e2363e5..2e2ee7aa05 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -12,7 +12,7 @@ import { formatScriptArg } from '@agent-device/ad-script'; 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, type ReplayReportAction } from '@agent-device/ad-replay'; import { buildReplayDivergenceSuggestionForNode, buildDivergenceScreen, @@ -22,7 +22,6 @@ import { } from './session-replay-divergence.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, diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index fc100b2d0e..9363e293fb 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-replay'; 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..108a9625e1 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/handlers/session-replay-repair-hint.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-replay'; 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-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 6c9163e883..34c89fb69a 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 { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-replay'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index d9cf4c5953..cb3b9ad74a 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import type { ReplayPlanDigestMetadata } from '../../replay/plan-digest.ts'; +import type { ReplayPlanDigestMetadata } from '@agent-device/ad-replay'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; import type { DaemonResponse } from '../types.ts'; import { errorResponse } from './response.ts'; diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 9b484ce6e2..a6207c2f41 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -11,7 +11,6 @@ import type { 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'; @@ -19,11 +18,12 @@ import type { ResponseLevel } from '@agent-device/kernel/contracts'; import { buildReplayVarScope, collectReplayShellEnv, + computeReplayPlanDigest, parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, type ReplayVarScope, -} from '../../replay/vars.ts'; +} from '@agent-device/ad-replay'; import { summarizeSnapshotTimingSamples, type SnapshotTimingSample, diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index ca50cea6f4..b1a2ce6d28 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -3,8 +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 + * (`action.targetEvidence`, parsed by `@agent-device/ad-script` / + * `@agent-device/ad-replay`'s `target-identity.ts`), 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 @@ -56,7 +56,7 @@ import { annotationLocalIdentity, classifyTargetBindingMatch, type LocalIdentity, -} from '../../replay/target-identity.ts'; +} from '@agent-device/ad-replay'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index ed1121ecf9..a4541dec13 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -5,11 +5,12 @@ import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { formatDivergenceActionLabel } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { + annotationLocalIdentity, collectReplayScrubbableVarValues, resolveReplayAction, + type LocalIdentity, type ReplayVarScope, -} from '../../replay/vars.ts'; -import { annotationLocalIdentity, type LocalIdentity } from '../../replay/target-identity.ts'; +} from '@agent-device/ad-replay'; import { createReplayDivergenceSanitizer, type ReplayDivergence, diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index ffed7f4b47..3f758702e6 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -5,7 +5,8 @@ * `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-replay` (`packages/ad-replay/src/internal/target-identity.ts`), + * shared with the parser. * * The structural helpers below (identity/ancestry/sibling/scroll-region/ * viewport-order) are exported so migration step 4's replay-time enforcement @@ -31,7 +32,7 @@ import { classifyTargetBindingMatch, matchesLocalIdentity, type LocalIdentity, -} from '../replay/target-identity.ts'; +} from '@agent-device/ad-replay'; import { serializeTargetAnnotationV1, utf8ByteLength, diff --git a/src/replay/target-evidence-tree.ts b/src/replay/target-evidence-tree.ts index 7befbd167f..759ee1d6b9 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-replay'; 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..1f5d0fddaa 100644 --- a/src/replay/target-identity-node.ts +++ b/src/replay/target-identity-node.ts @@ -19,7 +19,7 @@ import { truncateToUtf8Bytes, TARGET_ANNOTATION_MAX_FIELD_BYTES, } from '@agent-device/ad-script'; -import type { LocalIdentity } from './target-identity.ts'; +import type { LocalIdentity } from '@agent-device/ad-replay'; type IdentityTreeNode = Pick; From 0573f62a4d8f36d89f11068dec3ab9e6f1a0184f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 13:08:40 +0200 Subject: [PATCH 02/31] chore(layering): register packages/ad-replay in the workspace and DAG --- package.json | 3 ++- pnpm-lock.yaml | 15 +++++++++++++++ scripts/layering/daemon-modularity.test.ts | 2 +- scripts/layering/daemon-modularity.ts | 13 +++++++++---- scripts/layering/model.ts | 1 + 5 files changed, 28 insertions(+), 6 deletions(-) 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/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], From 0779e395177875fabc65129ee5f3bacc352494fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 13:29:35 +0200 Subject: [PATCH 03/31] refactor(replay): define the three-operation replay selector port with dual adapters (#1478 P5) --- packages/ad-replay/package.json | 4 + packages/ad-replay/src/index.ts | 18 ++ .../ad-replay/src/internal/selector-port.ts | 157 ++++++++++ .../testing/in-memory-selector-port.ts | 281 ++++++++++++++++++ .../replay-selector-port-contract.test.ts | 278 +++++++++++++++++ src/daemon/replay-selector-port.ts | 123 ++++++++ 6 files changed, 861 insertions(+) create mode 100644 packages/ad-replay/src/internal/selector-port.ts create mode 100644 packages/ad-replay/src/internal/testing/in-memory-selector-port.ts create mode 100644 src/daemon/__tests__/replay-selector-port-contract.test.ts create mode 100644 src/daemon/replay-selector-port.ts diff --git a/packages/ad-replay/package.json b/packages/ad-replay/package.json index 8a8b8f6736..bf6fa87f36 100644 --- a/packages/ad-replay/package.json +++ b/packages/ad-replay/package.json @@ -14,6 +14,10 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./testing": { + "types": "./src/internal/testing/in-memory-selector-port.ts", + "default": "./src/internal/testing/in-memory-selector-port.ts" } } } diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 5fce1bd2ba..6b4358a1a5 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -39,3 +39,21 @@ export type { export type { ReplayReportAction } from './internal/session-replay-report-action.ts'; export { rankAndDedupeReplaySuggestions } from './internal/session-replay-suggestion-ranking.ts'; + +// #1478 P5 stage B: the port TYPE only — root's production adapter +// (`src/daemon/replay-selector-port.ts`) implements it against +// `ReplaySelectorPort`'s three operations. The type is what rides in via +// `runAdReplay`'s runtime parameter once the daemon threads it (stage C); no +// selector AST type is ever exported here. +export type { + ReplaySelectorPort, + ReplaySelectorGrammar, + ReplaySelectorExpressionOutcome, + ReplayRecordedTargetPolicy, + ReplayRecordedTargetDisambiguation, + ReplayRecordedTargetResolved, + ReplayRecordedTargetUnresolved, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateAction, + ReplaySelectorCandidateOptions, +} from './internal/selector-port.ts'; 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..ad51c38f42 --- /dev/null +++ b/packages/ad-replay/src/internal/selector-port.ts @@ -0,0 +1,157 @@ +/** + * #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 + * (`./testing/in-memory-selector-port.ts`) for the package's own contract + * suite. + * + * Stage B builds the port and both adapters only. Handlers keep their direct + * `src/selectors` imports until stage C migrates them onto this port. + */ + +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/testing/in-memory-selector-port.ts b/packages/ad-replay/src/internal/testing/in-memory-selector-port.ts new file mode 100644 index 0000000000..8e0c9f1183 --- /dev/null +++ b/packages/ad-replay/src/internal/testing/in-memory-selector-port.ts @@ -0,0 +1,281 @@ +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { + ReplayRecordedTargetDisambiguation, + ReplayRecordedTargetPolicy, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from '../selector-port.ts'; + +/** + * #1478 P5 stage B: a deterministic, dependency-free `ReplaySelectorPort` + * adapter for `packages/ad-replay`'s own contract suite + * (`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 the #1269 shared-id demotion — the + * contract is about shapes and invariants, not grammar richness. + * + * 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). + */ +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; +} + +function buildSelectorCandidates( + node: SnapshotNode, + _platform: unknown, + _options: ReplaySelectorCandidateOptions = {}, +): readonly string[] { + const id = trimmedOrNull(node.identifier); + 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/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts new file mode 100644 index 0000000000..b81fcf7f09 --- /dev/null +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -0,0 +1,278 @@ +/** + * #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 package's deterministic in-memory adapter + * (`@agent-device/ad-replay/testing`). 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/`). + * + * 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 '@agent-device/ad-replay/testing'; +import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; + +const ADAPTERS: readonly (readonly [string, () => ReplaySelectorPort])[] = [ + ['production (src/selectors)', createDaemonReplaySelectorPort], + ['in-memory (packages/ad-replay testing)', 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"', + ]); + }); + + // ------------------------------------------------------------------- + // 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: [] }); + }); + }); +} diff --git a/src/daemon/replay-selector-port.ts b/src/daemon/replay-selector-port.ts new file mode 100644 index 0000000000..ed26b74397 --- /dev/null +++ b/src/daemon/replay-selector-port.ts @@ -0,0 +1,123 @@ +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { + ReplayRecordedTargetPolicy, + ReplayRecordedTargetResolution, + ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; +import { matchesSelector } from '../selectors/match.ts'; +import { + buildSelectorChainForNode, + listSelectorChainMatches, + resolveSelectorChain, + splitIsSelectorArgs, + splitSelectorFromArgs, + tryParseSelectorChain, +} 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); +} From a185b315ab9f990e34d2825a496d7e82c1c8375e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 14:01:02 +0200 Subject: [PATCH 04/31] refactor(daemon): route replay handlers through the selector port (#1478 P5) --- ...-replay-divergence-suggestion-port.test.ts | 4 + .../session-replay-divergence.test.ts | 15 ++++ ...-replay-target-classification-port.test.ts | 4 + ...ssion-replay-target-classification.test.ts | 18 +++++ .../session-replay-target-guard.test.ts | 5 ++ .../session-replay-target-token.test.ts | 11 ++- .../handlers/session-replay-divergence.ts | 64 +++++++--------- src/daemon/handlers/session-replay-heal.ts | 27 ++++--- .../session-replay-maestro-failure.ts | 10 +++ .../session-replay-runtime-failure.ts | 10 ++- src/daemon/handlers/session-replay-runtime.ts | 35 ++++----- .../session-replay-target-classification.ts | 50 +++++------- .../handlers/session-replay-target-token.ts | 12 ++- .../session-replay-target-verification.ts | 22 ++++-- src/daemon/replay-selector-port.ts | 76 +++++++++++++++++++ 15 files changed, 256 insertions(+), 107 deletions(-) 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 b5f3e22f46..b286465fdf 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 '@agent-device/ad-replay'; 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-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-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index d1a8a0b584..4207a1a9bc 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -9,13 +9,8 @@ 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 { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; @@ -30,7 +25,11 @@ import { type InternalObservationEvidence, } from '../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import { rankAndDedupeReplaySuggestions, type ReplayReportAction } from '@agent-device/ad-replay'; +import { + rankAndDedupeReplaySuggestions, + type ReplayReportAction, + type ReplaySelectorPort, +} from '@agent-device/ad-replay'; import type { SessionAction, SessionState } from '../types.ts'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, @@ -72,6 +71,7 @@ export async function buildReplayFailureDivergence(params: { /** SHA-256 digest of the canonical plan `planActions` came from (`computeReplayPlanDigest`). */ planDigest: string; signal?: AbortSignal; + port: ReplaySelectorPort; }): Promise { const { error, @@ -89,6 +89,7 @@ export async function buildReplayFailureDivergence(params: { planActions, planDigest, signal, + port, } = params; const sanitize = createReplayDivergenceSanitizer(scrubVars); @@ -114,6 +115,7 @@ export async function buildReplayFailureDivergence(params: { session, nodes: observation.nodes, sanitize, + port, }) : []; @@ -514,16 +516,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), @@ -535,13 +527,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 { @@ -576,8 +569,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 @@ -591,6 +585,7 @@ function rankSuggestionCandidates(params: { action, matching, sanitize, + port, }); if (!entry) continue; entries.push(entry); @@ -605,29 +600,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, }; } @@ -639,9 +632,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 db2e852c8d..6d7abd6394 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,6 +1,5 @@ -import { splitIsSelectorArgs, splitSelectorFromArgs } from '../../selectors/index.ts'; import { uniqueStrings } from '@agent-device/kernel/collections'; -import type { ReplayReportAction } from '@agent-device/ad-replay'; +import type { ReplayReportAction, ReplaySelectorPort } from '@agent-device/ad-replay'; import { isTouchTargetCommand } from '@agent-device/ad-script'; /** @@ -13,7 +12,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 +25,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 +68,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 2e2ee7aa05..0240cb8ccf 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -20,6 +20,7 @@ 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 { @@ -163,6 +164,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 }) => ({ @@ -178,6 +187,7 @@ function collectTypedMaestroSuggestions(params: { action: params.action, basis, sanitize: params.sanitize, + port, }), ); } diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 34c89fb69a..10318f9098 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,4 +1,8 @@ -import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-replay'; +import { + collectReplayScrubbableVarValues, + type ReplaySelectorPort, + type ReplayVarScope, +} from '@agent-device/ad-replay'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, @@ -32,6 +36,7 @@ export async function withReplayFailureDiagnostics(params: { logPath: string; planActions: SessionAction[]; planDigest: string; + port: ReplaySelectorPort; }): Promise { return await withReplayFailureContext({ ...params, @@ -57,6 +62,7 @@ async function withReplayFailureContext(params: { logPath: string; planActions: SessionAction[]; planDigest: string; + port: ReplaySelectorPort; }): Promise { const { response, @@ -75,6 +81,7 @@ async function withReplayFailureContext(params: { logPath, planActions, planDigest, + port, } = params; if (response.ok) return response; const failureSource = readReplayFailureSource(response.error.details?.replaySource); @@ -96,6 +103,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.ts b/src/daemon/handlers/session-replay-runtime.ts index a6207c2f41..4e5f993592 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -13,7 +13,10 @@ import { expandSessionPath } from '../session-paths.ts'; import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; import { errorResponse, noActiveSessionError } from './response.ts'; import { invokeReplayAction } from './session-replay-action-runtime.ts'; -import { tryParseSelectorChain } from '../../selectors/index.ts'; +import { + createDaemonReplaySelectorPort, + readReplaySelectorDisplayValue, +} from '../replay-selector-port.ts'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import { buildReplayVarScope, @@ -22,6 +25,7 @@ import { parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, + type ReplaySelectorPort, type ReplayVarScope, } from '@agent-device/ad-replay'; import { @@ -89,6 +93,8 @@ type ReplayStepContext = { 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; }; /** @@ -124,6 +130,7 @@ async function resolveReplayStepResponse( planActions: ctx.actions, planDigest: ctx.planDigest, signal: ctx.signal, + port: ctx.port, }); if (!verification.verified) return verification.response; const guard = verification.guard; @@ -195,6 +202,7 @@ async function convertIdentityRefusalResponse(params: { planActions: ctx.actions, planDigest: ctx.planDigest, signal: ctx.signal, + port: ctx.port, }; if (params.guard && isReplayTargetGuardMismatchResponse(response)) { return await buildReplayTargetGuardMismatchResponse({ ...mismatchParams, guard: params.guard }); @@ -232,6 +240,10 @@ export async function runReplayScriptFile(params: { // #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') { @@ -299,6 +311,7 @@ export async function runReplayScriptFile(params: { invoke, signal: getRequestSignal(req.meta?.requestId), coordinator, + port, }; const failure = await executeReplayActions({ req, @@ -427,6 +440,7 @@ async function buildReplayActionFailure( logPath: params.logPath, planActions: params.actions, planDigest: params.planDigest, + port: params.stepContext.port, }), ); } @@ -502,29 +516,12 @@ function replayActionStep( function replayActionStepValue(action: SessionAction): Pick { const positionals = action.positionals ?? []; - const selectorValue = readSelectorDisplayValue(positionals[0]); + const selectorValue = readReplaySelectorDisplayValue(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[]; diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index b1a2ce6d28..c73b9804a7 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -34,12 +34,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, @@ -56,6 +50,7 @@ import { annotationLocalIdentity, classifyTargetBindingMatch, type LocalIdentity, + type ReplaySelectorPort, } from '@agent-device/ad-replay'; 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; 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 a4541dec13..d1962341c1 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -9,6 +9,7 @@ import { collectReplayScrubbableVarValues, resolveReplayAction, type LocalIdentity, + type ReplaySelectorPort, type ReplayVarScope, } from '@agent-device/ad-replay'; import { @@ -31,7 +32,6 @@ 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, @@ -228,6 +228,7 @@ type ReplayTargetDivergenceParams = { planActions: SessionAction[]; planDigest: string; signal?: AbortSignal; + port: ReplaySelectorPort; }; export async function verifyReplayActionTarget( @@ -249,6 +250,7 @@ export async function verifyReplayActionTarget( planActions, planDigest, signal, + port, } = params; const recorded = action.targetEvidence; @@ -323,13 +325,22 @@ export async function verifyReplayActionTarget( return { verified: true, deferredLandmark: recorded }; } - const token = extractReplayTargetToken(resolvedAction); + const token = extractReplayTargetToken(resolvedAction, port); if (token === undefined) return { verified: true }; - if (!token.startsWith('@') && !tryParseSelectorChain(token)) { + if (!token.startsWith('@')) { // 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 }; + // would. `resolveRecordedTarget`'s early parse gate is the exact same + // `tryParseSelectorChain` check this used to run directly (empty `nodes` + // is safe: a parse failure short-circuits before any resolution work). + const parseCheck = port.resolveRecordedTarget(token, [], { + platform: session.device.platform, + requireRect: false, + allowDisambiguation: false, + }); + if (parseCheck.kind === 'unresolved' && parseCheck.reason === 'parse-invalid') { + return { verified: true }; + } } if (recorded.verification === 'unverifiable') { @@ -376,6 +387,7 @@ export async function verifyReplayActionTarget( refLabel: readRefLabel(action), requireRect: config.requiresRect, allowDisambiguation: config.allowDisambiguation, + port, }); if (classification.verified) { diff --git a/src/daemon/replay-selector-port.ts b/src/daemon/replay-selector-port.ts index ed26b74397..ac138f784d 100644 --- a/src/daemon/replay-selector-port.ts +++ b/src/daemon/replay-selector-port.ts @@ -7,6 +7,7 @@ import type { ReplaySelectorGrammar, ReplaySelectorPort, } from '@agent-device/ad-replay'; +import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; import { matchesSelector } from '../selectors/match.ts'; import { buildSelectorChainForNode, @@ -15,6 +16,7 @@ import { splitIsSelectorArgs, splitSelectorFromArgs, tryParseSelectorChain, + type Selector, } from '../selectors/index.ts'; /** @@ -121,3 +123,77 @@ function buildSelectorCandidates( ): 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; +} From 765f4387037d429c3f9f9315368d51c9667bd232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 14:17:44 +0200 Subject: [PATCH 05/31] refactor(replay): split target verification into engine policy and daemon authority (#1478 P5) --- packages/ad-replay/src/index.ts | 21 ++ .../ad-replay/src/internal/target-identity.ts | 55 +++++ .../src/internal/target-verification.ts | 212 ++++++++++++++++++ .../session-replay-target-classification.ts | 50 +---- .../session-replay-target-verification.ts | 158 ++++--------- 5 files changed, 328 insertions(+), 168 deletions(-) create mode 100644 packages/ad-replay/src/internal/target-verification.ts diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 6b4358a1a5..1386e0b354 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -27,6 +27,8 @@ export type { ReplayPlanDigestMetadata } from './internal/plan-digest.ts'; export { annotationLocalIdentity, classifyTargetBindingMatch, + firstAncestryMismatch, + identityFieldMismatches, matchesAncestryPrefix, matchesLocalIdentity, } from './internal/target-identity.ts'; @@ -36,6 +38,25 @@ export type { TargetBindingClassificationInput, } from './internal/target-identity.ts'; +// #1478 P5 stage C2a: the target-verification ENGINE policy split out of +// `session-replay-target-verification.ts` — pre-capture verification gating +// and post-dispatch mismatch-evidence derivation. See +// `./internal/target-verification.ts` for the daemon/engine ownership split. +export { + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + describeStructuralMismatch, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, + readAncestryEntries, + readGuardMismatchObservedIdentity, +} from './internal/target-verification.ts'; +export type { + ReplayPostDispatchMismatchEvidence, + ReplayPostResolutionVerificationPlan, + ReplayPreDispatchVerificationPlan, +} from './internal/target-verification.ts'; + export type { ReplayReportAction } from './internal/session-replay-report-action.ts'; export { rankAndDedupeReplaySuggestions } from './internal/session-replay-suggestion-ranking.ts'; diff --git a/packages/ad-replay/src/internal/target-identity.ts b/packages/ad-replay/src/internal/target-identity.ts index cba03bd749..fac1c59d07 100644 --- a/packages/ad-replay/src/internal/target-identity.ts +++ b/packages/ad-replay/src/internal/target-identity.ts @@ -150,3 +150,58 @@ export function classifyTargetBindingMatch( } return { path: 6, outcome: 'unverifiable', reason: 'no-signal-isolation' }; } + +// --------------------------------------------------------------------------- +// 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-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts new file mode 100644 index 0000000000..c42dfc5ba6 --- /dev/null +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -0,0 +1,212 @@ +/** + * #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. + * + * Two pure decisions live here: + * + * - `planPostResolutionTargetVerification` / `planPreDispatchTargetVerification`: + * should `verifyReplayActionTarget` even attempt verification, and with + * what token — mirrors the two branches of that function'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 raw (already + * neutral, `unknown`-typed) details bag, compute the observed identity and + * mismatch lines a target-binding divergence reports — the daemon then + * wraps the result into a `DaemonResponse`. + */ + +import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; +import { + firstAncestryMismatch, + identityFieldMismatches, + type LocalIdentity, +} from './target-identity.ts'; +import type { ReplaySelectorPort } from './selector-port.ts'; + +// --------------------------------------------------------------------------- +// 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 — + * `resolveRecordedTarget`'s own parse gate over empty `nodes` is the same + * `tryParseSelectorChain` check this used to run directly); only past both + * of those does a recorded-`unverifiable` annotation refuse pre-action. + */ +export function planPreDispatchTargetVerification(params: { + recorded: TargetAnnotationV1; + token: string | undefined; + platform: Platform | PublicPlatform; + port: ReplaySelectorPort; +}): ReplayPreDispatchVerificationPlan { + const { recorded, token, platform, port } = params; + if (token === undefined) return { kind: 'skip' }; + if (!token.startsWith('@')) { + const parseCheck = port.resolveRecordedTarget(token, [], { + platform, + requireRect: false, + allowDisambiguation: false, + }); + if (parseCheck.kind === 'unresolved' && parseCheck.reason === 'parse-invalid') { + 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 +// response whose `details` carry the observed evidence; this derives the +// SAME bounded identity-mismatch shape around their marker-specific evidence +// the daemon used to compute inline. +// --------------------------------------------------------------------------- + +export type ReplayPostDispatchMismatchEvidence = { + matchCount: number | undefined; + observed: LocalIdentity | undefined; + mismatches: string[]; + causeMessage: string; +}; + +export 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. */ +export 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; +} + +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 }; +} + +/** A `position:` mismatch line from the guard's structural denotations, when both are present and differ. */ +export 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}`; +} + +/** + * 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 `details`. + */ +export function deriveReplayTargetGuardMismatchEvidence( + recorded: TargetAnnotationV1, + details: Record | undefined, + matchCount: number, +): ReplayPostDispatchMismatchEvidence { + 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, + 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, + details: Record | undefined, +): ReplayPostDispatchMismatchEvidence { + 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.', + }; +} diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index c73b9804a7..169c27b599 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -49,7 +49,8 @@ import { import { annotationLocalIdentity, classifyTargetBindingMatch, - type LocalIdentity, + firstAncestryMismatch, + identityFieldMismatches, type ReplaySelectorPort, } from '@agent-device/ad-replay'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; @@ -324,50 +325,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-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index d1962341c1..c7a4469daf 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -7,8 +7,13 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { annotationLocalIdentity, collectReplayScrubbableVarValues, + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, resolveReplayAction, type LocalIdentity, + type ReplayPostDispatchMismatchEvidence, type ReplaySelectorPort, type ReplayVarScope, } from '@agent-device/ad-replay'; @@ -45,11 +50,7 @@ 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'; // --------------------------------------------------------------------------- @@ -316,36 +317,36 @@ export async function verifyReplayActionTarget( // front; a verifiable landmark is deferred into the wait's own loop. 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 }; - } - - const token = extractReplayTargetToken(resolvedAction, port); - if (token === undefined) return { verified: true }; - if (!token.startsWith('@')) { - // 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. `resolveRecordedTarget`'s early parse gate is the exact same - // `tryParseSelectorChain` check this used to run directly (empty `nodes` - // is safe: a parse failure short-circuits before any resolution work). - const parseCheck = port.resolveRecordedTarget(token, [], { - platform: session.device.platform, - requireRect: false, - allowDisambiguation: false, + const plan = planPostResolutionTargetVerification({ + recorded, + isSelectorWait: parsed?.kind === 'selector', }); - if (parseCheck.kind === 'unresolved' && parseCheck.reason === 'parse-invalid') { - return { verified: true }; + switch (plan.kind) { + case 'skip': + return { verified: true }; + case 'recorded-unverifiable': + return { verified: false, response: await buildRecordedUnverifiableResponse() }; + case 'deferred-landmark': + return { verified: true, deferredLandmark: plan.landmark }; } } - if (recorded.verification === 'unverifiable') { + // 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. `resolveRecordedTarget`'s early parse gate is the exact same + // `tryParseSelectorChain` check this used to run directly (empty `nodes` + // is safe: a parse failure short-circuits before any resolution work). + const preDispatchPlan = planPreDispatchTargetVerification({ + recorded, + token: extractReplayTargetToken(resolvedAction, port), + platform: session.device.platform, + port, + }); + if (preDispatchPlan.kind === 'skip') return { verified: true }; + if (preDispatchPlan.kind === 'recorded-unverifiable') { return { verified: false, response: await buildRecordedUnverifiableResponse() }; } + 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 @@ -444,13 +445,6 @@ 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 @@ -462,7 +456,7 @@ async function buildPostDispatchIdentityMismatchResponse( deriveEvidence: ( recorded: TargetAnnotationV1, details: Record | undefined, - ) => PostDispatchMismatchEvidence, + ) => ReplayPostDispatchMismatchEvidence, ): Promise { const { action, scope, failedResponse, sessionName, sessionStore, logPath } = params; // The refusal markers are only ever attached to an annotated action; fall @@ -526,26 +520,9 @@ function publicationEvidenceFrom( 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.', - }; - }); + return await buildPostDispatchIdentityMismatchResponse(params, (recorded, details) => + deriveReplayTargetGuardMismatchEvidence(recorded, details, params.guard.matchCount), + ); } // --------------------------------------------------------------------------- @@ -565,69 +542,10 @@ export function isWaitLandmarkMismatchResponse(response: DaemonResponse): boolea 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 }; + return await buildPostDispatchIdentityMismatchResponse( + params, + deriveWaitLandmarkMismatchEvidence, + ); } function sanitizeIdentity( From e1595871c9764a4e5f4b8a0da9be37630c8b662e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 14:40:09 +0200 Subject: [PATCH 06/31] refactor(replay): move the .ad step loop behind inspectAdReplay/runAdReplay (#1478 P5) --- packages/ad-replay/src/index.ts | 13 + packages/ad-replay/src/internal/inspect.ts | 53 +++ packages/ad-replay/src/internal/step-loop.ts | 207 ++++++++++++ src/daemon/handlers/session-replay-runtime.ts | 307 ++++++++---------- 4 files changed, 403 insertions(+), 177 deletions(-) create mode 100644 packages/ad-replay/src/internal/inspect.ts create mode 100644 packages/ad-replay/src/internal/step-loop.ts diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 1386e0b354..d6f9a33b3b 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -24,6 +24,19 @@ export type { ReplayVarScope, ReplayVarSources } from './internal/vars.ts'; export { computeReplayPlanDigest } from './internal/plan-digest.ts'; export type { ReplayPlanDigestMetadata } from './internal/plan-digest.ts'; +export { inspectAdReplay } from './internal/inspect.ts'; +export type { AdReplayManifest } from './internal/inspect.ts'; + +export { formatReplaySuccessMessage, runAdReplay } from './internal/step-loop.ts'; +export type { + AdReplayProgressSink, + AdReplayProgressStep, + AdReplayResponse, + AdReplayRunOutcome, + AdReplayRunRequest, + AdReplayStepRuntime, +} from './internal/step-loop.ts'; + export { annotationLocalIdentity, classifyTargetBindingMatch, diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts new file mode 100644 index 0000000000..f337a669fc --- /dev/null +++ b/packages/ad-replay/src/internal/inspect.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SessionAction } from '@agent-device/contracts/session'; +import { + parseReplayScriptDetailed, + readReplayScriptMetadata, + type ReplayScriptMetadata, +} from '@agent-device/ad-script'; + +/** + * #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. + */ +export type AdReplayManifest = Readonly<{ + actions: SessionAction[]; + actionLines: number[]; + actionSourcePaths: (string | undefined)[] | undefined; + metadata: ReplayScriptMetadata; +}>; + +/** + * Reads `sourcePath` once and returns its parsed actions/line table plus + * header metadata. 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. + */ +export function inspectAdReplay(sourcePath: string): 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); + return { + actions: parsed.actions, + actionLines: parsed.actionLines, + actionSourcePaths: parsed.actionSourcePaths, + metadata: readReplayScriptMetadata(script), + }; +} 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..310ca67954 --- /dev/null +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -0,0 +1,207 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; + +/** + * #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 below — this module only decides which + * action to run next, when to skip one, and when to stop. + * + * `TResponse` is the daemon's own response type, injected generically: the + * loop only ever reads its `ok` discriminant (never `DaemonError`, + * `SessionStore`, or a wire shape) and returns it unopened — the daemon + * adapter is the only side that ever constructs or interprets one. + */ + +/** The one field the step loop reads off a daemon response: pass/fail. */ +export type AdReplayResponse = Readonly<{ readonly ok: boolean }>; + +/** + * 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; + +/** + * 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. + */ +export type AdReplayStepRuntime = Readonly<{ + /** + * Verifies the recorded target (if any) then dispatches the action. + * Capture, the single `invoke` dispatch site, and the post-resolution + * guard/landmark-mismatch conversion are all daemon authority. + */ + executeStep( + action: SessionAction, + index: number, + artifactPaths: readonly string[], + ): Promise; + /** + * Wraps a failed step's response with replay failure diagnostics and + * repair-held marking — daemon authority (capture, `SessionStore`, the P4b + * coordinator). + */ + handleActionFailure(params: { + action: SessionAction; + index: number; + response: TResponse; + artifactPaths: readonly string[]; + snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; + }): Promise; + /** Reads the artifact paths a response surfaced — wire-shape authority. */ + collectArtifactPaths(response: TResponse): readonly string[]; + /** 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; +}>; + +export type AdReplayRunOutcome = + | Readonly<{ + readonly ok: true; + readonly replayed: number; + readonly artifactPaths: readonly string[]; + readonly snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; + }> + | Readonly<{ readonly ok: false; readonly response: TResponse }>; + +/** + * ADR 0012 step 4's step loop: for every executable action from + * `request.entryIndex` on, arm the save-script transaction, skip a + * repair-armed plan's terminal `close` (lifecycle, not a script step), report + * progress, dispatch through `runtime.executeStep`, and stop at the first + * failure. Moved verbatim from `executeReplayActions`'s composition order — + * only the daemon capabilities it calls through were narrowed into + * `runtime`. + */ +export async function runAdReplay( + request: AdReplayRunRequest, + runtime: AdReplayStepRuntime, +): Promise> { + const { actions, entryIndex } = request; + const artifactPaths = new Set(); + const snapshotDiagnosticSamples: SnapshotTimingSample[] = []; + 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 (isRepairArmedTerminalCloseAction(action, index, actions.length, runtime.isRepairArmed())) { + continue; + } + // `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)); + } + const sampleStart = runtime.diagnosticsMarker(); + const response = await runtime.executeStep(action, index, [...artifactPaths]); + snapshotDiagnosticSamples.push(...runtime.diagnosticsSince(sampleStart)); + runtime.collectArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); + if (response.ok) continue; + const failure = await runtime.handleActionFailure({ + action, + index, + response, + artifactPaths: [...artifactPaths], + snapshotDiagnosticSamples, + }); + return { ok: false, response: failure }; + } + return { + ok: true, + replayed: actions.length - entryIndex, + artifactPaths: [...artifactPaths], + snapshotDiagnosticSamples, + }; +} + +/** + * 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): 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. 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. Skipped exactly like the `replay` pseudo-command + * just above it in the loop — never dispatched, never divergence-checked, + * and (like that skip) not counted out of `replayed`. `repairArmed` reflects + * session state, not this invocation's own flags, matching R2: a repair stays + * armed across separate `--from` legs regardless of whether `--save-script` + * is repeated on each one. + */ +export function isRepairArmedTerminalCloseAction( + action: SessionAction, + index: number, + totalActions: number, + repairArmed: boolean, +): boolean { + if (action.command !== 'close') return false; + if (index !== totalActions - 1) return false; + return repairArmed; +} + +function buildAdReplayProgressStep( + actionIndex: number, + actionTotal: number, + action: SessionAction, + value: string | undefined, +): AdReplayProgressStep { + return { + index: actionIndex + 1, + total: actionTotal, + command: action.command, + ...(value !== undefined ? { value } : {}), + }; +} + +export 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`; +} diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 4e5f993592..cb1431d6fe 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import { parseReplayInput } from '../../compat/replay-input.ts'; import { asAppError } from '@agent-device/kernel/errors'; import type { DaemonInvokeFn, @@ -22,9 +21,14 @@ import { buildReplayVarScope, collectReplayShellEnv, computeReplayPlanDigest, + formatReplaySuccessMessage, + inspectAdReplay, parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, + runAdReplay, + type AdReplayManifest, + type AdReplayStepRuntime, type ReplaySelectorPort, type ReplayVarScope, } from '@agent-device/ad-replay'; @@ -55,7 +59,7 @@ import { } 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 type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; import { getRequestSignal } from '../../request/cancel.ts'; import { NO_SCRIPT_PUBLICATION, @@ -105,6 +109,12 @@ type ReplayStepContext = { * 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. + * + * #1478 P5 stage C2b: this is the daemon's `AdReplayStepRuntime.executeStep` + * implementation — capture, the single dispatch site, and post-resolution + * guard/landmark conversion are all daemon authority, so the engine step loop + * (`runAdReplay`, `@agent-device/ad-replay`) calls this as one opaque + * capability and never sees any of it. */ async function resolveReplayStepResponse( ctx: ReplayStepContext, @@ -236,6 +246,9 @@ export async function runReplayScriptFile(params: { const startedAt = Date.now(); const keepSession = req.flags?.replayKeepSession === true; let resolved = ''; + // Mirrors whatever the engine step loop's own `collectArtifactPaths` + // capability accumulates (see `createAdReplayStepRuntime`), 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. @@ -283,8 +296,6 @@ export async function runReplayScriptFile(params: { entryIndex, scope, actionTracePath, - snapshotDiagnosticSamples, - suppressedTerminalCloseIndex, } = planPreparation.value; const sessionPreparation = prepareReplaySession({ req, @@ -313,34 +324,23 @@ export async function runReplayScriptFile(params: { coordinator, port, }; - const failure = await executeReplayActions({ + const runtime = 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 }, runtime); + if (!outcome.ok) return outcome.response; return completeReplayRun({ startedAt, sessionName, sessionStore, - actions, - entryIndex, - artifactPaths, - snapshotDiagnosticSamples, + replayed: outcome.replayed, + artifactPaths: outcome.artifactPaths, + snapshotDiagnosticSamples: outcome.snapshotDiagnosticSamples, armSaveScript: sessionPreparation.armSaveScript, coordinator, keepSession, @@ -356,103 +356,116 @@ export async function runReplayScriptFile(params: { } } -type ReplayActionExecution = { +/** + * #1478 P5 stage C2b: the daemon's `AdReplayStepRuntime` adapter — the + * narrow execute/capture/observe/stamp 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. + */ +function createAdReplayStepRuntime(params: { + ctx: ReplayStepContext; 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; + /** The outer exception-reporting mirror (see `runReplayScriptFile`'s catch block). */ 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, +}): AdReplayStepRuntime { + const { ctx, req, artifactPaths, onStep, armSaveScript } = params; + return { + async executeStep(action, index, stepArtifactPaths) { + return await resolveReplayStepResponse(ctx, action, index, [...stepArtifactPaths]); + }, + async handleActionFailure({ + action, + index, + response, + artifactPaths: failureArtifactPaths, + snapshotDiagnosticSamples, + }) { + return await buildReplayActionFailure( + ctx, + req, + action, + index, + response as Extract, + [...failureArtifactPaths], + [...snapshotDiagnosticSamples], + ); + }, + collectArtifactPaths(response) { + const entries = collectReplayActionArtifactPaths(response); + entries.forEach((entry) => artifactPaths.add(entry)); + return entries; + }, + armStep: armSaveScript, + isRepairArmed: () => ctx.coordinator.view()?.repairBoundary !== undefined, + describeStepValue: (action) => describeReplayStepValue(action), 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; + diagnosticsMarker: () => readSessionSnapshotSampleCount(ctx.sessionStore, ctx.sessionName), + diagnosticsSince: (marker) => + readSessionSnapshotSamplesSince(ctx.sessionStore, ctx.sessionName, marker), + }; } async function buildReplayActionFailure( - params: ReplayActionExecution, + ctx: ReplayStepContext, + req: DaemonRequest, action: SessionAction, index: number, response: Extract, + artifactPaths: string[], + snapshotDiagnosticSamples: SnapshotTimingSample[], ): Promise { const heldResponse = (failure: DaemonResponse): DaemonResponse => - params.stepContext.coordinator.markSessionHeldIfArmed(failure); + ctx.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, - port: params.stepContext.port, + replayPath: ctx.resolved, + sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, + sourceLine: ctx.actionLines[index] ?? 1, + artifactPaths, + snapshotDiagnosticSamples, + scope: ctx.scope, + 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(' '); +} + 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; @@ -462,8 +475,7 @@ function completeReplayRun(params: { startedAt, sessionName, sessionStore, - actions, - entryIndex, + replayed, artifactPaths, snapshotDiagnosticSamples, armSaveScript, @@ -474,54 +486,21 @@ function completeReplayRun(params: { armSaveScript(); coordinator.markCompleteIfArmed(); const completedSession = sessionStore.get(sessionName); - const keepSessionFailure = requireLiveSessionForKeepSession({ - keepSession, - sessionName, - completedSession, - 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 = readReplaySelectorDisplayValue(positionals[0]); - if (selectorValue) return { value: selectorValue }; - if (positionals.length === 0) return {}; - return { value: positionals.join(' ') }; -} - type PreparedReplayPlan = { replayReq: DaemonRequest; actions: SessionAction[]; @@ -532,12 +511,8 @@ type PreparedReplayPlan = { entryIndex: number; scope: ReplayVarScope; actionTracePath: string | undefined; - snapshotDiagnosticSamples: SnapshotTimingSample[]; - suppressedTerminalCloseIndex: number | undefined; }; -type ParsedReplayInput = ReturnType; - function prepareReplayPlan(params: { req: DaemonRequest; sessionName: string; @@ -547,11 +522,9 @@ function prepareReplayPlan(params: { 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 { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; + const manifest = inspectAdReplay(resolved); + const { metadata, actions, actionLines, actionSourcePaths } = manifest; const replayReq = applyReplayMetadata( { ...req, flags: buildReplayScriptPlatformFlags(req.flags, actions) }, metadata, @@ -584,38 +557,13 @@ function prepareReplayPlan(params: { 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'], + metadata: AdReplayManifest['metadata'], ): DaemonRequest { if (!metadata.platform && !metadata.target) return req; return { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) }; @@ -626,7 +574,7 @@ function buildPreparedReplayScope(params: { replayReq: DaemonRequest; sessionName: string; resolved: string; - metadata: ParsedReplayInput['metadata']; + metadata: AdReplayManifest['metadata']; }): ReplayVarScope { const { req, replayReq, sessionName, resolved, metadata } = params; return buildReplayVarScope({ @@ -814,10 +762,16 @@ function preflightSaveScriptTarget(params: { } /** - * 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. + * 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 (`isRepairArmedTerminalCloseAction`) 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; @@ -834,15 +788,14 @@ function createReplaySaveScriptArmer(params: { }; } -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. +// 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 engine divergence-kind +// classification (that already happened engine-side, in +// `classifyReplayTarget`/`target-identity.ts`). function isCompleteTargetBindingDivergenceResponse(response: DaemonResponse): boolean { if (response.ok || response.error.code !== 'REPLAY_DIVERGENCE') return false; const divergence = response.error.details?.divergence; From f5da8d6b15f40767aa2d61fd99c80d72e9028021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 15:04:51 +0200 Subject: [PATCH 07/31] =?UTF-8?q?refactor(replay):=20lock=20the=20ad-repla?= =?UTF-8?q?y=20fa=C3=A7ade=20to=20its=20real=20consumers=20(#1478=20P5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fallow-baselines/health.json | 8 ++ packages/ad-replay/package.json | 4 - packages/ad-replay/src/index.ts | 135 ++++++++++++------ scripts/layering/package-boundaries.test.ts | 22 +++ .../in-memory-replay-selector-port.ts | 28 ++-- .../replay-selector-port-contract.test.ts | 16 ++- 6 files changed, 147 insertions(+), 66 deletions(-) rename packages/ad-replay/src/internal/testing/in-memory-selector-port.ts => src/__tests__/test-utils/in-memory-replay-selector-port.ts (87%) 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/packages/ad-replay/package.json b/packages/ad-replay/package.json index bf6fa87f36..8a8b8f6736 100644 --- a/packages/ad-replay/package.json +++ b/packages/ad-replay/package.json @@ -14,10 +14,6 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" - }, - "./testing": { - "types": "./src/internal/testing/in-memory-selector-port.ts", - "default": "./src/internal/testing/in-memory-selector-port.ts" } } } diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index d6f9a33b3b..ba4bbeef6c 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -1,14 +1,29 @@ /** - * The `ad-replay` package façade (#1478 P5 stage A). + * The `ad-replay` package façade (#1478 P5 stage D — narrowed). * - * STAGE-A WIDE FAÇADE — TEMPORARY. This re-exports every symbol carried over - * by the mechanical move so root consumers keep working unchanged apart from - * their import specifier. It is not the intended final shape: a later stage - * narrows this façade to `inspectAdReplay`/`runAdReplay` once the daemon - * handlers and runtime wiring move into this package too. Do not treat the - * current export list as a design decision — it is scaffolding. + * The binding design (issue comment 5156017698) is `inspectAdReplay` + + * `runAdReplay` and nothing else. Staged reality is wider than that: the + * daemon wire-builders and handlers call several engine POLICY functions + * directly rather than only through the two entrypoints (var substitution, + * plan-digest, target-identity/-verification, report shaping). Every export + * below is a `façade-deviation` from the binding-design ideal EXCEPT + * `inspectAdReplay`/`runAdReplay` themselves; each deviation names its real + * root consumer(s) so the PR body's deviation table is generated straight + * from this file. No selector AST, no engine IR, no prepared-plan type, and + * no internal subpath is exported — everything here has a live import site + * outside this package (see `packages/ad-replay/src/index.ts`'s sibling + * consumer grep in the P5 stage D commit message for the full map). */ +// --------------------------------------------------------------------------- +// vars.ts — `.ad` variable substitution surface. +// façade-deviation: the daemon resolves an action's variables directly +// (`session-replay-target-verification.ts`, `session-replay-action-runtime.ts`) +// and builds/collects env-derived scopes outside the step loop +// (`session-replay-runtime.ts`, `session-replay-maestro-runtime.ts`, +// `session-replay-runtime-failure.ts`) instead of only receiving resolved +// actions back from `runAdReplay`. +// --------------------------------------------------------------------------- export { buildReplayVarScope, collectReplayScrubbableVarValues, @@ -17,26 +32,44 @@ export { readReplayCliEnvEntries, readReplayShellEnvSource, resolveReplayAction, - resolveReplayString, } from './internal/vars.ts'; -export type { ReplayVarScope, ReplayVarSources } from './internal/vars.ts'; +export type { ReplayVarScope } from './internal/vars.ts'; +// --------------------------------------------------------------------------- +// plan-digest.ts — the `--from`/`--plan-digest` resume and save-script digest. +// façade-deviation: `session-replay-runtime-plan.ts`'s resume path and +// `request-router-repair-expired.test.ts` compute/read the digest directly, +// ahead of and independent from any `runAdReplay` call. +// --------------------------------------------------------------------------- export { computeReplayPlanDigest } from './internal/plan-digest.ts'; export type { ReplayPlanDigestMetadata } from './internal/plan-digest.ts'; +// --------------------------------------------------------------------------- +// inspect.ts — the read-only `.ad` manifest reader. On-design: this IS one +// of the two binding-design entrypoints. +// --------------------------------------------------------------------------- export { inspectAdReplay } from './internal/inspect.ts'; export type { AdReplayManifest } from './internal/inspect.ts'; +// --------------------------------------------------------------------------- +// step-loop.ts — the `.ad` step loop. On-design: this IS the other binding- +// design entrypoint; `AdReplayStepRuntime` is the runtime capability bag the +// daemon adapter (`session-replay-runtime.ts`) implements to thread it. +// --------------------------------------------------------------------------- export { formatReplaySuccessMessage, runAdReplay } from './internal/step-loop.ts'; -export type { - AdReplayProgressSink, - AdReplayProgressStep, - AdReplayResponse, - AdReplayRunOutcome, - AdReplayRunRequest, - AdReplayStepRuntime, -} from './internal/step-loop.ts'; +export type { AdReplayStepRuntime } from './internal/step-loop.ts'; +// --------------------------------------------------------------------------- +// target-identity.ts — record/replay local-identity primitives (ADR 0012 +// decision 3). +// façade-deviation: the record-time writer (`src/daemon/session-target-evidence.ts`) +// and replay-time classification core (`session-replay-target-classification.ts`) +// call these identity functions directly so both sides compute the SAME +// ordinal by construction; `wait`'s landmark poll +// (`src/commands/interaction/runtime/selector-wait.ts`) and the shared +// replay-zone tree helpers (`src/replay/target-evidence-tree.ts`, +// `src/replay/target-identity-node.ts`) also call in below `runAdReplay`. +// --------------------------------------------------------------------------- export { annotationLocalIdentity, classifyTargetBindingMatch, @@ -45,49 +78,57 @@ export { matchesAncestryPrefix, matchesLocalIdentity, } from './internal/target-identity.ts'; -export type { - LocalIdentity, - TargetBindingClassification, - TargetBindingClassificationInput, -} from './internal/target-identity.ts'; +export type { LocalIdentity } from './internal/target-identity.ts'; -// #1478 P5 stage C2a: the target-verification ENGINE policy split out of -// `session-replay-target-verification.ts` — pre-capture verification gating -// and post-dispatch mismatch-evidence derivation. See -// `./internal/target-verification.ts` for the daemon/engine ownership split. +// --------------------------------------------------------------------------- +// target-verification.ts — #1478 P5 stage C2a target-verification ENGINE +// policy (pre-capture verification gating, post-dispatch mismatch-evidence +// derivation), split out of `session-replay-target-verification.ts`. +// façade-deviation: that same daemon wire-builder is the direct caller of +// all four functions below — see `./internal/target-verification.ts` for the +// daemon/engine ownership split. +// --------------------------------------------------------------------------- export { deriveReplayTargetGuardMismatchEvidence, deriveWaitLandmarkMismatchEvidence, - describeStructuralMismatch, planPostResolutionTargetVerification, planPreDispatchTargetVerification, - readAncestryEntries, - readGuardMismatchObservedIdentity, -} from './internal/target-verification.ts'; -export type { - ReplayPostDispatchMismatchEvidence, - ReplayPostResolutionVerificationPlan, - ReplayPreDispatchVerificationPlan, } from './internal/target-verification.ts'; +export type { ReplayPostDispatchMismatchEvidence } from './internal/target-verification.ts'; +// --------------------------------------------------------------------------- +// session-replay-report-action.ts / session-replay-suggestion-ranking.ts — +// the divergence-report action shape and repair-suggestion ranking. +// façade-deviation: `session-replay-maestro-failure.ts`, `session-replay-heal.ts`, +// and `session-replay-divergence.ts` build/rank divergence reports directly; +// none of this rides back through `runAdReplay`'s return value. +// --------------------------------------------------------------------------- export type { ReplayReportAction } from './internal/session-replay-report-action.ts'; - export { rankAndDedupeReplaySuggestions } from './internal/session-replay-suggestion-ranking.ts'; -// #1478 P5 stage B: the port TYPE only — root's production adapter -// (`src/daemon/replay-selector-port.ts`) implements it against -// `ReplaySelectorPort`'s three operations. The type is what rides in via -// `runAdReplay`'s runtime parameter once the daemon threads it (stage C); no -// selector AST type is ever exported here. +// --------------------------------------------------------------------------- +// selector-port.ts — the `ReplaySelectorPort` port TYPE only (#1478 P5 stage +// B, the amendment's explicit rejection of a "seven-function selector-AST +// mirror"). Two adapters implement it: the production adapter +// (`src/daemon/replay-selector-port.ts`) and the in-memory adapter for this +// package's own contract suite, relocated to +// `src/__tests__/test-utils/in-memory-replay-selector-port.ts` (#1478 P5 +// stage D — package-internal code may not "reach back into root `src/`", +// R11, so the adapter could not stay inside `packages/ad-replay` once its +// only remaining consumer was a root test). +// façade-deviation: daemon handlers thread `ReplaySelectorPort` values +// directly (`session-replay-target-token.ts`, `session-replay-heal.ts`, +// `session-replay-target-classification.ts`, `session-replay-runtime-failure.ts`, +// `session-replay-runtime.ts`, `session-replay-target-verification.ts`) — +// the port rides in as `runAdReplay`'s runtime threads it, but the type +// itself is named at every one of those call sites. +// --------------------------------------------------------------------------- export type { - ReplaySelectorPort, - ReplaySelectorGrammar, - ReplaySelectorExpressionOutcome, - ReplayRecordedTargetPolicy, ReplayRecordedTargetDisambiguation, - ReplayRecordedTargetResolved, - ReplayRecordedTargetUnresolved, + ReplayRecordedTargetPolicy, ReplayRecordedTargetResolution, - ReplaySelectorCandidateAction, ReplaySelectorCandidateOptions, + ReplaySelectorExpressionOutcome, + ReplaySelectorGrammar, + ReplaySelectorPort, } from './internal/selector-port.ts'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index c87255ff26..a9776cdc42 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -228,6 +228,19 @@ 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', + ]); const providerWebDriverPackage = packages.find( (pkg) => pkg.name === '@agent-device/provider-webdriver', ); @@ -283,6 +296,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 +337,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 +367,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/packages/ad-replay/src/internal/testing/in-memory-selector-port.ts b/src/__tests__/test-utils/in-memory-replay-selector-port.ts similarity index 87% rename from packages/ad-replay/src/internal/testing/in-memory-selector-port.ts rename to src/__tests__/test-utils/in-memory-replay-selector-port.ts index 8e0c9f1183..e0439cd8c6 100644 --- a/packages/ad-replay/src/internal/testing/in-memory-selector-port.ts +++ b/src/__tests__/test-utils/in-memory-replay-selector-port.ts @@ -7,18 +7,30 @@ import type { ReplaySelectorExpressionOutcome, ReplaySelectorGrammar, ReplaySelectorPort, -} from '../selector-port.ts'; +} from '@agent-device/ad-replay'; /** * #1478 P5 stage B: a deterministic, dependency-free `ReplaySelectorPort` * adapter for `packages/ad-replay`'s own contract suite - * (`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 the #1269 shared-id demotion — the - * contract is about shapes and invariants, not grammar richness. + * (`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 the #1269 shared-id demotion + * — the contract is about shapes and invariants, not grammar richness. + * + * #1478 P5 stage D: relocated here from + * `packages/ad-replay/src/internal/testing/in-memory-selector-port.ts`. It + * only ever needed the exported `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`. * * Mini expression grammar: `key="value"` terms (space-separated, ANDed), * alternatives joined by ` || ` (first-match-wins, same as the real chain). diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts index b81fcf7f09..03bcd76199 100644 --- a/src/daemon/__tests__/replay-selector-port-contract.test.ts +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -2,11 +2,13 @@ * #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 package's deterministic in-memory adapter - * (`@agent-device/ad-replay/testing`). 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 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 @@ -21,12 +23,12 @@ 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 '@agent-device/ad-replay/testing'; +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 (packages/ad-replay testing)', createInMemoryReplaySelectorPort], + ['in-memory (test-utils)', createInMemoryReplaySelectorPort], ]; const saveNode: SnapshotNode = { From 9ba5e820500ad55bd93f1d7a24ca127bfe6362e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 15:55:46 +0200 Subject: [PATCH 08/31] test(replay): prove shared-id demotion on both selector-port adapters (#1555 review) --- .../in-memory-replay-selector-port.ts | 44 +++++++++++++++++-- .../replay-selector-port-contract.test.ts | 31 +++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/__tests__/test-utils/in-memory-replay-selector-port.ts b/src/__tests__/test-utils/in-memory-replay-selector-port.ts index e0439cd8c6..1364d1e2c0 100644 --- a/src/__tests__/test-utils/in-memory-replay-selector-port.ts +++ b/src/__tests__/test-utils/in-memory-replay-selector-port.ts @@ -17,8 +17,10 @@ import type { * 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 the #1269 shared-id demotion - * — the contract is about shapes and invariants, not grammar richness. + * 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 @@ -36,6 +38,17 @@ import type { * 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 { @@ -275,12 +288,35 @@ function trimmedOrNull(value: string | undefined): string | null { 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 = {}, + options: ReplaySelectorCandidateOptions = {}, ): readonly string[] { - const id = trimmedOrNull(node.identifier); + const id = selectableId(node, options.nodes); const role = (node.type ?? '').toLowerCase(); const label = trimmedOrNull(node.label); const value = trimmedOrNull(node.value); diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts index 03bcd76199..86b07aab37 100644 --- a/src/daemon/__tests__/replay-selector-port-contract.test.ts +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -260,6 +260,37 @@ for (const [name, createPort] of ADAPTERS) { ]); }); + // ------------------------------------------------------------------- + // 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 // ------------------------------------------------------------------- From 78f5102ddcdee2d83da1746ee7af75c5b2175d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 15:56:13 +0200 Subject: [PATCH 09/31] fix(replay): restore invalid replayBackend rejection on the native path (#1555 review) --- .../__tests__/session-replay-runtime.test.ts | 63 +++++++++++++++++++ src/daemon/handlers/session-replay-runtime.ts | 18 ++++++ 2 files changed, 81 insertions(+) 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/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index cb1431d6fe..7da6283d0a 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -523,6 +523,24 @@ function prepareReplayPlan(params: { keepSession: boolean; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; + // #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. + if (req.flags?.replayBackend && req.flags.replayBackend !== 'maestro') { + return { + ok: false, + response: errorResponse( + 'INVALID_ARGS', + `Unsupported replay backend "${req.flags.replayBackend}".`, + ), + }; + } const manifest = inspectAdReplay(resolved); const { metadata, actions, actionLines, actionSourcePaths } = manifest; const replayReq = applyReplayMetadata( From bd266d0d79aa31e8cc00e675d503162a9eb64781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 16:13:20 +0200 Subject: [PATCH 10/31] refactor(replay): move shared .ad vocabulary to its owner, packages/ad-script (#1555 review) --- packages/ad-replay/src/index.ts | 98 ++++++-------- .../ad-replay/src/internal/target-identity.ts | 123 ++--------------- .../src/internal/target-verification.ts | 2 +- packages/ad-script/src/index.ts | 44 ++++++- .../target-annotation-identity.test.ts} | 10 +- .../src/internal/__tests__/vars.test.ts | 2 +- packages/ad-script/src/internal/script.ts | 8 +- .../internal/target-annotation-identity.ts | 124 ++++++++++++++++++ .../src/internal/target-annotation-serde.ts | 14 +- .../src/internal/vars.ts | 8 +- .../interaction/runtime/selector-wait.ts | 2 +- ...-replay-divergence-suggestion-port.test.ts | 2 +- .../handlers/session-replay-action-runtime.ts | 2 +- .../handlers/session-replay-divergence.ts | 8 +- src/daemon/handlers/session-replay-heal.ts | 3 +- .../session-replay-maestro-failure.ts | 3 +- .../session-replay-maestro-runtime.ts | 2 +- .../handlers/session-replay-repair-hint.ts | 2 +- .../handlers}/session-replay-report-action.ts | 0 .../session-replay-runtime-failure.ts | 7 +- src/daemon/handlers/session-replay-runtime.ts | 14 +- .../session-replay-suggestion-ranking.ts | 0 .../session-replay-target-classification.ts | 5 +- .../session-replay-target-verification.ts | 12 +- src/daemon/session-target-evidence.ts | 13 +- src/replay/target-evidence-tree.ts | 2 +- src/replay/target-identity-node.ts | 2 +- 27 files changed, 275 insertions(+), 237 deletions(-) rename packages/{ad-replay/src/internal/__tests__/target-identity.test.ts => ad-script/src/internal/__tests__/target-annotation-identity.test.ts} (86%) rename packages/{ad-replay => ad-script}/src/internal/__tests__/vars.test.ts (99%) create mode 100644 packages/ad-script/src/internal/target-annotation-identity.ts rename packages/{ad-replay => ad-script}/src/internal/vars.ts (95%) rename {packages/ad-replay/src/internal => src/daemon/handlers}/session-replay-report-action.ts (100%) rename {packages/ad-replay/src/internal => src/daemon/handlers}/session-replay-suggestion-ranking.ts (100%) diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index ba4bbeef6c..001249b7eb 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -1,40 +1,35 @@ /** - * The `ad-replay` package façade (#1478 P5 stage D — narrowed). + * 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, "complete the binding façade instead of documenting + * deviations"). * * The binding design (issue comment 5156017698) is `inspectAdReplay` + * `runAdReplay` and nothing else. Staged reality is wider than that: the * daemon wire-builders and handlers call several engine POLICY functions - * directly rather than only through the two entrypoints (var substitution, - * plan-digest, target-identity/-verification, report shaping). Every export - * below is a `façade-deviation` from the binding-design ideal EXCEPT - * `inspectAdReplay`/`runAdReplay` themselves; each deviation names its real - * root consumer(s) so the PR body's deviation table is generated straight - * from this file. No selector AST, no engine IR, no prepared-plan type, and - * no internal subpath is exported — everything here has a live import site - * outside this package (see `packages/ad-replay/src/index.ts`'s sibling - * consumer grep in the P5 stage D commit message for the full map). + * directly rather than only through the two entrypoints (plan-digest, + * target-verification). Every export below is a `façade-deviation` from the + * binding-design ideal EXCEPT `inspectAdReplay`/`runAdReplay` themselves; + * each deviation names its real root consumer(s) so the PR body's deviation + * table is generated straight from this file. No selector AST, no engine IR, + * no prepared-plan type, and no internal subpath is exported — everything + * here has a live import site outside this package (see + * `packages/ad-replay/src/index.ts`'s sibling consumer grep in the P5 stage D + * commit message for the full map). + * + * `.ad` variable substitution (`vars.ts`), the local-identity + ancestry- + * prefix matching primitives and their diagnostic diffs (formerly part of + * `target-identity.ts`), and the divergence-report action shape/suggestion + * ranking (formerly `session-replay-report-action.ts` / + * `session-replay-suggestion-ranking.ts`) are NOT here: the review found + * these were never engine-owned policy reached only through the two + * entrypoints. Var substitution and identity matching are `.ad` script + * vocabulary the daemon and this engine both consume, so they moved to their + * proper shared owner, `@agent-device/ad-script`. Report-action/suggestion- + * ranking had no consumer inside this package at all — daemon-only — so they + * moved back to `src/daemon/handlers/`. */ -// --------------------------------------------------------------------------- -// vars.ts — `.ad` variable substitution surface. -// façade-deviation: the daemon resolves an action's variables directly -// (`session-replay-target-verification.ts`, `session-replay-action-runtime.ts`) -// and builds/collects env-derived scopes outside the step loop -// (`session-replay-runtime.ts`, `session-replay-maestro-runtime.ts`, -// `session-replay-runtime-failure.ts`) instead of only receiving resolved -// actions back from `runAdReplay`. -// --------------------------------------------------------------------------- -export { - buildReplayVarScope, - collectReplayScrubbableVarValues, - collectReplayShellEnv, - parseReplayCliEnvEntries, - readReplayCliEnvEntries, - readReplayShellEnvSource, - resolveReplayAction, -} from './internal/vars.ts'; -export type { ReplayVarScope } from './internal/vars.ts'; - // --------------------------------------------------------------------------- // plan-digest.ts — the `--from`/`--plan-digest` resume and save-script digest. // façade-deviation: `session-replay-runtime-plan.ts`'s resume path and @@ -60,25 +55,20 @@ export { formatReplaySuccessMessage, runAdReplay } from './internal/step-loop.ts export type { AdReplayStepRuntime } from './internal/step-loop.ts'; // --------------------------------------------------------------------------- -// target-identity.ts — record/replay local-identity primitives (ADR 0012 -// decision 3). -// façade-deviation: the record-time writer (`src/daemon/session-target-evidence.ts`) -// and replay-time classification core (`session-replay-target-classification.ts`) -// call these identity functions directly so both sides compute the SAME -// ordinal by construction; `wait`'s landmark poll -// (`src/commands/interaction/runtime/selector-wait.ts`) and the shared -// replay-zone tree helpers (`src/replay/target-evidence-tree.ts`, -// `src/replay/target-identity-node.ts`) also call in below `runAdReplay`. -// --------------------------------------------------------------------------- -export { - annotationLocalIdentity, - classifyTargetBindingMatch, - firstAncestryMismatch, - identityFieldMismatches, - matchesAncestryPrefix, - matchesLocalIdentity, -} from './internal/target-identity.ts'; -export type { LocalIdentity } from './internal/target-identity.ts'; +// target-identity.ts — record/replay-shared CLASSIFICATION core (ADR 0012 +// decision 3, replay-time verification paths 2-6). +// façade-deviation: the daemon's replay-time classification core +// (`session-replay-target-classification.ts`) and the record-time writer +// (`src/daemon/session-target-evidence.ts`) call `classifyTargetBindingMatch` +// directly so both sides compute the SAME verdict by construction, ahead of +// and independent from any `runAdReplay` call. The local-identity + +// ancestry-prefix matching primitives this module used to also export moved +// to `@agent-device/ad-script` — they are `.ad` script vocabulary the +// record-time writer, this classification core, and `wait`'s landmark poll +// (`src/commands/interaction/runtime/selector-wait.ts`) all consume +// directly, not engine policy (#1478 P5 review). +// --------------------------------------------------------------------------- +export { classifyTargetBindingMatch } from './internal/target-identity.ts'; // --------------------------------------------------------------------------- // target-verification.ts — #1478 P5 stage C2a target-verification ENGINE @@ -96,16 +86,6 @@ export { } from './internal/target-verification.ts'; export type { ReplayPostDispatchMismatchEvidence } from './internal/target-verification.ts'; -// --------------------------------------------------------------------------- -// session-replay-report-action.ts / session-replay-suggestion-ranking.ts — -// the divergence-report action shape and repair-suggestion ranking. -// façade-deviation: `session-replay-maestro-failure.ts`, `session-replay-heal.ts`, -// and `session-replay-divergence.ts` build/rank divergence reports directly; -// none of this rides back through `runAdReplay`'s return value. -// --------------------------------------------------------------------------- -export type { ReplayReportAction } from './internal/session-replay-report-action.ts'; -export { rankAndDedupeReplaySuggestions } from './internal/session-replay-suggestion-ranking.ts'; - // --------------------------------------------------------------------------- // selector-port.ts — the `ReplaySelectorPort` port TYPE only (#1478 P5 stage // B, the amendment's explicit rejection of a "seven-function selector-AST diff --git a/packages/ad-replay/src/internal/target-identity.ts b/packages/ad-replay/src/internal/target-identity.ts index fac1c59d07..9ff198d036 100644 --- a/packages/ad-replay/src/internal/target-identity.ts +++ b/packages/ad-replay/src/internal/target-identity.ts @@ -1,69 +1,19 @@ /** * 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. + * `@agent-device/ad-script` (#1478 P5 scoping dossier, "the codec seam"). + * The local-identity + ancestry-prefix matching primitives and the bounded + * diagnostic diffs built on them also moved there (#1478 P5 review, "keep + * genuinely shared recording vocabulary in its proper shared owner") — this + * module imports them from there rather than declaring them, since decision + * 3's classification core is engine-owned policy, not script vocabulary. */ -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 @@ -150,58 +100,3 @@ export function classifyTargetBindingMatch( } return { path: 6, outcome: 'unverifiable', reason: 'no-signal-isolation' }; } - -// --------------------------------------------------------------------------- -// 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-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts index c42dfc5ba6..1381aeceed 100644 --- a/packages/ad-replay/src/internal/target-verification.ts +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -27,7 +27,7 @@ import { firstAncestryMismatch, identityFieldMismatches, type LocalIdentity, -} from './target-identity.ts'; +} from '@agent-device/ad-script'; import type { ReplaySelectorPort } from './selector-port.ts'; // --------------------------------------------------------------------------- diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index a53bd97c25..210399de60 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,20 @@ * 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 - * either: it lives in `@agent-device/contracts/replay`, which every consumer - * (this package included) imports directly. + * 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`, decision 3's replay-time + * verification paths 2-6) IS engine policy and is NOT part of this codec — + * it stays in `@agent-device/ad-replay`'s `target-identity.ts`. 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. */ export { @@ -51,3 +61,23 @@ 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 { + buildReplayVarScope, + collectReplayScrubbableVarValues, + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + resolveReplayAction, +} from './internal/vars.ts'; +export type { ReplayVarScope } from './internal/vars.ts'; diff --git a/packages/ad-replay/src/internal/__tests__/target-identity.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts similarity index 86% rename from packages/ad-replay/src/internal/__tests__/target-identity.test.ts rename to packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts index 18b711df32..e0586ee5c1 100644 --- a/packages/ad-replay/src/internal/__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/packages/ad-replay/src/internal/__tests__/vars.test.ts b/packages/ad-script/src/internal/__tests__/vars.test.ts similarity index 99% rename from packages/ad-replay/src/internal/__tests__/vars.test.ts rename to packages/ad-script/src/internal/__tests__/vars.test.ts index 14990073c7..2cde1d78e3 100644 --- a/packages/ad-replay/src/internal/__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 }; 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/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..1a371fb03d --- /dev/null +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -0,0 +1,124 @@ +/** + * 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) is engine-owned policy, + * not script vocabulary — it stays in `@agent-device/ad-replay`'s + * `target-identity.ts`. + */ + +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..9404371bb0 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` — shared `.ad` recording vocabulary, not + * engine policy. The record/replay-shared CLASSIFICATION core + * (`classifyTargetBindingMatch`) IS engine policy and is not part of this + * codec — it stays in `@agent-device/ad-replay`'s `target-identity.ts`, + * which imports the shared shape types from `@agent-device/contracts/replay` + * (#1478 P5 scoping dossier, "the codec seam"; identity vocabulary + * relocated by the P5 review pass). */ import { AppError } from '@agent-device/kernel/errors'; diff --git a/packages/ad-replay/src/internal/vars.ts b/packages/ad-script/src/internal/vars.ts similarity index 95% rename from packages/ad-replay/src/internal/vars.ts rename to packages/ad-script/src/internal/vars.ts index 51d34d018e..2640f29e11 100644 --- a/packages/ad-replay/src/internal/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>; diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index c1703c127f..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 '@agent-device/ad-replay'; +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/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-suggestion-port.test.ts index b286465fdf..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 @@ -17,7 +17,7 @@ import { buildReplayDivergenceSuggestionForNode } from '../session-replay-diverg 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 '@agent-device/ad-replay'; +import type { ReplayReportAction } from '../session-replay-report-action.ts'; const identitySanitize = (value: string): string => value; const port = createDaemonReplaySelectorPort(); diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index ef2e362fec..b6e1cde5c2 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/handlers/session-replay-action-runtime.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import { resolveReplayAction, type ReplayVarScope } from '@agent-device/ad-replay'; +import { resolveReplayAction, type ReplayVarScope } from '@agent-device/ad-script'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { mergeParentFlags } from '../../core/batch.ts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 4207a1a9bc..6668842daf 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -25,11 +25,9 @@ import { type InternalObservationEvidence, } from '../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import { - rankAndDedupeReplaySuggestions, - type ReplayReportAction, - type ReplaySelectorPort, -} from '@agent-device/ad-replay'; +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, diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index 6d7abd6394..106b3e4253 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,6 +1,7 @@ import { uniqueStrings } from '@agent-device/kernel/collections'; -import type { ReplayReportAction, ReplaySelectorPort } from '@agent-device/ad-replay'; +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 — diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index 0240cb8ccf..a505a7e9e3 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -12,7 +12,8 @@ import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '../../request/cancel.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { rankAndDedupeReplaySuggestions, type ReplayReportAction } from '@agent-device/ad-replay'; +import type { ReplayReportAction } from './session-replay-report-action.ts'; +import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { buildReplayDivergenceSuggestionForNode, buildDivergenceScreen, diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index 9363e293fb..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 '@agent-device/ad-replay'; +} 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 108a9625e1..0276664c53 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/handlers/session-replay-repair-hint.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 '@agent-device/ad-replay'; +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/packages/ad-replay/src/internal/session-replay-report-action.ts b/src/daemon/handlers/session-replay-report-action.ts similarity index 100% rename from packages/ad-replay/src/internal/session-replay-report-action.ts rename to src/daemon/handlers/session-replay-report-action.ts diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 10318f9098..6a57ad4001 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,8 +1,5 @@ -import { - collectReplayScrubbableVarValues, - type ReplaySelectorPort, - type ReplayVarScope, -} from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 7da6283d0a..d2f9a8505a 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -18,20 +18,22 @@ import { } from '../replay-selector-port.ts'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import { - buildReplayVarScope, - collectReplayShellEnv, computeReplayPlanDigest, formatReplaySuccessMessage, inspectAdReplay, - parseReplayCliEnvEntries, - readReplayCliEnvEntries, - readReplayShellEnvSource, runAdReplay, type AdReplayManifest, type AdReplayStepRuntime, type ReplaySelectorPort, - type ReplayVarScope, } from '@agent-device/ad-replay'; +import { + buildReplayVarScope, + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + type ReplayVarScope, +} from '@agent-device/ad-script'; import { summarizeSnapshotTimingSamples, type SnapshotTimingSample, diff --git a/packages/ad-replay/src/internal/session-replay-suggestion-ranking.ts b/src/daemon/handlers/session-replay-suggestion-ranking.ts similarity index 100% rename from packages/ad-replay/src/internal/session-replay-suggestion-ranking.ts rename to src/daemon/handlers/session-replay-suggestion-ranking.ts diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index 169c27b599..986727374b 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -46,13 +46,12 @@ import { scrollRegionKeysEqual, orderByViewportPosition, } from '../session-target-evidence.ts'; +import { classifyTargetBindingMatch, type ReplaySelectorPort } from '@agent-device/ad-replay'; import { annotationLocalIdentity, - classifyTargetBindingMatch, firstAncestryMismatch, identityFieldMismatches, - type ReplaySelectorPort, -} from '@agent-device/ad-replay'; +} from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index c7a4469daf..c295dbb572 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -2,20 +2,22 @@ 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 { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; -import { formatDivergenceActionLabel } from '@agent-device/ad-script'; -import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import { annotationLocalIdentity, collectReplayScrubbableVarValues, + formatDivergenceActionLabel, + resolveReplayAction, + type LocalIdentity, + type ReplayVarScope, +} from '@agent-device/ad-script'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import { deriveReplayTargetGuardMismatchEvidence, deriveWaitLandmarkMismatchEvidence, planPostResolutionTargetVerification, planPreDispatchTargetVerification, - resolveReplayAction, - type LocalIdentity, type ReplayPostDispatchMismatchEvidence, type ReplaySelectorPort, - type ReplayVarScope, } from '@agent-device/ad-replay'; import { createReplayDivergenceSanitizer, diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index 3f758702e6..fa1a37da2c 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -5,8 +5,11 @@ * `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 `@agent-device/ad-replay` (`packages/ad-replay/src/internal/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 + * `@agent-device/ad-replay` (the classification core, + * `packages/ad-replay/src/internal/target-identity.ts`), 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 @@ -28,14 +31,12 @@ import { buildIndexMap, filterIdentitySet, } from '../replay/target-evidence-tree.ts'; +import { classifyTargetBindingMatch } from '@agent-device/ad-replay'; import { - classifyTargetBindingMatch, matchesLocalIdentity, - type LocalIdentity, -} from '@agent-device/ad-replay'; -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 759ee1d6b9..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 '@agent-device/ad-replay'; +} 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 1f5d0fddaa..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 '@agent-device/ad-replay'; type IdentityTreeNode = Pick; From 4ebdebd9e93218c8016c2d7efae19ec756390521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 16:59:04 +0200 Subject: [PATCH 11/31] refactor(replay): neutral step/run outcomes and digest/resume behind inspectAdReplay (#1555 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 "do not smuggle daemon wire failures through a generic": drop the TResponse generic from AdReplayStepRuntime/runAdReplay. executeStep and handleActionFailure now return neutral tagged AdReplayStepOutcome/ AdReplayStepFailure values (kind/message/artifactPaths only); runAdReplay returns a neutral completed/failed AdReplayRunOutcome. The engine never holds or returns a DaemonResponse. The daemon adapter (createAdReplayStepRuntime, session-replay-runtime.ts) keeps its real wire response in a local side-map as it builds each neutral outcome, and runReplayScriptFile reads it back once runAdReplay reports which step failed, so the final response is byte-identical to before this split. P1 "parsing/planning/digest/resume must also occur behind runAdReplay": relocate computeReplayPlanDigest's call site and the --from/--plan-digest resume-point math (resolveReplayEntryIndex) behind inspectAdReplay's manifest as planDigest and a resolveEntryIndex closure. Neither is a new top-level export -- inspectAdReplay/runAdReplay stay the only two. Timing is preserved exactly (still called eagerly in prepareReplayPlan, before prepareReplaySession's coordinator-mutating side effects) since moving resume validation to run inside runAdReplay itself would let a rejected --from request mutate coordinator/session state first -- a real ordering hazard, not just a cosmetic one. computeReplayPlanDigest/ReplayPlanDigestMetadata/resolveReplayEntryIndex leave the ad-replay façade; request-router-repair-expired.test.ts and prepareReplayPlan read the digest/resume result off the manifest instead. --- packages/ad-replay/src/internal/inspect.ts | 92 ++++++++- packages/ad-replay/src/internal/resume.ts | 188 +++++++++++++++++ packages/ad-replay/src/internal/step-loop.ts | 83 +++++--- .../request-router-repair-expired.test.ts | 12 +- .../handlers/session-replay-runtime-plan.ts | 193 ++---------------- src/daemon/handlers/session-replay-runtime.ts | 138 +++++++++---- 6 files changed, 438 insertions(+), 268 deletions(-) create mode 100644 packages/ad-replay/src/internal/resume.ts diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts index f337a669fc..5a3d42338e 100644 --- a/packages/ad-replay/src/internal/inspect.ts +++ b/packages/ad-replay/src/internal/inspect.ts @@ -6,6 +6,8 @@ import { readReplayScriptMetadata, 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 @@ -16,25 +18,57 @@ import { * `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 plus - * header metadata. 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. + * 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): AdReplayManifest { +export function inspectAdReplay( + sourcePath: string, + digestFlags?: AdReplayDigestFlags, +): AdReplayManifest { const script = fs.readFileSync(sourcePath, 'utf8'); const firstNonWhitespace = script.trimStart()[0]; if (firstNonWhitespace === '{' || firstNonWhitespace === '[') { @@ -44,10 +78,48 @@ export function inspectAdReplay(sourcePath: string): AdReplayManifest { ); } const parsed = parseReplayScriptDetailed(script); + const metadata = readReplayScriptMetadata(script); + const planDigest = computeReplayPlanDigest({ + actions: parsed.actions, + actionLines: parsed.actionLines, + actionSourcePaths: parsed.actionSourcePaths, + metadata: { + platform: + digestFlags?.platform ?? declaredScriptPlatform(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: readReplayScriptMetadata(script), + metadata, + planDigest, + resolveEntryIndex: (params) => resolveReplayEntryIndex(params, actionCount, planDigest), }; } + +/** + * Mirrors the platform half of the daemon's `readScriptReplaySelection` + * (`src/daemon/replay-device-selection.ts`) — deliberately duplicated rather + * than imported: that daemon-owned function also resolves an app-target + * device-selection result the digest never needs, and a root `src/` file + * cannot become a façade dependency (R11). Both copies must keep computing + * the SAME effective platform for the SAME script; `plan-digest.test.ts` + * covers this one directly, and `session-replay-runtime-plan.test.ts`'s + * "native replay applies an authored Android platform" case exercises the + * daemon's copy against the same `runtime set --platform` shape. + */ +function declaredScriptPlatform(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; +} 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/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 310ca67954..1536cf20d1 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -10,14 +10,33 @@ import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; * `AdReplayStepRuntime` capabilities below — this module only decides which * action to run next, when to skip one, and when to stop. * - * `TResponse` is the daemon's own response type, injected generically: the - * loop only ever reads its `ok` discriminant (never `DaemonError`, - * `SessionStore`, or a wire shape) and returns it unopened — the daemon - * adapter is the only side that ever constructs or interprets one. + * #1555 review P1 ("do not smuggle daemon wire failures through a generic"): + * `AdReplayStepRuntime` no longer carries a `TResponse` type parameter. The + * loop never sees a `DaemonResponse`/wire object at all, not even opaquely — + * `executeStep`/`handleActionFailure` return the NEUTRAL tagged types below + * (`AdReplayStepOutcome`, `AdReplayStepFailure`), built only from plain + * values (a `kind` string, a `message` string, artifact paths). The daemon + * adapter (`createAdReplayStepRuntime`, `session-replay-runtime.ts`) is the + * only place a real `DaemonResponse` is constructed or read; it keeps its + * OWN wire response in a local variable ("the side-map") as it builds each + * neutral outcome, and `runReplayScriptFile` reads that variable back after + * `runAdReplay` reports which step failed, so the final response returned to + * the client is byte-identical to before this split — it was never + * round-tripped through the engine's return value at all. */ -/** The one field the step loop reads off a daemon response: pass/fail. */ -export type AdReplayResponse = Readonly<{ readonly ok: boolean }>; +/** 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[]; +}>; + +/** `executeStep`'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 @@ -41,33 +60,32 @@ export type AdReplayProgressSink = (step: AdReplayProgressStep) => void; * 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. + * stream — and, as of the #1555 review pass, never a `DaemonResponse` + * either. */ -export type AdReplayStepRuntime = Readonly<{ +export type AdReplayStepRuntime = Readonly<{ /** * Verifies the recorded target (if any) then dispatches the action. * Capture, the single `invoke` dispatch site, and the post-resolution - * guard/landmark-mismatch conversion are all daemon authority. + * guard/landmark-mismatch conversion are all daemon authority; only the + * neutral pass/fail projection crosses back into the engine. */ executeStep( action: SessionAction, index: number, artifactPaths: readonly string[], - ): Promise; + ): Promise; /** - * Wraps a failed step's response with replay failure diagnostics and - * repair-held marking — daemon authority (capture, `SessionStore`, the P4b - * coordinator). + * 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. */ handleActionFailure(params: { action: SessionAction; index: number; - response: TResponse; artifactPaths: readonly string[]; snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; - }): Promise; - /** Reads the artifact paths a response surfaced — wire-shape authority. */ - collectArtifactPaths(response: TResponse): readonly string[]; + }): 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. */ @@ -88,14 +106,19 @@ export type AdReplayRunRequest = Readonly<{ readonly entryIndex: number; }>; -export type AdReplayRunOutcome = +/** Neutral run-level outcome: `runAdReplay` never returns or holds a `DaemonResponse`. */ +export type AdReplayRunOutcome = | Readonly<{ - readonly ok: true; + readonly status: 'completed'; readonly replayed: number; readonly artifactPaths: readonly string[]; readonly snapshotDiagnosticSamples: readonly SnapshotTimingSample[]; }> - | Readonly<{ readonly ok: false; readonly response: TResponse }>; + | Readonly<{ + readonly status: 'failed'; + readonly stepIndex: number; + readonly failure: AdReplayStepFailure; + }>; /** * ADR 0012 step 4's step loop: for every executable action from @@ -106,10 +129,10 @@ export type AdReplayRunOutcome = * only the daemon capabilities it calls through were narrowed into * `runtime`. */ -export async function runAdReplay( +export async function runAdReplay( request: AdReplayRunRequest, - runtime: AdReplayStepRuntime, -): Promise> { + runtime: AdReplayStepRuntime, +): Promise { const { actions, entryIndex } = request; const artifactPaths = new Set(); const snapshotDiagnosticSamples: SnapshotTimingSample[] = []; @@ -130,21 +153,23 @@ export async function runAdReplay( runtime.onStep(buildAdReplayProgressStep(index, actions.length, action, value)); } const sampleStart = runtime.diagnosticsMarker(); - const response = await runtime.executeStep(action, index, [...artifactPaths]); + const stepOutcome = await runtime.executeStep(action, index, [...artifactPaths]); snapshotDiagnosticSamples.push(...runtime.diagnosticsSince(sampleStart)); - runtime.collectArtifactPaths(response).forEach((entry) => artifactPaths.add(entry)); - if (response.ok) continue; + 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, - response, artifactPaths: [...artifactPaths], snapshotDiagnosticSamples, }); - return { ok: false, response: failure }; + return { status: 'failed', stepIndex: index, failure }; } return { - ok: true, + status: 'completed', replayed: actions.length - entryIndex, artifactPaths: [...artifactPaths], snapshotDiagnosticSamples, diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 7deeae7917..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 '@agent-device/ad-replay'; -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/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index cb3b9ad74a..692708ddb3 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -1,9 +1,18 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import type { ReplayPlanDigestMetadata } from '@agent-device/ad-replay'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; -import type { DaemonResponse } from '../types.ts'; -import { errorResponse } from './response.ts'; +/** + * #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. + */ export function buildReplayMetadataFlags( flags: CommandFlags | undefined, metadata: ReplayScriptMetadata, @@ -18,181 +27,3 @@ export function buildReplayMetadataFlags( : {}), }; } - -/** The digest binds the same platform/target values the replay invokes with. */ -export function readEffectiveReplayPlanDigestMetadata( - flags: CommandFlags | undefined, -): ReplayPlanDigestMetadata { - return { - platform: typeof flags?.platform === 'string' ? flags.platform : undefined, - target: typeof flags?.target === 'string' ? flags.target : undefined, - }; -} - -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). - */ -export type PendingRecordAndHeal = { expectedFrom: number; actionsCountAtDivergence: number }; - -/** - * 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. - */ -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, - }); - return message ? invalidReplayEntryIndex(message) : { ok: true, value: from - 1 }; -} - -function invalidReplayEntryIndex(message: string): ReplayEntryIndexResult { - return { ok: false, response: errorResponse('INVALID_ARGS', message) }; -} - -/** 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 (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. - */ -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 — 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. - * - * #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. - */ -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/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index d2f9a8505a..f57b8f0961 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -18,11 +18,11 @@ import { } from '../replay-selector-port.ts'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import { - computeReplayPlanDigest, formatReplaySuccessMessage, inspectAdReplay, runAdReplay, type AdReplayManifest, + type AdReplayStepFailure, type AdReplayStepRuntime, type ReplaySelectorPort, } from '@agent-device/ad-replay'; @@ -46,11 +46,7 @@ import { } 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 { buildReplayMetadataFlags } from './session-replay-runtime-plan.ts'; import { buildReplayTargetGuardMismatchResponse, buildWaitLandmarkMismatchResponse, @@ -326,7 +322,7 @@ export async function runReplayScriptFile(params: { coordinator, port, }; - const runtime = createAdReplayStepRuntime({ + const { runtime, readLastResponse } = createAdReplayStepRuntime({ ctx: stepContext, req, artifactPaths, @@ -335,7 +331,22 @@ export async function runReplayScriptFile(params: { suppressedTerminalCloseIndex, }); const outcome = await runAdReplay({ actions, entryIndex }, runtime); - if (!outcome.ok) return outcome.response; + 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 `executeStep`/`handleActionFailure`, 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 (`executeStep` + // always records a response 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, @@ -359,11 +370,21 @@ export async function runReplayScriptFile(params: { } /** - * #1478 P5 stage C2b: the daemon's `AdReplayStepRuntime` adapter — the - * narrow execute/capture/observe/stamp capability bag `runAdReplay`'s step - * loop threads through. Every member closes over this one request's + * #1478 P5 stage C2b (narrowed further by the #1555 review's neutral-outcomes + * pass): the daemon's `AdReplayStepRuntime` adapter — the narrow + * execute/capture/observe/stamp 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. + * + * `lastResponse` is the side-map the neutral-outcomes design relies on: the + * ONLY place a real `DaemonResponse` is built or held. `executeStep` and + * `handleActionFailure` each record the wire response they 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. */ function createAdReplayStepRuntime(params: { ctx: ReplayStepContext; @@ -372,33 +393,44 @@ function createAdReplayStepRuntime(params: { artifactPaths: Set; onStep: ReplayTestAttemptStepSink | undefined; armSaveScript: () => void; -}): AdReplayStepRuntime { +}): { runtime: AdReplayStepRuntime; readLastResponse: () => DaemonResponse | undefined } { const { ctx, req, artifactPaths, onStep, armSaveScript } = params; - return { + let lastResponse: DaemonResponse | undefined; + const runtime: AdReplayStepRuntime = { async executeStep(action, index, stepArtifactPaths) { - return await resolveReplayStepResponse(ctx, action, index, [...stepArtifactPaths]); + const response = await resolveReplayStepResponse(ctx, action, index, [...stepArtifactPaths]); + lastResponse = response; + const entries = collectReplayActionArtifactPaths(response); + entries.forEach((entry) => artifactPaths.add(entry)); + if (response.ok) return { status: 'ok', artifactPaths: entries }; + return { status: 'failed', failure: toAdReplayStepFailure(response, entries) }; }, async handleActionFailure({ action, index, - response, artifactPaths: failureArtifactPaths, snapshotDiagnosticSamples, }) { - return await buildReplayActionFailure( + const failedResponse = asFailedReplayStepResponse(lastResponse); + const finalResponse = await buildReplayActionFailure( ctx, req, action, index, - response as Extract, + failedResponse, [...failureArtifactPaths], [...snapshotDiagnosticSamples], ); - }, - collectArtifactPaths(response) { - const entries = collectReplayActionArtifactPaths(response); - entries.forEach((entry) => artifactPaths.add(entry)); - return entries; + lastResponse = finalResponse; + // `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 toAdReplayStepFailure( + asFailedReplayStepResponse(finalResponse), + collectReplayActionArtifactPaths(finalResponse), + ); }, armStep: armSaveScript, isRepairArmed: () => ctx.coordinator.view()?.repairBoundary !== undefined, @@ -408,6 +440,33 @@ function createAdReplayStepRuntime(params: { diagnosticsSince: (marker) => readSessionSnapshotSamplesSince(ctx.sessionStore, ctx.sessionName, marker), }; + return { runtime, readLastResponse: () => lastResponse }; +} + +/** + * `runAdReplay` only ever calls `handleActionFailure` right after + * `executeStep` reported `status: 'failed'`, and `executeStep` always sets + * `lastResponse` to that same failed response 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; +} + +/** Projects a wire response down to the neutral shape the engine's outcome carries. */ +function toAdReplayStepFailure( + response: Extract, + artifactPaths: readonly string[], +): AdReplayStepFailure { + return { kind: response.error.code, message: response.error.message, artifactPaths }; } async function buildReplayActionFailure( @@ -543,27 +602,30 @@ function prepareReplayPlan(params: { ), }; } - const manifest = inspectAdReplay(resolved); - const { metadata, actions, actionLines, actionSourcePaths } = manifest; + // #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)`. + const manifest = inspectAdReplay(resolved, { + platform: req.flags?.platform, + target: req.flags?.target, + }); + const { metadata, actions, actionLines, actionSourcePaths, planDigest } = manifest; 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; + 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, From 87c3db38792aa3eca1da374161a97e3fea7e3874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 16:59:17 +0200 Subject: [PATCH 12/31] =?UTF-8?q?refactor(replay):=20relocate=20classifyTa?= =?UTF-8?q?rgetBindingMatch=20and=20pin=20the=20ad-replay=20fa=C3=A7ade=20?= =?UTF-8?q?(#1555=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 "complete the binding façade instead of documenting deviations": classifyTargetBindingMatch never had a real consumer reachable through inspectAdReplay/runAdReplay -- both its callers (the daemon's record-time self-check in session-target-evidence.ts and its replay-time classification wrapper in session-replay-target-classification.ts) are daemon files that imported it directly. It interprets TargetAnnotationV1 evidence semantics shared beyond the engine, so it moves to packages/ad-script alongside target-annotation-identity.ts (new target-annotation-classification.ts + its test), and both daemon call sites now import it from there instead of @agent-device/ad-replay. One deviation remains and is reported rather than papered over per the review's own instruction: the four target-verification policy functions (planPreDispatchTargetVerification, planPostResolutionTargetVerification, deriveReplayTargetGuardMismatchEvidence, deriveWaitLandmarkMismatchEvidence) and the ReplaySelectorPort type family stay exported. Their sole caller, session-replay-target-verification.ts, interleaves these pure decisions with daemon-only async work (capture, SessionStore, coordinator/resume stamping, wire shaping) that must stay outside the engine by design; moving their call sites to live only behind runAdReplay would require restructuring that whole orchestration into new fine-grained AdReplayStepRuntime capabilities, which is out of scope for this pass. See packages/ad-replay/src/index.ts's header comment for the full reasoning. P1 "add the reviewer-required exact exported-symbol gate": adds readNamedExports (scripts/layering/package-boundaries.ts), a small parser over a façade's `export { .. } from`, `export type { .. } from`, and direct-declaration forms, and pins @agent-device/ad-replay's exact 21-symbol export list in package-boundaries.test.ts. Plant-verified: a stray `export const` addition failed the assertion; removed it and the gate went green again. --- packages/ad-replay/src/index.ts | 97 +++++++++---------- packages/ad-script/src/index.ts | 23 +++-- .../target-annotation-classification.test.ts} | 2 +- .../target-annotation-classification.ts} | 21 ++-- .../internal/target-annotation-identity.ts | 7 +- .../src/internal/target-annotation-serde.ts | 14 +-- scripts/layering/package-boundaries.test.ts | 66 +++++++++++++ scripts/layering/package-boundaries.ts | 32 ++++++ .../session-replay-target-classification.ts | 8 +- src/daemon/session-target-evidence.ts | 12 ++- 10 files changed, 196 insertions(+), 86 deletions(-) rename packages/{ad-replay/src/internal/__tests__/target-identity-classification.test.ts => ad-script/src/internal/__tests__/target-annotation-classification.test.ts} (97%) rename packages/{ad-replay/src/internal/target-identity.ts => ad-script/src/internal/target-annotation-classification.ts} (83%) diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 001249b7eb..5e2865f48d 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -1,50 +1,54 @@ /** * 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, "complete the binding façade instead of documenting - * deviations"). + * review pass; plan-digest/resume and `classifyTargetBindingMatch` further + * narrowed by the #1555 review pass, "complete the binding façade instead of + * documenting deviations"). `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 fails that gate, not just a + * comment mismatch. * * The binding design (issue comment 5156017698) is `inspectAdReplay` + - * `runAdReplay` and nothing else. Staged reality is wider than that: the - * daemon wire-builders and handlers call several engine POLICY functions - * directly rather than only through the two entrypoints (plan-digest, - * target-verification). Every export below is a `façade-deviation` from the - * binding-design ideal EXCEPT `inspectAdReplay`/`runAdReplay` themselves; - * each deviation names its real root consumer(s) so the PR body's deviation - * table is generated straight from this file. No selector AST, no engine IR, - * no prepared-plan type, and no internal subpath is exported — everything - * here has a live import site outside this package (see - * `packages/ad-replay/src/index.ts`'s sibling consumer grep in the P5 stage D - * commit message for the full map). + * `runAdReplay` and nothing else. As of the #1555 review pass, parsing, + * variable substitution, planning, digest/resume, and classification are ALL + * on-design: `inspectAdReplay`'s manifest carries the digest and the + * `--from`/`--plan-digest` resume math internally, and + * `classifyTargetBindingMatch` moved to its real owner, + * `@agent-device/ad-script` (both daemon consumers — record-time self-check + * and replay-time classification — never went through this façade at all). * - * `.ad` variable substitution (`vars.ts`), the local-identity + ancestry- - * prefix matching primitives and their diagnostic diffs (formerly part of - * `target-identity.ts`), and the divergence-report action shape/suggestion - * ranking (formerly `session-replay-report-action.ts` / - * `session-replay-suggestion-ranking.ts`) are NOT here: the review found - * these were never engine-owned policy reached only through the two - * entrypoints. Var substitution and identity matching are `.ad` script - * vocabulary the daemon and this engine both consume, so they moved to their - * proper shared owner, `@agent-device/ad-script`. Report-action/suggestion- - * ranking had no consumer inside this package at all — daemon-only — so they - * moved back to `src/daemon/handlers/`. + * ONE deviation remains, reported rather than papered over per the review's + * own instruction ("if a genuine remainder must stay callable from the + * daemon, STOP and report rather than re-exporting"): the four + * target-verification policy functions below, and the `ReplaySelectorPort` + * type family they (and other daemon handlers) need to name. Their sole + * caller, `session-replay-target-verification.ts`, is the daemon's + * verify-then-dispatch orchestrator — it interleaves these PURE decisions + * with daemon-only async work (snapshot capture, `SessionStore` reads, + * coordinator/resume stamping, wire-response sanitization/shaping) that must + * stay outside the engine by design. Moving the CALL SITES for these four + * functions to live only "behind runAdReplay" would require restructuring + * that whole orchestration into new fine-grained `AdReplayStepRuntime` + * capabilities (e.g. a capture capability, a wire-shaping capability) so the + * engine's own code could drive it end to end — a materially larger, + * higher-risk change than the neutral-outcomes and plan/digest/resume work + * in this same pass, and out of scope here; see the #1555 R2 handoff notes. */ -// --------------------------------------------------------------------------- -// plan-digest.ts — the `--from`/`--plan-digest` resume and save-script digest. -// façade-deviation: `session-replay-runtime-plan.ts`'s resume path and -// `request-router-repair-expired.test.ts` compute/read the digest directly, -// ahead of and independent from any `runAdReplay` call. -// --------------------------------------------------------------------------- -export { computeReplayPlanDigest } from './internal/plan-digest.ts'; -export type { ReplayPlanDigestMetadata } from './internal/plan-digest.ts'; - // --------------------------------------------------------------------------- // inspect.ts — the read-only `.ad` manifest reader. On-design: this IS one -// of the two binding-design entrypoints. +// of the two binding-design entrypoints. #1555 review P1 ("digest/resume +// must also occur behind runAdReplay"): the plan-digest hash +// (`plan-digest.ts`, `computeReplayPlanDigest`) and the `--from`/ +// `--plan-digest` resume-point math (`resume.ts`, `resolveReplayEntryIndex`) +// are internal-only now — neither is exported here. `inspectAdReplay`'s +// manifest carries the digest as `planDigest` and the resume math as a +// `resolveEntryIndex` closure instead, so `session-replay-runtime.ts`'s +// `prepareReplayPlan` and `request-router-repair-expired.test.ts` read them +// off the manifest rather than importing the underlying functions. // --------------------------------------------------------------------------- export { inspectAdReplay } from './internal/inspect.ts'; -export type { AdReplayManifest } from './internal/inspect.ts'; +export type { AdReplayDigestFlags, AdReplayManifest } from './internal/inspect.ts'; // --------------------------------------------------------------------------- // step-loop.ts — the `.ad` step loop. On-design: this IS the other binding- @@ -52,23 +56,12 @@ export type { AdReplayManifest } from './internal/inspect.ts'; // daemon adapter (`session-replay-runtime.ts`) implements to thread it. // --------------------------------------------------------------------------- export { formatReplaySuccessMessage, runAdReplay } from './internal/step-loop.ts'; -export type { AdReplayStepRuntime } from './internal/step-loop.ts'; - -// --------------------------------------------------------------------------- -// target-identity.ts — record/replay-shared CLASSIFICATION core (ADR 0012 -// decision 3, replay-time verification paths 2-6). -// façade-deviation: the daemon's replay-time classification core -// (`session-replay-target-classification.ts`) and the record-time writer -// (`src/daemon/session-target-evidence.ts`) call `classifyTargetBindingMatch` -// directly so both sides compute the SAME verdict by construction, ahead of -// and independent from any `runAdReplay` call. The local-identity + -// ancestry-prefix matching primitives this module used to also export moved -// to `@agent-device/ad-script` — they are `.ad` script vocabulary the -// record-time writer, this classification core, and `wait`'s landmark poll -// (`src/commands/interaction/runtime/selector-wait.ts`) all consume -// directly, not engine policy (#1478 P5 review). -// --------------------------------------------------------------------------- -export { classifyTargetBindingMatch } from './internal/target-identity.ts'; +export type { + AdReplayRunOutcome, + AdReplayStepFailure, + AdReplayStepOutcome, + AdReplayStepRuntime, +} from './internal/step-loop.ts'; // --------------------------------------------------------------------------- // target-verification.ts — #1478 P5 stage C2a target-verification ENGINE diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index 210399de60..4507db2d68 100644 --- a/packages/ad-script/src/index.ts +++ b/packages/ad-script/src/index.ts @@ -15,12 +15,17 @@ * 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`, decision 3's replay-time - * verification paths 2-6) IS engine policy and is NOT part of this codec — - * it stays in `@agent-device/ad-replay`'s `target-identity.ts`. The - * annotation SHAPE is not exported here either: it lives in - * `@agent-device/contracts/replay`, which every consumer (this package - * included) imports directly. + * 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 @@ -71,6 +76,12 @@ export { } 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, diff --git a/packages/ad-replay/src/internal/__tests__/target-identity-classification.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-classification.test.ts similarity index 97% rename from packages/ad-replay/src/internal/__tests__/target-identity-classification.test.ts rename to packages/ad-script/src/internal/__tests__/target-annotation-classification.test.ts index ba689680a3..78de4e4737 100644 --- a/packages/ad-replay/src/internal/__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/packages/ad-replay/src/internal/target-identity.ts b/packages/ad-script/src/internal/target-annotation-classification.ts similarity index 83% rename from packages/ad-replay/src/internal/target-identity.ts rename to packages/ad-script/src/internal/target-annotation-classification.ts index 9ff198d036..f16396b16a 100644 --- a/packages/ad-replay/src/internal/target-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-classification.ts @@ -4,14 +4,19 @@ * 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"). - * The local-identity + ancestry-prefix matching primitives and the bounded - * diagnostic diffs built on them also moved there (#1478 P5 review, "keep - * genuinely shared recording vocabulary in its proper shared owner") — this - * module imports them from there rather than declaring them, since decision - * 3's classification core is engine-owned policy, not script vocabulary. + * #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. */ // --------------------------------------------------------------------------- diff --git a/packages/ad-script/src/internal/target-annotation-identity.ts b/packages/ad-script/src/internal/target-annotation-identity.ts index 1a371fb03d..1de5e2a23f 100644 --- a/packages/ad-script/src/internal/target-annotation-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -10,9 +10,10 @@ * 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) is engine-owned policy, - * not script vocabulary — it stays in `@agent-device/ad-replay`'s - * `target-identity.ts`. + * 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'; diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts index 9404371bb0..07360f9e83 100644 --- a/packages/ad-script/src/internal/target-annotation-serde.ts +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -8,13 +8,13 @@ * * The local-identity + ancestry-prefix matching primitives and their * diagnostic diffs live alongside this in the sibling - * `target-annotation-identity.ts` — shared `.ad` recording vocabulary, not - * engine policy. The record/replay-shared CLASSIFICATION core - * (`classifyTargetBindingMatch`) IS engine policy and is not part of this - * codec — it stays in `@agent-device/ad-replay`'s `target-identity.ts`, - * which imports the shared shape types from `@agent-device/contracts/replay` - * (#1478 P5 scoping dossier, "the codec seam"; identity vocabulary - * relocated by the P5 review pass). + * `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/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index a9776cdc42..0fa9df16a4 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,31 @@ 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('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 @@ -241,6 +268,45 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/contracts', '@agent-device/kernel', ]); + // #1555 review P1 ("add the reviewer-required exact exported-symbol + // gate"): 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 (value and type-only together): the two binding-design + // entrypoints (`inspectAdReplay`, `runAdReplay`), the types their + // signatures reference, and the ONE reported façade deviation (the four + // target-verification policy functions plus the `ReplaySelectorPort` + // family) — see that file's own header comment for why the deviation + // remains. A stray export — intentional or not — 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'), + ), + [ + 'AdReplayDigestFlags', + 'AdReplayManifest', + 'AdReplayRunOutcome', + 'AdReplayStepFailure', + 'AdReplayStepOutcome', + 'AdReplayStepRuntime', + 'ReplayPostDispatchMismatchEvidence', + 'ReplayRecordedTargetDisambiguation', + 'ReplayRecordedTargetPolicy', + 'ReplayRecordedTargetResolution', + 'ReplaySelectorCandidateOptions', + 'ReplaySelectorExpressionOutcome', + 'ReplaySelectorGrammar', + 'ReplaySelectorPort', + 'deriveReplayTargetGuardMismatchEvidence', + 'deriveWaitLandmarkMismatchEvidence', + 'formatReplaySuccessMessage', + 'inspectAdReplay', + 'planPostResolutionTargetVerification', + 'planPreDispatchTargetVerification', + 'runAdReplay', + ], + ); const providerWebDriverPackage = packages.find( (pkg) => pkg.name === '@agent-device/provider-webdriver', ); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index d7befbb91d..a2418f0223 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -55,6 +55,38 @@ 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) and + * direct declarations (`export function`/`const`/`class`/`type`/ + * `interface`). 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. + */ +export function readNamedExports(source: string): string[] { + const names = new Set(); + const braceExportRe = /export\s+(?:type\s+)?\{([\s\S]*?)\}(?:\s*from\s*['"][^'"]+['"])?/g; + for (const match of source.matchAll(braceExportRe)) { + for (const rawEntry of match[1]!.split(',')) { + const entry = rawEntry.trim(); + if (!entry) continue; + const aliasMatch = /^(?:type\s+)?\S+\s+as\s+(\S+)$/.exec(entry); + const name = aliasMatch ? aliasMatch[1]! : entry.replace(/^type\s+/, '').trim(); + if (name) names.add(name); + } + } + const declarationRe = + /export\s+(?:default\s+)?(?:async\s+function|function|const|class|type|interface)\s+([A-Za-z0-9_$]+)/g; + for (const match of source.matchAll(declarationRe)) { + names.add(match[1]!); + } + return [...names].sort(); +} + export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { const packagesDir = path.join(repoRoot, 'packages'); if (!fs.existsSync(packagesDir)) return []; diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index 986727374b..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 `@agent-device/ad-script` / - * `@agent-device/ad-replay`'s `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 @@ -46,9 +45,10 @@ import { scrollRegionKeysEqual, orderByViewportPosition, } from '../session-target-evidence.ts'; -import { classifyTargetBindingMatch, type ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import { annotationLocalIdentity, + classifyTargetBindingMatch, firstAncestryMismatch, identityFieldMismatches, } from '@agent-device/ad-script'; diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index fa1a37da2c..5b4b068c3c 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -5,10 +5,12 @@ * `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 `@agent-device/ad-script` (local-identity + ancestry-prefix matching, - * `packages/ad-script/src/internal/target-annotation-identity.ts`) and - * `@agent-device/ad-replay` (the classification core, - * `packages/ad-replay/src/internal/target-identity.ts`), shared with the + * 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/ @@ -31,8 +33,8 @@ import { buildIndexMap, filterIdentitySet, } from '../replay/target-evidence-tree.ts'; -import { classifyTargetBindingMatch } from '@agent-device/ad-replay'; import { + classifyTargetBindingMatch, matchesLocalIdentity, serializeTargetAnnotationV1, utf8ByteLength, From 69ee0d051a4ec4c69cb53f8882a0d08ccb828561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 17:31:27 +0200 Subject: [PATCH 13/31] refactor(replay): drive target verification from the engine step loop (#1555 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the verify-then-dispatch decision flow into packages/ad-replay's step loop so the four target-verification policy functions (plan{PostResolution,PreDispatch}TargetVerification, derive{ReplayTargetGuardMismatch,WaitLandmark}MismatchEvidence) become engine-private and leave the ad-replay façade. The daemon (session-replay-target-verification.ts) shrinks to the narrow AdReplayStepRuntime capabilities the engine drives: routing (beginTargetVerification), capture (captureObservation), classification (classifyTarget), dispatch (dispatchStep), and wire-building (buildRecordedUnverifiableFailure, buildTargetBindingFailure, buildPostDispatchTargetBindingFailure). Wire output and replay-compat stay byte-identical; the exact-symbol façade gate is updated to the shrunken export list. --- packages/ad-replay/src/index.ts | 65 +-- packages/ad-replay/src/internal/step-loop.ts | 426 ++++++++++++++- .../src/internal/target-verification.ts | 17 +- scripts/layering/package-boundaries.test.ts | 18 +- src/daemon/handlers/session-replay-runtime.ts | 389 ++++++++----- .../session-replay-target-verification.ts | 513 ++++++++---------- 6 files changed, 915 insertions(+), 513 deletions(-) diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 5e2865f48d..8cdbdc96c0 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -3,36 +3,24 @@ * 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"). `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 fails that gate, not just a - * comment mismatch. + * 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 fails that gate, not just a comment + * mismatch. * * The binding design (issue comment 5156017698) is `inspectAdReplay` + - * `runAdReplay` and nothing else. As of the #1555 review pass, parsing, - * variable substitution, planning, digest/resume, and classification are ALL - * on-design: `inspectAdReplay`'s manifest carries the digest and the - * `--from`/`--plan-digest` resume math internally, and - * `classifyTargetBindingMatch` moved to its real owner, - * `@agent-device/ad-script` (both daemon consumers — record-time self-check - * and replay-time classification — never went through this façade at all). - * - * ONE deviation remains, reported rather than papered over per the review's - * own instruction ("if a genuine remainder must stay callable from the - * daemon, STOP and report rather than re-exporting"): the four - * target-verification policy functions below, and the `ReplaySelectorPort` - * type family they (and other daemon handlers) need to name. Their sole - * caller, `session-replay-target-verification.ts`, is the daemon's - * verify-then-dispatch orchestrator — it interleaves these PURE decisions - * with daemon-only async work (snapshot capture, `SessionStore` reads, - * coordinator/resume stamping, wire-response sanitization/shaping) that must - * stay outside the engine by design. Moving the CALL SITES for these four - * functions to live only "behind runAdReplay" would require restructuring - * that whole orchestration into new fine-grained `AdReplayStepRuntime` - * capabilities (e.g. a capture capability, a wire-shaping capability) so the - * engine's own code could drive it end to end — a materially larger, - * higher-risk change than the neutral-outcomes and plan/digest/resume work - * in this same pass, and out of scope here; see the #1555 R2 handoff notes. + * `runAdReplay` and nothing else — as of R3, with NO reported deviation: the + * four target-verification policy functions (`planPostResolutionTargetVerification`, + * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, + * `deriveWaitLandmarkMismatchEvidence`) are called only from + * `./internal/step-loop.ts`'s `verifyAndDispatchStep` — the step loop's own + * verify-then-dispatch orchestration, which drives the daemon-owned pieces + * (capture, classification, dispatch, wire-building) through narrow + * `AdReplayStepRuntime` capabilities instead of the daemon calling the policy + * functions directly. See `./internal/target-verification.ts` and + * `./internal/step-loop.ts` for the split. */ // --------------------------------------------------------------------------- @@ -66,18 +54,12 @@ export type { // --------------------------------------------------------------------------- // target-verification.ts — #1478 P5 stage C2a target-verification ENGINE // policy (pre-capture verification gating, post-dispatch mismatch-evidence -// derivation), split out of `session-replay-target-verification.ts`. -// façade-deviation: that same daemon wire-builder is the direct caller of -// all four functions below — see `./internal/target-verification.ts` for the -// daemon/engine ownership split. +// derivation). As of the #1555 review's R3 pass, its four functions are +// called ONLY from `./internal/step-loop.ts` (`verifyAndDispatchStep`) — the +// engine's own step loop, never the daemon — so nothing from this module is +// re-exported here anymore. See `./internal/target-verification.ts`'s header +// for the full daemon/engine ownership split. // --------------------------------------------------------------------------- -export { - deriveReplayTargetGuardMismatchEvidence, - deriveWaitLandmarkMismatchEvidence, - planPostResolutionTargetVerification, - planPreDispatchTargetVerification, -} from './internal/target-verification.ts'; -export type { ReplayPostDispatchMismatchEvidence } from './internal/target-verification.ts'; // --------------------------------------------------------------------------- // selector-port.ts — the `ReplaySelectorPort` port TYPE only (#1478 P5 stage @@ -93,8 +75,9 @@ export type { ReplayPostDispatchMismatchEvidence } from './internal/target-verif // directly (`session-replay-target-token.ts`, `session-replay-heal.ts`, // `session-replay-target-classification.ts`, `session-replay-runtime-failure.ts`, // `session-replay-runtime.ts`, `session-replay-target-verification.ts`) — -// the port rides in as `runAdReplay`'s runtime threads it, but the type -// itself is named at every one of those call sites. +// the port rides in as `runAdReplay`'s runtime threads it (as of R3, also as +// `AdReplayStepRuntime.port` itself, for the engine's own pre-dispatch plan), +// but the type is named at every one of those call sites too. // --------------------------------------------------------------------------- export type { ReplayRecordedTargetDisambiguation, diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 1536cf20d1..02524a182a 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -1,5 +1,17 @@ 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 { LocalIdentity } from '@agent-device/ad-script'; +import type { ReplaySelectorPort } from './selector-port.ts'; +import { + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, +} from './target-verification.ts'; /** * #1478 P5 stage C2b: the `.ad` step-loop ENGINE policy, split out of @@ -13,16 +25,30 @@ import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; * #1555 review P1 ("do not smuggle daemon wire failures through a generic"): * `AdReplayStepRuntime` no longer carries a `TResponse` type parameter. The * loop never sees a `DaemonResponse`/wire object at all, not even opaquely — - * `executeStep`/`handleActionFailure` return the NEUTRAL tagged types below - * (`AdReplayStepOutcome`, `AdReplayStepFailure`), built only from plain - * values (a `kind` string, a `message` string, artifact paths). The daemon - * adapter (`createAdReplayStepRuntime`, `session-replay-runtime.ts`) is the - * only place a real `DaemonResponse` is constructed or read; it keeps its - * OWN wire response in a local variable ("the side-map") as it builds each + * the capabilities below return the NEUTRAL tagged types in this file (built + * only from plain values — a `kind`/`reason` string, a `message` string, + * snapshot nodes, artifact paths). The daemon adapter + * (`createAdReplayStepRuntime`, `session-replay-runtime.ts`) is the only + * place a real `DaemonResponse` is constructed or read; it keeps its OWN + * wire response in a local variable ("the side-map") as it builds each * neutral outcome, and `runReplayScriptFile` reads that variable back after * `runAdReplay` reports which step failed, so the final response returned to * the client is byte-identical to before this split — it was never * round-tripped through the engine's return value at all. + * + * #1555 review P1 remainder ("target verification must happen INSIDE the + * engine"): `verifyAndDispatchStep` below is the verify-then-dispatch + * orchestrator that used to live daemon-side + * (`session-replay-target-verification.ts`'s `verifyReplayActionTarget` / + * `convertIdentityRefusalResponse`) calling OUT to this package's four + * target-verification policy functions. The call sites for those four + * functions now live here — engine-private, never re-exported by the façade + * — and the daemon side shrinks to the narrow capabilities this function + * drives: routing (`beginTargetVerification`), capture + * (`captureObservation`), classification (`classifyTarget`), dispatch + * (`dispatchStep`), and wire-building the resulting divergence + * (`buildRecordedUnverifiableFailure`, `buildTargetBindingFailure`, + * `buildPostDispatchTargetBindingFailure`). */ /** Neutral per-step failure: no `DaemonResponse`, no wire shape — just what the engine needs to report. */ @@ -54,6 +80,109 @@ export type AdReplayProgressStep = Readonly<{ 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 structural position within its capture (document order + sibling). */ +export type AdReplayTargetStructuralDenotation = Readonly<{ + documentOrder: number; + sibling: number; +}>; + +/** + * 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. + */ +export type AdReplayDispatchOutcome = Readonly< + | { readonly status: 'ok'; readonly artifactPaths: readonly string[] } + | { readonly status: 'failed'; readonly failure: AdReplayStepFailure } + | { + readonly status: 'guard-mismatch' | 'landmark-mismatch'; + readonly details: Record | undefined; + 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 @@ -65,16 +194,91 @@ export type AdReplayProgressSink = (step: AdReplayProgressStep) => void; */ export type AdReplayStepRuntime = Readonly<{ /** - * Verifies the recorded target (if any) then dispatches the action. - * Capture, the single `invoke` dispatch site, and the post-resolution - * guard/landmark-mismatch conversion are all daemon authority; only the - * neutral pass/fail projection crosses back into the engine. + * 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. + */ + beginTargetVerification(action: 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). + */ + dispatchStep( + action: 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). + */ + buildRecordedUnverifiableFailure( + action: SessionAction, + index: number, + artifactPaths: readonly string[], + ): 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. + */ + buildTargetBindingFailure( + action: SessionAction, + index: number, + evidence: AdReplayTargetBindingEvidence, + artifactPaths: readonly string[], + ): 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). */ - executeStep( + buildPostDispatchTargetBindingFailure( action: SessionAction, index: number, + evidence: AdReplayTargetBindingEvidence, artifactPaths: readonly string[], - ): Promise; + ): Promise; /** * Wraps a failed step with replay failure diagnostics and repair-held * marking — daemon authority (capture, `SessionStore`, the P4b @@ -124,9 +328,9 @@ export type AdReplayRunOutcome = * ADR 0012 step 4's step loop: for every executable action from * `request.entryIndex` on, arm the save-script transaction, skip a * repair-armed plan's terminal `close` (lifecycle, not a script step), report - * progress, dispatch through `runtime.executeStep`, and stop at the first - * failure. Moved verbatim from `executeReplayActions`'s composition order — - * only the daemon capabilities it calls through were narrowed into + * 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`. */ export async function runAdReplay( @@ -153,7 +357,7 @@ export async function runAdReplay( runtime.onStep(buildAdReplayProgressStep(index, actions.length, action, value)); } const sampleStart = runtime.diagnosticsMarker(); - const stepOutcome = await runtime.executeStep(action, index, [...artifactPaths]); + const stepOutcome = await verifyAndDispatchStep(runtime, action, index, [...artifactPaths]); snapshotDiagnosticSamples.push(...runtime.diagnosticsSince(sampleStart)); if (stepOutcome.status === 'ok') { stepOutcome.artifactPaths.forEach((entry) => artifactPaths.add(entry)); @@ -176,6 +380,196 @@ export async function runAdReplay( }; } +/** + * The verify-then-dispatch orchestrator: ADR 0012 step 4 verify + dispatch + + * guard, ENGINE-side as of the #1555 review pass. Mirrors + * `verifyReplayActionTarget`'s exact branch order (moved verbatim from + * `session-replay-target-verification.ts`) — only the async daemon-owned + * pieces (registry/session/wait-form routing, capture, classification, + * dispatch, wire-building) were narrowed into `runtime` capabilities; the + * plan/derive DECISIONS (`planPostResolutionTargetVerification`, + * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, + * `deriveWaitLandmarkMismatchEvidence`) are called from here, never from the + * daemon. + */ +async function verifyAndDispatchStep( + runtime: AdReplayStepRuntime, + action: SessionAction, + index: number, + artifactPaths: readonly string[], +): Promise { + const recorded = action.targetEvidence; + if (!recorded) return dispatchNoGuard(runtime, action, index, artifactPaths); + + const entry = runtime.beginTargetVerification(action, index); + if (entry.kind === 'inactive') return dispatchNoGuard(runtime, action, 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, index, artifactPaths); + case 'recorded-unverifiable': + return { + status: 'failed', + failure: await runtime.buildRecordedUnverifiableFailure(action, index, artifactPaths), + }; + case 'deferred-landmark': + return dispatchWithGuard(runtime, action, index, artifactPaths, { + kind: 'landmark', + landmark: plan.landmark, + }); + } + } + + // entry.kind === 'pre-dispatch': the ordinary gate. + const preDispatchPlan = planPreDispatchTargetVerification({ + recorded, + token: entry.token, + platform: entry.platform, + port: runtime.port, + }); + if (preDispatchPlan.kind === 'skip') + return dispatchNoGuard(runtime, action, index, artifactPaths); + if (preDispatchPlan.kind === 'recorded-unverifiable') { + return { + status: 'failed', + failure: await runtime.buildRecordedUnverifiableFailure(action, index, artifactPaths), + }; + } + 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, + ), + }; + } + + const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); + if (classification.verified) { + return dispatchWithGuard(runtime, action, 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, + ), + }; +} + +/** 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, + index: number, + artifactPaths: readonly string[], +): Promise { + const outcome = await runtime.dispatchStep(action, 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, + action: SessionAction, + index: number, + artifactPaths: readonly string[], + guard: AdReplayDispatchGuard, +): Promise { + const outcome = await runtime.dispatchStep(action, 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.details, + guard.kind === 'target' ? guard.guard.matchCount : 0, + ) + : deriveWaitLandmarkMismatchEvidence(recorded, outcome.details); + + 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, + ), + }; +} + /** * 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 diff --git a/packages/ad-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts index 1381aeceed..68958addb3 100644 --- a/packages/ad-replay/src/internal/target-verification.ts +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -7,13 +7,22 @@ * 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 `verifyReplayActionTarget` even attempt verification, and with - * what token — mirrors the two branches of that function's original - * pre-capture gating exactly (#1349's deferred-landmark `wait` case, and - * the ordinary pre-dispatch token/parse gate). + * 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 raw (already * neutral, `unknown`-typed) details bag, compute the observed identity and diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 0fa9df16a4..588202e4bb 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -274,11 +274,14 @@ test('the real tree parses, declares, and passes R11', () => { // point actually NAMES. This pins the exact symbol list `packages/ad-replay/src/index.ts` // exports (value and type-only together): the two binding-design // entrypoints (`inspectAdReplay`, `runAdReplay`), the types their - // signatures reference, and the ONE reported façade deviation (the four - // target-verification policy functions plus the `ReplaySelectorPort` - // family) — see that file's own header comment for why the deviation - // remains. A stray export — intentional or not — must edit this list too, - // not just slip through the exports-subpath check. + // signatures reference, and the `ReplaySelectorPort` family (still named at + // every daemon call site that threads a port value). As of the #1555 + // review's R3 pass, the four target-verification policy functions and + // `ReplayPostDispatchMismatchEvidence` are GONE from this list — they moved + // engine-private (`./internal/step-loop.ts`'s `verifyAndDispatchStep`), so + // there is no longer a reported façade deviation. A stray export — + // intentional or not — 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'), @@ -290,7 +293,6 @@ test('the real tree parses, declares, and passes R11', () => { 'AdReplayStepFailure', 'AdReplayStepOutcome', 'AdReplayStepRuntime', - 'ReplayPostDispatchMismatchEvidence', 'ReplayRecordedTargetDisambiguation', 'ReplayRecordedTargetPolicy', 'ReplayRecordedTargetResolution', @@ -298,12 +300,8 @@ test('the real tree parses, declares, and passes R11', () => { 'ReplaySelectorExpressionOutcome', 'ReplaySelectorGrammar', 'ReplaySelectorPort', - 'deriveReplayTargetGuardMismatchEvidence', - 'deriveWaitLandmarkMismatchEvidence', 'formatReplaySuccessMessage', 'inspectAdReplay', - 'planPostResolutionTargetVerification', - 'planPreDispatchTargetVerification', 'runAdReplay', ], ); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index f57b8f0961..164f793cd5 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -28,6 +28,7 @@ import { } from '@agent-device/ad-replay'; import { buildReplayVarScope, + collectReplayScrubbableVarValues, collectReplayShellEnv, parseReplayCliEnvEntries, readReplayCliEnvEntries, @@ -38,7 +39,7 @@ import { summarizeSnapshotTimingSamples, type SnapshotTimingSample, } from '@agent-device/contracts/capture'; -import type { ReplayCommandResult, TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import { isMaestroYamlPath, maestroBackendRequiredMessage, @@ -48,12 +49,19 @@ import { collectReplayActionArtifactPaths } from './session-replay-runtime-artif import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; import { buildReplayMetadataFlags } from './session-replay-runtime-plan.ts'; import { - buildReplayTargetGuardMismatchResponse, - buildWaitLandmarkMismatchResponse, + captureDivergenceObservation, + type DivergenceObservation, +} from './session-replay-divergence.ts'; +import { + buildPostDispatchTargetBindingFailureResponse, + buildRecordedUnverifiableFailureResponse, + buildTargetBindingFailureResponse, + classifyPreDispatchTarget, isReplayTargetGuardMismatchResponse, isWaitLandmarkMismatchResponse, - verifyReplayActionTarget, - type ReplayVerifiedTargetGuard, + resolveTargetVerificationEntry, + type TargetBindingDivergenceContext, + type TargetBindingFailureEvidence, } from './session-replay-target-verification.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; @@ -99,128 +107,6 @@ type ReplayStepContext = { port: ReplaySelectorPort; }; -/** - * 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. - * - * #1478 P5 stage C2b: this is the daemon's `AdReplayStepRuntime.executeStep` - * implementation — capture, the single dispatch site, and post-resolution - * guard/landmark conversion are all daemon authority, so the engine step loop - * (`runAdReplay`, `@agent-device/ad-replay`) calls this as one opaque - * capability and never sees any of it. - */ -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, - port: ctx.port, - }); - 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, - }); -} - -/** - * 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. - */ -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, - port: ctx.port, - }; - 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; @@ -369,22 +255,56 @@ export async function runReplayScriptFile(params: { } } +/** + * The engine's evidence-bag type for `buildTargetBindingFailure`/ + * `buildPostDispatchTargetBindingFailure`, read off `AdReplayStepRuntime` + * itself (`Parameters<...>`) rather than a named façade export — the R3 pass + * deliberately did not add `AdReplayTargetBindingEvidence` to + * `@agent-device/ad-replay`'s export list, so this is how a daemon helper + * still gets a precise parameter type without widening the façade. + */ +type EngineTargetBindingEvidence = Parameters[2]; + +/** Converts the engine's (readonly-array) evidence shape to this module's own mutable-array `TargetBindingFailureEvidence`. */ +function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingFailureEvidence { + return { + 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 } : {}), + }; +} + /** * #1478 P5 stage C2b (narrowed further by the #1555 review's neutral-outcomes - * pass): the daemon's `AdReplayStepRuntime` adapter — the narrow - * execute/capture/observe/stamp 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. + * 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. `executeStep` and - * `handleActionFailure` each record the wire response they 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. + * 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. */ function createAdReplayStepRuntime(params: { ctx: ReplayStepContext; @@ -396,15 +316,194 @@ function createAdReplayStepRuntime(params: { }): { 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. */ + const buildDivergenceContext = ( + action: SessionAction, + index: number, + stepArtifactPaths: readonly string[], + ): 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: collectReplayScrubbableVarValues(ctx.scope), + 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 = { - async executeStep(action, index, stepArtifactPaths) { - const response = await resolveReplayStepResponse(ctx, action, index, [...stepArtifactPaths]); + port: ctx.port, + + beginTargetVerification(action, index) { + return resolveTargetVerificationEntry({ + action, + scope: ctx.scope, + sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, + sourceLine: ctx.actionLines[index] ?? 1, + 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, index, _stepArtifactPaths, guard) { + const sourceLine = ctx.actionLines[index] ?? 1; + const guardInternal = + guard?.kind === 'target' + ? { replayTargetGuard: guard.guard.expected } + : guard?.kind === 'landmark' + ? { replayLandmarkGuard: guard.landmark } + : 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, + }); lastResponse = response; const entries = collectReplayActionArtifactPaths(response); entries.forEach((entry) => artifactPaths.add(entry)); if (response.ok) return { status: 'ok', artifactPaths: entries }; - return { status: 'failed', failure: toAdReplayStepFailure(response, entries) }; + const plainFailure = toAdReplayStepFailure(response, entries); + if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { + return { + status: 'guard-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { + return { + status: 'landmark-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + return { status: 'failed', failure: plainFailure }; }, + + async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths) { + const response = await buildRecordedUnverifiableFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths), + { + 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) { + 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), + toDaemonEvidence(evidence), + observation, + ); + return recordFailure(response); + }, + + async buildPostDispatchTargetBindingFailure(action, index, evidence, stepArtifactPaths) { + const response = await buildPostDispatchTargetBindingFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths), + toDaemonEvidence(evidence), + { + session: ctx.sessionStore.get(ctx.sessionName), + sessionName: ctx.sessionName, + sessionStore: ctx.sessionStore, + logPath: ctx.logPath, + action, + }, + ); + return recordFailure(response); + }, + async handleActionFailure({ action, index, @@ -421,16 +520,12 @@ function createAdReplayStepRuntime(params: { [...failureArtifactPaths], [...snapshotDiagnosticSamples], ); - lastResponse = finalResponse; // `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 toAdReplayStepFailure( - asFailedReplayStepResponse(finalResponse), - collectReplayActionArtifactPaths(finalResponse), - ); + return recordFailure(finalResponse); }, armStep: armSaveScript, isRepairArmed: () => ctx.coordinator.view()?.repairBoundary !== undefined, diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index c295dbb572..f9d93ae5b9 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -1,5 +1,6 @@ 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 { @@ -11,14 +12,7 @@ import { type ReplayVarScope, } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { - deriveReplayTargetGuardMismatchEvidence, - deriveWaitLandmarkMismatchEvidence, - planPostResolutionTargetVerification, - planPreDispatchTargetVerification, - type ReplayPostDispatchMismatchEvidence, - type ReplaySelectorPort, -} from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import { createReplayDivergenceSanitizer, type ReplayDivergence, @@ -34,7 +28,7 @@ import { } 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'; @@ -44,6 +38,7 @@ import { captureDivergenceObservation, resolveSuggestionMatchingConfig, toReplayRepairHintCapture, + type DivergenceObservation, } from './session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; import { @@ -56,7 +51,30 @@ 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`) now lives entirely inside the +// engine's step loop (`packages/ad-replay/src/internal/step-loop.ts`, +// `verifyAndDispatchStep`). This module never imports those functions or the +// package's `@agent-device/ad-replay` decision types — it 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.ts`'s `createAdReplayStepRuntime` is the thin +// adapter that wires these into the `AdReplayStepRuntime` object and supplies +// the per-request context (scope, resume stamper, artifact accumulator, +// side-map response holder) these functions need but do not own. // --------------------------------------------------------------------------- /** @@ -73,23 +91,7 @@ export type ReplayVerifiedTargetGuard = { 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; @@ -214,90 +216,78 @@ function buildTargetBindingDivergenceResponse( }); } -type ReplayTargetDivergenceParams = { - action: SessionAction; - scope: ReplayVarScope; - sourcePath: string; - sourceLine: number; - replayPath: string; - step: number; - 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; - port: ReplaySelectorPort; +/** The evidence bag every target-binding failure builder wraps into a wire divergence. */ +export type TargetBindingFailureEvidence = { + kind: ReplayDivergenceTargetBindingKind; + matchCount: number | undefined; + observed: LocalIdentity | undefined; + candidateNodes: SnapshotNode[]; + mismatches: string[]; + causeCode: string; + causeMessage: string; + causeHint?: string; }; -export async function verifyReplayActionTarget( - params: ReplayTargetDivergenceParams, -): Promise { - const { - action, - scope, - sourcePath, - sourceLine, - replayPath, - step, - sessionName, - sessionStore, - resumeStamper, - logPath, - artifactPaths, - responseLevel, - planActions, - planDigest, - signal, - port, - } = params; - - const recorded = action.targetEvidence; - if (!recorded) return { verified: true }; - - const session = sessionStore.get(sessionName); - if (!session) return { verified: true }; +/** Assembles a target-binding divergence from already-computed `evidence` and a capture `observation`. */ +export function buildTargetBindingFailureResponse( + context: TargetBindingDivergenceContext, + evidence: TargetBindingFailureEvidence, + 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), + }); +} - // 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 }); +async function captureFreshObservation(params: { + session: SessionState | undefined; + sessionName: string; + sessionStore: SessionStore; + logPath: string; + 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, @@ -306,93 +296,129 @@ 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: TargetBindingFailureEvidence, + 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; +} - // #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. +// --------------------------------------------------------------------------- +// `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). +// --------------------------------------------------------------------------- + +export type TargetVerificationEntry = + | { kind: 'inactive' } + | { kind: 'post-resolution'; isSelectorWait: boolean } + | { kind: 'pre-dispatch'; token: string | undefined; platform: Platform | PublicPlatform }; + +/** + * #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. + */ +export function resolveTargetVerificationEntry(params: { + action: SessionAction; + scope: ReplayVarScope; + sourcePath: string; + sourceLine: number; + sessionName: string; + sessionStore: SessionStore; + port: ReplaySelectorPort; +}): TargetVerificationEntry { + const { action, scope, sourcePath, sourceLine, sessionName, sessionStore, port } = params; + const session = sessionStore.get(sessionName); + if (!session) return { kind: 'inactive' }; + // Resolved ONLY to extract the match token 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). + const resolvedAction = resolveReplayAction(action, scope, { file: sourcePath, line: sourceLine }); if (resolveTargetIdentityVerification(action.command) === 'post-resolution') { const parsed = parseWaitPositionals(resolvedAction.positionals ?? []); - const plan = planPostResolutionTargetVerification({ - recorded, - isSelectorWait: parsed?.kind === 'selector', - }); - switch (plan.kind) { - case 'skip': - return { verified: true }; - case 'recorded-unverifiable': - return { verified: false, response: await buildRecordedUnverifiableResponse() }; - case 'deferred-landmark': - return { verified: true, deferredLandmark: plan.landmark }; - } + return { kind: 'post-resolution', isSelectorWait: parsed?.kind === 'selector' }; } - - // 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. `resolveRecordedTarget`'s early parse gate is the exact same - // `tryParseSelectorChain` check this used to run directly (empty `nodes` - // is safe: a parse failure short-circuits before any resolution work). - const preDispatchPlan = planPreDispatchTargetVerification({ - recorded, + 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; `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, - port, - }); - if (preDispatchPlan.kind === 'skip') return { verified: true }; - if (preDispatchPlan.kind === 'recorded-unverifiable') { - return { verified: false, response: await buildRecordedUnverifiableResponse() }; - } - 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, 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. +// --------------------------------------------------------------------------- + +export type TargetClassificationOutcome = + | { verified: true; guard: ReplayVerifiedTargetGuard } + | { + verified: false; + kind: ReplayDivergenceTargetBindingKind; + matchCount: number | undefined; + observed: LocalIdentity | undefined; + candidateNodes: SnapshotNode[]; + mismatches: string[]; + causeCode: string; + causeMessage: string; }; - } +export function classifyPreDispatchTarget(params: { + recorded: TargetAnnotationV1; + token: string; + action: SessionAction; + nodes: SnapshotNode[]; + platform: Platform | PublicPlatform; + port: ReplaySelectorPort; +}): TargetClassificationOutcome { + 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, @@ -402,29 +428,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, }; } @@ -435,121 +455,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; -}; - /** - * 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, - ) => ReplayPostDispatchMismatchEvidence, -): 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) => - deriveReplayTargetGuardMismatchEvidence(recorded, details, params.guard.matchCount), - ); -} - -// --------------------------------------------------------------------------- -// #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, - deriveWaitLandmarkMismatchEvidence, - ); -} - function sanitizeIdentity( identity: ReplayDivergenceTargetIdentity, sanitize: (value: string, limit?: number) => string, From d9272cc46f6ebf1c998a7e7f1cb534973877cb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 17:59:09 +0200 Subject: [PATCH 14/31] refactor(daemon): decompose the replay adapter's two over-threshold functions (#1555) --- src/daemon/handlers/session-replay-runtime.ts | 191 +++++++++++------- 1 file changed, 123 insertions(+), 68 deletions(-) diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 164f793cd5..6c41dec9ee 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -279,6 +279,54 @@ function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingF }; } +/** The engine's pre-action identity guard, read off `AdReplayStepRuntime` itself (see `EngineTargetBindingEvidence` above for why `Parameters<...>` rather than a named façade export). */ +type ReplayDispatchGuard = Parameters[3]; + +/** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ +type ReplayDispatchOutcome = Awaited>; + +/** 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. */ +function applyReplayDispatchGuard( + replayReq: DaemonRequest, + guard: ReplayDispatchGuard, +): 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; +} + +/** 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. */ +function classifyReplayDispatchFailure( + response: Extract, + guard: ReplayDispatchGuard, + entries: readonly string[], +): ReplayDispatchOutcome { + const plainFailure = toAdReplayStepFailure(response, entries); + if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { + return { + status: 'guard-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { + return { + status: 'landmark-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + return { status: 'failed', failure: plainFailure }; +} + /** * #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 @@ -416,17 +464,8 @@ function createAdReplayStepRuntime(params: { // ever reached the target-binding wire builders (`build*Failure` below). async dispatchStep(action, index, _stepArtifactPaths, guard) { const sourceLine = ctx.actionLines[index] ?? 1; - const guardInternal = - guard?.kind === 'target' - ? { replayTargetGuard: guard.guard.expected } - : guard?.kind === 'landmark' - ? { replayLandmarkGuard: guard.landmark } - : undefined; - const guardedReq = guardInternal - ? { ...ctx.replayReq, internal: { ...ctx.replayReq.internal, ...guardInternal } } - : ctx.replayReq; const response = await invokeReplayAction({ - req: guardedReq, + req: applyReplayDispatchGuard(ctx.replayReq, guard), sessionName: ctx.sessionName, action, scope: ctx.scope, @@ -441,24 +480,7 @@ function createAdReplayStepRuntime(params: { const entries = collectReplayActionArtifactPaths(response); entries.forEach((entry) => artifactPaths.add(entry)); if (response.ok) return { status: 'ok', artifactPaths: entries }; - const plainFailure = toAdReplayStepFailure(response, entries); - if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { - return { - status: 'guard-mismatch', - details: response.error.details, - plainFailure, - artifactPaths: entries, - }; - } - if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { - return { - status: 'landmark-mismatch', - details: response.error.details, - plainFailure, - artifactPaths: entries, - }; - } - return { status: 'failed', failure: plainFailure }; + return classifyReplayDispatchFailure(response, guard, entries); }, async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths) { @@ -679,48 +701,19 @@ function prepareReplayPlan(params: { keepSession: boolean; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; - // #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. - if (req.flags?.replayBackend && req.flags.replayBackend !== 'maestro') { - return { - ok: false, - response: errorResponse( - 'INVALID_ARGS', - `Unsupported replay backend "${req.flags.replayBackend}".`, - ), - }; - } - // #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)`. - const manifest = inspectAdReplay(resolved, { - platform: req.flags?.platform, - target: req.flags?.target, - }); + 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 replayReq = applyReplayMetadata( - { ...req, flags: buildReplayScriptPlatformFlags(req.flags, actions) }, - metadata, - ); const preEntrySession = sessionStore.get(sessionName); - const entryIndex = manifest.resolveEntryIndex({ - from: req.flags?.replayFrom, - digest: req.flags?.replayPlanDigest, - pendingRecordAndHeal: coordinator.view()?.pendingRecordAndHeal, - sessionActionsLength: preEntrySession?.actions.length ?? 0, + const entryIndexResult = resolveReplayPlanEntryIndex({ + req, + coordinator, + manifest, + preEntrySession, }); - if (!entryIndex.ok) { - return { ok: false, response: errorResponse('INVALID_ARGS', entryIndex.message) }; - } + if (!entryIndexResult.ok) return { ok: false, response: entryIndexResult.response }; return { ok: true, @@ -731,13 +724,75 @@ function prepareReplayPlan(params: { actionSourcePaths, planDigest, preEntrySession, - entryIndex: entryIndex.value, + entryIndex: entryIndexResult.value, scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, }, }; } +/** + * #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. + */ +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; +} + +/** + * #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)`. + */ +function inspectReplayPlanManifest( + req: DaemonRequest, + resolved: string, +): { manifest: AdReplayManifest; replayReq: DaemonRequest } { + const manifest = inspectAdReplay(resolved, { + platform: req.flags?.platform, + target: req.flags?.target, + }); + const replayReq = applyReplayMetadata( + { ...req, flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions) }, + manifest.metadata, + ); + return { manifest, replayReq }; +} + +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 }; +} + function applyReplayMetadata( req: DaemonRequest, metadata: AdReplayManifest['metadata'], From 2f52a5645af15a45186b7a3dc50708eae28c27bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 18:58:55 +0200 Subject: [PATCH 15/31] refactor(replay): fold #1554's keep-session terminal-lifecycle policy into the ad-replay engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing p5/extract-ad-replay onto main pulled in #1554's --keep-session feature, which had grown its own daemon-side terminal-close-suppression predicate (session-replay-terminal-lifecycle.ts's resolveSuppressedTerminalCloseIndex/countExecutedReplayActions) independently of this branch's own engine-side one (step-loop.ts's isRepairArmedTerminalCloseAction). Both are the same decision family — replay --keep-session and an active --save-script repair now share ONE structural resolution (resolveSuppressedTerminalCloseIndex, generalized to "terminal among EXECUTABLE actions" rather than the old physical-last-index check) and one suppression check inside runAdReplay, gated on keepSession OR runtime.isRepairArmed(). AdReplayRunRequest grew a keepSession field; the neutral 'replayed' count in AdReplayRunOutcome is now computed inline in the loop instead of the daemon's old actions.length - entryIndex approximation. requireLiveSessionForKeepSession (the --keep-session live-session postcondition) stays daemon-side, inlined into session-replay-runtime.ts, since it inspects SessionStore state the engine never sees. The daemon-only session-replay-terminal-lifecycle.ts this arrived with is deleted entirely — its isExecutableReplayAction was a duplicate of the engine's own. runReplayScriptFile's Maestro-format routing (including the new --keep-session Maestro rejection) was extracted into routeMaestroReplay to keep the function under fallow's complexity threshold after re-threading keepSession through it. Added packages/ad-replay/src/internal/__tests__/step-loop.test.ts covering the unified suppression decision (both keepSession and repair-armed) directly against runAdReplay, including the terminal-among-executable-actions case with a trailing nested replay marker. The daemon-level integration tests (6 tests in session-replay-terminal-lifecycle.test.ts, exercising the same behavior through runReplayScriptFile) and the SDK provider-scenario test (active-session-script-publication.test.ts) needed no changes and pass unmodified. --- .../src/internal/__tests__/step-loop.test.ts | 123 ++++++++++++++++++ packages/ad-replay/src/internal/step-loop.ts | 102 +++++++++++---- src/daemon/handlers/session-replay-runtime.ts | 109 +++++++++++----- .../session-replay-terminal-lifecycle.ts | 65 --------- 4 files changed, 274 insertions(+), 125 deletions(-) create mode 100644 packages/ad-replay/src/internal/__tests__/step-loop.test.ts delete mode 100644 src/daemon/handlers/session-replay-terminal-lifecycle.ts diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts new file mode 100644 index 0000000000..4d5da809e7 --- /dev/null +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { runAdReplay, type AdReplayStepRuntime } from '../step-loop.ts'; +import type { SessionAction } from '@agent-device/contracts/session'; +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-terminal-lifecycle.test.ts`; + * 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 }; +} + +/** + * 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, _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({ actions, entryIndex: 0, 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({ actions, entryIndex: 0, 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({ actions, entryIndex: 0, 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({ actions, entryIndex: 0, 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({ actions, entryIndex: 0, 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); +}); diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 02524a182a..ee836ccd85 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -49,6 +49,21 @@ import { * (`dispatchStep`), and wire-building the resulting divergence * (`buildRecordedUnverifiableFailure`, `buildTargetBindingFailure`, * `buildPostDispatchTargetBindingFailure`). + * + * #1554 fold-in (rebase onto main's `replay --keep-session`): main grew this + * exact 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 — now 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. */ /** Neutral per-step failure: no `DaemonResponse`, no wire shape — just what the engine needs to report. */ @@ -308,6 +323,15 @@ 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`) + * 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 + * below, one OR'd into the single suppression check `runAdReplay` makes. + */ + readonly keepSession: boolean; }>; /** Neutral run-level outcome: `runAdReplay` never returns or holds a `DaemonResponse`. */ @@ -327,28 +351,44 @@ export type AdReplayRunOutcome = /** * ADR 0012 step 4's step loop: for every executable action from * `request.entryIndex` on, arm the save-script transaction, skip a - * repair-armed 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`. + * 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. */ export async function runAdReplay( request: AdReplayRunRequest, runtime: AdReplayStepRuntime, ): Promise { - const { actions, entryIndex } = request; + const { actions, entryIndex, keepSession } = request; 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 (isRepairArmedTerminalCloseAction(action, index, actions.length, runtime.isRepairArmed())) { + 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. @@ -374,7 +414,7 @@ export async function runAdReplay( } return { status: 'completed', - replayed: actions.length - entryIndex, + replayed, artifactPaths: [...artifactPaths], snapshotDiagnosticSamples, }; @@ -582,27 +622,33 @@ export function isExecutableReplayAction( } /** - * 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. 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. Skipped exactly like the `replay` pseudo-command - * just above it in the loop — never dispatched, never divergence-checked, - * and (like that skip) not counted out of `replayed`. `repairArmed` reflects - * session state, not this invocation's own flags, matching R2: a repair stays - * armed across separate `--from` legs regardless of whether `--save-script` - * is repeated on each one. + * 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 isRepairArmedTerminalCloseAction( - action: SessionAction, - index: number, - totalActions: number, - repairArmed: boolean, -): boolean { - if (action.command !== 'close') return false; - if (index !== totalActions - 1) return false; - return repairArmed; +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( diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 6c41dec9ee..962c2e6675 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -78,12 +78,6 @@ import { 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 = { @@ -146,21 +140,14 @@ export async function runReplayScriptFile(params: { 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, @@ -168,7 +155,6 @@ export async function runReplayScriptFile(params: { tracePath, resolved, coordinator, - keepSession, }); if (!planPreparation.ok) return planPreparation.response; const { @@ -214,9 +200,8 @@ export async function runReplayScriptFile(params: { artifactPaths, onStep, armSaveScript: sessionPreparation.armSaveScript, - suppressedTerminalCloseIndex, }); - const outcome = await runAdReplay({ actions, entryIndex }, runtime); + const outcome = await runAdReplay({ actions, entryIndex, keepSession }, 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 @@ -243,7 +228,6 @@ export async function runReplayScriptFile(params: { armSaveScript: sessionPreparation.armSaveScript, coordinator, keepSession, - suppressedTerminalCloseIndex, }); } catch (err) { const appErr = asAppError(err); @@ -255,6 +239,38 @@ export async function runReplayScriptFile(params: { } } +/** + * 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. + */ +async function routeMaestroReplay(params: { + resolved: string; + req: DaemonRequest; + keepSession: boolean; + coordinator: ReplayCoordinator; + maestroParams: Parameters[0]; +}): 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 engine's evidence-bag type for `buildTargetBindingFailure`/ * `buildPostDispatchTargetBindingFailure`, read off `AdReplayStepRuntime` @@ -647,7 +663,6 @@ function completeReplayRun(params: { armSaveScript: () => void; coordinator: ReplayCoordinator; keepSession: boolean; - suppressedTerminalCloseIndex: number | undefined; }): DaemonResponse { const { startedAt, @@ -659,11 +674,17 @@ function completeReplayRun(params: { armSaveScript, coordinator, keepSession, - suppressedTerminalCloseIndex, } = params; armSaveScript(); coordinator.markCompleteIfArmed(); const completedSession = sessionStore.get(sessionName); + const keepSessionFailure = requireLiveSessionForKeepSession({ + keepSession, + sessionName, + completedSession, + artifactPaths, + }); + if (keepSessionFailure) return keepSessionFailure; const snapshotDiagnosticsSummary = summarizeSnapshotTimingSamples([...snapshotDiagnosticSamples]); return { ok: true, @@ -679,6 +700,31 @@ function completeReplayRun(params: { }; } +/** + * `--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 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, + ); +} + type PreparedReplayPlan = { replayReq: DaemonRequest; actions: SessionAction[]; @@ -698,7 +744,6 @@ function prepareReplayPlan(params: { tracePath: string | undefined; resolved: string; coordinator: ReplayCoordinator; - keepSession: boolean; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; const backendRejection = validateReplayBackendFlag(req); @@ -1000,10 +1045,10 @@ function preflightSaveScriptTarget(params: { * (`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 (`isRepairArmedTerminalCloseAction`) 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. + * 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; 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, - ); -} From 8e20e3e2968315854bff77d47591fd35ba3b3f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 19:25:11 +0200 Subject: [PATCH 16/31] refactor(daemon): decompose session-replay-runtime.ts into three modules (#1555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the ~1096-line replay runtime into cohesive pieces, keeping session-replay-runtime.ts as thin orchestration (~240 LOC): - session-replay-runtime-engine-adapter.ts: the AdReplayStepRuntime adapter (createAdReplayStepRuntime, the build*Failure capability implementations, and the lastResponse/lastObservation side-map mechanics), extracted verbatim. - session-replay-runtime-plan.ts: extended with the plan-side helpers (validateReplayBackendFlag, inspectReplayPlanManifest, resolveReplayPlanEntryIndex, prepareReplayPlan, routeMaestroReplay) alongside the buildReplayMetadataFlags helper already there — buildReplayMetadataFlags is now module-private since its one caller moved into the same file. Also introduces ReplayScriptFileParams, named here (instead of derived via Parameters) so routeMaestroReplay can reference the shape without importing back from session-replay-runtime.ts. - session-replay-runtime-session.ts (new): session preparation (prepareReplaySession and its coordinator arming/repair-preflight helpers), extracted verbatim. Coordinator ownership is unchanged: createReplayCoordinator is still constructed only in session-replay-runtime.ts, matching replay-coordinator-ownership.test.ts's allowlist as-is — every extracted module receives the already-constructed ReplayCoordinator as a parameter. Pure move; no behavior change. --- .../session-replay-runtime-engine-adapter.ts | 473 +++++++++ .../handlers/session-replay-runtime-plan.ts | 236 ++++- .../session-replay-runtime-session.ts | 219 ++++ src/daemon/handlers/session-replay-runtime.ts | 932 +----------------- 4 files changed, 965 insertions(+), 895 deletions(-) create mode 100644 src/daemon/handlers/session-replay-runtime-engine-adapter.ts create mode 100644 src/daemon/handlers/session-replay-runtime-session.ts 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..ba3e43a17a --- /dev/null +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -0,0 +1,473 @@ +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import { errorResponse } from './response.ts'; +import { invokeReplayAction } from './session-replay-action-runtime.ts'; +import { readReplaySelectorDisplayValue } from '../replay-selector-port.ts'; +import type { ResponseLevel } from '@agent-device/kernel/contracts'; +import type { + AdReplayStepFailure, + AdReplayStepRuntime, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; +import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; +import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; +import { + captureDivergenceObservation, + type DivergenceObservation, +} from './session-replay-divergence.ts'; +import { + buildPostDispatchTargetBindingFailureResponse, + buildRecordedUnverifiableFailureResponse, + buildTargetBindingFailureResponse, + classifyPreDispatchTarget, + isReplayTargetGuardMismatchResponse, + isWaitLandmarkMismatchResponse, + resolveTargetVerificationEntry, + type TargetBindingDivergenceContext, + type TargetBindingFailureEvidence, +} from './session-replay-target-verification.ts'; +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; + +/** + * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out + * of `session-replay-runtime.ts`, which now only constructs a `ReplayStepContext` and calls + * `createAdReplayStepRuntime`. See that file's `runReplayScriptFile` for the request-level + * orchestration this adapter plugs into. + */ + +/** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */ +export 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; + /** #1478 P5 stage C: the one selector-port instance this request threads through the divergence-report chain. */ + port: ReplaySelectorPort; +}; + +/** + * The engine's evidence-bag type for `buildTargetBindingFailure`/ + * `buildPostDispatchTargetBindingFailure`, read off `AdReplayStepRuntime` + * itself (`Parameters<...>`) rather than a named façade export — the R3 pass + * deliberately did not add `AdReplayTargetBindingEvidence` to + * `@agent-device/ad-replay`'s export list, so this is how a daemon helper + * still gets a precise parameter type without widening the façade. + */ +type EngineTargetBindingEvidence = Parameters[2]; + +/** Converts the engine's (readonly-array) evidence shape to this module's own mutable-array `TargetBindingFailureEvidence`. */ +function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingFailureEvidence { + return { + 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 } : {}), + }; +} + +/** The engine's pre-action identity guard, read off `AdReplayStepRuntime` itself (see `EngineTargetBindingEvidence` above for why `Parameters<...>` rather than a named façade export). */ +type ReplayDispatchGuard = Parameters[3]; + +/** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ +type ReplayDispatchOutcome = Awaited>; + +/** 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. */ +function applyReplayDispatchGuard( + replayReq: DaemonRequest, + guard: ReplayDispatchGuard, +): 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; +} + +/** 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. */ +function classifyReplayDispatchFailure( + response: Extract, + guard: ReplayDispatchGuard, + entries: readonly string[], +): ReplayDispatchOutcome { + const plainFailure = toAdReplayStepFailure(response, entries); + if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { + return { + status: 'guard-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { + return { + status: 'landmark-mismatch', + details: response.error.details, + plainFailure, + artifactPaths: entries, + }; + } + return { status: 'failed', failure: plainFailure }; +} + +/** + * #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. + */ +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. */ + const buildDivergenceContext = ( + action: SessionAction, + index: number, + stepArtifactPaths: readonly string[], + ): 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: collectReplayScrubbableVarValues(ctx.scope), + 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, index) { + return resolveTargetVerificationEntry({ + action, + scope: ctx.scope, + sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, + sourceLine: ctx.actionLines[index] ?? 1, + 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, index, _stepArtifactPaths, guard) { + const sourceLine = ctx.actionLines[index] ?? 1; + const response = await invokeReplayAction({ + req: applyReplayDispatchGuard(ctx.replayReq, guard), + 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, + }); + 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) { + const response = await buildRecordedUnverifiableFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths), + { + 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) { + 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), + toDaemonEvidence(evidence), + observation, + ); + return recordFailure(response); + }, + + async buildPostDispatchTargetBindingFailure(action, index, evidence, stepArtifactPaths) { + const response = await buildPostDispatchTargetBindingFailureResponse( + buildDivergenceContext(action, index, stepArtifactPaths), + toDaemonEvidence(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, + }) { + const failedResponse = asFailedReplayStepResponse(lastResponse); + const finalResponse = await buildReplayActionFailure( + ctx, + req, + action, + index, + failedResponse, + [...failureArtifactPaths], + [...snapshotDiagnosticSamples], + ); + // `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: 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 }; +} + +/** + * `runAdReplay` only ever calls `handleActionFailure` right after + * `executeStep` reported `status: 'failed'`, and `executeStep` always sets + * `lastResponse` to that same failed response 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; +} + +/** Projects a wire response down to the neutral shape the engine's outcome carries. */ +function toAdReplayStepFailure( + response: Extract, + artifactPaths: readonly string[], +): AdReplayStepFailure { + return { kind: response.error.code, message: response.error.message, artifactPaths }; +} + +async function buildReplayActionFailure( + ctx: ReplayStepContext, + req: DaemonRequest, + action: SessionAction, + index: number, + response: Extract, + artifactPaths: string[], + snapshotDiagnosticSamples: SnapshotTimingSample[], +): 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, + scope: ctx.scope, + 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 engine divergence-kind +// classification (that already happened engine-side, in +// `classifyReplayTarget`/`target-identity.ts`). +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-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index 692708ddb3..d8edbad064 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -1,5 +1,233 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import type { ReplayScriptMetadata } from '@agent-device/ad-script'; +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 } from '@agent-device/ad-replay'; +import { + buildReplayVarScope, + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, + type ReplayScriptMetadata, + type ReplayVarScope, +} 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'; + +/** + * #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); +} + +export type PreparedReplayPlan = { + replayReq: DaemonRequest; + actions: SessionAction[]; + actionLines: number[]; + actionSourcePaths: (string | undefined)[] | undefined; + planDigest: string; + preEntrySession: SessionState | undefined; + entryIndex: number; + scope: ReplayVarScope; + 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 { + ok: true, + value: { + replayReq, + actions, + actionLines, + actionSourcePaths, + planDigest, + preEntrySession, + entryIndex: entryIndexResult.value, + scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), + actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, + }, + }; +} + +/** + * #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. + */ +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; +} + +/** + * #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)`. + */ +function inspectReplayPlanManifest( + req: DaemonRequest, + resolved: string, +): { manifest: AdReplayManifest; replayReq: DaemonRequest } { + const manifest = inspectAdReplay(resolved, { + platform: req.flags?.platform, + target: req.flags?.target, + }); + const replayReq = applyReplayMetadata( + { ...req, flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions) }, + manifest.metadata, + ); + return { manifest, replayReq }; +} + +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 }; +} + +function applyReplayMetadata( + req: DaemonRequest, + metadata: AdReplayManifest['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: AdReplayManifest['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)), + }); +} /** * #1555 review P1 ("digest/resume must also occur behind runAdReplay"): the @@ -12,8 +240,12 @@ import type { ReplayScriptMetadata } from '@agent-device/ad-script'; * `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. + * + * 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`). */ -export function buildReplayMetadataFlags( +function buildReplayMetadataFlags( flags: CommandFlags | undefined, metadata: ReplayScriptMetadata, ): CommandFlags { 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 962c2e6675..58c691b107 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -1,120 +1,50 @@ -import fs from 'node:fs'; 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 { errorResponse, noActiveSessionError } from './response.ts'; -import { invokeReplayAction } from './session-replay-action-runtime.ts'; -import { - createDaemonReplaySelectorPort, - readReplaySelectorDisplayValue, -} from '../replay-selector-port.ts'; -import type { ResponseLevel } from '@agent-device/kernel/contracts'; -import { - formatReplaySuccessMessage, - inspectAdReplay, - runAdReplay, - type AdReplayManifest, - type AdReplayStepFailure, - type AdReplayStepRuntime, - type ReplaySelectorPort, -} from '@agent-device/ad-replay'; -import { - buildReplayVarScope, - collectReplayScrubbableVarValues, - collectReplayShellEnv, - parseReplayCliEnvEntries, - readReplayCliEnvEntries, - readReplayShellEnvSource, - type ReplayVarScope, -} from '@agent-device/ad-script'; -import { - summarizeSnapshotTimingSamples, - type SnapshotTimingSample, -} from '@agent-device/contracts/capture'; +import { errorResponse } from './response.ts'; +import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; +import { formatReplaySuccessMessage, 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, - resolveReplayFormat, -} from '../../replay/format.ts'; -import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; -import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; -import { buildReplayMetadataFlags } from './session-replay-runtime-plan.ts'; -import { - captureDivergenceObservation, - type DivergenceObservation, -} from './session-replay-divergence.ts'; -import { - buildPostDispatchTargetBindingFailureResponse, - buildRecordedUnverifiableFailureResponse, - buildTargetBindingFailureResponse, - classifyPreDispatchTarget, - isReplayTargetGuardMismatchResponse, - isWaitLandmarkMismatchResponse, - resolveTargetVerificationEntry, - type TargetBindingDivergenceContext, - type TargetBindingFailureEvidence, -} from './session-replay-target-verification.ts'; -import { buildReplayBuiltinVars } from './session-replay-vars.ts'; -import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; -import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +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'; + prepareReplayPlan, + routeMaestroReplay, + type ReplayScriptFileParams, +} from './session-replay-runtime-plan.ts'; +import { prepareReplaySession } from './session-replay-runtime-session.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; - /** #1478 P5 stage C: the one selector-port instance this request threads through the divergence-report chain. */ - port: ReplaySelectorPort; -}; +/** + * #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. + */ -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) { @@ -239,420 +169,6 @@ export async function runReplayScriptFile(params: { } } -/** - * 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. - */ -async function routeMaestroReplay(params: { - resolved: string; - req: DaemonRequest; - keepSession: boolean; - coordinator: ReplayCoordinator; - maestroParams: Parameters[0]; -}): 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 engine's evidence-bag type for `buildTargetBindingFailure`/ - * `buildPostDispatchTargetBindingFailure`, read off `AdReplayStepRuntime` - * itself (`Parameters<...>`) rather than a named façade export — the R3 pass - * deliberately did not add `AdReplayTargetBindingEvidence` to - * `@agent-device/ad-replay`'s export list, so this is how a daemon helper - * still gets a precise parameter type without widening the façade. - */ -type EngineTargetBindingEvidence = Parameters[2]; - -/** Converts the engine's (readonly-array) evidence shape to this module's own mutable-array `TargetBindingFailureEvidence`. */ -function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingFailureEvidence { - return { - 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 } : {}), - }; -} - -/** The engine's pre-action identity guard, read off `AdReplayStepRuntime` itself (see `EngineTargetBindingEvidence` above for why `Parameters<...>` rather than a named façade export). */ -type ReplayDispatchGuard = Parameters[3]; - -/** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ -type ReplayDispatchOutcome = Awaited>; - -/** 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. */ -function applyReplayDispatchGuard( - replayReq: DaemonRequest, - guard: ReplayDispatchGuard, -): 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; -} - -/** 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. */ -function classifyReplayDispatchFailure( - response: Extract, - guard: ReplayDispatchGuard, - entries: readonly string[], -): ReplayDispatchOutcome { - const plainFailure = toAdReplayStepFailure(response, entries); - if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { - return { - status: 'guard-mismatch', - details: response.error.details, - plainFailure, - artifactPaths: entries, - }; - } - if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { - return { - status: 'landmark-mismatch', - details: response.error.details, - plainFailure, - artifactPaths: entries, - }; - } - return { status: 'failed', failure: plainFailure }; -} - -/** - * #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. - */ -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. */ - const buildDivergenceContext = ( - action: SessionAction, - index: number, - stepArtifactPaths: readonly string[], - ): 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: collectReplayScrubbableVarValues(ctx.scope), - 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, index) { - return resolveTargetVerificationEntry({ - action, - scope: ctx.scope, - sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, - sourceLine: ctx.actionLines[index] ?? 1, - 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, index, _stepArtifactPaths, guard) { - const sourceLine = ctx.actionLines[index] ?? 1; - const response = await invokeReplayAction({ - req: applyReplayDispatchGuard(ctx.replayReq, guard), - 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, - }); - 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) { - const response = await buildRecordedUnverifiableFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths), - { - 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) { - 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), - toDaemonEvidence(evidence), - observation, - ); - return recordFailure(response); - }, - - async buildPostDispatchTargetBindingFailure(action, index, evidence, stepArtifactPaths) { - const response = await buildPostDispatchTargetBindingFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths), - toDaemonEvidence(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, - }) { - const failedResponse = asFailedReplayStepResponse(lastResponse); - const finalResponse = await buildReplayActionFailure( - ctx, - req, - action, - index, - failedResponse, - [...failureArtifactPaths], - [...snapshotDiagnosticSamples], - ); - // `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: 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 }; -} - -/** - * `runAdReplay` only ever calls `handleActionFailure` right after - * `executeStep` reported `status: 'failed'`, and `executeStep` always sets - * `lastResponse` to that same failed response 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; -} - -/** Projects a wire response down to the neutral shape the engine's outcome carries. */ -function toAdReplayStepFailure( - response: Extract, - artifactPaths: readonly string[], -): AdReplayStepFailure { - return { kind: response.error.code, message: response.error.message, artifactPaths }; -} - -async function buildReplayActionFailure( - ctx: ReplayStepContext, - req: DaemonRequest, - action: SessionAction, - index: number, - response: Extract, - artifactPaths: string[], - snapshotDiagnosticSamples: SnapshotTimingSample[], -): 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, - scope: ctx.scope, - 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(' '); -} - function completeReplayRun(params: { startedAt: number; sessionName: string; @@ -724,373 +240,3 @@ function requireLiveSessionForKeepSession(params: { artifactPaths.length > 0 ? { artifactPaths: [...artifactPaths] } : 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; -}; - -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 { - ok: true, - value: { - replayReq, - actions, - actionLines, - actionSourcePaths, - planDigest, - preEntrySession, - entryIndex: entryIndexResult.value, - scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), - actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, - }, - }; -} - -/** - * #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. - */ -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; -} - -/** - * #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)`. - */ -function inspectReplayPlanManifest( - req: DaemonRequest, - resolved: string, -): { manifest: AdReplayManifest; replayReq: DaemonRequest } { - const manifest = inspectAdReplay(resolved, { - platform: req.flags?.platform, - target: req.flags?.target, - }); - const replayReq = applyReplayMetadata( - { ...req, flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions) }, - manifest.metadata, - ); - return { manifest, replayReq }; -} - -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 }; -} - -function applyReplayMetadata( - req: DaemonRequest, - metadata: AdReplayManifest['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: AdReplayManifest['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. - */ -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; - }; -} - -// 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 engine divergence-kind -// classification (that already happened engine-side, in -// `classifyReplayTarget`/`target-identity.ts`). -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) ?? []; -} From 1e706e5538865279c79246bfb00bd596777ecc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 19:25:21 +0200 Subject: [PATCH 17/31] test(replay): cover pre-step artifact ordering and resume-before-mutation (#1555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two invariants found during the P5 decomposition pass now have direct counterfactual-verified coverage: - packages/ad-replay/src/internal/__tests__/step-loop.test.ts: a post-dispatch target-binding mismatch (dispatchWithGuard) must report the accumulated PRE-step artifact snapshot it was called with, never the artifacts the failed dispatch itself produced. Verified red by swapping the buildPostDispatchTargetBindingFailure call to outcome.artifactPaths. - src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts: a rejected --from/--plan-digest resume must never reach prepareReplaySession's coordinator-mutating writes (the R2 ordering invariant) — a pre-armed repair transaction and corrective-resume watermark are asserted byte-for-byte unchanged after rejection. Verified red by calling prepareReplaySession before honoring the plan-validation rejection. --- .../src/internal/__tests__/step-loop.test.ts | 95 +++++++++++++++++++ .../session-replay-runtime-plan.test.ts | 61 ++++++++++++ 2 files changed, 156 insertions(+) diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index 4d5da809e7..3c619fad46 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { runAdReplay, type AdReplayStepRuntime } from '../step-loop.ts'; import type { SessionAction } from '@agent-device/contracts/session'; +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplaySelectorPort } from '../selector-port.ts'; /** @@ -121,3 +122,97 @@ test('a close-less plan suppresses nothing and arms every executable step, inclu // 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, _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', + details: {}, + 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, + ) { + 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( + { actions: [openAction, waitAction], entryIndex: 0, 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/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')); From b3b9ae5a1020f63d16aaca4d9bd442a25fc9c59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 08:33:22 +0200 Subject: [PATCH 18/31] fix(ad-replay): enforce the exact two-entrypoint facade (#1555 review P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/ad-replay/src/index.ts now exports exactly two value symbols, inspectAdReplay and runAdReplay, and zero types — formatReplaySuccessMessage (presentation) moves beside its one caller in session-replay-runtime.ts, and every type a root daemon file needs is derived structurally off the two entrypoints in the one new src/daemon/ad-replay-facade-types.ts module instead of being named off the façade. scripts/layering/package-boundaries.ts's readNamedExports is rewritten on oxc-parser's own static-export table instead of a regex, so it can no longer silently miss a widening export form: a bare `export *` re-export or an `export default` now throws (an un-enumerable, and therefore un-pinnable, export), while `export * as ns` and every other enumerable form is still counted. The pinned exact-symbol assertion in package-boundaries.test.ts is narrowed to ['inspectAdReplay', 'runAdReplay']. --- packages/ad-replay/src/index.ts | 137 ++++++++---------- packages/ad-replay/src/internal/step-loop.ts | 6 - scripts/layering/package-boundaries.test.ts | 70 +++++---- scripts/layering/package-boundaries.ts | 60 +++++--- .../in-memory-replay-selector-port.ts | 26 ++-- .../replay-selector-port-contract.test.ts | 2 +- src/daemon/ad-replay-facade-types.ts | 56 +++++++ .../handlers/session-replay-divergence.ts | 2 +- src/daemon/handlers/session-replay-heal.ts | 2 +- .../session-replay-runtime-engine-adapter.ts | 2 +- .../session-replay-runtime-failure.ts | 2 +- .../handlers/session-replay-runtime-plan.ts | 3 +- src/daemon/handlers/session-replay-runtime.ts | 15 +- .../session-replay-target-classification.ts | 2 +- .../handlers/session-replay-target-token.ts | 2 +- .../session-replay-target-verification.ts | 2 +- src/daemon/replay-selector-port.ts | 2 +- 17 files changed, 238 insertions(+), 153 deletions(-) create mode 100644 src/daemon/ad-replay-facade-types.ts diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 8cdbdc96c0..97947ca1e2 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -5,86 +5,69 @@ * 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 fails that gate, not just a comment - * mismatch. + * INSIDE the engine"; every type export dropped and `formatReplaySuccessMessage` + * moved daemon-side by the #1555 review's second pass, "enforce the accepted + * two-entrypoint facade"). `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 `inspectAdReplay` + - * `runAdReplay` and nothing else — as of R3, with NO reported deviation: the - * four target-verification policy functions (`planPostResolutionTargetVerification`, - * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, - * `deriveWaitLandmarkMismatchEvidence`) are called only from - * `./internal/step-loop.ts`'s `verifyAndDispatchStep` — the step loop's own - * verify-then-dispatch orchestration, which drives the daemon-owned pieces - * (capture, classification, dispatch, wire-building) through narrow - * `AdReplayStepRuntime` capabilities instead of the daemon calling the policy - * functions directly. See `./internal/target-verification.ts` and - * `./internal/step-loop.ts` for the split. + * `runAdReplay` and NOTHING else — no types, no third value. Every type this + * package's signatures reference is available to a root consumer by deriving + * it structurally off these two functions (`Parameters<...>`, + * `ReturnType<...>`, `Awaited<...>`) — `src/daemon/ad-replay-facade-types.ts` + * is the one root module that does this derivation, so it happens exactly + * once; every other root file imports the derived names from there instead + * of re-deriving them or reaching for a named façade export. Presentation + * (`formatReplaySuccessMessage`) is not engine policy either, so it moved to + * sit beside its one caller (`completeReplayRun`, + * `src/daemon/handlers/session-replay-runtime.ts`). + * + * `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. + * + * `runAdReplay` is the `.ad` step loop; `AdReplayStepRuntime` (derived, not + * exported) is the runtime capability bag the daemon adapter + * (`session-replay-runtime-engine-adapter.ts`) implements to thread it, + * including the `ReplaySelectorPort` instance (`AdReplayStepRuntime['port']`) + * every daemon call site that threads a port value names by the SAME derived + * 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). + * + * `./target-verification.ts`'s four policy functions + * (`planPostResolutionTargetVerification`, `planPreDispatchTargetVerification`, + * `deriveReplayTargetGuardMismatchEvidence`, `deriveWaitLandmarkMismatchEvidence`) + * are called only from `./internal/step-loop.ts`'s `verifyAndDispatchStep` — + * the engine's own verify-then-dispatch orchestration, which drives the + * daemon-owned pieces (capture, classification, dispatch, wire-building) + * through the narrow `AdReplayStepRuntime` capabilities instead of the + * daemon calling the policy functions directly — so nothing from that module + * is exported here. + * + * `${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 are threaded the same + * direction, as an explicit argument on each build*Failure/handleActionFailure + * capability, computed from the engine's own live scope — never recomputed + * daemon-side from a second scope object. */ -// --------------------------------------------------------------------------- -// inspect.ts — the read-only `.ad` manifest reader. On-design: this IS one -// of the two binding-design entrypoints. #1555 review P1 ("digest/resume -// must also occur behind runAdReplay"): the plan-digest hash -// (`plan-digest.ts`, `computeReplayPlanDigest`) and the `--from`/ -// `--plan-digest` resume-point math (`resume.ts`, `resolveReplayEntryIndex`) -// are internal-only now — neither is exported here. `inspectAdReplay`'s -// manifest carries the digest as `planDigest` and the resume math as a -// `resolveEntryIndex` closure instead, so `session-replay-runtime.ts`'s -// `prepareReplayPlan` and `request-router-repair-expired.test.ts` read them -// off the manifest rather than importing the underlying functions. -// --------------------------------------------------------------------------- export { inspectAdReplay } from './internal/inspect.ts'; -export type { AdReplayDigestFlags, AdReplayManifest } from './internal/inspect.ts'; - -// --------------------------------------------------------------------------- -// step-loop.ts — the `.ad` step loop. On-design: this IS the other binding- -// design entrypoint; `AdReplayStepRuntime` is the runtime capability bag the -// daemon adapter (`session-replay-runtime.ts`) implements to thread it. -// --------------------------------------------------------------------------- -export { formatReplaySuccessMessage, runAdReplay } from './internal/step-loop.ts'; -export type { - AdReplayRunOutcome, - AdReplayStepFailure, - AdReplayStepOutcome, - AdReplayStepRuntime, -} from './internal/step-loop.ts'; - -// --------------------------------------------------------------------------- -// target-verification.ts — #1478 P5 stage C2a target-verification ENGINE -// policy (pre-capture verification gating, post-dispatch mismatch-evidence -// derivation). As of the #1555 review's R3 pass, its four functions are -// called ONLY from `./internal/step-loop.ts` (`verifyAndDispatchStep`) — the -// engine's own step loop, never the daemon — so nothing from this module is -// re-exported here anymore. See `./internal/target-verification.ts`'s header -// for the full daemon/engine ownership split. -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// selector-port.ts — the `ReplaySelectorPort` port TYPE only (#1478 P5 stage -// B, the amendment's explicit rejection of a "seven-function selector-AST -// mirror"). Two adapters implement it: the production adapter -// (`src/daemon/replay-selector-port.ts`) and the in-memory adapter for this -// package's own contract suite, relocated to -// `src/__tests__/test-utils/in-memory-replay-selector-port.ts` (#1478 P5 -// stage D — package-internal code may not "reach back into root `src/`", -// R11, so the adapter could not stay inside `packages/ad-replay` once its -// only remaining consumer was a root test). -// façade-deviation: daemon handlers thread `ReplaySelectorPort` values -// directly (`session-replay-target-token.ts`, `session-replay-heal.ts`, -// `session-replay-target-classification.ts`, `session-replay-runtime-failure.ts`, -// `session-replay-runtime.ts`, `session-replay-target-verification.ts`) — -// the port rides in as `runAdReplay`'s runtime threads it (as of R3, also as -// `AdReplayStepRuntime.port` itself, for the engine's own pre-dispatch plan), -// but the type is named at every one of those call sites too. -// --------------------------------------------------------------------------- -export type { - ReplayRecordedTargetDisambiguation, - ReplayRecordedTargetPolicy, - ReplayRecordedTargetResolution, - ReplaySelectorCandidateOptions, - ReplaySelectorExpressionOutcome, - ReplaySelectorGrammar, - ReplaySelectorPort, -} from './internal/selector-port.ts'; +export { runAdReplay } from './internal/step-loop.ts'; diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index ee836ccd85..96a159cb71 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -664,9 +664,3 @@ function buildAdReplayProgressStep( ...(value !== undefined ? { value } : {}), }; } - -export 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`; -} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 588202e4bb..5ec58ef080 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -97,6 +97,30 @@ test('readNamedExports never reports the original name behind an `as` alias', () 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 @@ -269,41 +293,25 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/kernel', ]); // #1555 review P1 ("add the reviewer-required exact exported-symbol - // gate"): 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 (value and type-only together): the two binding-design - // entrypoints (`inspectAdReplay`, `runAdReplay`), the types their - // signatures reference, and the `ReplaySelectorPort` family (still named at - // every daemon call site that threads a port value). As of the #1555 - // review's R3 pass, the four target-verification policy functions and - // `ReplayPostDispatchMismatchEvidence` are GONE from this list — they moved - // engine-private (`./internal/step-loop.ts`'s `verifyAndDispatchStep`), so - // there is no longer a reported façade deviation. A stray export — - // intentional or not — must edit this list too, not just slip through the - // exports-subpath check. + // gate"; second pass, "enforce the accepted two-entrypoint facade"): 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 to the binding design's two entrypoints, `inspectAdReplay` and + // `runAdReplay`, and NOTHING else — no type export, no third value. + // `formatReplaySuccessMessage` (presentation) and every type the two + // entrypoints' signatures reference (`AdReplayManifest`, + // `AdReplayStepRuntime`, the `ReplaySelectorPort` family, …) are gone from + // this list on purpose: root consumers derive them structurally instead + // (`src/daemon/ad-replay-facade-types.ts`). 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'), ), - [ - 'AdReplayDigestFlags', - 'AdReplayManifest', - 'AdReplayRunOutcome', - 'AdReplayStepFailure', - 'AdReplayStepOutcome', - 'AdReplayStepRuntime', - 'ReplayRecordedTargetDisambiguation', - 'ReplayRecordedTargetPolicy', - 'ReplayRecordedTargetResolution', - 'ReplaySelectorCandidateOptions', - 'ReplaySelectorExpressionOutcome', - 'ReplaySelectorGrammar', - 'ReplaySelectorPort', - 'formatReplaySuccessMessage', - 'inspectAdReplay', - 'runAdReplay', - ], + ['inspectAdReplay', 'runAdReplay'], ); const providerWebDriverPackage = packages.find( (pkg) => pkg.name === '@agent-device/provider-webdriver', diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index a2418f0223..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 = { @@ -61,29 +62,52 @@ export function specifierSites(file: string, source: string): SpecifierSite[] { * "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) and - * direct declarations (`export function`/`const`/`class`/`type`/ - * `interface`). 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. + * 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(); - const braceExportRe = /export\s+(?:type\s+)?\{([\s\S]*?)\}(?:\s*from\s*['"][^'"]+['"])?/g; - for (const match of source.matchAll(braceExportRe)) { - for (const rawEntry of match[1]!.split(',')) { - const entry = rawEntry.trim(); - if (!entry) continue; - const aliasMatch = /^(?:type\s+)?\S+\s+as\s+(\S+)$/.exec(entry); - const name = aliasMatch ? aliasMatch[1]! : entry.replace(/^type\s+/, '').trim(); - if (name) names.add(name); + 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); } } - const declarationRe = - /export\s+(?:default\s+)?(?:async\s+function|function|const|class|type|interface)\s+([A-Za-z0-9_$]+)/g; - for (const match of source.matchAll(declarationRe)) { - names.add(match[1]!); - } return [...names].sort(); } diff --git a/src/__tests__/test-utils/in-memory-replay-selector-port.ts b/src/__tests__/test-utils/in-memory-replay-selector-port.ts index 1364d1e2c0..b3a528cf7a 100644 --- a/src/__tests__/test-utils/in-memory-replay-selector-port.ts +++ b/src/__tests__/test-utils/in-memory-replay-selector-port.ts @@ -7,7 +7,7 @@ import type { ReplaySelectorExpressionOutcome, ReplaySelectorGrammar, ReplaySelectorPort, -} from '@agent-device/ad-replay'; +} from '../../daemon/ad-replay-facade-types.ts'; /** * #1478 P5 stage B: a deterministic, dependency-free `ReplaySelectorPort` @@ -24,15 +24,21 @@ import type { * * #1478 P5 stage D: relocated here from * `packages/ad-replay/src/internal/testing/in-memory-selector-port.ts`. It - * only ever needed the exported `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`. + * 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 review P1 (second pass, "enforce the accepted two-entrypoint + * facade"): the package façade no longer exports any type at all — the port + * family above is derived off `runAdReplay` in + * `src/daemon/ad-replay-facade-types.ts` (the one root module that does + * this), which this file imports from instead of `@agent-device/ad-replay` + * directly. * * Mini expression grammar: `key="value"` terms (space-separated, ANDed), * alternatives joined by ` || ` (first-match-wins, same as the real chain). diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts index 86b07aab37..12ce98f7a3 100644 --- a/src/daemon/__tests__/replay-selector-port-contract.test.ts +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -22,7 +22,7 @@ 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 type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import { createInMemoryReplaySelectorPort } from '../../__tests__/test-utils/in-memory-replay-selector-port.ts'; import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; diff --git a/src/daemon/ad-replay-facade-types.ts b/src/daemon/ad-replay-facade-types.ts new file mode 100644 index 0000000000..d2572f19bc --- /dev/null +++ b/src/daemon/ad-replay-facade-types.ts @@ -0,0 +1,56 @@ +import { inspectAdReplay, runAdReplay } from '@agent-device/ad-replay'; + +/** + * #1555 review P1 ("enforce the accepted two-entrypoint facade"): `@agent-device/ad-replay` + * exports exactly two value symbols — `inspectAdReplay` and `runAdReplay` — and no types at all + * (`packages/ad-replay/src/index.ts`'s header has the full design; `scripts/layering/package-boundaries.test.ts`'s + * exact-symbol pin is the gate that enforces it). Every type a root daemon file used to import + * directly off the façade is derived here instead — `Parameters<...>`/`ReturnType<...>`/ + * `Awaited<...>` off those two functions, exactly the idiom `session-replay-runtime-engine-adapter.ts` + * already used for `ReplayDispatchGuard`/`ReplayDispatchOutcome`/`EngineTargetBindingEvidence` — in + * exactly ONE place, so the derivation is never duplicated per call site. Every other root consumer + * imports the names below instead of re-deriving them. + */ + +/** `inspectAdReplay`'s read-only `.ad` manifest — actions, header metadata, plan digest, and the `--from`/`--plan-digest` resume-index resolver. */ +export type AdReplayManifest = ReturnType; + +/** `runAdReplay`'s injected capability bag — the daemon-implemented runtime the engine's step loop drives through. */ +export type AdReplayStepRuntime = Parameters[1]; + +/** A step's neutral failure shape — the resolved type any `AdReplayStepRuntime` build-failure/handle-failure capability returns. */ +export type AdReplayStepFailure = Awaited>; + +/** + * The selector-port instance `AdReplayStepRuntime` threads through classification and the engine's + * own pre-dispatch verification plan. Two adapters implement it: the production adapter + * (`replay-selector-port.ts`) and the in-memory adapter for the package's own contract suite + * (`src/__tests__/test-utils/in-memory-replay-selector-port.ts`). + */ +export type ReplaySelectorPort = AdReplayStepRuntime['port']; + +/** Which positional grammar a command's selector-bearing arguments follow (`readSelectorExpression`'s first parameter). */ +export type ReplaySelectorGrammar = Parameters[0]; + +/** `readSelectorExpression`'s tagged result. */ +export type ReplaySelectorExpressionOutcome = ReturnType< + ReplaySelectorPort['readSelectorExpression'] +>; + +/** `resolveRecordedTarget`'s resolution policy (platform, rect/disambiguation requirements). */ +export type ReplayRecordedTargetPolicy = Parameters[2]; + +/** `resolveRecordedTarget`'s tagged resolved/unresolved result. */ +export type ReplayRecordedTargetResolution = ReturnType< + ReplaySelectorPort['resolveRecordedTarget'] +>; + +/** Present on a resolved result only when the heuristic picked among N>1 matches for the winning alternative. */ +export type ReplayRecordedTargetDisambiguation = NonNullable< + Extract['disambiguation'] +>; + +/** `buildSelectorCandidates`'s optional options bag. */ +export type ReplaySelectorCandidateOptions = NonNullable< + Parameters[2] +>; diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 6668842daf..a6f5cdcd3a 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -25,7 +25,7 @@ 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 { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import type { SessionAction, SessionState } from '../types.ts'; diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index 106b3e4253..ce5e992ad1 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,5 +1,5 @@ import { uniqueStrings } from '@agent-device/kernel/collections'; -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import { isTouchTargetCommand } from '@agent-device/ad-script'; import type { ReplayReportAction } from './session-replay-report-action.ts'; diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index ba3e43a17a..cdd9d0ae62 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -8,7 +8,7 @@ import type { AdReplayStepFailure, AdReplayStepRuntime, ReplaySelectorPort, -} from '@agent-device/ad-replay'; +} from '../ad-replay-facade-types.ts'; import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 6a57ad4001..e75a62e4a1 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,4 +1,4 @@ -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; import { summarizeSnapshotTimingSamples, diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index d8edbad064..021ac7a75d 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -10,7 +10,8 @@ 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 } from '@agent-device/ad-replay'; +import { inspectAdReplay } from '@agent-device/ad-replay'; +import type { AdReplayManifest } from '../ad-replay-facade-types.ts'; import { buildReplayVarScope, collectReplayShellEnv, diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 58c691b107..bdc2fc29c7 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -3,7 +3,7 @@ import type { DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { errorResponse } from './response.ts'; import { createDaemonReplaySelectorPort } from '../replay-selector-port.ts'; -import { formatReplaySuccessMessage, runAdReplay } from '@agent-device/ad-replay'; +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'; @@ -226,6 +226,19 @@ function completeReplayRun(params: { * `@agent-device/ad-replay`'s step loop): it inspects `SessionState`, which * the engine never sees. */ +/** + * #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 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`; +} + function requireLiveSessionForKeepSession(params: { keepSession: boolean; sessionName: string; diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index 79b03384df..edb54c5aca 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -45,7 +45,7 @@ import { scrollRegionKeysEqual, orderByViewportPosition, } from '../session-target-evidence.ts'; -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import { annotationLocalIdentity, classifyTargetBindingMatch, diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/handlers/session-replay-target-token.ts index b754766066..e66a9b4533 100644 --- a/src/daemon/handlers/session-replay-target-token.ts +++ b/src/daemon/handlers/session-replay-target-token.ts @@ -1,5 +1,5 @@ import { isTouchTargetCommand } from '@agent-device/ad-script'; -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import type { SessionAction } from '../types.ts'; /** Returns the resolved-target token carried by an eligible replay action. */ diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index f9d93ae5b9..e1a9699cf0 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -12,7 +12,7 @@ import { type ReplayVarScope, } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; import { createReplayDivergenceSanitizer, type ReplayDivergence, diff --git a/src/daemon/replay-selector-port.ts b/src/daemon/replay-selector-port.ts index ac138f784d..b36d5ee0a5 100644 --- a/src/daemon/replay-selector-port.ts +++ b/src/daemon/replay-selector-port.ts @@ -6,7 +6,7 @@ import type { ReplaySelectorExpressionOutcome, ReplaySelectorGrammar, ReplaySelectorPort, -} from '@agent-device/ad-replay'; +} from './ad-replay-facade-types.ts'; import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; import { matchesSelector } from '../selectors/match.ts'; import { From 3e93f3106d8ac1b719fc38c5e982fd75c7d6ba39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 08:45:25 +0200 Subject: [PATCH 19/31] fix(ad-replay): translate wire failures before the engine boundary (#1555 review P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdReplayDispatchOutcome's guard-mismatch/landmark-mismatch variants carried a generic `details: Record | undefined` bag straight off the wire response — a daemon wire projection crossing into the engine even though the outcome itself was already a neutral type. The daemon adapter (session-replay-runtime-engine-adapter.ts) now narrows that bag into the typed AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence shapes (observed identity, expected/observed structural denotation, ancestry entries, match count) before returning the outcome; the unknown-parsing readers move there with the wire-reading responsibility they always were. target-verification.ts's deriveReplayTargetGuardMismatchEvidence/ deriveWaitLandmarkMismatchEvidence now consume only the typed values — no `unknown`-valued record type remains on any engine-crossing signature. --- .../src/internal/__tests__/step-loop.test.ts | 2 +- packages/ad-replay/src/internal/step-loop.ts | 31 ++-- .../src/internal/target-verification.ts | 132 +++++++++--------- .../session-replay-runtime-engine-adapter.ts | 98 ++++++++++++- 4 files changed, 185 insertions(+), 78 deletions(-) diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index 3c619fad46..d4c0a38582 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -172,7 +172,7 @@ test("a post-dispatch target-binding mismatch reports the pre-step artifact snap assert.deepEqual(artifactPaths, ['open-snapshot.png']); return { status: 'landmark-mismatch', - details: {}, + evidence: { matchCount: undefined, observed: undefined, observedAncestry: [] }, plainFailure: { kind: 'REPLAY_DIVERGENCE', message: 'mismatch', artifactPaths: [] }, artifactPaths: ['open-snapshot.png', 'post-dispatch-only.png'], }; diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 96a159cb71..87a77fbe89 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -11,6 +11,9 @@ import { deriveWaitLandmarkMismatchEvidence, planPostResolutionTargetVerification, planPreDispatchTargetVerification, + type AdReplayGuardMismatchEvidence, + type AdReplayLandmarkMismatchEvidence, + type AdReplayTargetStructuralDenotation, } from './target-verification.ts'; /** @@ -103,12 +106,6 @@ export type AdReplayProgressSink = (step: AdReplayProgressStep) => void; // daemon-request-shaped value. // --------------------------------------------------------------------------- -/** The verified member's structural position within its capture (document order + sibling). */ -export type AdReplayTargetStructuralDenotation = Readonly<{ - documentOrder: number; - sibling: number; -}>; - /** * The verified member's identity + structural denotation, threaded to * dispatch as its own pre-action guard (so dispatch's independent resolution @@ -186,13 +183,27 @@ export type AdReplayDispatchGuard = Readonly< * 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' | 'landmark-mismatch'; - readonly details: Record | undefined; + 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[]; } @@ -586,10 +597,10 @@ async function dispatchWithGuard( outcome.status === 'guard-mismatch' ? deriveReplayTargetGuardMismatchEvidence( recorded, - outcome.details, + outcome.evidence, guard.kind === 'target' ? guard.guard.matchCount : 0, ) - : deriveWaitLandmarkMismatchEvidence(recorded, outcome.details); + : deriveWaitLandmarkMismatchEvidence(recorded, outcome.evidence); return { status: 'failed', diff --git a/packages/ad-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts index 68958addb3..dd6e846d69 100644 --- a/packages/ad-replay/src/internal/target-verification.ts +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -24,10 +24,24 @@ * 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 raw (already - * neutral, `unknown`-typed) details bag, compute the observed identity and - * mismatch lines a target-binding divergence reports — the daemon then - * wraps the result into a `DaemonResponse`. + * 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'; @@ -39,6 +53,37 @@ import { } 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). // --------------------------------------------------------------------------- @@ -102,10 +147,11 @@ export function planPreDispatchTargetVerification(params: { // --------------------------------------------------------------------------- // Post-dispatch identity-mismatch evidence (the guard mismatch and the wait -// landmark mismatch): both refusal markers arrive as a failed dispatch -// response whose `details` carry the observed evidence; this derives the -// SAME bounded identity-mismatch shape around their marker-specific evidence -// the daemon used to compute inline. +// 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 = { @@ -115,75 +161,34 @@ export type ReplayPostDispatchMismatchEvidence = { causeMessage: string; }; -export 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. */ -export 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; -} - -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 }; -} - /** A `position:` mismatch line from the guard's structural denotations, when both are present and differ. */ export function describeStructuralMismatch( - expected: unknown, - observed: unknown, + expected: AdReplayTargetStructuralDenotation | undefined, + observed: AdReplayTargetStructuralDenotation | undefined, ): 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}`; + 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 `details`. + * own recorded-selector match count) — never re-derived from `evidence`. */ export function deriveReplayTargetGuardMismatchEvidence( recorded: TargetAnnotationV1, - details: Record | undefined, + evidence: AdReplayGuardMismatchEvidence, matchCount: number, ): ReplayPostDispatchMismatchEvidence { - const observed = readGuardMismatchObservedIdentity(details?.observed); + 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( - details?.expectedStructural, - details?.observedStructural, - ); + const structuralMismatch = describeStructuralMismatch(expectedStructural, observedStructural); return { matchCount, observed, @@ -202,12 +207,11 @@ export function deriveReplayTargetGuardMismatchEvidence( */ export function deriveWaitLandmarkMismatchEvidence( recorded: TargetAnnotationV1, - details: Record | undefined, + evidence: AdReplayLandmarkMismatchEvidence, ): ReplayPostDispatchMismatchEvidence { - const observed = readGuardMismatchObservedIdentity(details?.observed); - const observedAncestry = readAncestryEntries(details?.observedAncestry); + const { matchCount, observed, observedAncestry } = evidence; return { - matchCount: typeof details?.matchCount === 'number' ? details.matchCount : undefined, + matchCount, observed, mismatches: observed ? [ diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index cdd9d0ae62..62f639c0d6 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -9,8 +9,13 @@ import type { AdReplayStepRuntime, ReplaySelectorPort, } from '../ad-replay-facade-types.ts'; -import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; +import { + collectReplayScrubbableVarValues, + type LocalIdentity, + type ReplayVarScope, +} from '@agent-device/ad-script'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; import { @@ -90,6 +95,21 @@ type ReplayDispatchGuard = Parameters[3]; /** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ type ReplayDispatchOutcome = Awaited>; +/** + * The two post-resolution refusal markers' typed evidence shapes, read off + * `ReplayDispatchOutcome` itself — the SAME `Parameters<...>`/`Extract<...>` + * idiom as `ReplayDispatchGuard`/`EngineTargetBindingEvidence` above, rather + * than a named façade export. + */ +type ReplayGuardMismatchEvidence = Extract< + ReplayDispatchOutcome, + { status: 'guard-mismatch' } +>['evidence']; +type ReplayLandmarkMismatchEvidence = Extract< + ReplayDispatchOutcome, + { status: 'landmark-mismatch' } +>['evidence']; + /** 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. */ function applyReplayDispatchGuard( replayReq: DaemonRequest, @@ -106,6 +126,78 @@ function applyReplayDispatchGuard( : replayReq; } +/** + * #1555 review P1 (second pass, "translate wire failures before the engine + * boundary"): the wire response's `details: Record | undefined` + * bag is read HERE, at the adapter — 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 that used to live in the package (reading + * `details.observed`/`details.expectedStructural`/`details.observedStructural`/ + * `details.observedAncestry`/`details.matchCount` defensively) moved here + * with the wire-reading responsibility they always were. + */ +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, +): ReplayGuardMismatchEvidence { + return { + observed: readGuardMismatchObservedIdentity(details?.observed), + expectedStructural: readTargetStructuralDenotation(details?.expectedStructural), + observedStructural: readTargetStructuralDenotation(details?.observedStructural), + }; +} + +function readLandmarkMismatchEvidence( + details: Record | undefined, +): ReplayLandmarkMismatchEvidence { + return { + matchCount: typeof details?.matchCount === 'number' ? details.matchCount : undefined, + observed: readGuardMismatchObservedIdentity(details?.observed), + observedAncestry: readAncestryEntries(details?.observedAncestry), + }; +} + /** 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. */ function classifyReplayDispatchFailure( response: Extract, @@ -116,7 +208,7 @@ function classifyReplayDispatchFailure( if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { return { status: 'guard-mismatch', - details: response.error.details, + evidence: readGuardMismatchEvidence(response.error.details), plainFailure, artifactPaths: entries, }; @@ -124,7 +216,7 @@ function classifyReplayDispatchFailure( if (guard?.kind === 'landmark' && isWaitLandmarkMismatchResponse(response)) { return { status: 'landmark-mismatch', - details: response.error.details, + evidence: readLandmarkMismatchEvidence(response.error.details), plainFailure, artifactPaths: entries, }; From fe0ee9122ce77e41aa2a4d9a4aeb5bd39ea5b6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 08:59:18 +0200 Subject: [PATCH 20/31] fix(ad-replay): move variable semantics/planning behind runAdReplay (#1555 review P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon assembled the `${VAR}` scope (buildPreparedReplayScope) and interpolated actions at two independent call sites: dispatch's own (invokeReplayAction) and target verification's separate one (resolveTargetVerificationEntry) — duplicated orchestration the P5 design assigns to the engine. runAdReplay's request now carries the raw scope INPUTS (varSources: plain builtins/file/shell/cli-env data, plus actionLines/actionSourcePaths/ resolvedPath for interpolation-error location) instead of a built scope; the engine builds the scope and resolves each action exactly once per step, handing the RESOLVED action to dispatchStep/beginTargetVerification while every other capability still receives the ORIGINAL recorded action (a target-binding divergence reports the recorded selector, never an expanded ${VAR}). This is the one resolution site now — session-replay-action-runtime.ts's invokeReplayAction and session-replay-target-verification.ts's resolveTargetVerificationEntry no longer hold a scope or call resolveReplayAction themselves. Scrub-value collection (collectReplayScrubbableVarValues, for divergence-report redaction) is kept single-sourced in the engine too: it's computed from the engine's own live scope and threaded to each build-failure/handleActionFailure capability as an explicit scrubVars argument, rather than the daemon recomputing it from a second scope object (which would have gone stale, since expandedBuiltinNames tracking now only happens engine-side). The Maestro replay path's own daemon-side vars usage is unrelated (a different engine) and is out of scope here. --- .../src/internal/__tests__/step-loop.test.ts | 38 +++- packages/ad-replay/src/internal/step-loop.ts | 172 ++++++++++++++++-- src/daemon/ad-replay-facade-types.ts | 13 ++ .../session-replay-action-runtime.test.ts | 9 +- .../handlers/session-replay-action-runtime.ts | 34 ++-- .../session-replay-runtime-engine-adapter.ts | 61 ++++--- .../session-replay-runtime-failure.ts | 11 +- .../handlers/session-replay-runtime-plan.ts | 36 +++- src/daemon/handlers/session-replay-runtime.ts | 16 +- .../session-replay-target-verification.ts | 34 ++-- 10 files changed, 330 insertions(+), 94 deletions(-) diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index d4c0a38582..ab7475bce6 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -24,6 +24,27 @@ function action(command: string, overrides: Partial = {}): Sessio 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 @@ -47,7 +68,7 @@ function createFakeRuntime(params: { isRepairArmed?: () => boolean } = {}): { classifyTarget: () => { throw new Error('classifyTarget: not used by this fixture (no targetEvidence)'); }, - async dispatchStep(dispatchedAction, _index, artifactPaths) { + async dispatchStep(dispatchedAction, _resolvedAction, _index, artifactPaths) { dispatched.push(dispatchedAction.command); return { status: 'ok', artifactPaths }; }, @@ -80,7 +101,7 @@ test('--keep-session suppresses a close that is terminal among executable action // 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({ actions, entryIndex: 0, keepSession: true }, runtime); + 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); @@ -89,7 +110,7 @@ test('--keep-session suppresses a close that is terminal among executable action 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({ actions, entryIndex: 0, keepSession: false }, runtime); + 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); @@ -98,7 +119,7 @@ test('repair-armed suppresses the same terminal-among-executable close (unified 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({ actions, entryIndex: 0, keepSession: true }, runtime); + 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); @@ -107,7 +128,7 @@ test('an interior close is preserved instead of broad command filtering', async 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({ actions, entryIndex: 0, keepSession: false }, runtime); + 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); @@ -116,7 +137,7 @@ test('a terminal close dispatches normally when neither keepSession nor repair i 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({ actions, entryIndex: 0, keepSession: true }, runtime); + 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. @@ -162,7 +183,7 @@ test("a post-dispatch target-binding mismatch reports the pre-step artifact snap classifyTarget: () => { throw new Error('classifyTarget: not used — deferred-landmark skips straight to dispatch'); }, - async dispatchStep(dispatchedAction, _index, artifactPaths, _guard) { + async dispatchStep(dispatchedAction, _resolvedAction, _index, artifactPaths, _guard) { if (dispatchedAction.command === 'open') { return { status: 'ok', artifactPaths: ['open-snapshot.png'] }; } @@ -190,6 +211,7 @@ test("a post-dispatch target-binding mismatch reports the pre-step artifact snap _index, _evidence, artifactPaths, + _scrubVars, ) { receivedArtifactPaths = artifactPaths; return { kind: 'REPLAY_DIVERGENCE', message: 'mismatch', artifactPaths: [] }; @@ -207,7 +229,7 @@ test("a post-dispatch target-binding mismatch reports the pre-step artifact snap }; const outcome = await runAdReplay( - { actions: [openAction, waitAction], entryIndex: 0, keepSession: false }, + runRequest([openAction, waitAction], { keepSession: false }), runtime, ); diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 87a77fbe89..ef17874277 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -4,7 +4,13 @@ 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 { LocalIdentity } from '@agent-device/ad-script'; +import { + buildReplayVarScope, + collectReplayScrubbableVarValues, + resolveReplayAction, + type LocalIdentity, + type ReplayVarScope, +} from '@agent-device/ad-script'; import type { ReplaySelectorPort } from './selector-port.ts'; import { deriveReplayTargetGuardMismatchEvidence, @@ -67,8 +73,47 @@ import { * 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`), + * threaded to each build-failure/`handleActionFailure` capability as an + * explicit `scrubVars` argument rather than recomputed daemon-side from a + * second scope object — the daemon no longer holds a `ReplayVarScope` value + * at all. */ +/** + * `${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. + */ +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. */ @@ -230,9 +275,17 @@ export type AdReplayStepRuntime = Readonly<{ * 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. + * `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, index: number): AdReplayVerificationEntry; + 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 @@ -258,10 +311,14 @@ export type AdReplayStepRuntime = Readonly<{ * 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). + * 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, @@ -272,23 +329,28 @@ export type AdReplayStepRuntime = Readonly<{ * 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. + * 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 @@ -297,24 +359,27 @@ export type AdReplayStepRuntime = Readonly<{ * `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). + * 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; @@ -343,6 +408,26 @@ export type AdReplayRunRequest = Readonly<{ * below, 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`. */ @@ -386,6 +471,10 @@ export async function runAdReplay( 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); @@ -407,8 +496,14 @@ export async function runAdReplay( 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)); const sampleStart = runtime.diagnosticsMarker(); - const stepOutcome = await verifyAndDispatchStep(runtime, action, index, [...artifactPaths]); + const stepOutcome = await verifyAndDispatchStep(runtime, scope, action, resolvedAction, index, [ + ...artifactPaths, + ]); snapshotDiagnosticSamples.push(...runtime.diagnosticsSince(sampleStart)); if (stepOutcome.status === 'ok') { stepOutcome.artifactPaths.forEach((entry) => artifactPaths.add(entry)); @@ -420,6 +515,7 @@ export async function runAdReplay( index, artifactPaths: [...artifactPaths], snapshotDiagnosticSamples, + scrubVars: collectReplayScrubbableVarValues(scope), }); return { status: 'failed', stepIndex: index, failure }; } @@ -431,6 +527,17 @@ export async function runAdReplay( }; } +/** `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, + }; +} + /** * The verify-then-dispatch orchestrator: ADR 0012 step 4 verify + dispatch + * guard, ENGINE-side as of the #1555 review pass. Mirrors @@ -445,15 +552,19 @@ export async function runAdReplay( */ async function verifyAndDispatchStep( runtime: AdReplayStepRuntime, + scope: ReplayVarScope, action: SessionAction, + resolvedAction: SessionAction, index: number, artifactPaths: readonly string[], ): Promise { const recorded = action.targetEvidence; - if (!recorded) return dispatchNoGuard(runtime, action, index, artifactPaths); + if (!recorded) return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); - const entry = runtime.beginTargetVerification(action, index); - if (entry.kind === 'inactive') return dispatchNoGuard(runtime, action, 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 @@ -467,14 +578,19 @@ async function verifyAndDispatchStep( }); switch (plan.kind) { case 'skip': - return dispatchNoGuard(runtime, action, index, artifactPaths); + return dispatchNoGuard(runtime, action, resolvedAction, index, artifactPaths); case 'recorded-unverifiable': return { status: 'failed', - failure: await runtime.buildRecordedUnverifiableFailure(action, index, artifactPaths), + failure: await runtime.buildRecordedUnverifiableFailure( + action, + index, + artifactPaths, + collectReplayScrubbableVarValues(scope), + ), }; case 'deferred-landmark': - return dispatchWithGuard(runtime, action, index, artifactPaths, { + return dispatchWithGuard(runtime, scope, action, resolvedAction, index, artifactPaths, { kind: 'landmark', landmark: plan.landmark, }); @@ -488,12 +604,18 @@ async function verifyAndDispatchStep( platform: entry.platform, port: runtime.port, }); - if (preDispatchPlan.kind === 'skip') - return dispatchNoGuard(runtime, action, index, artifactPaths); + 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), + failure: await runtime.buildRecordedUnverifiableFailure( + action, + index, + artifactPaths, + collectReplayScrubbableVarValues(scope), + ), }; } const token = preDispatchPlan.token; @@ -519,13 +641,14 @@ async function verifyAndDispatchStep( ...(observation.hint !== undefined ? { causeHint: observation.hint } : {}), }, artifactPaths, + collectReplayScrubbableVarValues(scope), ), }; } const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); if (classification.verified) { - return dispatchWithGuard(runtime, action, index, artifactPaths, { + return dispatchWithGuard(runtime, scope, action, resolvedAction, index, artifactPaths, { kind: 'target', guard: classification.guard, }); @@ -545,6 +668,7 @@ async function verifyAndDispatchStep( causeMessage: classification.causeMessage, }, artifactPaths, + collectReplayScrubbableVarValues(scope), ), }; } @@ -553,10 +677,17 @@ async function verifyAndDispatchStep( async function dispatchNoGuard( runtime: AdReplayStepRuntime, action: SessionAction, + resolvedAction: SessionAction, index: number, artifactPaths: readonly string[], ): Promise { - const outcome = await runtime.dispatchStep(action, index, artifactPaths, undefined); + const outcome = await runtime.dispatchStep( + action, + resolvedAction, + index, + artifactPaths, + undefined, + ); switch (outcome.status) { case 'ok': return { status: 'ok', artifactPaths: outcome.artifactPaths }; @@ -579,12 +710,14 @@ async function dispatchNoGuard( */ async function dispatchWithGuard( runtime: AdReplayStepRuntime, + scope: ReplayVarScope, action: SessionAction, + resolvedAction: SessionAction, index: number, artifactPaths: readonly string[], guard: AdReplayDispatchGuard, ): Promise { - const outcome = await runtime.dispatchStep(action, index, artifactPaths, guard); + 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 }; @@ -617,6 +750,7 @@ async function dispatchWithGuard( causeMessage: evidence.causeMessage, }, artifactPaths, + collectReplayScrubbableVarValues(scope), ), }; } diff --git a/src/daemon/ad-replay-facade-types.ts b/src/daemon/ad-replay-facade-types.ts index d2572f19bc..0b358d02d4 100644 --- a/src/daemon/ad-replay-facade-types.ts +++ b/src/daemon/ad-replay-facade-types.ts @@ -15,6 +15,19 @@ import { inspectAdReplay, runAdReplay } from '@agent-device/ad-replay'; /** `inspectAdReplay`'s read-only `.ad` manifest — actions, header metadata, plan digest, and the `--from`/`--plan-digest` resume-index resolver. */ export type AdReplayManifest = ReturnType; +/** + * `runAdReplay`'s request shape — actions, entry index, `--keep-session`, + * per-action source location, and `${VAR}` scope inputs. The engine builds + * the scope and resolves every action itself from this (#1555 review P1, + * "move variable semantics/planning behind the replay entrypoint") — the + * daemon only ever assembles this plain request, never a scope or a + * resolved action, of its own. + */ +export type AdReplayRunRequest = Parameters[0]; + +/** `AdReplayRunRequest`'s `${VAR}` scope inputs — plain data (builtins/file/shell/cli env) the daemon reads from the request/process. */ +export type AdReplayVarSources = AdReplayRunRequest['varSources']; + /** `runAdReplay`'s injected capability bag — the daemon-implemented runtime the engine's step loop drives through. */ export type AdReplayStepRuntime = Parameters[1]; 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/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index b6e1cde5c2..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 '@agent-device/ad-script'; 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-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index 62f639c0d6..6416863f93 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -9,11 +9,7 @@ import type { AdReplayStepRuntime, ReplaySelectorPort, } from '../ad-replay-facade-types.ts'; -import { - collectReplayScrubbableVarValues, - type LocalIdentity, - type ReplayVarScope, -} from '@agent-device/ad-script'; +import type { LocalIdentity } from '@agent-device/ad-script'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; @@ -43,9 +39,14 @@ import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; * orchestration this adapter plugs into. */ -/** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */ +/** + * 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; this adapter never resolves an action + * or reads a scope value itself. + */ export type ReplayStepContext = { - scope: ReplayVarScope; replayReq: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -90,7 +91,7 @@ function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingF } /** The engine's pre-action identity guard, read off `AdReplayStepRuntime` itself (see `EngineTargetBindingEvidence` above for why `Parameters<...>` rather than a named façade export). */ -type ReplayDispatchGuard = Parameters[3]; +type ReplayDispatchGuard = Parameters[4]; /** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ type ReplayDispatchOutcome = Awaited>; @@ -263,11 +264,18 @@ export function createAdReplayStepRuntime(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. */ + /** + * 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). @@ -282,7 +290,7 @@ export function createAdReplayStepRuntime(params: { sessionStore: ctx.sessionStore, resumeStamper: ctx.coordinator.resumeStamper, responseLevel: ctx.responseLevel, - scrubVars: collectReplayScrubbableVarValues(ctx.scope), + scrubVars, planActions: ctx.actions, planDigest: ctx.planDigest, signal: ctx.signal, @@ -300,12 +308,10 @@ export function createAdReplayStepRuntime(params: { const runtime: AdReplayStepRuntime = { port: ctx.port, - beginTargetVerification(action, index) { + beginTargetVerification(action, resolvedAction, _index) { return resolveTargetVerificationEntry({ action, - scope: ctx.scope, - sourcePath: ctx.actionSourcePaths?.[index] ?? ctx.resolved, - sourceLine: ctx.actionLines[index] ?? 1, + resolvedAction, sessionName: ctx.sessionName, sessionStore: ctx.sessionStore, port: ctx.port, @@ -359,13 +365,13 @@ export function createAdReplayStepRuntime(params: { // `_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, index, _stepArtifactPaths, guard) { + 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, - scope: ctx.scope, + resolved: resolvedAction, filePath: ctx.resolved, line: sourceLine, sourcePath: ctx.actionSourcePaths?.[index], @@ -380,9 +386,9 @@ export function createAdReplayStepRuntime(params: { return classifyReplayDispatchFailure(response, guard, entries); }, - async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths) { + async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths, scrubVars) { const response = await buildRecordedUnverifiableFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths), + buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), { session: ctx.sessionStore.get(ctx.sessionName), sessionName: ctx.sessionName, @@ -394,23 +400,29 @@ export function createAdReplayStepRuntime(params: { return recordFailure(response); }, - async buildTargetBindingFailure(action, index, evidence, stepArtifactPaths) { + 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), + buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), toDaemonEvidence(evidence), observation, ); return recordFailure(response); }, - async buildPostDispatchTargetBindingFailure(action, index, evidence, stepArtifactPaths) { + async buildPostDispatchTargetBindingFailure( + action, + index, + evidence, + stepArtifactPaths, + scrubVars, + ) { const response = await buildPostDispatchTargetBindingFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths), + buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), toDaemonEvidence(evidence), { session: ctx.sessionStore.get(ctx.sessionName), @@ -428,6 +440,7 @@ export function createAdReplayStepRuntime(params: { index, artifactPaths: failureArtifactPaths, snapshotDiagnosticSamples, + scrubVars, }) { const failedResponse = asFailedReplayStepResponse(lastResponse); const finalResponse = await buildReplayActionFailure( @@ -438,6 +451,7 @@ export function createAdReplayStepRuntime(params: { failedResponse, [...failureArtifactPaths], [...snapshotDiagnosticSamples], + [...scrubVars], ); // `buildReplayActionFailure` is typed `Promise` (it // shares its return type with the ordinary success path elsewhere in @@ -491,6 +505,7 @@ async function buildReplayActionFailure( response: Extract, artifactPaths: string[], snapshotDiagnosticSamples: SnapshotTimingSample[], + scrubVars: TargetBindingDivergenceContext['scrubVars'], ): Promise { const heldResponse = (failure: DaemonResponse): DaemonResponse => ctx.coordinator.markSessionHeldIfArmed(failure); @@ -505,7 +520,7 @@ async function buildReplayActionFailure( sourceLine: ctx.actionLines[index] ?? 1, artifactPaths, snapshotDiagnosticSamples, - scope: ctx.scope, + scrubVars, req, sessionName: ctx.sessionName, sessionStore: ctx.sessionStore, diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index e75a62e4a1..7d44925911 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,5 +1,5 @@ import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; -import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; +import type { collectReplayScrubbableVarValues } from '@agent-device/ad-script'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, @@ -24,7 +24,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: ReturnType; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -50,7 +51,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: ReturnType; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -70,7 +72,7 @@ async function withReplayFailureContext(params: { sourceLine, artifactPaths = [], snapshotDiagnostics, - scope, + scrubVars, req, sessionName, sessionStore, @@ -82,7 +84,6 @@ async function withReplayFailureContext(params: { } = 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, diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index 021ac7a75d..31a079ebff 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -11,15 +11,13 @@ import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; import { errorResponse } from './response.ts'; import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; import { inspectAdReplay } from '@agent-device/ad-replay'; -import type { AdReplayManifest } from '../ad-replay-facade-types.ts'; +import type { AdReplayManifest, AdReplayVarSources } from '../ad-replay-facade-types.ts'; import { - buildReplayVarScope, collectReplayShellEnv, parseReplayCliEnvEntries, readReplayCliEnvEntries, readReplayShellEnvSource, type ReplayScriptMetadata, - type ReplayVarScope, } from '@agent-device/ad-script'; import { resolveReplayFormat } from '../../replay/format.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; @@ -96,7 +94,13 @@ export type PreparedReplayPlan = { planDigest: string; preEntrySession: SessionState | undefined; entryIndex: number; - scope: ReplayVarScope; + /** + * `${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; }; @@ -133,7 +137,13 @@ export function prepareReplayPlan(params: { planDigest, preEntrySession, entryIndex: entryIndexResult.value, - scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), + varSources: buildPreparedReplayVarSources({ + req, + replayReq, + sessionName, + resolved, + metadata, + }), actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, }, }; @@ -209,15 +219,23 @@ function applyReplayMetadata( return { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) }; } -function buildPreparedReplayScope(params: { +/** + * 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 buildPreparedReplayVarSources(params: { req: DaemonRequest; replayReq: DaemonRequest; sessionName: string; resolved: string; metadata: AdReplayManifest['metadata']; -}): ReplayVarScope { +}): AdReplayVarSources { const { req, replayReq, sessionName, resolved, metadata } = params; - return buildReplayVarScope({ + return { builtins: buildReplayBuiltinVars({ req: replayReq, sessionName, @@ -227,7 +245,7 @@ function buildPreparedReplayScope(params: { fileEnv: metadata.env, shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), cliEnv: parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), - }); + }; } /** diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index bdc2fc29c7..c055c25bde 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -94,7 +94,7 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi actionSourcePaths, planDigest, entryIndex, - scope, + varSources, actionTracePath, } = planPreparation.value; const sessionPreparation = prepareReplaySession({ @@ -107,7 +107,6 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi }); if (!sessionPreparation.ok) return sessionPreparation.response; const stepContext: ReplayStepContext = { - scope, replayReq, sessionName, sessionStore, @@ -131,7 +130,18 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi onStep, armSaveScript: sessionPreparation.armSaveScript, }); - const outcome = await runAdReplay({ actions, entryIndex, keepSession }, runtime); + 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 diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index e1a9699cf0..6a7ef15406 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -7,9 +7,7 @@ import { annotationLocalIdentity, collectReplayScrubbableVarValues, formatDivergenceActionLabel, - resolveReplayAction, type LocalIdentity, - type ReplayVarScope, } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; @@ -71,10 +69,12 @@ import { extractReplayTargetToken, readRefLabel } from './session-replay-target- // - `isReplayTargetGuardMismatchResponse` / `isWaitLandmarkMismatchResponse` // — post-dispatch refusal-marker detection for `dispatchStep`. // -// `session-replay-runtime.ts`'s `createAdReplayStepRuntime` is the thin -// adapter that wires these into the `AdReplayStepRuntime` object and supplies -// the per-request context (scope, resume stamper, artifact accumulator, -// side-map response holder) these functions need but do not own. +// `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. // --------------------------------------------------------------------------- /** @@ -348,24 +348,28 @@ export type TargetVerificationEntry = * 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; - scope: ReplayVarScope; - sourcePath: string; - sourceLine: number; + resolvedAction: SessionAction; sessionName: string; sessionStore: SessionStore; port: ReplaySelectorPort; }): TargetVerificationEntry { - const { action, scope, sourcePath, sourceLine, sessionName, sessionStore, port } = params; + const { action, resolvedAction, sessionName, sessionStore, port } = params; const session = sessionStore.get(sessionName); if (!session) return { kind: 'inactive' }; - // Resolved ONLY to extract the match token 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). - const resolvedAction = resolveReplayAction(action, scope, { file: sourcePath, line: sourceLine }); if (resolveTargetIdentityVerification(action.command) === 'post-resolution') { const parsed = parseWaitPositionals(resolvedAction.positionals ?? []); return { kind: 'post-resolution', isSelectorWait: parsed?.kind === 'selector' }; From b1983f5e17afb90ed14be44beb54fcaf3c05882f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 09:30:25 +0200 Subject: [PATCH 21/31] fix(ad-script): make ${VAR} interpolation a linear scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the interpolation regex's fallback group as js/polynomial-redos once vars.ts moved into packages/ (library-input classification): every ${NAME:- prefix of an unclosed input rescanned to end-of-string, quadratic overall — 1,857 ms measured on 20k repetitions of '${A:-['. Replaced with a single-pass scanner; failed fallback scans emit their span verbatim and resume after it (escape-pair alignment is identical from every candidate start inside the span, so no later candidate can terminate where the failed scan could not). Equivalence: 200k-trial differential fuzz against the retired regex over the adversarial alphabet, zero mismatches; both adversarial shapes now resolve in 1-2 ms. --- .../src/internal/__tests__/vars.test.ts | 27 ++++ packages/ad-script/src/internal/vars.ts | 120 ++++++++++++++---- 2 files changed, 123 insertions(+), 24 deletions(-) diff --git a/packages/ad-script/src/internal/__tests__/vars.test.ts b/packages/ad-script/src/internal/__tests__/vars.test.ts index 2cde1d78e3..88226e1f4c 100644 --- a/packages/ad-script/src/internal/__tests__/vars.test.ts +++ b/packages/ad-script/src/internal/__tests__/vars.test.ts @@ -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/vars.ts b/packages/ad-script/src/internal/vars.ts index 2640f29e11..5c97c315dd 100644 --- a/packages/ad-script/src/internal/vars.ts +++ b/packages/ad-script/src/internal/vars.ts @@ -19,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_'; @@ -114,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( From e4c479893a2964b86e5caa31122826f262eacaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:15:23 +0200 Subject: [PATCH 22/31] =?UTF-8?q?refactor(ad-replay):=20typed=20fa=C3=A7ad?= =?UTF-8?q?e=20replaces=20the=20zero-type=20rule=20(#1555=20structural-qua?= =?UTF-8?q?lity=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the exact-two-value zero-type export shape #1555's second review pass established: it forced every root type derivation through one shim (src/daemon/ad-replay-facade-types.ts) and left four daemon-side twin types (TargetVerificationEntry, TargetClassificationOutcome, TargetBindingFailureEvidence, ReplayVerifiedTargetGuard) plus a toDaemonEvidence copy translator shadowing the engine's own shapes. packages/ad-replay/src/index.ts now exports inspectAdReplay/runAdReplay (unchanged, still the only two values) plus the neutral vocabulary their signatures are built from, by name — following packages/maestro's façade precedent. The exact-symbol gate in scripts/layering/package-boundaries.test.ts is widened to pin the full sorted list (values + types). The four daemon twins are deleted; session-replay-target-verification.ts and session-replay-runtime-engine-adapter.ts now use the engine's own AdReplayVerificationEntry/AdReplayTargetClassification/ AdReplayTargetBindingEvidence/AdReplayVerifiedTargetGuard directly. TargetBindingDivergenceBuilt's array fields are now readonly-compatible, so toDaemonEvidence's copy is gone — evidence flows through unchanged. --- packages/ad-replay/src/index.ts | 125 ++++++++++++------ packages/ad-replay/src/internal/step-loop.ts | 2 +- scripts/layering/package-boundaries.test.ts | 53 ++++++-- .../in-memory-replay-selector-port.ts | 12 +- .../replay-selector-port-contract.test.ts | 2 +- src/daemon/ad-replay-facade-types.ts | 69 ---------- .../handlers/session-replay-divergence.ts | 2 +- src/daemon/handlers/session-replay-heal.ts | 2 +- .../session-replay-runtime-engine-adapter.ts | 75 ++++------- .../session-replay-runtime-failure.ts | 2 +- .../handlers/session-replay-runtime-plan.ts | 7 +- .../session-replay-target-classification.ts | 2 +- .../handlers/session-replay-target-token.ts | 2 +- .../session-replay-target-verification.ts | 109 +++++++-------- src/daemon/replay-selector-port.ts | 2 +- 15 files changed, 222 insertions(+), 244 deletions(-) delete mode 100644 src/daemon/ad-replay-facade-types.ts diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 97947ca1e2..96a8074da0 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -5,25 +5,32 @@ * 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"; every type export dropped and `formatReplaySuccessMessage` - * moved daemon-side by the #1555 review's second pass, "enforce the accepted - * two-entrypoint facade"). `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. + * 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 `inspectAdReplay` + - * `runAdReplay` and NOTHING else — no types, no third value. Every type this - * package's signatures reference is available to a root consumer by deriving - * it structurally off these two functions (`Parameters<...>`, - * `ReturnType<...>`, `Awaited<...>`) — `src/daemon/ad-replay-facade-types.ts` - * is the one root module that does this derivation, so it happens exactly - * once; every other root file imports the derived names from there instead - * of re-deriving them or reaching for a named façade export. Presentation - * (`formatReplaySuccessMessage`) is not engine policy either, so it moved to - * sit beside its one caller (`completeReplayRun`, - * `src/daemon/handlers/session-replay-runtime.ts`). + * 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`/ @@ -32,29 +39,37 @@ * 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. + * 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` (derived, not - * exported) is the runtime capability bag the daemon adapter + * `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 (`AdReplayStepRuntime['port']`) - * every daemon call site that threads a port value names by the SAME derived - * 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). + * 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). * - * `./target-verification.ts`'s four policy functions + * `./internal/target-verification.ts`'s four policy functions * (`planPostResolutionTargetVerification`, `planPreDispatchTargetVerification`, * `deriveReplayTargetGuardMismatchEvidence`, `deriveWaitLandmarkMismatchEvidence`) - * are called only from `./internal/step-loop.ts`'s `verifyAndDispatchStep` — - * the engine's own verify-then-dispatch orchestration, which drives the - * daemon-owned pieces (capture, classification, dispatch, wire-building) - * through the narrow `AdReplayStepRuntime` capabilities instead of the - * daemon calling the policy functions directly — so nothing from that module - * is exported here. + * 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 @@ -62,12 +77,42 @@ * `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 are threaded the same - * direction, as an explicit argument on each build*Failure/handleActionFailure - * capability, computed from the engine's own live scope — never recomputed - * daemon-side from a second scope object. + * `${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/step-loop.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/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index ef17874277..2c2467afa0 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -103,7 +103,7 @@ import { * (`@agent-device/ad-script` does not export its own `ReplayVarSources` type * by name) rather than duplicating the shape. */ -type AdReplayVarSources = Parameters[0]; +export type AdReplayVarSources = Parameters[0]; /** * A `${VAR}` value eligible for divergence-report redaction — the engine's diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 5ec58ef080..932833636e 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -293,25 +293,50 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/kernel', ]); // #1555 review P1 ("add the reviewer-required exact exported-symbol - // gate"; second pass, "enforce the accepted two-entrypoint facade"): the - // exports-subpath assertion above only proves the package exposes one `.` - // entry point — it says nothing about what that entry point actually + // 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 to the binding design's two entrypoints, `inspectAdReplay` and - // `runAdReplay`, and NOTHING else — no type export, no third value. - // `formatReplaySuccessMessage` (presentation) and every type the two - // entrypoints' signatures reference (`AdReplayManifest`, - // `AdReplayStepRuntime`, the `ReplaySelectorPort` family, …) are gone from - // this list on purpose: root consumers derive them structurally instead - // (`src/daemon/ad-replay-facade-types.ts`). 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. + // 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'), ), - ['inspectAdReplay', 'runAdReplay'], + [ + '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', diff --git a/src/__tests__/test-utils/in-memory-replay-selector-port.ts b/src/__tests__/test-utils/in-memory-replay-selector-port.ts index b3a528cf7a..bd66b1cad5 100644 --- a/src/__tests__/test-utils/in-memory-replay-selector-port.ts +++ b/src/__tests__/test-utils/in-memory-replay-selector-port.ts @@ -7,7 +7,7 @@ import type { ReplaySelectorExpressionOutcome, ReplaySelectorGrammar, ReplaySelectorPort, -} from '../../daemon/ad-replay-facade-types.ts'; +} from '@agent-device/ad-replay'; /** * #1478 P5 stage B: a deterministic, dependency-free `ReplaySelectorPort` @@ -33,12 +33,10 @@ import type { * alongside its only caller, following the same `src/__tests__/test-utils/` * convention as `store-factory.ts` and `session-factories.ts`. * - * #1555 review P1 (second pass, "enforce the accepted two-entrypoint - * facade"): the package façade no longer exports any type at all — the port - * family above is derived off `runAdReplay` in - * `src/daemon/ad-replay-facade-types.ts` (the one root module that does - * this), which this file imports from instead of `@agent-device/ad-replay` - * directly. + * #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). diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts index 12ce98f7a3..86b07aab37 100644 --- a/src/daemon/__tests__/replay-selector-port-contract.test.ts +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -22,7 +22,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'vitest'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; +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'; diff --git a/src/daemon/ad-replay-facade-types.ts b/src/daemon/ad-replay-facade-types.ts deleted file mode 100644 index 0b358d02d4..0000000000 --- a/src/daemon/ad-replay-facade-types.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { inspectAdReplay, runAdReplay } from '@agent-device/ad-replay'; - -/** - * #1555 review P1 ("enforce the accepted two-entrypoint facade"): `@agent-device/ad-replay` - * exports exactly two value symbols — `inspectAdReplay` and `runAdReplay` — and no types at all - * (`packages/ad-replay/src/index.ts`'s header has the full design; `scripts/layering/package-boundaries.test.ts`'s - * exact-symbol pin is the gate that enforces it). Every type a root daemon file used to import - * directly off the façade is derived here instead — `Parameters<...>`/`ReturnType<...>`/ - * `Awaited<...>` off those two functions, exactly the idiom `session-replay-runtime-engine-adapter.ts` - * already used for `ReplayDispatchGuard`/`ReplayDispatchOutcome`/`EngineTargetBindingEvidence` — in - * exactly ONE place, so the derivation is never duplicated per call site. Every other root consumer - * imports the names below instead of re-deriving them. - */ - -/** `inspectAdReplay`'s read-only `.ad` manifest — actions, header metadata, plan digest, and the `--from`/`--plan-digest` resume-index resolver. */ -export type AdReplayManifest = ReturnType; - -/** - * `runAdReplay`'s request shape — actions, entry index, `--keep-session`, - * per-action source location, and `${VAR}` scope inputs. The engine builds - * the scope and resolves every action itself from this (#1555 review P1, - * "move variable semantics/planning behind the replay entrypoint") — the - * daemon only ever assembles this plain request, never a scope or a - * resolved action, of its own. - */ -export type AdReplayRunRequest = Parameters[0]; - -/** `AdReplayRunRequest`'s `${VAR}` scope inputs — plain data (builtins/file/shell/cli env) the daemon reads from the request/process. */ -export type AdReplayVarSources = AdReplayRunRequest['varSources']; - -/** `runAdReplay`'s injected capability bag — the daemon-implemented runtime the engine's step loop drives through. */ -export type AdReplayStepRuntime = Parameters[1]; - -/** A step's neutral failure shape — the resolved type any `AdReplayStepRuntime` build-failure/handle-failure capability returns. */ -export type AdReplayStepFailure = Awaited>; - -/** - * The selector-port instance `AdReplayStepRuntime` threads through classification and the engine's - * own pre-dispatch verification plan. Two adapters implement it: the production adapter - * (`replay-selector-port.ts`) and the in-memory adapter for the package's own contract suite - * (`src/__tests__/test-utils/in-memory-replay-selector-port.ts`). - */ -export type ReplaySelectorPort = AdReplayStepRuntime['port']; - -/** Which positional grammar a command's selector-bearing arguments follow (`readSelectorExpression`'s first parameter). */ -export type ReplaySelectorGrammar = Parameters[0]; - -/** `readSelectorExpression`'s tagged result. */ -export type ReplaySelectorExpressionOutcome = ReturnType< - ReplaySelectorPort['readSelectorExpression'] ->; - -/** `resolveRecordedTarget`'s resolution policy (platform, rect/disambiguation requirements). */ -export type ReplayRecordedTargetPolicy = Parameters[2]; - -/** `resolveRecordedTarget`'s tagged resolved/unresolved result. */ -export type ReplayRecordedTargetResolution = ReturnType< - ReplaySelectorPort['resolveRecordedTarget'] ->; - -/** Present on a resolved result only when the heuristic picked among N>1 matches for the winning alternative. */ -export type ReplayRecordedTargetDisambiguation = NonNullable< - Extract['disambiguation'] ->; - -/** `buildSelectorCandidates`'s optional options bag. */ -export type ReplaySelectorCandidateOptions = NonNullable< - Parameters[2] ->; diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index a6f5cdcd3a..6668842daf 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -25,7 +25,7 @@ import { type InternalObservationEvidence, } from '../internal-observation.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.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'; diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index ce5e992ad1..106b3e4253 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,5 +1,5 @@ import { uniqueStrings } from '@agent-device/kernel/collections'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.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'; diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index 6416863f93..d4141c1c0d 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -5,10 +5,14 @@ import { invokeReplayAction } from './session-replay-action-runtime.ts'; import { readReplaySelectorDisplayValue } from '../replay-selector-port.ts'; import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { + AdReplayDispatchGuard, + AdReplayDispatchOutcome, + AdReplayGuardMismatchEvidence, + AdReplayLandmarkMismatchEvidence, AdReplayStepFailure, AdReplayStepRuntime, ReplaySelectorPort, -} from '../ad-replay-facade-types.ts'; +} from '@agent-device/ad-replay'; import type { LocalIdentity } from '@agent-device/ad-script'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; @@ -27,7 +31,6 @@ import { isWaitLandmarkMismatchResponse, resolveTargetVerificationEntry, type TargetBindingDivergenceContext, - type TargetBindingFailureEvidence, } from './session-replay-target-verification.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; @@ -67,54 +70,24 @@ export type ReplayStepContext = { }; /** - * The engine's evidence-bag type for `buildTargetBindingFailure`/ - * `buildPostDispatchTargetBindingFailure`, read off `AdReplayStepRuntime` - * itself (`Parameters<...>`) rather than a named façade export — the R3 pass - * deliberately did not add `AdReplayTargetBindingEvidence` to - * `@agent-device/ad-replay`'s export list, so this is how a daemon helper - * still gets a precise parameter type without widening the façade. + * #1555 structural-quality review ("typed façade replaces the zero-type + * rule"): this module used to read the engine's evidence/guard/outcome + * shapes off `AdReplayStepRuntime` by hand (`Parameters<...>`/ + * `ReturnType<...>`/`Extract<...>`) because the façade exported no types at + * all — including a `toDaemonEvidence` copy translator between the engine's + * readonly-array evidence and this module's own mutable-array twin + * (`TargetBindingFailureEvidence`, since deleted from + * `session-replay-target-verification.ts`). `@agent-device/ad-replay` now + * exports `AdReplayDispatchGuard`/`AdReplayDispatchOutcome`/ + * `AdReplayTargetBindingEvidence`/`AdReplayGuardMismatchEvidence`/ + * `AdReplayLandmarkMismatchEvidence` directly, so every call site below uses + * the engine's own value with no copy. */ -type EngineTargetBindingEvidence = Parameters[2]; - -/** Converts the engine's (readonly-array) evidence shape to this module's own mutable-array `TargetBindingFailureEvidence`. */ -function toDaemonEvidence(evidence: EngineTargetBindingEvidence): TargetBindingFailureEvidence { - return { - 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 } : {}), - }; -} - -/** The engine's pre-action identity guard, read off `AdReplayStepRuntime` itself (see `EngineTargetBindingEvidence` above for why `Parameters<...>` rather than a named façade export). */ -type ReplayDispatchGuard = Parameters[4]; - -/** `dispatchStep`'s result shape, read off `AdReplayStepRuntime` itself for the same reason. */ -type ReplayDispatchOutcome = Awaited>; - -/** - * The two post-resolution refusal markers' typed evidence shapes, read off - * `ReplayDispatchOutcome` itself — the SAME `Parameters<...>`/`Extract<...>` - * idiom as `ReplayDispatchGuard`/`EngineTargetBindingEvidence` above, rather - * than a named façade export. - */ -type ReplayGuardMismatchEvidence = Extract< - ReplayDispatchOutcome, - { status: 'guard-mismatch' } ->['evidence']; -type ReplayLandmarkMismatchEvidence = Extract< - ReplayDispatchOutcome, - { status: 'landmark-mismatch' } ->['evidence']; /** 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. */ function applyReplayDispatchGuard( replayReq: DaemonRequest, - guard: ReplayDispatchGuard, + guard: AdReplayDispatchGuard | undefined, ): DaemonRequest { const guardInternal = guard?.kind === 'target' @@ -181,7 +154,7 @@ function readTargetStructuralDenotation( function readGuardMismatchEvidence( details: Record | undefined, -): ReplayGuardMismatchEvidence { +): AdReplayGuardMismatchEvidence { return { observed: readGuardMismatchObservedIdentity(details?.observed), expectedStructural: readTargetStructuralDenotation(details?.expectedStructural), @@ -191,7 +164,7 @@ function readGuardMismatchEvidence( function readLandmarkMismatchEvidence( details: Record | undefined, -): ReplayLandmarkMismatchEvidence { +): AdReplayLandmarkMismatchEvidence { return { matchCount: typeof details?.matchCount === 'number' ? details.matchCount : undefined, observed: readGuardMismatchObservedIdentity(details?.observed), @@ -202,9 +175,9 @@ function readLandmarkMismatchEvidence( /** 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. */ function classifyReplayDispatchFailure( response: Extract, - guard: ReplayDispatchGuard, + guard: AdReplayDispatchGuard | undefined, entries: readonly string[], -): ReplayDispatchOutcome { +): AdReplayDispatchOutcome { const plainFailure = toAdReplayStepFailure(response, entries); if (guard?.kind === 'target' && isReplayTargetGuardMismatchResponse(response)) { return { @@ -408,7 +381,7 @@ export function createAdReplayStepRuntime(params: { }; const response = buildTargetBindingFailureResponse( buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), - toDaemonEvidence(evidence), + evidence, observation, ); return recordFailure(response); @@ -423,7 +396,7 @@ export function createAdReplayStepRuntime(params: { ) { const response = await buildPostDispatchTargetBindingFailureResponse( buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), - toDaemonEvidence(evidence), + evidence, { session: ctx.sessionStore.get(ctx.sessionName), sessionName: ctx.sessionName, diff --git a/src/daemon/handlers/session-replay-runtime-failure.ts b/src/daemon/handlers/session-replay-runtime-failure.ts index 7d44925911..e40b0c2edc 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,4 +1,4 @@ -import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import type { collectReplayScrubbableVarValues } from '@agent-device/ad-script'; import { summarizeSnapshotTimingSamples, diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index 31a079ebff..e8751c9e15 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -10,8 +10,11 @@ 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 } from '@agent-device/ad-replay'; -import type { AdReplayManifest, AdReplayVarSources } from '../ad-replay-facade-types.ts'; +import { + inspectAdReplay, + type AdReplayManifest, + type AdReplayVarSources, +} from '@agent-device/ad-replay'; import { collectReplayShellEnv, parseReplayCliEnvEntries, diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index edb54c5aca..79b03384df 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -45,7 +45,7 @@ import { scrollRegionKeysEqual, orderByViewportPosition, } from '../session-target-evidence.ts'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; +import type { ReplaySelectorPort } from '@agent-device/ad-replay'; import { annotationLocalIdentity, classifyTargetBindingMatch, diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/handlers/session-replay-target-token.ts index e66a9b4533..b754766066 100644 --- a/src/daemon/handlers/session-replay-target-token.ts +++ b/src/daemon/handlers/session-replay-target-token.ts @@ -1,5 +1,5 @@ import { isTouchTargetCommand } from '@agent-device/ad-script'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.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. */ diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index 6a7ef15406..d13642aa9a 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -10,7 +10,12 @@ import { type LocalIdentity, } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import type { ReplaySelectorPort } from '../ad-replay-facade-types.ts'; +import type { + AdReplayTargetBindingEvidence, + AdReplayTargetClassification, + AdReplayVerificationEntry, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; import { createReplayDivergenceSanitizer, type ReplayDivergence, @@ -22,7 +27,6 @@ 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'; @@ -53,11 +57,16 @@ import { extractReplayTargetToken, readRefLabel } from './session-replay-target- // verify-then-dispatch DECISION flow (the four `@agent-device/ad-replay` // policy functions, `planPostResolutionTargetVerification` / // `planPreDispatchTargetVerification` / `deriveReplayTargetGuardMismatchEvidence` -// / `deriveWaitLandmarkMismatchEvidence`) now lives entirely inside the -// engine's step loop (`packages/ad-replay/src/internal/step-loop.ts`, -// `verifyAndDispatchStep`). This module never imports those functions or the -// package's `@agent-device/ad-replay` decision types — it implements only the -// narrow `AdReplayStepRuntime` capabilities the engine loop drives: +// / `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`. @@ -78,18 +87,18 @@ import { extractReplayTargetToken, readRefLabel } from './session-replay-target- // --------------------------------------------------------------------------- /** - * 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 TargetBindingDivergenceContext = { recorded: TargetAnnotationV1; @@ -115,8 +124,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; @@ -216,22 +225,23 @@ function buildTargetBindingDivergenceResponse( }); } -/** The evidence bag every target-binding failure builder wraps into a wire divergence. */ -export type TargetBindingFailureEvidence = { - kind: ReplayDivergenceTargetBindingKind; - matchCount: number | undefined; - observed: LocalIdentity | undefined; - candidateNodes: SnapshotNode[]; - mismatches: string[]; - causeCode: string; - causeMessage: string; - causeHint?: string; -}; +/** + * #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: TargetBindingFailureEvidence, + evidence: AdReplayTargetBindingEvidence, observation: DivergenceObservation, ): DaemonResponse { const sanitize = createReplayDivergenceSanitizer(context.scrubVars); @@ -308,7 +318,7 @@ export async function buildRecordedUnverifiableFailureResponse( */ export async function buildPostDispatchTargetBindingFailureResponse( context: TargetBindingDivergenceContext, - evidence: TargetBindingFailureEvidence, + evidence: AdReplayTargetBindingEvidence, params: { session: SessionState | undefined; sessionName: string; @@ -335,13 +345,13 @@ function publicationEvidenceFrom( // 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`. // --------------------------------------------------------------------------- -export type TargetVerificationEntry = - | { kind: 'inactive' } - | { kind: 'post-resolution'; isSelectorWait: boolean } - | { kind: 'pre-dispatch'; token: string | undefined; platform: Platform | PublicPlatform }; - /** * #1349 post-resolution phase (`wait`): NEVER the generic pre-dispatch * resolution — an absent landmark is a wait's expected starting condition. @@ -366,7 +376,7 @@ export function resolveTargetVerificationEntry(params: { sessionName: string; sessionStore: SessionStore; port: ReplaySelectorPort; -}): TargetVerificationEntry { +}): AdReplayVerificationEntry { const { action, resolvedAction, sessionName, sessionStore, port } = params; const session = sessionStore.get(sessionName); if (!session) return { kind: 'inactive' }; @@ -388,21 +398,14 @@ export function resolveTargetVerificationEntry(params: { // --------------------------------------------------------------------------- // `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 type TargetClassificationOutcome = - | { verified: true; guard: ReplayVerifiedTargetGuard } - | { - verified: false; - kind: ReplayDivergenceTargetBindingKind; - matchCount: number | undefined; - observed: LocalIdentity | undefined; - candidateNodes: SnapshotNode[]; - mismatches: string[]; - causeCode: string; - causeMessage: string; - }; - export function classifyPreDispatchTarget(params: { recorded: TargetAnnotationV1; token: string; @@ -410,7 +413,7 @@ export function classifyPreDispatchTarget(params: { nodes: SnapshotNode[]; platform: Platform | PublicPlatform; port: ReplaySelectorPort; -}): TargetClassificationOutcome { +}): AdReplayTargetClassification { const { recorded, token, action, nodes, platform, port } = params; const config = resolveSuggestionMatchingConfig(action); const classification = classifyReplayTarget({ diff --git a/src/daemon/replay-selector-port.ts b/src/daemon/replay-selector-port.ts index b36d5ee0a5..ac138f784d 100644 --- a/src/daemon/replay-selector-port.ts +++ b/src/daemon/replay-selector-port.ts @@ -6,7 +6,7 @@ import type { ReplaySelectorExpressionOutcome, ReplaySelectorGrammar, ReplaySelectorPort, -} from './ad-replay-facade-types.ts'; +} from '@agent-device/ad-replay'; import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; import { matchesSelector } from '../selectors/match.ts'; import { From 23bb3207d79a32b04116fba110ad55bf46b5a8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:18:18 +0200 Subject: [PATCH 23/31] fix(ad-replay): honor the selector port's own contract in the parse gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit target-verification.ts's planPreDispatchTargetVerification used resolveRecordedTarget (operation 2, resolve) over an empty node tree purely to read its parse-invalid reason — a resolve call standing in for a parse call, even though readSelectorExpression (operation 1, parse) exists to answer exactly that question and was already unused inside the engine. Replaced with port.readSelectorExpression('ordinary', [token]). The mapping is not 'invalid' -> skip: production's 'ordinary'/'wait' grammars only ever record a boundary once it has already parsed, so a single malformed token can only come back 'not-applicable' there ('invalid' is unreachable from this call site on the production adapter). Both non-'expression' outcomes map to skip, matching the historical behavior (a single parse-invalid reason covered both cases). platform dropped from the function's params — it was only ever threaded to the resolve call this replaces. Added a contract-suite cell pinning the exact (diverging) discriminant each adapter reports for a selector-shaped-but-malformed bare token, and why the divergence is harmless for the one real consumer. --- packages/ad-replay/src/internal/step-loop.ts | 1 - .../src/internal/target-verification.ts | 41 ++++++++++++------ .../replay-selector-port-contract.test.ts | 42 +++++++++++++++++++ 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 2c2467afa0..a6a031d452 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -601,7 +601,6 @@ async function verifyAndDispatchStep( const preDispatchPlan = planPreDispatchTargetVerification({ recorded, token: entry.token, - platform: entry.platform, port: runtime.port, }); if (preDispatchPlan.kind === 'skip') { diff --git a/packages/ad-replay/src/internal/target-verification.ts b/packages/ad-replay/src/internal/target-verification.ts index dd6e846d69..bb19764fe5 100644 --- a/packages/ad-replay/src/internal/target-verification.ts +++ b/packages/ad-replay/src/internal/target-verification.ts @@ -45,7 +45,6 @@ */ import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import { firstAncestryMismatch, identityFieldMismatches, @@ -118,26 +117,42 @@ export type ReplayPreDispatchVerificationPlan = /** * 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 — - * `resolveRecordedTarget`'s own parse gate over empty `nodes` is the same - * `tryParseSelectorChain` check this used to run directly); only past both - * of those does a recorded-`unverifiable` annotation refuse pre-action. + * 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; - platform: Platform | PublicPlatform; port: ReplaySelectorPort; }): ReplayPreDispatchVerificationPlan { - const { recorded, token, platform, port } = params; + const { recorded, token, port } = params; if (token === undefined) return { kind: 'skip' }; if (!token.startsWith('@')) { - const parseCheck = port.resolveRecordedTarget(token, [], { - platform, - requireRect: false, - allowDisambiguation: false, - }); - if (parseCheck.kind === 'unresolved' && parseCheck.reason === 'parse-invalid') { + const parseCheck = port.readSelectorExpression('ordinary', [token]); + if (parseCheck.kind !== 'expression') { return { kind: 'skip' }; } } diff --git a/src/daemon/__tests__/replay-selector-port-contract.test.ts b/src/daemon/__tests__/replay-selector-port-contract.test.ts index 86b07aab37..571094bbab 100644 --- a/src/daemon/__tests__/replay-selector-port-contract.test.ts +++ b/src/daemon/__tests__/replay-selector-port-contract.test.ts @@ -307,5 +307,47 @@ for (const [name, createPort] of ADAPTERS) { 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'); + }); }); } From 6dba5a51eca1d97b8720111396e7fb874c0b3ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:27:14 +0200 Subject: [PATCH 24/31] refactor(ad-replay): split step-loop.ts and shrink the daemon adapter (#1555 structural-quality review) step-loop.ts (810 LOC) splits three ways, following packages/maestro's own precedent: - internal/runtime-port-types.ts: the AdReplayStepRuntime boundary vocabulary (all the neutral types the engine/daemon exchange). - internal/verify-dispatch.ts: verifyAndDispatchStep + its dispatchNoGuard/ dispatchWithGuard helpers. - internal/step-loop.ts: runAdReplay itself plus the terminal-close/ executable-action structural logic (isExecutableReplayAction, resolveSuppressedTerminalCloseIndex). packages/ad-replay/src/index.ts's type exports now source from runtime-port-types.ts. step-loop.test.ts's AdReplayStepRuntime import moves to the new path (no assertion changes). src/daemon/handlers/session-replay-runtime-engine-adapter.ts (553 LOC after item 1's twin removal) shrinks to 294 via two further extractions: - session-replay-dispatch-narrowing.ts: the wire `details` bag -> typed evidence narrowing and dispatch-failure classification. - session-replay-runtime-step-support.ts: ReplayStepContext (moved here to avoid a cycle with the adapter, which re-exports it by name) plus the failure-wrapping/diagnostics-support helpers. Final LOC: adapter 294, dispatch-narrowing 148, step-support 153, step-loop 225, verify-dispatch 246, runtime-port-types 374. --- packages/ad-replay/src/index.ts | 2 +- .../src/internal/__tests__/step-loop.test.ts | 3 +- .../src/internal/runtime-port-types.ts | 374 ++++++++++ packages/ad-replay/src/internal/step-loop.ts | 647 +----------------- .../ad-replay/src/internal/verify-dispatch.ts | 246 +++++++ .../session-replay-dispatch-narrowing.ts | 148 ++++ .../session-replay-runtime-engine-adapter.ts | 311 +-------- .../session-replay-runtime-step-support.ts | 153 +++++ 8 files changed, 981 insertions(+), 903 deletions(-) create mode 100644 packages/ad-replay/src/internal/runtime-port-types.ts create mode 100644 packages/ad-replay/src/internal/verify-dispatch.ts create mode 100644 src/daemon/handlers/session-replay-dispatch-narrowing.ts create mode 100644 src/daemon/handlers/session-replay-runtime-step-support.ts diff --git a/packages/ad-replay/src/index.ts b/packages/ad-replay/src/index.ts index 96a8074da0..5d87a89a77 100644 --- a/packages/ad-replay/src/index.ts +++ b/packages/ad-replay/src/index.ts @@ -100,7 +100,7 @@ export type { AdReplayVarSources, AdReplayVerificationEntry, AdReplayVerifiedTargetGuard, -} from './internal/step-loop.ts'; +} from './internal/runtime-port-types.ts'; export type { AdReplayGuardMismatchEvidence, diff --git a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts index ab7475bce6..715733d723 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { runAdReplay, type AdReplayStepRuntime } from '../step-loop.ts'; +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'; 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/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index a6a031d452..66a9c72a09 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -1,26 +1,17 @@ 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 { buildReplayVarScope, collectReplayScrubbableVarValues, resolveReplayAction, - type LocalIdentity, - type ReplayVarScope, } from '@agent-device/ad-script'; -import type { ReplaySelectorPort } from './selector-port.ts'; -import { - deriveReplayTargetGuardMismatchEvidence, - deriveWaitLandmarkMismatchEvidence, - planPostResolutionTargetVerification, - planPreDispatchTargetVerification, - type AdReplayGuardMismatchEvidence, - type AdReplayLandmarkMismatchEvidence, - type AdReplayTargetStructuralDenotation, -} from './target-verification.ts'; +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 @@ -28,46 +19,30 @@ import { * `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 below — this module only decides which - * action to run next, when to skip one, and when to stop. - * - * #1555 review P1 ("do not smuggle daemon wire failures through a generic"): - * `AdReplayStepRuntime` no longer carries a `TResponse` type parameter. The - * loop never sees a `DaemonResponse`/wire object at all, not even opaquely — - * the capabilities below return the NEUTRAL tagged types in this file (built - * only from plain values — a `kind`/`reason` string, a `message` string, - * snapshot nodes, artifact paths). The daemon adapter - * (`createAdReplayStepRuntime`, `session-replay-runtime.ts`) is the only - * place a real `DaemonResponse` is constructed or read; it keeps its OWN - * wire response in a local variable ("the side-map") as it builds each - * neutral outcome, and `runReplayScriptFile` reads that variable back after - * `runAdReplay` reports which step failed, so the final response returned to - * the client is byte-identical to before this split — it was never - * round-tripped through the engine's return value at all. + * `AdReplayStepRuntime` capabilities (`./runtime-port-types.ts`) — this + * module only decides which action to run next, when to skip one, and when + * to stop. * - * #1555 review P1 remainder ("target verification must happen INSIDE the - * engine"): `verifyAndDispatchStep` below is the verify-then-dispatch - * orchestrator that used to live daemon-side - * (`session-replay-target-verification.ts`'s `verifyReplayActionTarget` / - * `convertIdentityRefusalResponse`) calling OUT to this package's four - * target-verification policy functions. The call sites for those four - * functions now live here — engine-private, never re-exported by the façade - * — and the daemon side shrinks to the narrow capabilities this function - * drives: routing (`beginTargetVerification`), capture - * (`captureObservation`), classification (`classifyTarget`), dispatch - * (`dispatchStep`), and wire-building the resulting divergence - * (`buildRecordedUnverifiableFailure`, `buildTargetBindingFailure`, - * `buildPostDispatchTargetBindingFailure`). + * #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 this - * exact terminal-close-suppression decision independently, daemon-side, as + * #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 — now lives here instead, + * — `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 @@ -90,359 +65,10 @@ import { * `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`), - * threaded to each build-failure/`handleActionFailure` capability as an - * explicit `scrubVars` argument rather than recomputed daemon-side from a - * second scope object — the daemon no longer holds a `ReplayVarScope` value - * at all. - */ - -/** - * `${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[]; -}>; - -/** `executeStep`'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). + * 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. */ -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`) - * 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 - * below, 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; - }>; /** * ADR 0012 step 4's step loop: for every executable action from @@ -465,6 +91,11 @@ export type AdReplayRunOutcome = * 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 here, from the run's one live scope, and threaded down to + * `verifyAndDispatchStep`/`handleActionFailure` as an explicit argument — + * never recomputed per call inside the verify/dispatch chain. */ export async function runAdReplay( request: AdReplayRunRequest, @@ -538,222 +169,6 @@ function resolveActionLoc( }; } -/** - * The verify-then-dispatch orchestrator: ADR 0012 step 4 verify + dispatch + - * guard, ENGINE-side as of the #1555 review pass. Mirrors - * `verifyReplayActionTarget`'s exact branch order (moved verbatim from - * `session-replay-target-verification.ts`) — only the async daemon-owned - * pieces (registry/session/wait-form routing, capture, classification, - * dispatch, wire-building) were narrowed into `runtime` capabilities; the - * plan/derive DECISIONS (`planPostResolutionTargetVerification`, - * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, - * `deriveWaitLandmarkMismatchEvidence`) are called from here, never from the - * daemon. - */ -async function verifyAndDispatchStep( - runtime: AdReplayStepRuntime, - scope: ReplayVarScope, - 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, - collectReplayScrubbableVarValues(scope), - ), - }; - case 'deferred-landmark': - return dispatchWithGuard(runtime, scope, 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, - collectReplayScrubbableVarValues(scope), - ), - }; - } - 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, - collectReplayScrubbableVarValues(scope), - ), - }; - } - - const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); - if (classification.verified) { - return dispatchWithGuard(runtime, scope, 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, - collectReplayScrubbableVarValues(scope), - ), - }; -} - -/** 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, - scope: ReplayVarScope, - 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, - collectReplayScrubbableVarValues(scope), - ), - }; -} - /** * 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 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..b85bef18fa --- /dev/null +++ b/packages/ad-replay/src/internal/verify-dispatch.ts @@ -0,0 +1,246 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; +import { + deriveReplayTargetGuardMismatchEvidence, + deriveWaitLandmarkMismatchEvidence, + planPostResolutionTargetVerification, + planPreDispatchTargetVerification, +} from './target-verification.ts'; +import type { + AdReplayDispatchGuard, + 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. + */ + +/** + * The verify-then-dispatch orchestrator: ADR 0012 step 4 verify + dispatch + + * guard, ENGINE-side as of the #1555 review pass. Mirrors + * `verifyReplayActionTarget`'s exact branch order (moved verbatim from + * `session-replay-target-verification.ts`) — only the async daemon-owned + * pieces (registry/session/wait-form routing, capture, classification, + * dispatch, wire-building) were narrowed into `runtime` capabilities; the + * plan/derive DECISIONS (`planPostResolutionTargetVerification`, + * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, + * `deriveWaitLandmarkMismatchEvidence`) are called from here, never from the + * daemon. + */ +export async function verifyAndDispatchStep( + runtime: AdReplayStepRuntime, + scope: ReplayVarScope, + 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, + collectReplayScrubbableVarValues(scope), + ), + }; + case 'deferred-landmark': + return dispatchWithGuard(runtime, scope, 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, + collectReplayScrubbableVarValues(scope), + ), + }; + } + 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, + collectReplayScrubbableVarValues(scope), + ), + }; + } + + const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); + if (classification.verified) { + return dispatchWithGuard(runtime, scope, 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, + collectReplayScrubbableVarValues(scope), + ), + }; +} + +/** 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, + scope: ReplayVarScope, + 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, + collectReplayScrubbableVarValues(scope), + ), + }; +} 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-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index d4141c1c0d..88b72dea62 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -1,23 +1,12 @@ -import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { errorResponse } from './response.ts'; +import type { DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { invokeReplayAction } from './session-replay-action-runtime.ts'; -import { readReplaySelectorDisplayValue } from '../replay-selector-port.ts'; -import type { ResponseLevel } from '@agent-device/kernel/contracts'; -import type { - AdReplayDispatchGuard, - AdReplayDispatchOutcome, - AdReplayGuardMismatchEvidence, - AdReplayLandmarkMismatchEvidence, - AdReplayStepFailure, - AdReplayStepRuntime, - ReplaySelectorPort, -} from '@agent-device/ad-replay'; -import type { LocalIdentity } from '@agent-device/ad-script'; -import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; -import type { TargetAncestryEntry } from '@agent-device/contracts/replay'; +import type { AdReplayStepFailure, AdReplayStepRuntime } from '@agent-device/ad-replay'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; -import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; +import { + applyReplayDispatchGuard, + classifyReplayDispatchFailure, + toAdReplayStepFailure, +} from './session-replay-dispatch-narrowing.ts'; import { captureDivergenceObservation, type DivergenceObservation, @@ -27,176 +16,36 @@ import { buildRecordedUnverifiableFailureResponse, buildTargetBindingFailureResponse, classifyPreDispatchTarget, - isReplayTargetGuardMismatchResponse, - isWaitLandmarkMismatchResponse, resolveTargetVerificationEntry, type TargetBindingDivergenceContext, } from './session-replay-target-verification.ts'; +import { + asFailedReplayStepResponse, + buildReplayActionFailure, + describeReplayStepValue, + readSessionSnapshotSampleCount, + readSessionSnapshotSamplesSince, + type ReplayStepContext, +} from './session-replay-runtime-step-support.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; /** * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out * of `session-replay-runtime.ts`, which now only constructs a `ReplayStepContext` and calls * `createAdReplayStepRuntime`. See that file's `runReplayScriptFile` for the request-level * orchestration this adapter plugs into. + * + * #1555 structural-quality review ("shrink the runtime adapter toward the + * plan's <300 LOC metric"): the wire-narrowing concern (guard threading, + * `details` bag -> typed evidence, dispatch-failure classification) moved to + * `session-replay-dispatch-narrowing.ts`; `ReplayStepContext` and the + * failure-wrapping/diagnostics support helpers moved to + * `session-replay-runtime-step-support.ts` (re-exporting `ReplayStepContext` + * by name so this file's own importers see no path change). This file is + * left with exactly the `createAdReplayStepRuntime` factory and the small + * closures only it needs. */ - -/** - * 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; this 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; -}; - -/** - * #1555 structural-quality review ("typed façade replaces the zero-type - * rule"): this module used to read the engine's evidence/guard/outcome - * shapes off `AdReplayStepRuntime` by hand (`Parameters<...>`/ - * `ReturnType<...>`/`Extract<...>`) because the façade exported no types at - * all — including a `toDaemonEvidence` copy translator between the engine's - * readonly-array evidence and this module's own mutable-array twin - * (`TargetBindingFailureEvidence`, since deleted from - * `session-replay-target-verification.ts`). `@agent-device/ad-replay` now - * exports `AdReplayDispatchGuard`/`AdReplayDispatchOutcome`/ - * `AdReplayTargetBindingEvidence`/`AdReplayGuardMismatchEvidence`/ - * `AdReplayLandmarkMismatchEvidence` directly, so every call site below uses - * the engine's own value with no copy. - */ - -/** 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. */ -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; -} - -/** - * #1555 review P1 (second pass, "translate wire failures before the engine - * boundary"): the wire response's `details: Record | undefined` - * bag is read HERE, at the adapter — 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 that used to live in the package (reading - * `details.observed`/`details.expectedStructural`/`details.observedStructural`/ - * `details.observedAncestry`/`details.matchCount` defensively) moved here - * with the wire-reading responsibility they always were. - */ -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), - }; -} - -/** 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. */ -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 }; -} +export type { ReplayStepContext } from './session-replay-runtime-step-support.ts'; /** * #1478 P5 stage C2b (narrowed further by the #1555 review's neutral-outcomes @@ -443,111 +292,3 @@ export function createAdReplayStepRuntime(params: { }; return { runtime, readLastResponse: () => lastResponse }; } - -/** - * `runAdReplay` only ever calls `handleActionFailure` right after - * `executeStep` reported `status: 'failed'`, and `executeStep` always sets - * `lastResponse` to that same failed response 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; -} - -/** Projects a wire response down to the neutral shape the engine's outcome carries. */ -function toAdReplayStepFailure( - response: Extract, - artifactPaths: readonly string[], -): AdReplayStepFailure { - return { kind: response.error.code, message: response.error.message, artifactPaths }; -} - -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 engine divergence-kind -// classification (that already happened engine-side, in -// `classifyReplayTarget`/`target-identity.ts`). -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-step-support.ts b/src/daemon/handlers/session-replay-runtime-step-support.ts new file mode 100644 index 0000000000..a0ae574f2a --- /dev/null +++ b/src/daemon/handlers/session-replay-runtime-step-support.ts @@ -0,0 +1,153 @@ +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 { ReplaySelectorPort } from '@agent-device/ad-replay'; +import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; +import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; +import type { TargetBindingDivergenceContext } from './session-replay-target-verification.ts'; +import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; + +/** + * #1555 structural-quality review ("shrink the runtime adapter toward the + * plan's <300 LOC metric"): extracted out of + * `session-replay-runtime-engine-adapter.ts` — `ReplayStepContext` (moved + * here so both this module and the adapter can depend on it without a + * cycle) plus the failure-wrapping and per-step diagnostics support + * `createAdReplayStepRuntime`'s `handleActionFailure`/`describeStepValue`/ + * `diagnosticsMarker`/`diagnosticsSince` capabilities delegate to, none of + * which touch the `lastResponse`/`lastObservation` side-map the factory + * itself owns. The adapter re-exports `ReplayStepContext` by name so its + * existing importers (`session-replay-runtime.ts`) see no path change. + */ + +/** + * 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 + * `executeStep` reported `status: 'failed'`, and `executeStep` always sets + * `lastResponse` to that same failed response 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. + */ +export 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; +} + +export 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. + */ +export 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 engine divergence-kind +// classification (that already happened engine-side, in +// `classifyReplayTarget`/`target-identity.ts`). +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'; +} + +export function readSessionSnapshotSampleCount( + sessionStore: SessionStore, + sessionName: string, +): number { + return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; +} + +export function readSessionSnapshotSamplesSince( + sessionStore: SessionStore, + sessionName: string, + start: number, +): SnapshotTimingSample[] { + return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(start) ?? []; +} From c36cd1d076ae5a752b3fa2b2f97bc63956551057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:31:38 +0200 Subject: [PATCH 25/31] test(ad-replay): package-local tests for resume.ts/target-verification.ts + terminal-lifecycle test rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resume.test.ts covers resolveReplayEntryIndex directly (previously only exercised transitively through the daemon's session-replay-runtime-plan tests): no --from/--plan-digest, the paired-flags requirement, in-range --from, out-of-range rejection, stale-digest rejection, the authorized empty-tail boundary (actionCount + 1) gated on a matching watermark, and the unperformed-record-and-heal growth check. Counterfactual run and restored: widening describeOutOfRangeResumeFrom's bound turns the out-of-range/ empty-tail-without-watermark assertions red (2 failures observed). target-verification.test.ts covers all four engine policy functions directly: the two plan* pre-capture gates and the two derive* post-dispatch evidence builders, including item 2's own new decision surface (a fake ReplaySelectorPort proving both non-'expression' readSelectorExpression outcomes map to skip). Counterfactual run and restored: narrowing the check to the literal `'invalid' -> skip` reading turns the 'not-applicable' case red (reports recorded-unverifiable instead of skip). session-replay-terminal-lifecycle.test.ts renamed to session-replay-runtime-keep-session.test.ts: its production module (session-replay-terminal-lifecycle.ts) was already deleted by the #1554 fold-in, and its six cases drive the full runReplayScriptFile round trip against a real SessionStore (including daemon-only postconditions the engine's step loop never reaches) rather than testing engine policy through the façade in isolation — the engine's own terminal-close-suppression decision already has direct, cheaper coverage in step-loop.test.ts. No assertion changes; both files' header comments cross-reference the split. --- .../src/internal/__tests__/resume.test.ts | 209 +++++++++++++++ .../src/internal/__tests__/step-loop.test.ts | 4 +- .../__tests__/target-verification.test.ts | 239 ++++++++++++++++++ ...ssion-replay-runtime-keep-session.test.ts} | 31 +++ 4 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 packages/ad-replay/src/internal/__tests__/resume.test.ts create mode 100644 packages/ad-replay/src/internal/__tests__/target-verification.test.ts rename src/daemon/handlers/__tests__/{session-replay-terminal-lifecycle.test.ts => session-replay-runtime-keep-session.test.ts} (78%) 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 index 715733d723..0e01e6fedc 100644 --- a/packages/ad-replay/src/internal/__tests__/step-loop.test.ts +++ b/packages/ad-replay/src/internal/__tests__/step-loop.test.ts @@ -15,7 +15,9 @@ import type { ReplaySelectorPort } from '../selector-port.ts'; * `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-terminal-lifecycle.test.ts`; + * `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. 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/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() }; From 00d375cf76a963d04f533eb8ff7ba877d542025e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:37:20 +0200 Subject: [PATCH 26/31] refactor(ad-replay): compute scrub values once per step, one name end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectReplayScrubbableVarValues(scope) was called fresh at 5 separate return points inside one verifyAndDispatchStep invocation plus once more in handleActionFailure — always the same result, since nothing between them mutates scope. step-loop.ts's runAdReplay now computes scrubVars ONCE per step, right after resolveReplayAction (the one call that can grow the scope's expanded-builtins set), and threads it as a plain readonly AdReplayScrubValue[] value; verify-dispatch.ts no longer imports ReplayVarScope or collectReplayScrubbableVarValues at all. "One name" end to end: the daemon's TargetBindingDivergenceContext.scrubVars and withReplayFailureDiagnostics's scrubVars param used a separately-derived ReturnType (mutable array) instead of the engine's own AdReplayScrubValue, requiring a [...scrubVars] copy at every daemon call site to satisfy the mutable-array type. Both now use readonly AdReplayScrubValue[]/readonly ReplayVarScrubEntry[] (structurally identical, already readonly-safe downstream — scrubReplayVarValues and createReplayDivergenceSanitizer already accepted readonly arrays), so the four [...scrubVars] copies in session-replay-runtime-engine-adapter.ts are gone. --- packages/ad-replay/src/internal/step-loop.ts | 26 ++++++++--- .../ad-replay/src/internal/verify-dispatch.ts | 45 +++++++++---------- .../handlers/session-replay-divergence.ts | 2 +- .../session-replay-runtime-engine-adapter.ts | 8 ++-- ...session-replay-runtime-failure-response.ts | 4 +- .../session-replay-runtime-failure.ts | 7 ++- .../session-replay-target-verification.ts | 12 ++++- 7 files changed, 61 insertions(+), 43 deletions(-) diff --git a/packages/ad-replay/src/internal/step-loop.ts b/packages/ad-replay/src/internal/step-loop.ts index 66a9c72a09..3fbd57182e 100644 --- a/packages/ad-replay/src/internal/step-loop.ts +++ b/packages/ad-replay/src/internal/step-loop.ts @@ -93,9 +93,12 @@ import type { * dispatched, never divergence-checked, never counted. * * `scrubVars` (the `${VAR}` values a divergence report may redact) is - * computed ONCE here, from the run's one live scope, and threaded down to - * `verifyAndDispatchStep`/`handleActionFailure` as an explicit argument — - * never recomputed per call inside the verify/dispatch chain. + * 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, @@ -131,10 +134,19 @@ export async function runAdReplay( // 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, scope, action, resolvedAction, index, [ - ...artifactPaths, - ]); + 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)); @@ -146,7 +158,7 @@ export async function runAdReplay( index, artifactPaths: [...artifactPaths], snapshotDiagnosticSamples, - scrubVars: collectReplayScrubbableVarValues(scope), + scrubVars, }); return { status: 'failed', stepIndex: index, failure }; } diff --git a/packages/ad-replay/src/internal/verify-dispatch.ts b/packages/ad-replay/src/internal/verify-dispatch.ts index b85bef18fa..36e6c36d80 100644 --- a/packages/ad-replay/src/internal/verify-dispatch.ts +++ b/packages/ad-replay/src/internal/verify-dispatch.ts @@ -1,5 +1,4 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import { collectReplayScrubbableVarValues, type ReplayVarScope } from '@agent-device/ad-script'; import { deriveReplayTargetGuardMismatchEvidence, deriveWaitLandmarkMismatchEvidence, @@ -8,6 +7,7 @@ import { } from './target-verification.ts'; import type { AdReplayDispatchGuard, + AdReplayScrubValue, AdReplayStepOutcome, AdReplayStepRuntime, } from './runtime-port-types.ts'; @@ -27,23 +27,22 @@ import type { * (`buildRecordedUnverifiableFailure`, `buildTargetBindingFailure`, * `buildPostDispatchTargetBindingFailure`). `./step-loop.ts`'s `runAdReplay` * is this module's one caller. - */ - -/** - * The verify-then-dispatch orchestrator: ADR 0012 step 4 verify + dispatch + - * guard, ENGINE-side as of the #1555 review pass. Mirrors - * `verifyReplayActionTarget`'s exact branch order (moved verbatim from - * `session-replay-target-verification.ts`) — only the async daemon-owned - * pieces (registry/session/wait-form routing, capture, classification, - * dispatch, wire-building) were narrowed into `runtime` capabilities; the - * plan/derive DECISIONS (`planPostResolutionTargetVerification`, - * `planPreDispatchTargetVerification`, `deriveReplayTargetGuardMismatchEvidence`, - * `deriveWaitLandmarkMismatchEvidence`) are called from here, never from the - * daemon. + * + * #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, - scope: ReplayVarScope, + scrubVars: readonly AdReplayScrubValue[], action: SessionAction, resolvedAction: SessionAction, index: number, @@ -77,11 +76,11 @@ export async function verifyAndDispatchStep( action, index, artifactPaths, - collectReplayScrubbableVarValues(scope), + scrubVars, ), }; case 'deferred-landmark': - return dispatchWithGuard(runtime, scope, action, resolvedAction, index, artifactPaths, { + return dispatchWithGuard(runtime, scrubVars, action, resolvedAction, index, artifactPaths, { kind: 'landmark', landmark: plan.landmark, }); @@ -104,7 +103,7 @@ export async function verifyAndDispatchStep( action, index, artifactPaths, - collectReplayScrubbableVarValues(scope), + scrubVars, ), }; } @@ -131,14 +130,14 @@ export async function verifyAndDispatchStep( ...(observation.hint !== undefined ? { causeHint: observation.hint } : {}), }, artifactPaths, - collectReplayScrubbableVarValues(scope), + scrubVars, ), }; } const classification = runtime.classifyTarget({ action, index, token, nodes: observation.nodes }); if (classification.verified) { - return dispatchWithGuard(runtime, scope, action, resolvedAction, index, artifactPaths, { + return dispatchWithGuard(runtime, scrubVars, action, resolvedAction, index, artifactPaths, { kind: 'target', guard: classification.guard, }); @@ -158,7 +157,7 @@ export async function verifyAndDispatchStep( causeMessage: classification.causeMessage, }, artifactPaths, - collectReplayScrubbableVarValues(scope), + scrubVars, ), }; } @@ -200,7 +199,7 @@ async function dispatchNoGuard( */ async function dispatchWithGuard( runtime: AdReplayStepRuntime, - scope: ReplayVarScope, + scrubVars: readonly AdReplayScrubValue[], action: SessionAction, resolvedAction: SessionAction, index: number, @@ -240,7 +239,7 @@ async function dispatchWithGuard( causeMessage: evidence.causeMessage, }, artifactPaths, - collectReplayScrubbableVarValues(scope), + scrubVars, ), }; } diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 6668842daf..2dab6763aa 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -63,7 +63,7 @@ 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`). */ diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index 88b72dea62..fe98dd582b 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -210,7 +210,7 @@ export function createAdReplayStepRuntime(params: { async buildRecordedUnverifiableFailure(action, index, stepArtifactPaths, scrubVars) { const response = await buildRecordedUnverifiableFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), { session: ctx.sessionStore.get(ctx.sessionName), sessionName: ctx.sessionName, @@ -229,7 +229,7 @@ export function createAdReplayStepRuntime(params: { hint: 'No capture was recorded before this target-binding failure.', }; const response = buildTargetBindingFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), evidence, observation, ); @@ -244,7 +244,7 @@ export function createAdReplayStepRuntime(params: { scrubVars, ) { const response = await buildPostDispatchTargetBindingFailureResponse( - buildDivergenceContext(action, index, stepArtifactPaths, [...scrubVars]), + buildDivergenceContext(action, index, stepArtifactPaths, scrubVars), evidence, { session: ctx.sessionStore.get(ctx.sessionName), @@ -273,7 +273,7 @@ export function createAdReplayStepRuntime(params: { failedResponse, [...failureArtifactPaths], [...snapshotDiagnosticSamples], - [...scrubVars], + scrubVars, ); // `buildReplayActionFailure` is typed `Promise` (it // shares its return type with the ordinary success path elsewhere in 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 e40b0c2edc..541612cf72 100644 --- a/src/daemon/handlers/session-replay-runtime-failure.ts +++ b/src/daemon/handlers/session-replay-runtime-failure.ts @@ -1,5 +1,4 @@ -import type { ReplaySelectorPort } from '@agent-device/ad-replay'; -import type { collectReplayScrubbableVarValues } from '@agent-device/ad-script'; +import type { AdReplayScrubValue, ReplaySelectorPort } from '@agent-device/ad-replay'; import { summarizeSnapshotTimingSamples, type SnapshotDiagnosticsSummary, @@ -25,7 +24,7 @@ export async function withReplayFailureDiagnostics(params: { artifactPaths: string[]; snapshotDiagnosticSamples: SnapshotTimingSample[]; /** The engine's own live `${VAR}` scrub list, as of this point in the run — never recomputed here from a second scope object. */ - scrubVars: ReturnType; + scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -52,7 +51,7 @@ async function withReplayFailureContext(params: { artifactPaths?: string[]; snapshotDiagnostics?: SnapshotDiagnosticsSummary; /** The engine's own live `${VAR}` scrub list, as of this point in the run — never recomputed here from a second scope object. */ - scrubVars: ReturnType; + scrubVars: readonly AdReplayScrubValue[]; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index d13642aa9a..c03febfddf 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -5,12 +5,12 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { annotationLocalIdentity, - collectReplayScrubbableVarValues, formatDivergenceActionLabel, type LocalIdentity, } from '@agent-device/ad-script'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { + AdReplayScrubValue, AdReplayTargetBindingEvidence, AdReplayTargetClassification, AdReplayVerificationEntry, @@ -113,7 +113,15 @@ export 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; From efa2585477abcf401a822c8ff14065b2909a403f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:44:07 +0200 Subject: [PATCH 27/31] fix(daemon): make lastObservation genuinely per-step, not per-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAdReplayStepRuntime's lastObservation closure lives for the whole replay run (one factory call covers every step), but was never reset between steps. Every current buildTargetBindingFailure call site happens to be preceded by this same step's own captureObservation, so the `lastObservation ?? { reason: 'observation-missing' }` fallback could never actually fire — but if it ever did (a future call path reaching buildTargetBindingFailure without capturing first), it would silently attach the PREVIOUS step's screen instead of reporting the missing-capture condition the fallback message claims. armStep runs exactly once per step, before any of that step's other capabilities (verified against step-loop.ts's runAdReplay loop order) — the natural per-step boundary. It now clears lastObservation first. No behavior change on any reachable path today (full daemon + ad-replay suite: 1766/1766 green); an unrelated device-claim-prune contention flake was observed once and did not reproduce on isolated or full-suite reruns. --- .../session-replay-runtime-engine-adapter.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index fe98dd582b..789a469df3 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -73,6 +73,20 @@ export type { ReplayStepContext } from './session-replay-runtime-step-support.ts * — 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; @@ -282,7 +296,13 @@ export function createAdReplayStepRuntime(params: { // marking, never to turn one into a success. return recordFailure(finalResponse); }, - armStep: armSaveScript, + 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, From 9b8472c80c1c9439e82c13ca750953c6bbe1f4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:46:39 +0200 Subject: [PATCH 28/31] docs(ad-replay): fix decayed review-changelog comments naming defunct symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four comments named symbols/paths that no longer exist, left behind by earlier review passes describing PR history rather than the current constraint: - session-replay-runtime-step-support.ts / session-replay-runtime.ts (2 sites): referenced a function called executeStep, which was never reintroduced under that name after the P5 split — the actual mechanism is the runtime's dispatch/build-failure capabilities recording into the lastResponse side-map. - session-replay-runtime.ts: referenced an engine collectArtifactPaths capability that does not exist — artifactPaths is a daemon-side Set the adapter mutates via collectReplayActionArtifactPaths. - packages/ad-replay/src/internal/selector-port.ts: pointed at ./testing/in-memory-selector-port.ts, the in-memory adapter's pre-stage-D location — it has lived at src/__tests__/test-utils/in-memory-replay-selector-port.ts since. - session-replay-repair-hint.ts / session-replay-runtime-step-support.ts (2 sites): named target-identity.ts, which does not exist (the real file is target-identity-node.ts); the second site additionally mislabeled classifyReplayTarget as engine-side when it is daemon-side (session-replay-target-classification.ts). Comment-only; no behavior change. --- .../ad-replay/src/internal/selector-port.ts | 11 +++++++---- .../handlers/session-replay-repair-hint.ts | 2 +- .../session-replay-runtime-step-support.ts | 19 ++++++++++--------- src/daemon/handlers/session-replay-runtime.ts | 15 ++++++++------- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/ad-replay/src/internal/selector-port.ts b/packages/ad-replay/src/internal/selector-port.ts index ad51c38f42..c39af4be23 100644 --- a/packages/ad-replay/src/internal/selector-port.ts +++ b/packages/ad-replay/src/internal/selector-port.ts @@ -13,11 +13,14 @@ * 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 - * (`./testing/in-memory-selector-port.ts`) for the package's own contract - * suite. + * (`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 builds the port and both adapters only. Handlers keep their direct - * `src/selectors` imports until stage C migrates them onto this port. + * 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'; diff --git a/src/daemon/handlers/session-replay-repair-hint.ts b/src/daemon/handlers/session-replay-repair-hint.ts index 0276664c53..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`) diff --git a/src/daemon/handlers/session-replay-runtime-step-support.ts b/src/daemon/handlers/session-replay-runtime-step-support.ts index a0ae574f2a..6bfd9a53b8 100644 --- a/src/daemon/handlers/session-replay-runtime-step-support.ts +++ b/src/daemon/handlers/session-replay-runtime-step-support.ts @@ -50,12 +50,13 @@ export type ReplayStepContext = { }; /** - * `runAdReplay` only ever calls `handleActionFailure` right after - * `executeStep` reported `status: 'failed'`, and `executeStep` always sets - * `lastResponse` to that same failed response 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. + * `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. */ export function asFailedReplayStepResponse( response: DaemonResponse | undefined, @@ -124,9 +125,9 @@ export function describeReplayStepValue(action: SessionAction): string | undefin // 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 engine divergence-kind -// classification (that already happened engine-side, in -// `classifyReplayTarget`/`target-identity.ts`). +// 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; diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index c055c25bde..c2947561fd 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -54,9 +54,10 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi const startedAt = Date.now(); const keepSession = req.flags?.replayKeepSession === true; let resolved = ''; - // Mirrors whatever the engine step loop's own `collectArtifactPaths` - // capability accumulates (see `createAdReplayStepRuntime`), so a mid-loop - // exception still reports the artifacts collected up to that point. + // 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. @@ -146,13 +147,13 @@ export async function runReplayScriptFile(params: ReplayScriptFileParams): Promi // #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 `executeStep`/`handleActionFailure`, which + // 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 (`executeStep` - // always records a response before any failure can be reported) and - // exists only so this stays total. + // 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') From 310a084759762b61df586c2fc579aed599cb4c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:51:01 +0200 Subject: [PATCH 29/31] refactor(ad-script): move declaredScriptPlatform to its natural shared owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/ad-replay/src/internal/inspect.ts's declaredScriptPlatform and src/daemon/replay-device-selection.ts's readScriptReplaySelection each kept their own copy of the same "platform declared before the first open" scan over runtime/open actions — .ad script semantics, not engine or daemon policy, needed independently by ad-replay's plan-digest precedence and the daemon's device-selection platform resolution. Verified this was a genuine duplicate (not the single-sourced state I initially reported): readScriptReplaySelection's platform-tracking loop computes the identical result via a differently-shaped traversal fused with its own app-target scan. resolveDeclaredScriptPlatform now lives in packages/ad-script (its natural owner: the one package both ad-replay and the daemon already depend on, avoiding the R11 issue that justified the original duplication). The daemon's app-target scan stays its own separate pass; fusing it back into the shared function would smuggle a daemon-only concern into ad-script for no measurable cost (the actions array is small, and the shared function already stops at the same point the app-target scan needs to look). --- packages/ad-replay/src/internal/inspect.ts | 27 ++-------------- packages/ad-script/src/index.ts | 11 +++++++ .../ad-script/src/internal/open-script.ts | 31 +++++++++++++++++++ src/daemon/replay-device-selection.ts | 26 ++++++++++++---- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/packages/ad-replay/src/internal/inspect.ts b/packages/ad-replay/src/internal/inspect.ts index 5a3d42338e..510262f0b9 100644 --- a/packages/ad-replay/src/internal/inspect.ts +++ b/packages/ad-replay/src/internal/inspect.ts @@ -4,6 +4,7 @@ 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'; @@ -85,7 +86,7 @@ export function inspectAdReplay( actionSourcePaths: parsed.actionSourcePaths, metadata: { platform: - digestFlags?.platform ?? declaredScriptPlatform(parsed.actions) ?? metadata.platform, + digestFlags?.platform ?? resolveDeclaredScriptPlatform(parsed.actions) ?? metadata.platform, target: digestFlags?.target ?? metadata.target, }, }); @@ -99,27 +100,3 @@ export function inspectAdReplay( resolveEntryIndex: (params) => resolveReplayEntryIndex(params, actionCount, planDigest), }; } - -/** - * Mirrors the platform half of the daemon's `readScriptReplaySelection` - * (`src/daemon/replay-device-selection.ts`) — deliberately duplicated rather - * than imported: that daemon-owned function also resolves an app-target - * device-selection result the digest never needs, and a root `src/` file - * cannot become a façade dependency (R11). Both copies must keep computing - * the SAME effective platform for the SAME script; `plan-digest.test.ts` - * covers this one directly, and `session-replay-runtime-plan.test.ts`'s - * "native replay applies an authored Android platform" case exercises the - * daemon's copy against the same `runtime set --platform` shape. - */ -function declaredScriptPlatform(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; -} diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index 4507db2d68..13d3059081 100644 --- a/packages/ad-script/src/index.ts +++ b/packages/ad-script/src/index.ts @@ -30,6 +30,15 @@ * 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 { @@ -39,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, 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/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 }; From 2875c059f8534fa1d126a049e1b32b891e8821c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 13:52:18 +0200 Subject: [PATCH 30/31] =?UTF-8?q?docs(ad-replay):=20fix=20package.json=20d?= =?UTF-8?q?escription=20to=20match=20the=20current=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Described "target-identity, variable substitution, plan-digest, and report primitives" — the wide pre-#1555-review façade shape. Vars/identity/report vocabulary moved to ad-script/daemon across the P5 and #1555 review passes; the package now exports exactly inspectAdReplay/runAdReplay plus the neutral AdReplayStepRuntime vocabulary. Description updated to match. --- packages/ad-replay/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ad-replay/package.json b/packages/ad-replay/package.json index 8a8b8f6736..7729d7b817 100644 --- a/packages/ad-replay/package.json +++ b/packages/ad-replay/package.json @@ -4,7 +4,7 @@ "private": true, "sideEffects": false, "type": "module", - "description": "Private replay target-identity, variable substitution, plan-digest, and report primitives for agent-device.", + "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:*", From 6a695544c04be6439b788358ee4b4270d4038ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 3 Aug 2026 14:42:55 +0200 Subject: [PATCH 31/31] refactor(daemon): fold the step-support fragment back into the engine adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A simplicity audit judged session-replay-runtime-step-support.ts a size-target fragment, not a concern boundary: four unrelated concerns, one consumer, and a header comment admitting it existed to satisfy the <300 LOC metric. Folded back; the previously-exported helpers are module-private again; the adapter's honest size is renegotiated from the plan metric (dispatch-narrowing stays extracted — it has one nameable job). --- .../session-replay-runtime-engine-adapter.ts | 170 +++++++++++++++--- .../session-replay-runtime-step-support.ts | 154 ---------------- 2 files changed, 150 insertions(+), 174 deletions(-) delete mode 100644 src/daemon/handlers/session-replay-runtime-step-support.ts diff --git a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts index 789a469df3..34b9250aff 100644 --- a/src/daemon/handlers/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/handlers/session-replay-runtime-engine-adapter.ts @@ -1,6 +1,17 @@ -import type { DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; +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 } from '@agent-device/ad-replay'; +import type { + AdReplayStepFailure, + AdReplayStepRuntime, + ReplaySelectorPort, +} from '@agent-device/ad-replay'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { applyReplayDispatchGuard, @@ -19,14 +30,6 @@ import { resolveTargetVerificationEntry, type TargetBindingDivergenceContext, } from './session-replay-target-verification.ts'; -import { - asFailedReplayStepResponse, - buildReplayActionFailure, - describeReplayStepValue, - readSessionSnapshotSampleCount, - readSessionSnapshotSamplesSince, - type ReplayStepContext, -} from './session-replay-runtime-step-support.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; /** @@ -35,17 +38,16 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; * `createAdReplayStepRuntime`. See that file's `runReplayScriptFile` for the request-level * orchestration this adapter plugs into. * - * #1555 structural-quality review ("shrink the runtime adapter toward the - * plan's <300 LOC metric"): the wire-narrowing concern (guard threading, - * `details` bag -> typed evidence, dispatch-failure classification) moved to - * `session-replay-dispatch-narrowing.ts`; `ReplayStepContext` and the - * failure-wrapping/diagnostics support helpers moved to - * `session-replay-runtime-step-support.ts` (re-exporting `ReplayStepContext` - * by name so this file's own importers see no path change). This file is - * left with exactly the `createAdReplayStepRuntime` factory and the small - * closures only it needs. + * 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. */ -export type { ReplayStepContext } from './session-replay-runtime-step-support.ts'; /** * #1478 P5 stage C2b (narrowed further by the #1555 review's neutral-outcomes @@ -312,3 +314,131 @@ export function createAdReplayStepRuntime(params: { }; 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-step-support.ts b/src/daemon/handlers/session-replay-runtime-step-support.ts deleted file mode 100644 index 6bfd9a53b8..0000000000 --- a/src/daemon/handlers/session-replay-runtime-step-support.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 { ReplaySelectorPort } from '@agent-device/ad-replay'; -import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; -import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; -import type { TargetBindingDivergenceContext } from './session-replay-target-verification.ts'; -import type { ReplayCoordinator } from '../session-replay-coordinator.ts'; - -/** - * #1555 structural-quality review ("shrink the runtime adapter toward the - * plan's <300 LOC metric"): extracted out of - * `session-replay-runtime-engine-adapter.ts` — `ReplayStepContext` (moved - * here so both this module and the adapter can depend on it without a - * cycle) plus the failure-wrapping and per-step diagnostics support - * `createAdReplayStepRuntime`'s `handleActionFailure`/`describeStepValue`/ - * `diagnosticsMarker`/`diagnosticsSince` capabilities delegate to, none of - * which touch the `lastResponse`/`lastObservation` side-map the factory - * itself owns. The adapter re-exports `ReplayStepContext` by name so its - * existing importers (`session-replay-runtime.ts`) see no path change. - */ - -/** - * 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. - */ -export 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; -} - -export 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. - */ -export 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'; -} - -export function readSessionSnapshotSampleCount( - sessionStore: SessionStore, - sessionName: string, -): number { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; -} - -export function readSessionSnapshotSamplesSince( - sessionStore: SessionStore, - sessionName: string, - start: number, -): SnapshotTimingSample[] { - return sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.slice(start) ?? []; -}