diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/01-flow.yaml b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/01-flow.yaml new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/01-flow.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson new file mode 100644 index 0000000000..7fe0f1956c --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -0,0 +1,6 @@ +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:27:51.710Z","replayPath":"/tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-faJ9EM/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:27:51.712Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:27:51.712Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:27:51.712Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:27:51.712Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:27:51.712Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay.ad b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay.ad new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay.ad @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt new file mode 100644 index 0000000000..0e3556cb8b --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt @@ -0,0 +1,6 @@ +file: /tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-faJ9EM/01-flow.yaml +session: default:test:req-maestro-test-wire-true:1-01-flow:attempt-1 +attempt: 1/1 +status: passed +replayed: 1 +healed: 0 diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/02-flow.yaml b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/02-flow.yaml new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/02-flow.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson new file mode 100644 index 0000000000..b54b3b7d9a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -0,0 +1,6 @@ +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:27:51.713Z","replayPath":"/tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-faJ9EM/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:27:51.714Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:27:51.714Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:27:51.714Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:27:51.714Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:27:51.714Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay.ad b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay.ad new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay.ad @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt new file mode 100644 index 0000000000..b7dcd5187d --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt @@ -0,0 +1,6 @@ +file: /tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-faJ9EM/02-flow.yaml +session: default:test:req-maestro-test-wire-true:2-02-flow:attempt-1 +attempt: 1/1 +status: passed +replayed: 1 +healed: 0 diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/01-flow.yaml b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/01-flow.yaml new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/01-flow.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson new file mode 100644 index 0000000000..7ce290b053 --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -0,0 +1,6 @@ +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:27:51.718Z","replayPath":"/tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-QE9WSU/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:27:51.718Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:27:51.718Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:27:51.719Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":1} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:27:51.719Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:27:51.719Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay.ad b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay.ad new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay.ad @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt new file mode 100644 index 0000000000..dc98ee7bbf --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt @@ -0,0 +1,6 @@ +file: /tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-QE9WSU/01-flow.yaml +session: default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1 +attempt: 1/1 +status: passed +replayed: 1 +healed: 0 diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/02-flow.yaml b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/02-flow.yaml new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/02-flow.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson new file mode 100644 index 0000000000..0fd8a373a4 --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -0,0 +1,6 @@ +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:27:51.720Z","replayPath":"/tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-QE9WSU/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:27:51.720Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:27:51.720Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:27:51.720Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:27:51.721Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:27:51.721Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay.ad b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay.ad new file mode 100644 index 0000000000..c0908c9a5a --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay.ad @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- evalScript: ${output.sum = 1 + 2} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt new file mode 100644 index 0000000000..a0d000690d --- /dev/null +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt @@ -0,0 +1,6 @@ +file: /tmp/agent-device-test-run-23838-sCm2rx/agent-device-maestro-remote-test-QE9WSU/02-flow.yaml +session: default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1 +attempt: 1/1 +status: passed +replayed: 1 +healed: 0 diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index 2ed8efd035..e498c751c3 100644 --- a/packages/ad-script/src/index.ts +++ b/packages/ad-script/src/index.ts @@ -63,6 +63,7 @@ export { } from './internal/format.ts'; export { + inferFillText, readRecordedInputVariableName, recordedInputPlaceholder, validateRecordedInputVariableName, diff --git a/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts index 95b3414bde..05b4c927e2 100644 --- a/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-identity.test.ts @@ -70,7 +70,7 @@ test('matchesLocalIdentity: with no recorded id, role+label must both match, abs // --------------------------------------------------------------------------- // Shared id demotion. `idMatchCountInTree` / `demoteNonUniqueLocalIdentity` // are the ONE shared uniqueness predicate behind both id-demotion sites (the -// `target-v1` identity tuple `session-target-evidence.ts` writes at record +// `target-v1` identity tuple `@agent-device/selectors/target-evidence` writes at record // time, and the selector chain `buildSelectorChainForNode` builds — see // `src/__tests__/selectors-build.test.ts` for that consumer's own coverage). Neither // consumer test exercises the pair directly; these tests pin the shared diff --git a/packages/ad-script/src/internal/recorded-input.ts b/packages/ad-script/src/internal/recorded-input.ts index af909de7d7..e74e5e56c2 100644 --- a/packages/ad-script/src/internal/recorded-input.ts +++ b/packages/ad-script/src/internal/recorded-input.ts @@ -1,3 +1,4 @@ +import type { SessionAction } from '@agent-device/contracts/session'; import { AppError } from '@agent-device/kernel/errors'; import { REPLAY_VAR_KEY_RE } from './script.ts'; @@ -29,3 +30,24 @@ export function readRecordedInputVariableName(value: string): string | undefined if (!match?.[1] || match[1].startsWith('AD_')) return undefined; return match[1]; } + +/** The text a recorded `fill` typed: its result text when recorded, else its positionals past the target. */ +export function inferFillText(action: SessionAction): string { + const resultText = action.result?.text; + if (typeof resultText === 'string') return resultText; + const positionals = action.positionals ?? []; + if (positionals.length === 0) return ''; + const first = positionals[0]; + if (first?.startsWith('@')) { + if (positionals.length >= 3) return positionals.slice(2).join(' '); + return positionals.slice(1).join(' '); + } + if ( + positionals.length >= 3 && + !Number.isNaN(Number(positionals[0])) && + !Number.isNaN(Number(positionals[1])) + ) { + return positionals.slice(2).join(' '); + } + return positionals.slice(1).join(' '); +} diff --git a/packages/ad-script/src/internal/target-annotation-classification.ts b/packages/ad-script/src/internal/target-annotation-classification.ts index bc0a4ead6a..875e9a799d 100644 --- a/packages/ad-script/src/internal/target-annotation-classification.ts +++ b/packages/ad-script/src/internal/target-annotation-classification.ts @@ -8,7 +8,7 @@ * 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`) + * daemon's RECORD-time self-check (`@agent-device/selectors/target-evidence`) * and its REPLAY-time classification wrapper * (`src/daemon/replay/internal/session-replay-target-classification.ts`) — both * daemon files, neither reachable through `inspectAdReplay`/`runAdReplay`. diff --git a/packages/ad-script/src/internal/target-annotation-identity.ts b/packages/ad-script/src/internal/target-annotation-identity.ts index 1a3faa5465..b54f69a90a 100644 --- a/packages/ad-script/src/internal/target-annotation-identity.ts +++ b/packages/ad-script/src/internal/target-annotation-identity.ts @@ -2,7 +2,7 @@ * 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 + * `SnapshotNode`-derived values, `@agent-device/selectors/target-evidence`) and * replay-time verification (`src/daemon/replay/internal/session-replay-target-classification.ts` and * `src/commands/interaction/runtime/selector-wait.ts`) share this verbatim so both * sides compute the SAME identity/ancestry match by construction (#1478 P5 @@ -49,7 +49,7 @@ type IdentityTreeNode = Pick; * ADR 0012 decision 3: the ONE snapshot-node local-identity reader — * normalized (NFC, label whitespace collapse, `normalizeType` role) AND * 256-byte field-capped, on every path. Shared by the record-time writer - * (`src/daemon/session-target-evidence.ts`), replay-time verification + * (`@agent-device/selectors/target-evidence`), replay-time verification * (`src/daemon/replay/internal/session-replay-target-verification.ts`), and the * dispatch-side post-resolution guard * (`src/commands/interaction/runtime/resolution.ts`), so all three compute diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts index 6c41ad7d0f..4467925bdf 100644 --- a/packages/ad-script/src/internal/target-annotation-serde.ts +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -1,7 +1,7 @@ /** * ADR 0012 decision 3: versioned `.ad` target-binding evidence — the * comment-line SERDE half shared by the writer - * (`src/daemon/session-target-evidence.ts`) and the parser + * (`@agent-device/selectors/target-evidence`) and the parser * (`packages/ad-script/src/internal/script.ts`). Owns the wire type, * canonical field order, normalization, size caps, and payload * parsing/validation. diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 154cae9d3e..e8a3de273c 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -106,6 +106,10 @@ "types": "./src/ios-snapshot-engine/index.ts", "default": "./src/ios-snapshot-engine/index.ts" }, + "./ios-snapshot-tree": { + "types": "./src/ios-snapshot-engine/tree.ts", + "default": "./src/ios-snapshot-engine/tree.ts" + }, "./ios-snapshot-planning": { "types": "./src/ios-snapshot-planning.ts", "default": "./src/ios-snapshot-planning.ts" diff --git a/packages/capture-kit/src/ios-snapshot-engine/index.ts b/packages/capture-kit/src/ios-snapshot-engine/index.ts index fe401fdae6..e433ba74ce 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/index.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/index.ts @@ -5,6 +5,6 @@ export { presentIosInteractiveSnapshot, } from './semantic-index.ts'; export { collectIosStructuralIdentifierSuppression } from './noise-structural.ts'; -export { findNearestScrollableContainer, mergeReplacement, updateReplacement } from './tree.ts'; +export { mergeReplacement, updateReplacement } from './tree.ts'; export { IosSnapshotEngineError, toIosSnapshotEngineErrorDetails } from './types.ts'; export type { SnapshotTreeRuleContext } from './tree.ts'; diff --git a/src/daemon/daemon-request-wire.ts b/packages/contracts/src/daemon-wire-request.ts similarity index 60% rename from src/daemon/daemon-request-wire.ts rename to packages/contracts/src/daemon-wire-request.ts index 086a5aa9ca..29c85bb76c 100644 --- a/src/daemon/daemon-request-wire.ts +++ b/packages/contracts/src/daemon-wire-request.ts @@ -1,20 +1,21 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; +import type { CommandFlags } from './command-flags.ts'; import type { + DaemonInstallSource, LeaseBackend, - DaemonInstallSource as PublicDaemonInstallSource, DaemonRequestMeta as PublicDaemonRequestMeta, DaemonRequest as WireRequest, } from '@agent-device/kernel/contracts'; import type { PlatformSelector } from '@agent-device/kernel/device'; -export type DaemonInstallSource = PublicDaemonInstallSource; - /** * Request metadata as a client may send it, with the two fields the daemon narrows past what the * wire can express and the lease coordinates the router reads. Public throughout: nothing here * names live session state. */ -type DaemonRequestMeta = Omit & { +export type DaemonWireRequestMeta = Omit< + PublicDaemonRequestMeta, + 'installSource' | 'lockPlatform' +> & { installSource?: DaemonInstallSource; lockPlatform?: PlatformSelector; leaseBackend?: LeaseBackend; @@ -24,15 +25,15 @@ type DaemonRequestMeta = Omit & { token: string; session: string; flags?: CommandFlags; - meta?: DaemonRequestMeta; + meta?: DaemonWireRequestMeta; }; diff --git a/packages/contracts/src/device-inventory.ts b/packages/contracts/src/device-inventory.ts index e4afa08f7c..3f9bda99b8 100644 --- a/packages/contracts/src/device-inventory.ts +++ b/packages/contracts/src/device-inventory.ts @@ -97,3 +97,22 @@ function deviceInventoryGroupForDevice(device: DeviceInfo): DeviceInventoryGroup if (isIosFamily(device) || isMacOs(device)) return 'apple'; return device.platform; } + +/** + * The refusal reasons a host-local device claim can answer with when another owner holds the + * device. Replay reads them to tell an infrastructure conflict from a script failure. + */ +export type DeviceClaimConflictReason = + | 'DEVICE_CLAIM_LIVE_OWNER' + | 'DEVICE_CLAIM_RECOVERY_PENDING' + | 'DEVICE_CLAIM_OWNER_UNCERTAIN'; + +const DEVICE_CLAIM_CONFLICT_REASONS = new Set([ + 'DEVICE_CLAIM_LIVE_OWNER', + 'DEVICE_CLAIM_RECOVERY_PENDING', + 'DEVICE_CLAIM_OWNER_UNCERTAIN', +]); + +export function isDeviceClaimConflictReason(value: unknown): value is DeviceClaimConflictReason { + return DEVICE_CLAIM_CONFLICT_REASONS.has(value as DeviceClaimConflictReason); +} diff --git a/packages/contracts/src/facades/command.ts b/packages/contracts/src/facades/command.ts index 2b23d2c16e..ae5c2ce614 100644 --- a/packages/contracts/src/facades/command.ts +++ b/packages/contracts/src/facades/command.ts @@ -10,6 +10,7 @@ export { export type { DaemonBatchStep } from '../batch-step.ts'; export type { CliFlags, DaemonExcludedCliFlag } from '../cli-flags.ts'; export type { CommandFlags, MaestroRuntimeFlags } from '../command-flags.ts'; +export type { DaemonWireRequest, DaemonWireRequestMeta } from '../daemon-wire-request.ts'; export type { DispatchedCommand } from '../dispatched-command.ts'; export { readOptionalInteger, readOptionalNumber } from '../input-validation.ts'; export { diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index c4ce2d77f1..88c5044c08 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -7,9 +7,11 @@ export { WEB_DESKTOP_DEVICE, countDeviceInventoryByGroup, filterDeviceInventoryProjection, + isDeviceClaimConflictReason, projectProviderDeviceInventoryRequest, } from '../device-inventory.ts'; export type { + DeviceClaimConflictReason, DeviceInventoryGroup, DeviceInventoryGroupCounts, DeviceInventoryRequest, diff --git a/packages/contracts/src/facades/replay.ts b/packages/contracts/src/facades/replay.ts index b36668a4f3..76f85624a3 100644 --- a/packages/contracts/src/facades/replay.ts +++ b/packages/contracts/src/facades/replay.ts @@ -2,6 +2,8 @@ export type { RefFrameEffect } from '../ref-frame-effect.ts'; export { REPLAY_TARGET_GUARD_MISMATCH_REASON, WAIT_LANDMARK_MISMATCH_REASON } from '../replay.ts'; export type { ReplayCommandResult, + ReplayDispatchOptions, + ReplayOpenLifecycle, ReplayObservationAuthority, ReplayObservationAuthorityBinder, ReplayObservationCapture, diff --git a/packages/contracts/src/facades/session.ts b/packages/contracts/src/facades/session.ts index 08b4d62297..6750c38f76 100644 --- a/packages/contracts/src/facades/session.ts +++ b/packages/contracts/src/facades/session.ts @@ -1,3 +1,4 @@ export type { SessionAction } from '../session-action.ts'; +export type { SessionScope } from '../session-scope.ts'; export { SESSION_SURFACES, parseSessionSurface } from '../session-surface.ts'; export type { SessionSurface } from '../session-surface.ts'; diff --git a/packages/contracts/src/replay.ts b/packages/contracts/src/replay.ts index 0d72f65b02..19eced6fe7 100644 --- a/packages/contracts/src/replay.ts +++ b/packages/contracts/src/replay.ts @@ -1,10 +1,14 @@ import type { DaemonError } from '@agent-device/kernel/errors'; -import type { SnapshotState } from '@agent-device/kernel/snapshot'; +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; +import type { GestureExecutionProfile } from './gesture-plan-types.ts'; +import type { SessionScope } from './session-scope.ts'; import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts'; import type { LocalIdentity, NodeStructuralDenotation, TargetAncestryEntry, + TargetAnnotationV1, } from './target-annotation.ts'; import { WAIT_REASONS } from './wait.ts'; @@ -221,3 +225,65 @@ export type ReplayObservationAuthority = Readonly<{ * session-store pair the binding is drawn from. */ export type ReplayObservationAuthorityBinder = (signal?: AbortSignal) => ReplayObservationAuthority; + +/** Runs before an `open` dispatches; a failure response aborts the dispatch with it. */ +export type ReplayOpenLifecycle = Readonly<{ + beforeDispatch: () => Promise; +}>; + +/** + * What a replay-issued dispatch asks the daemon to honor beyond the wire request. Declared once: + * the daemon's request-private half extends this shape, and the replay port emits it, so a key + * renamed on one side fails to compile on the other instead of being silently ignored. + */ +export type ReplayDispatchOptions = Readonly<{ + /** + * PROVENANCE, set by the replay runtime on every action it dispatches from a replay plan, + * annotated or not. It marks the action as AUTHORED (it came from the `.ad` under repair) rather + * than typed out-of-band by the agent mid-repair. The repair-segment exclusion keys off its + * ABSENCE: an authored `get`/`is`/`find`/`snapshot` step must survive into its own healed script, + * while an interactive diagnostic read used only to LOCATE the repair target must not. + * Trustworthy because the private half is daemon-only: the transport never copies it off the + * wire, so no client can spoof authored provenance. + */ + replayPlanStep?: boolean; + /** + * Implicit caller scope resolved before a nested dispatch replaces the public session name with + * its effective scoped key. + */ + resolvedSessionScope?: SessionScope; + /** + * ADR 0012 step 4 post-resolution guard: the verified target member's normalized local identity + * AND structural denotation (document order + sibling ordinal), set ONLY by the replay step loop + * when dispatching an annotated action whose pre-action verification passed. Interaction handlers + * thread it into command options as `expectedResolvedTarget`; dispatch's own resolution refuses + * (pre-action) when its winner differs in local identity OR structural position. + */ + replayTargetGuard?: ReplayTargetGuardDenotation; + /** Dual-endpoint counterpart of `replayTargetGuard` for target-authored drag. */ + replayTargetGuards?: Readonly<{ + source: ReplayTargetGuardDenotation; + destination: ReplayTargetGuardDenotation; + }>; + /** + * ADR 0012 / #1349 deferred (post-resolution) identity verification: the recorded `target-v1` + * landmark of an annotated selector `wait`, set ONLY by the replay step loop. The wait dispatch + * threads it into the polling loop as `recordedLandmark`; success then requires a selector match + * carrying this identity, and a timeout with rejected candidates surfaces the + * `WAIT_LANDMARK_MISMATCH_REASON` refusal the step loop converts into an identity-mismatch + * divergence. + */ + replayLandmarkGuard?: TargetAnnotationV1; + openLifecycle?: ReplayOpenLifecycle; + /** Terminate the targeted app without ending the owning daemon session. */ + closeAppOnly?: boolean; + /** + * Daemon-composed hierarchy capture used as operational evidence only. It must not issue or + * replace client ref authority. + */ + observationOnly?: true; + /** Provider-owned viewport already resolved while normalizing a nested gesture command. */ + gestureViewport?: Rect; + /** Maestro-compat execution profile for timed coordinate swipes projected to `gesture pan`. */ + gestureExecutionProfile?: GestureExecutionProfile; +}>; diff --git a/packages/contracts/src/session-scope.ts b/packages/contracts/src/session-scope.ts new file mode 100644 index 0000000000..9bc6291e1a --- /dev/null +++ b/packages/contracts/src/session-scope.ts @@ -0,0 +1,9 @@ +/** + * The isolation scope a session was opened under. Resolved by the daemon from the request's + * tenant/cwd/session metadata; read wherever a request must be matched to the sessions it may see. + */ +export type SessionScope = + | { kind: 'cwd'; id: string } + | { kind: 'tenant'; id: string } + | { kind: 'named-local' } + | { kind: 'global-default' }; diff --git a/packages/device-selection/package.json b/packages/device-selection/package.json index 2fe1998b4e..57e6e8e52e 100644 --- a/packages/device-selection/package.json +++ b/packages/device-selection/package.json @@ -22,6 +22,10 @@ "./device-inventory-context": { "types": "./src/device-inventory-context.ts", "default": "./src/device-inventory-context.ts" + }, + "./open-target": { + "types": "./src/open-target.ts", + "default": "./src/open-target.ts" } } } diff --git a/src/daemon/open-device-selection.ts b/packages/device-selection/src/open-target.ts similarity index 100% rename from src/daemon/open-device-selection.ts rename to packages/device-selection/src/open-target.ts diff --git a/packages/selectors/package.json b/packages/selectors/package.json index edd45f37de..0b53d1f786 100644 --- a/packages/selectors/package.json +++ b/packages/selectors/package.json @@ -4,7 +4,7 @@ "private": true, "sideEffects": false, "type": "module", - "description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19); `./parameterized-recorded-fill` parameterizes recorded fill payloads against their selectors; the interaction-resolution subpaths (`./selector-pipeline`, `./interaction-targeting`, `./interaction-touch-point`, `./press-retarget`, `./absence-observation*`, …) host the engine execution surface owned by the pipeline; `./snapshot-geometry-fixtures` is the canonical home for the geometry/touch-point test fixtures this package and root tests both build on (it re-exports `makeSnapshotState` from `@agent-device/capture-kit/snapshot-state-fixtures`, its own canonical home).", + "description": "Shared selector matching, argument, and replay semantics for agent-device. `.` is string-only; `./ast` is the published parser surface behind `agent-device/selectors`; `./engine` is the resolve/list surface reserved for the selector-pipeline owner (R19); `./parameterized-recorded-fill` parameterizes recorded fill payloads against their selectors; the interaction-resolution subpaths (`./selector-pipeline`, `./interaction-targeting`, `./interaction-touch-point`, `./press-retarget`, `./absence-observation*`, \u2026) host the engine execution surface owned by the pipeline; `./snapshot-geometry-fixtures` is the canonical home for the geometry/touch-point test fixtures this package and root tests both build on (it re-exports `makeSnapshotState` from `@agent-device/capture-kit/snapshot-state-fixtures`, its own canonical home).", "dependencies": { "@agent-device/ad-script": "workspace:*", "@agent-device/capture-kit": "workspace:*", @@ -79,6 +79,10 @@ "./touch-semantics": { "types": "./src/touch-semantics.ts", "default": "./src/touch-semantics.ts" + }, + "./target-evidence": { + "types": "./src/target-evidence.ts", + "default": "./src/target-evidence.ts" } }, "devDependencies": { diff --git a/src/daemon/__tests__/session-target-evidence.test.ts b/packages/selectors/src/target-evidence.test.ts similarity index 99% rename from src/daemon/__tests__/session-target-evidence.test.ts rename to packages/selectors/src/target-evidence.test.ts index 262e3d4020..c81b39a084 100644 --- a/src/daemon/__tests__/session-target-evidence.test.ts +++ b/packages/selectors/src/target-evidence.test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; -import { computeTargetEvidence } from '../session-target-evidence.ts'; +import { computeTargetEvidence } from './target-evidence.ts'; import { parseTargetAnnotationV1Payload, serializeTargetAnnotationV1, diff --git a/src/daemon/session-target-evidence.ts b/packages/selectors/src/target-evidence.ts similarity index 99% rename from src/daemon/session-target-evidence.ts rename to packages/selectors/src/target-evidence.ts index 0a60907d01..401565ca7b 100644 --- a/src/daemon/session-target-evidence.ts +++ b/packages/selectors/src/target-evidence.ts @@ -22,7 +22,13 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { resolveRectCenter } from '@agent-device/kernel/rect-center'; -import { findNearestScrollableContainer } from '@agent-device/capture-kit/ios-snapshot-engine'; +import { findNearestScrollableContainer } from '@agent-device/capture-kit/ios-snapshot-tree'; +import type { + TargetAncestryEntry, + TargetAnnotationV1, + TargetScrollRegion, + TargetVerification, +} from '@agent-device/contracts/replay'; import { buildAncestryChain, buildIndexMap, @@ -38,12 +44,6 @@ import { TARGET_ANNOTATION_MAX_ANCESTRY, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, } from '@agent-device/ad-script'; -import type { - TargetAncestryEntry, - TargetAnnotationV1, - TargetScrollRegion, - TargetVerification, -} from '@agent-device/contracts/replay'; /** ADR 0012 decision 3: the resolved winner and the tree it was resolved from. */ export type RecordedTargetCapture = { diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 252259ee6c..2d937da7c1 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -13,7 +13,23 @@ export type LogicalModulePolicy = Readonly<{ const DAEMON_REPLAY_FACADE = { root: 'src/daemon/replay/index.ts', - exports: ['ReplaySession', 'ReplayTestVideoOwner', 'runReplayCommand', 'runReplayTestCommand'], + exports: [ + 'ReplayCoordinator', + 'ReplayDaemonDependencies', + 'ReplayDispatchRequest', + 'ReplayInvoke', + 'ReplayResumeStamper', + 'ReplaySession', + 'ReplaySessionView', + 'ReplayTestVideoOwner', + 'appTargetResolutionOptions', + 'buildMaestroReplayTargetDeviceResolutionOptions', + 'buildReplayScriptPlatformFlags', + 'healedScriptSiblingPath', + 'readScriptReplaySelection', + 'runReplayCommand', + 'runReplayTestCommand', + ], } as const; const DAEMON_SESSION_LIFECYCLE_FACADE = { diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 26d8ff08d5..75f8f3834d 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -27,11 +27,11 @@ export const DAEMON_MODULARITY_BASELINE = { } as const; // The modules that own the daemon's dispatch vocabulary since #2338 split `daemon/types.ts`: -// the request shape, its wire-only half, and the live session record. All three are ratcheted +// the request shape and the live session record (the wire-only half moved to +// `@agent-device/contracts/command` as `DaemonWireRequest`). Both are ratcheted // together, so moving a symbol between them cannot reopen the boundary to a new outside zone. const DAEMON_TYPE_MODULES: readonly string[] = [ 'src/daemon/daemon-request.ts', - 'src/daemon/daemon-request-wire.ts', 'src/daemon/session-state.ts', ]; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index c2d9218270..8c357cc525 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -406,6 +406,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/ios-snapshot-engine', '@agent-device/capture-kit/ios-snapshot-planning', '@agent-device/capture-kit/ios-snapshot-runtime', + '@agent-device/capture-kit/ios-snapshot-tree', '@agent-device/capture-kit/mobile-snapshot-semantics', '@agent-device/capture-kit/perf-capture-admission-ledger', '@agent-device/capture-kit/perf-capture-recovery', @@ -690,6 +691,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/selectors/selector-pipeline', '@agent-device/selectors/selector-pipeline-policy', '@agent-device/selectors/snapshot-geometry-fixtures', + '@agent-device/selectors/target-evidence', '@agent-device/selectors/touch-semantics', ]); assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [ diff --git a/src/__tests__/selectors-build.test.ts b/src/__tests__/selectors-build.test.ts index 1a2a358040..1e264f89ef 100644 --- a/src/__tests__/selectors-build.test.ts +++ b/src/__tests__/selectors-build.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { buildNodes } from './test-utils/snapshot-builders.ts'; -import { computeTargetEvidence } from '../daemon/session-target-evidence.ts'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import { buildSelectorChainForNode } from '@agent-device/selectors'; function findByLabel(nodes: SnapshotNode[], label: string): SnapshotNode { diff --git a/src/commands/interaction/runtime/wait-selector.test.ts b/src/commands/interaction/runtime/wait-selector.test.ts index 0a7b962d88..e35ffba80a 100644 --- a/src/commands/interaction/runtime/wait-selector.test.ts +++ b/src/commands/interaction/runtime/wait-selector.test.ts @@ -9,7 +9,7 @@ import { } from '../../../runtime.ts'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { createFakeClock } from './__tests__/test-utils/index.ts'; -import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import { WAIT_LANDMARK_MISMATCH_REASON } from '@agent-device/contracts/replay'; import { AppError } from '@agent-device/kernel/errors'; diff --git a/src/core/press-retarget.test.ts b/src/core/press-retarget.test.ts index d845070245..55f20dad11 100644 --- a/src/core/press-retarget.test.ts +++ b/src/core/press-retarget.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { buildNodes } from '../__tests__/test-utils/snapshot-builders.ts'; -import { computeTargetEvidence } from '../daemon/session-target-evidence.ts'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import { buildSelectorChainForNode, resolveRecordedTarget } from '@agent-device/selectors'; import { readNodeLocalIdentity } from '@agent-device/ad-script'; import { resolvePressRecordingTarget } from '@agent-device/selectors/press-retarget'; diff --git a/src/daemon/__tests__/daemon-request-wire.test.ts b/src/daemon/__tests__/daemon-request-wire.test.ts index f328e8062b..d4867e1e5d 100644 --- a/src/daemon/__tests__/daemon-request-wire.test.ts +++ b/src/daemon/__tests__/daemon-request-wire.test.ts @@ -1,7 +1,8 @@ import { expect, test } from 'vitest'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { DaemonRequest } from '../daemon-request.ts'; -import type { DaemonWireRequest } from '../daemon-request-wire.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { ExecutionPlan } from '../execution-plan.ts'; import type { SessionState } from '../session-state.ts'; /** @@ -53,7 +54,10 @@ const wireCannotReachDeviceLease: IsExactly, 'internal'> = true; -const requestReachesSessionState: IsExactly, true> = true; +// The private half no longer names `SessionState` itself (its open-lifecycle hook takes no +// session since the replay dispatch options moved to contracts); the batch execution plan is the +// daemon-only shape it still carries, so it stands as the second positive control. +const requestReachesExecutionPlan: IsExactly, true> = true; const requestReachesDeviceLease: IsExactly, true> = true; test('the wire request shape carries no daemon-only half', () => { @@ -65,7 +69,7 @@ test('the wire request shape carries no daemon-only half', () => { }); test('the daemon request shape does reach both, so the walk above can find them', () => { - expect([requestHasInternalKey, requestReachesSessionState, requestReachesDeviceLease]).toEqual([ + expect([requestHasInternalKey, requestReachesExecutionPlan, requestReachesDeviceLease]).toEqual([ true, true, true, diff --git a/src/daemon/__tests__/device-claim-conflict.test.ts b/src/daemon/__tests__/device-claim-conflict.test.ts index 3caf22daeb..b295f5f133 100644 --- a/src/daemon/__tests__/device-claim-conflict.test.ts +++ b/src/daemon/__tests__/device-claim-conflict.test.ts @@ -10,8 +10,8 @@ import { buildDeviceClaimInspectionCommand, decideAllocatorHeldAdmission, DEVICE_CLAIM_ALLOCATOR_HELD, - isDeviceClaimConflictReason, } from '../device-claim-conflict.ts'; +import { isDeviceClaimConflictReason } from '@agent-device/contracts/device'; import type { AllocatorHeldClaimAdmission } from '../device-claim-allocator.ts'; import type { InspectedDeviceClaim } from '../device-claim-inspection.ts'; diff --git a/src/daemon/__tests__/replay-coordinator-ownership.test.ts b/src/daemon/__tests__/replay-coordinator-ownership.test.ts index c41b5bec56..84bec5481d 100644 --- a/src/daemon/__tests__/replay-coordinator-ownership.test.ts +++ b/src/daemon/__tests__/replay-coordinator-ownership.test.ts @@ -28,7 +28,7 @@ const COORDINATOR_MODULE = 'src/daemon/session-replay-coordinator.ts'; const TRANSACTION_MODULE = 'src/daemon/session-replay-transaction.ts'; const SESSION_STORE_MODULE = 'src/daemon/session-store.ts'; -const RUNTIME_FILE = 'src/daemon/replay/internal/native-command.ts'; +const RUNTIME_FILE = 'src/daemon/handlers/session-replay-command.ts'; /** The divergence-report chain: never a second `ReplayCoordinator`, never a bare `SessionStore`. */ const DIVERGENCE_CHAIN_FILES = [ diff --git a/src/daemon/__tests__/replay-device-selection.test.ts b/src/daemon/__tests__/replay-device-selection.test.ts index 0a2914a729..c9d8db3cfe 100644 --- a/src/daemon/__tests__/replay-device-selection.test.ts +++ b/src/daemon/__tests__/replay-device-selection.test.ts @@ -2,10 +2,8 @@ import { test, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { parseReplayInput } from '@agent-device/ad-script'; -import { - buildReplayScriptPlatformFlags, - buildReplayTargetDeviceResolution, -} from '../replay-device-selection.ts'; +import { buildReplayTargetDeviceResolution } from '../replay-device-selection.ts'; +import { buildReplayScriptPlatformFlags } from '../replay/index.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { replayScriptSourceBundleFor } from '../../__tests__/test-utils/replay-script-source.ts'; diff --git a/src/daemon/__tests__/session-routing.test.ts b/src/daemon/__tests__/session-routing.test.ts index 20aee7731c..2de5ff75dc 100644 --- a/src/daemon/__tests__/session-routing.test.ts +++ b/src/daemon/__tests__/session-routing.test.ts @@ -7,7 +7,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { SessionStore } from '../session-store.ts'; import { resolveEffectiveSessionName, resolveSessionScope } from '../session-routing.ts'; import type { DaemonRequest } from '../daemon-request.ts'; -import type { SessionScope, SessionState } from '../session-state.ts'; +import type { SessionState } from '../session-state.ts'; +import type { SessionScope } from '@agent-device/contracts/session'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; function makeSession(name: string): SessionState { diff --git a/src/daemon/action-utils.ts b/src/daemon/action-utils.ts deleted file mode 100644 index c8502f64fd..0000000000 --- a/src/daemon/action-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { SessionAction } from '@agent-device/contracts/session'; - -export function inferFillText(action: SessionAction): string { - const resultText = action.result?.text; - if (typeof resultText === 'string') return resultText; - const positionals = action.positionals ?? []; - if (positionals.length === 0) return ''; - const first = positionals[0]; - if (first?.startsWith('@')) { - if (positionals.length >= 3) return positionals.slice(2).join(' '); - return positionals.slice(1).join(' '); - } - if ( - positionals.length >= 3 && - !Number.isNaN(Number(positionals[0])) && - !Number.isNaN(Number(positionals[1])) - ) { - return positionals.slice(2).join(' '); - } - return positionals.slice(1).join(' '); -} diff --git a/src/daemon/daemon-request.ts b/src/daemon/daemon-request.ts index cb00d50b2e..d011f62cdb 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -1,22 +1,16 @@ import type { ExecutionPlan } from './execution-plan.ts'; -import type { GestureExecutionProfile } from '@agent-device/contracts/gesture-plan-types'; import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction'; import type { DeviceLease } from '@agent-device/contracts/device'; -import type { - ReplayTargetGuardDenotation, - TargetAnnotationV1, -} from '@agent-device/contracts/replay'; +import type { ReplayDispatchOptions } from '@agent-device/contracts/replay'; import type { DaemonArtifact as PublicDaemonArtifact, DaemonResponse as PublicDaemonResponse, DaemonResponseData as PublicDaemonResponseData, } from '@agent-device/kernel/contracts'; -import type { Rect } from '@agent-device/kernel/snapshot'; -import type { DaemonWireRequest } from './daemon-request-wire.ts'; -import type { SessionState } from './session-state.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; /** - * The daemon's own request and response vocabulary: the wire shape from `daemon-request-wire.ts` + * The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`) * plus what only the daemon may see. A consumer that must stay free of live session state takes * `DaemonWireRequest` directly. */ @@ -24,11 +18,12 @@ import type { SessionState } from './session-state.ts'; export type DaemonArtifact = PublicDaemonArtifact; export type DaemonResponseData = PublicDaemonResponseData; -export type DaemonOpenLifecycle = { - beforeDispatch?: (session: SessionState) => Promise; -}; - -type DaemonRequestInternal = { +/** + * What only the daemon may see on a request: the replay dispatch options (declared once in + * `@agent-device/contracts/replay`, so the port and the daemon cannot drift), plus the callbacks, + * plan and lease the daemon composes for itself. + */ +type DaemonRequestInternal = ReplayDispatchOptions & { /** * What an `open` spent from its `--wait` budget while it waited for this device outside the * device execution lock. The refusal the open ends with has to say the budget was spent, and @@ -37,7 +32,6 @@ type DaemonRequestInternal = { */ openDeviceWait?: { waitedMs: number }; publicNetworkOnly?: true; - openLifecycle?: DaemonOpenLifecycle; /** * The steps a batch still has ahead of this one. The open seam derives platform readiness * policy (runner demand) from it; the transport strips `internal`, so it never arrives from a @@ -51,50 +45,6 @@ type DaemonRequestInternal = { */ retainDeviceExecutionLock?: (deviceId: string) => Promise; admittedLease?: DeviceLease; - /** - * Daemon-composed hierarchy capture used as operational evidence only. - * It must not issue or replace client ref authority. - */ - observationOnly?: true; - /** - * Implicit caller scope resolved before a nested dispatch replaces the - * public session name with its effective scoped key. - */ - resolvedSessionScope?: SessionState['sessionScope']; - /** Terminate the targeted app without ending the owning daemon session. */ - closeAppOnly?: boolean; - /** Provider-owned viewport already resolved while normalizing a nested gesture command. */ - gestureViewport?: Rect; - /** Maestro-compat execution profile for timed coordinate swipes projected to `gesture pan`. */ - gestureExecutionProfile?: GestureExecutionProfile; - /** - * ADR 0012 step 4 post-resolution guard: the verified target member's - * normalized local identity AND structural denotation (document order + - * sibling ordinal), set ONLY by the replay step loop when dispatching an - * annotated action whose pre-action verification passed. Interaction - * handlers thread it into command options as `expectedResolvedTarget`; - * dispatch's own resolution refuses (pre-action) when its winner differs in - * local identity OR structural position — the latter distinguishes a - * different same-identity duplicate. - */ - replayTargetGuard?: ReplayTargetGuardDenotation; - /** Dual-endpoint counterpart of replayTargetGuard for target-authored drag. */ - replayTargetGuards?: { - source: ReplayTargetGuardDenotation; - destination: ReplayTargetGuardDenotation; - }; - /** - * ADR 0012 / #1349 deferred (post-resolution) identity verification: the - * recorded `target-v1` landmark of an annotated selector `wait`, set ONLY - * by the replay step loop. The wait dispatch threads it into the polling - * loop as `recordedLandmark`; success then requires a selector match - * carrying this identity, and a timeout with rejected candidates surfaces - * the `WAIT_LANDMARK_MISMATCH_REASON` refusal the step loop converts into - * an identity-mismatch divergence. Never used by the generic pre-dispatch - * verification path — a wait's landmark may legitimately be absent when - * the step starts. - */ - replayLandmarkGuard?: TargetAnnotationV1; /** * ADR 0014 / #1654: the complete ref/node/tree target a mutating `find` * resolved against its fresh capture. Its presence both marks the ref as @@ -104,32 +54,11 @@ type DaemonRequestInternal = { * side-effect seam and expires the frame. */ findResolvedTarget?: PreresolvedInteractionTarget; - /** - * #1271 stage 2 (ADR 0012 decision 6 amendment): PROVENANCE — set by the - * replay runtime (`invokeResolvedReplayAction`, - * `session-replay-action-runtime.ts`) on every action it dispatches from a - * replay plan, annotated or not. It marks the action as AUTHORED (it came - * from the `.ad` under repair) rather than typed out-of-band by the agent - * mid-repair. - * - * The repair-segment exclusion keys off its ABSENCE: an authored - * `get`/`is`/`find`/`snapshot` step must survive into its own healed script - * (silently dropping it would make the heal quietly stop asserting what the - * original flow asserted), while an interactive diagnostic read used only to - * LOCATE the repair target must not. Command class alone cannot tell those - * apart — they are the same command — so provenance is the discriminator and - * `--record` is only for deliberately inserting an interactive read. - * - * Trustworthy because `internal` is daemon-only: `toDaemonRequest` - * (`server/http-server.ts`) never copies it off the wire, so no client can - * spoof authored provenance. Same channel as `replayTargetGuard` above. - */ - replayPlanStep?: boolean; }; /** * The server-side request: `DaemonWireRequest` plus what only the daemon may see. `internal` - * carries `SessionState` callbacks and the admitted lease — which is why this type stays in the + * carries the admitted lease, the execution plan and request-owned callbacks — which is why this type stays in the * daemon and why the wire half is declared separately. Zones below it that only need to classify * a command take `contracts/dispatched-command.ts` instead. */ diff --git a/src/daemon/device-claim-conflict.ts b/src/daemon/device-claim-conflict.ts index dbbcad5631..c0764d1be2 100644 --- a/src/daemon/device-claim-conflict.ts +++ b/src/daemon/device-claim-conflict.ts @@ -17,21 +17,7 @@ import { } from './device-claim-inspection.ts'; import type { DaemonResponse } from './daemon-request.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; - -export type DeviceClaimConflictReason = - | 'DEVICE_CLAIM_LIVE_OWNER' - | 'DEVICE_CLAIM_RECOVERY_PENDING' - | 'DEVICE_CLAIM_OWNER_UNCERTAIN'; - -const DEVICE_CLAIM_CONFLICT_REASONS = new Set([ - 'DEVICE_CLAIM_LIVE_OWNER', - 'DEVICE_CLAIM_RECOVERY_PENDING', - 'DEVICE_CLAIM_OWNER_UNCERTAIN', -]); - -export function isDeviceClaimConflictReason(value: unknown): value is DeviceClaimConflictReason { - return DEVICE_CLAIM_CONFLICT_REASONS.has(value as DeviceClaimConflictReason); -} +import type { DeviceClaimConflictReason } from '@agent-device/contracts/device'; /** * The reason of the allocator-held arm's refusal when no allocator-held claim diff --git a/src/daemon/handlers/__tests__/session-replay-command-trust.test.ts b/src/daemon/handlers/__tests__/session-replay-command-trust.test.ts index 1a95103a9a..8c3f7a5ca8 100644 --- a/src/daemon/handlers/__tests__/session-replay-command-trust.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-command-trust.test.ts @@ -21,13 +21,17 @@ import type { SessionCommandParams } from '../session-command-input.ts'; import { makeSessionStore } from './session-test-harness.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -const runReplayCommand = vi.fn(async (_command: ReplayCommand) => ({ - ok: true as const, - data: {}, -})); -const runReplayTestCommand = vi.fn(async (_command: ReplayTestCommand) => ({ - ok: true as const, - data: {}, +// Hoisted: the handler reaches the replay façade through a daemon module that this file's own +// imports pull in, so the factory below runs before a plain `const` would be initialized. +const { runReplayCommand, runReplayTestCommand } = vi.hoisted(() => ({ + runReplayCommand: vi.fn(async (_command: ReplayCommand) => ({ + ok: true as const, + data: {}, + })), + runReplayTestCommand: vi.fn(async (_command: ReplayTestCommand) => ({ + ok: true as const, + data: {}, + })), })); vi.mock('../../replay/index.ts', () => ({ @@ -95,7 +99,7 @@ test('replay handler maps internal.publicNetworkOnly to the command input', asyn test('replay handler marks a local request as trusted', async () => { await handleReplayCommand(baseParams('replay')); - expect(runReplayCommand.mock.calls[0]?.[0].publicNetworkOnly).toBe(false); + expect(runReplayCommand.mock.calls[0]?.[0].publicNetworkOnly).toBeUndefined(); }); test('replay test handler maps internal.publicNetworkOnly to the command input', async () => { @@ -105,5 +109,5 @@ test('replay test handler maps internal.publicNetworkOnly to the command input', test('replay test handler marks a local request as trusted', async () => { await handleReplayTestCommand(testParams()); - expect(runReplayTestCommand.mock.calls[0]?.[0].publicNetworkOnly).toBe(false); + expect(runReplayTestCommand.mock.calls[0]?.[0].publicNetworkOnly).toBeUndefined(); }); diff --git a/src/daemon/handlers/__tests__/session-replay-command.test.ts b/src/daemon/handlers/__tests__/session-replay-command.test.ts index 35b2536b52..e409840ace 100644 --- a/src/daemon/handlers/__tests__/session-replay-command.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-command.test.ts @@ -8,7 +8,8 @@ import { import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import type { SessionCommandParams } from '../session-command-input.ts'; -import { handleReplayTestCommand } from '../session-replay-command.ts'; +import { handleReplayTestCommand, replayInvoke } from '../session-replay-command.ts'; +import type { DaemonRequest } from '../../daemon-request.ts'; import { makeSessionStore } from './session-test-harness.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -51,3 +52,63 @@ test('replay test handler threads the complete video owner into the application expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).not.toContain('Screen-recording runtime is not configured'); }); + +test('replayInvoke re-attaches the originating private half, then folds the dispatch bag over it', async () => { + const base: DaemonRequest = { + token: 'token', + session: 'default', + command: 'replay', + positionals: [], + internal: { + publicNetworkOnly: true, + replayTargetGuard: { + identity: { role: 'button', label: 'Old' }, + structural: { documentOrder: 0, sibling: 0 }, + }, + }, + }; + const invoked: DaemonRequest[] = []; + const invoke = replayInvoke(async (request) => { + invoked.push(request); + return { ok: true, data: {} }; + }, base); + + await invoke({ + token: 'token', + session: 'default', + command: 'tap', + positionals: ['@e1'], + dispatch: { + replayPlanStep: true, + replayTargetGuard: { + identity: { role: 'button', label: 'New' }, + structural: { documentOrder: 1, sibling: 0 }, + }, + }, + }); + await invoke({ token: 'token', session: 'default', command: 'snapshot', positionals: [] }); + + expect(invoked[0]?.internal).toEqual({ + publicNetworkOnly: true, + replayPlanStep: true, + replayTargetGuard: { + identity: { role: 'button', label: 'New' }, + structural: { documentOrder: 1, sibling: 0 }, + }, + }); + expect(invoked[0]).not.toHaveProperty('dispatch'); + expect(invoked[1]?.internal).toEqual(base.internal); +}); + +test('replayInvoke sends no private half when neither side carries one', async () => { + const invoked: DaemonRequest[] = []; + const invoke = replayInvoke( + async (request) => { + invoked.push(request); + return { ok: true, data: {} }; + }, + { token: 'token', session: 'default', command: 'replay', positionals: [] }, + ); + await invoke({ token: 'token', session: 'default', command: 'snapshot', positionals: [] }); + expect(invoked[0]).not.toHaveProperty('internal'); +}); diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index fc9cce74be..a60752743d 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -1,16 +1,33 @@ +import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; -import type { DaemonRequest } from '../daemon-request.ts'; +import { stripUndefined } from '@agent-device/kernel/record'; +import type { DaemonInvokeFn, DaemonRequest } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; import { bindInternalObservationAuthority } from '../internal-observation.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; -import { runReplayCommand, runReplayTestCommand, type ReplaySession } from '../replay/index.ts'; +import { + runReplayCommand, + runReplayTestCommand, + type ReplayDaemonDependencies, + type ReplayDispatchRequest, + type ReplayInvoke, + type ReplaySession, +} from '../replay/index.ts'; +import { createReplayCoordinator } from '../session-replay-coordinator.ts'; +import { assertSessionSelectorMatches } from '../session-selector.ts'; +import { resolveSessionScope } from '../session-routing.ts'; +import { resolveEffectiveOpenRuntimeHints } from '../session-runtime.ts'; +import { captureSnapshot } from '../snapshot-capture.ts'; import { handleSessionCloseCommands } from '../session-lifecycle/index.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; import type { SessionCommandHandler } from './session-command-input.ts'; +/** The daemon policy replay consults without owning. */ +export const replayDaemonDependencies: ReplayDaemonDependencies = { resolveSessionScope }; + export const handleReplayCommand: SessionCommandHandler = async ({ req, sessionName, @@ -20,10 +37,10 @@ export const handleReplayCommand: SessionCommandHandler = async ({ invokeReplayAction, }) => await runReplayCommand({ - request: req, + ...replayCommandEnvelope(req), session: createReplaySession(sessionName, logPath, sessionStore), - invoke: invokeReplayAction ?? invoke, - publicNetworkOnly: req.internal?.publicNetworkOnly === true, + invoke: replayInvoke(invokeReplayAction ?? invoke, req), + dependencies: replayDaemonDependencies, }); export const handleReplayTestCommand: SessionCommandHandler = async ({ @@ -59,12 +76,12 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ throwIfCanceled, }); return await runReplayTestCommand({ - request: req, + ...replayCommandEnvelope(req), session: createReplaySession(sessionName, logPath, sessionStore), createSession: (testSessionName, testLogPath) => createReplaySession(testSessionName, testLogPath, sessionStore), - invoke: invokeReplayAction ?? invoke, - publicNetworkOnly: req.internal?.publicNetworkOnly === true, + invoke: replayInvoke(invokeReplayAction ?? invoke, req), + dependencies: replayDaemonDependencies, cleanupSession: async (testSessionName) => await closeReplayTestSession({ req, @@ -80,11 +97,48 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ }); }; +/** + * The request as replay may read it: its wire half, plus the two admission facts the daemon + * resolved into the private half that replay's own decisions depend on. + */ +export function replayCommandEnvelope(req: DaemonRequest): { + request: DaemonWireRequest; + publicNetworkOnly?: true; + resolvedSessionScope?: SessionState['sessionScope']; +} { + const { internal, ...request } = req; + return { + request, + ...(internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), + ...(internal?.resolvedSessionScope + ? { resolvedSessionScope: internal.resolvedSessionScope } + : {}), + }; +} + +/** + * Dispatches replay's nested requests through the daemon with the private half re-attached: the + * originating request's own (`executionPlan`, `admittedLease`, ...) first, then whatever the + * dispatch bag asks for. Replay never composes `internal`; this is the one place it is folded in. + */ +export function replayInvoke(invoke: DaemonInvokeFn, base: DaemonRequest): ReplayInvoke { + return async (request) => await invoke(withDaemonPrivateHalf(base, request)); +} + +function withDaemonPrivateHalf( + base: DaemonRequest, + { dispatch, ...request }: ReplayDispatchRequest, +): DaemonRequest { + const internal = stripUndefined({ ...base.internal, ...dispatch }); + return Object.keys(internal).length > 0 ? { ...request, internal } : request; +} + export function createReplaySession( name: string, logPath: string, store: SessionStore, ): ReplaySession { + const get = () => store.get(name); const updateSession = (mutate: (session: SessionState) => void): boolean => { const session = store.get(name); if (!session) return false; @@ -92,11 +146,9 @@ export function createReplaySession( store.set(name, session); return true; }; - return { - name, - logPath, - store: { - get: () => store.get(name), + const coordinator = createReplayCoordinator({ + sessionStore: { + get, lookup: () => store.lookup(name), getRuntimeHints: () => store.getRuntimeHints(name), ensureSessionDir: () => store.ensureSessionDir(name), @@ -105,15 +157,53 @@ export function createReplaySession( update: updateSession, clearRepairTombstone: () => store.clearRepairTombstone(name), }, + }); + return { + name, + logPath, + store: { + get, + lookup: () => store.lookup(name), + getRuntimeHints: () => store.getRuntimeHints(name), + ensureSessionDir: () => store.ensureSessionDir(name), + assertSelectorMatches: (flags) => { + const ref = store.lookup(name); + if (ref) assertSessionSelectorMatches(ref, flags); + }, + resolveOpenRuntimeHints: ({ request, device, platform }) => + resolveEffectiveOpenRuntimeHints({ + req: request, + sessionStore: { + getRuntimeHints: (requestedSessionName) => + requestedSessionName === name ? store.getRuntimeHints(name) : undefined, + }, + sessionName: name, + device, + platform, + }), + }, observationStore: { - get: () => store.get(name), + get, bindAuthority: (signal) => bindInternalObservationAuthority({ - sessionStore: { get: () => store.get(name), update: updateSession }, + sessionStore: { get, update: updateSession }, sessionName: name, ...(signal ? { signal } : {}), }), + capture: async ({ flags, logPath: captureLogPath }) => { + const session = store.get(name); + if (!session) { + throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); + } + return await captureSnapshot({ + device: session.device, + session, + flags, + logPath: captureLogPath, + }); + }, }, + coordinator, }; } diff --git a/src/daemon/install-source-resolution.ts b/src/daemon/install-source-resolution.ts index f92cf4a803..07ebbcfeef 100644 --- a/src/daemon/install-source-resolution.ts +++ b/src/daemon/install-source-resolution.ts @@ -1,7 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { LocalInstallSource } from '@agent-device/kernel/contracts'; +import type { DaemonInstallSource, LocalInstallSource } from '@agent-device/kernel/contracts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from './artifact-tracking.ts'; -import type { DaemonInstallSource } from './daemon-request-wire.ts'; import type { DaemonRequest } from './daemon-request.ts'; function assertUnsupportedInstallSource(source: never): never { diff --git a/src/daemon/interaction/internal/interaction-common.ts b/src/daemon/interaction/internal/interaction-common.ts index 4311d5716a..4f7d21bf98 100644 --- a/src/daemon/interaction/internal/interaction-common.ts +++ b/src/daemon/interaction/internal/interaction-common.ts @@ -5,10 +5,9 @@ import { stripInternalInteractionFlags } from '../../interaction-outcome-policy. import { computeTargetEvidence, type RecordedTargetCapture, -} from '../../session-target-evidence.ts'; +} from '@agent-device/selectors/target-evidence'; import type { MultiTargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { inferFillText } from '../../action-utils.ts'; -import { recordedInputPlaceholder } from '@agent-device/ad-script'; +import { inferFillText, recordedInputPlaceholder } from '@agent-device/ad-script'; import { parameterizeRecordedFillPayload } from '@agent-device/selectors/parameterized-recorded-fill'; import type { InteractionFinalizationOperations } from './types.ts'; diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index 33842845fe..7e4192bc89 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -36,7 +36,7 @@ import { finalizeTouchInteraction, } from './interaction-runtime.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; -import type { RecordedTargetCapture } from '../../session-target-evidence.ts'; +import type { RecordedTargetCapture } from '@agent-device/selectors/target-evidence'; import { gestureResponseData } from './interaction-gesture-response.ts'; import { noActiveSessionError } from '@agent-device/kernel/contracts'; diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 6ec23de5cd..7238a8677b 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -16,7 +16,7 @@ import { type InteractionResponseDataTransformCommand, } from '../../../core/interaction-response-data-transform.ts'; import { issueSettleRefs } from '../../session-snapshot.ts'; -import type { RecordedTargetCapture } from '../../session-target-evidence.ts'; +import type { RecordedTargetCapture } from '@agent-device/selectors/target-evidence'; import type { SessionState } from '../../session-state.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { resolveDirectTouchReferenceFrameSafely } from './interaction-touch-reference-frame.ts'; diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index 458f3297ff..92f0c09d67 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -1,15 +1,15 @@ -import type { SessionAction } from '@agent-device/contracts/session'; import { inspectMaestroFlow } from '@agent-device/maestro'; -import { - parseReplayInput, - resolveDeclaredScriptPlatform, - resolveReplayFormat, -} from '@agent-device/ad-script'; +import { parseReplayInput, resolveReplayFormat } from '@agent-device/ad-script'; import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve'; -import { isDeepLinkTarget, type CommandFlags } from '@agent-device/contracts/command'; +import type { CommandFlags } from '@agent-device/contracts/command'; import { readReplayScriptSourceFile } from './replay-script-source.ts'; -import { appleSimulatorAppTargetForOpenTarget } from './open-device-selection.ts'; import type { DaemonRequest } from './daemon-request.ts'; +import { + appTargetResolutionOptions, + buildMaestroReplayTargetDeviceResolutionOptions, + buildReplayScriptPlatformFlags, + readScriptReplaySelection, +} from './replay/index.ts'; export type ReplayTargetDeviceResolution = { flags: CommandFlags; @@ -61,66 +61,3 @@ export function buildReplayTargetDeviceResolution( return undefined; } } - -export function buildMaestroReplayTargetDeviceResolutionOptions( - appTarget: string | undefined, - platform: CommandFlags['platform'] | undefined, -): ResolveTargetDeviceOptions { - if (platform !== 'ios') return {}; - 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; -} { - // `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 !== 'open') continue; - const target = action.positionals?.[0]; - if (isStaticAppTarget(target)) return { appTarget: target, platform }; - return { appTarget: undefined, platform }; - } - return { appTarget: undefined, platform }; -} - -/** Applies a platform configured before the first open to replay dispatch. */ -export function buildReplayScriptPlatformFlags( - flags: CommandFlags | undefined, - actions: SessionAction[], -): CommandFlags { - const selection = readScriptReplaySelection(actions); - if (flags?.platform !== undefined || !selection.platform) { - return flags ?? {}; - } - return { ...flags, platform: selection.platform }; -} - -function isStaticAppTarget(value: string | undefined): value is string { - return Boolean(value && value.trim() && !value.includes('$') && !isDeepLinkTarget(value)); -} - -function appTargetResolutionOptions( - openTarget: string | undefined, -): ResolveTargetDeviceOptions | undefined { - const appTarget = appleSimulatorAppTargetForOpenTarget(openTarget); - return appTarget ? { appleSimulatorAppTarget: appTarget } : undefined; -} diff --git a/src/daemon/replay/__tests__/application.test.ts b/src/daemon/replay/__tests__/application.test.ts index ae70b63dd5..58d5e03643 100644 --- a/src/daemon/replay/__tests__/application.test.ts +++ b/src/daemon/replay/__tests__/application.test.ts @@ -13,7 +13,12 @@ import { makeIosSession } from '../../../__tests__/test-utils/session-factories. import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import { SessionStore } from '../../session-store.ts'; -import { createReplaySession } from '../../handlers/session-replay-command.ts'; +import { + createReplaySession, + replayCommandEnvelope, + replayDaemonDependencies, + replayInvoke, +} from '../../handlers/session-replay-command.ts'; import { runReplayCommand, runReplayTestCommand } from '../index.ts'; import type { ReplayCommand, ReplayTestCommand } from '../internal/command-types.ts'; import { captureSnapshotWithInteractor } from '../../snapshot-interactor-capture.ts'; @@ -40,9 +45,10 @@ function replayCommand( invoke: (request: DaemonRequest) => Promise, ): ReplayCommand { return { - request: req, + ...replayCommandEnvelope(req), session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), - invoke, + invoke: replayInvoke(invoke, req), + dependencies: replayDaemonDependencies, }; } diff --git a/src/daemon/replay/__tests__/replay-command-fixture.ts b/src/daemon/replay/__tests__/replay-command-fixture.ts index f95fba76bf..481e4de54e 100644 --- a/src/daemon/replay/__tests__/replay-command-fixture.ts +++ b/src/daemon/replay/__tests__/replay-command-fixture.ts @@ -1,8 +1,14 @@ import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionStore } from '../../session-store.ts'; -import { createReplaySession } from '../../handlers/session-replay-command.ts'; +import { + createReplaySession, + replayCommandEnvelope, + replayDaemonDependencies, + replayInvoke, +} from '../../handlers/session-replay-command.ts'; import { runReplayCommand } from '../index.ts'; +import type { ReplayCommand } from '../internal/command-types.ts'; export type ReplayCommandTestInput = Readonly<{ req: DaemonRequest; @@ -14,13 +20,23 @@ export type ReplayCommandTestInput = Readonly<{ onStep?: ReplayTestAttemptStepSink; }>; -export function runReplayForTest(params: ReplayCommandTestInput): Promise { +/** + * A replay command bound the way the handler binds it: the wire request and its admission facts, + * the daemon's session capabilities, and nested dispatch that re-attaches the private half so a + * test's `invoke` sees the same `DaemonRequest` the daemon would. + */ +export function replayCommandForTest(params: ReplayCommandTestInput): ReplayCommand { const { req, sessionName, logPath, sessionStore, invoke, tracePath, onStep } = params; - return runReplayCommand({ - request: req, + return { + ...replayCommandEnvelope(req), session: createReplaySession(sessionName, logPath, sessionStore), - invoke, + invoke: replayInvoke(invoke, req), + dependencies: replayDaemonDependencies, ...(tracePath === undefined ? {} : { tracePath }), ...(onStep === undefined ? {} : { onStep }), - }); + }; +} + +export function runReplayForTest(params: ReplayCommandTestInput): Promise { + return runReplayCommand(replayCommandForTest(params)); } diff --git a/src/daemon/replay/index.ts b/src/daemon/replay/index.ts index 8ad0b041ee..22ebb33d45 100644 --- a/src/daemon/replay/index.ts +++ b/src/daemon/replay/index.ts @@ -1,4 +1,20 @@ /** Public replay application seam for daemon handlers and their admitted owner capabilities. */ export { runReplayCommand } from './internal/native-command.ts'; export { runReplayTestCommand } from './internal/test-command.ts'; -export type { ReplaySession, ReplayTestVideoOwner } from './internal/command-types.ts'; +export type { + ReplayCoordinator, + ReplayDaemonDependencies, + ReplayDispatchRequest, + ReplayInvoke, + ReplayResumeStamper, + ReplaySession, + ReplaySessionView, + ReplayTestVideoOwner, +} from './internal/command-types.ts'; +export { healedScriptSiblingPath } from './internal/session-replay-heal.ts'; +export { + appTargetResolutionOptions, + buildMaestroReplayTargetDeviceResolutionOptions, + buildReplayScriptPlatformFlags, + readScriptReplaySelection, +} from './internal/replay-script-selection.ts'; diff --git a/src/daemon/replay/internal/__tests__/replay-session-fixture.ts b/src/daemon/replay/internal/__tests__/replay-session-fixture.ts index feea551a39..fad1c48201 100644 --- a/src/daemon/replay/internal/__tests__/replay-session-fixture.ts +++ b/src/daemon/replay/internal/__tests__/replay-session-fixture.ts @@ -1,5 +1,4 @@ import { createReplaySession } from '../../../handlers/session-replay-command.ts'; -import { createReplayCoordinator } from '../../../session-replay-coordinator.ts'; import type { SessionStore } from '../../../session-store.ts'; export function replaySessionForTest(sessionStore: SessionStore, sessionName: string) { @@ -7,24 +6,16 @@ export function replaySessionForTest(sessionStore: SessionStore, sessionName: st } export function replayCoordinatorForTest(sessionStore: SessionStore, sessionName: string) { - const session = replaySessionForTest(sessionStore, sessionName); - return createReplayCoordinator({ - sessionStore: session.store, - mutationStore: session.mutationStore, - }); + return replaySessionForTest(sessionStore, sessionName).coordinator; } export function replayDivergenceForTest(sessionStore: SessionStore, sessionName: string) { const session = replaySessionForTest(sessionStore, sessionName); - const coordinator = createReplayCoordinator({ - sessionStore: session.store, - mutationStore: session.mutationStore, - }); return { session: session.observationStore.get(), sessionName, sessionStore: session.store, observationStore: session.observationStore, - resumeStamper: coordinator.resumeStamper, + resumeStamper: session.coordinator.resumeStamper, }; } diff --git a/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts b/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts index 53a4b726cc..a9fac5e9f5 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-action-runtime.test.ts @@ -4,6 +4,7 @@ import { makeIosSession } from '../../../../__tests__/test-utils/session-factori import { recordActionEntry } from '../../../session-action-recorder.ts'; import type { DaemonRequest } from '../../../daemon-request.ts'; import { invokeReplayAction } from '../session-replay-action-runtime.ts'; +import { replayDaemonDependencies } from '../../../handlers/session-replay-command.ts'; import { resolveReplayAction } from '@agent-device/ad-script'; const REPLAY_REQUEST: DaemonRequest = { @@ -38,6 +39,8 @@ test.each(['', ' '])( filePath: 'login.ad', line: 1, step: 1, + resolvedSessionScope: undefined, + dependencies: replayDaemonDependencies, invoke: async (request) => { recordActionEntry(session, { command: request.command, diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts index 60c652de4e..73e10d05d6 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-error-projection.test.ts @@ -4,7 +4,7 @@ import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; import { SessionStore } from '../../../session-store.ts'; -import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import { replayCommandForTest } from '../../__tests__/replay-command-fixture.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; @@ -35,11 +35,15 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { meta: { requestId: 'req-maestro-hint' }, } as unknown as DaemonRequest; - const response = await runTypedMaestroReplay({ - request: req, - session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), - invoke: async () => ({ ok: true, data: {} }), - }); + const response = await runTypedMaestroReplay( + replayCommandForTest({ + req, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }), + ); expect(response.ok).toBe(false); if (response.ok) return; diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-request.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-request.test.ts index 6f51bb37a8..ec151da7ac 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-request.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-request.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon-request.ts'; -import { maestroOperationDaemonRequest } from '../session-replay-maestro-request.ts'; +import type { ReplayDispatchRequest } from '../command-types.ts'; +import { maestroOperationDispatchRequest } from '../session-replay-maestro-request.ts'; -const replay: DaemonRequest = { +const replay: ReplayDispatchRequest = { token: 'nested-token', session: 'maestro-nested', command: 'replay', @@ -20,11 +20,11 @@ const replay: DaemonRequest = { metroPort: 8081, bundleUrl: 'http://127.0.0.1:8081/index.bundle', }, - internal: { publicNetworkOnly: true }, + dispatch: { observationOnly: true }, }; test('keeps the replay request envelope and replaces the command it carried', () => { - const request = maestroOperationDaemonRequest(replay, { + const request = maestroOperationDispatchRequest(replay, { command: 'open', positionals: ['com.example.app'], flags: { platform: 'android', target: 'mobile', noRecord: true, relaunch: true }, @@ -38,22 +38,22 @@ test('keeps the replay request envelope and replaces the command it carried', () command: 'open', positionals: ['com.example.app'], flags: { platform: 'android', target: 'mobile', noRecord: true, relaunch: true }, - internal: { publicNetworkOnly: true }, + dispatch: { observationOnly: true }, }); expect(request).not.toHaveProperty('input'); }); -test('folds the dispatch options into the request-private half beside the replay state', () => { +test('folds the operation dispatch options beside the ones the replay carries', () => { const viewport = { x: 0, y: 100, width: 402, height: 650 }; - const request = maestroOperationDaemonRequest(replay, { + const request = maestroOperationDispatchRequest(replay, { command: 'gesture', positionals: [], input: { kind: 'pan', origin: { x: 1, y: 2 }, delta: { x: -3, y: 0 }, durationMs: 300 }, dispatch: { gestureExecutionProfile: 'endpoint-hold', gestureViewport: viewport }, }); - expect(request.internal).toEqual({ - publicNetworkOnly: true, + expect(request.dispatch).toEqual({ + observationOnly: true, gestureExecutionProfile: 'endpoint-hold', gestureViewport: viewport, }); @@ -65,8 +65,8 @@ test('folds the dispatch options into the request-private half beside the replay }); }); -test('omits internal entirely when neither side carries request-private state', () => { - const request = maestroOperationDaemonRequest( +test('omits dispatch entirely when neither side carries options', () => { + const request = maestroOperationDispatchRequest( { token: 'token', session: 'session', command: 'replay', positionals: [] }, { command: 'snapshot', positionals: [], flags: { noRecord: true } }, ); @@ -81,29 +81,29 @@ test('omits internal entirely when neither side carries request-private state', }); test('marks projected hierarchy captures as observation-only for the daemon', () => { - const request = maestroOperationDaemonRequest( + const request = maestroOperationDispatchRequest( { token: 'token', session: 'session', command: 'replay', positionals: [] }, { command: 'snapshot', positionals: [], dispatch: { observationOnly: true } }, ); - expect(request.internal).toEqual({ observationOnly: true }); + expect(request.dispatch).toEqual({ observationOnly: true }); }); -test('leaves request-private state the operation does not set untouched', () => { +test('leaves dispatch options the operation does not set untouched', () => { const viewport = { x: 0, y: 0, width: 402, height: 874 }; - const request = maestroOperationDaemonRequest( + const request = maestroOperationDispatchRequest( { token: 'token', session: 'session', command: 'replay', positionals: [], - internal: { publicNetworkOnly: true, gestureViewport: viewport }, + dispatch: { replayPlanStep: true, gestureViewport: viewport }, }, { command: 'open', positionals: [], dispatch: { closeAppOnly: true } }, ); - expect(request.internal).toEqual({ - publicNetworkOnly: true, + expect(request.dispatch).toEqual({ + replayPlanStep: true, gestureViewport: viewport, closeAppOnly: true, }); diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts index 493a4360b9..c0e64bd49c 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-session-address.test.ts @@ -4,7 +4,7 @@ import { expect, test } from 'vitest'; import { stringify } from 'yaml'; import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; import { SessionStore } from '../../../session-store.ts'; -import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import { replayCommandForTest } from '../../__tests__/replay-command-fixture.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; @@ -37,11 +37,15 @@ test('a typed Maestro selector conflict names the session store key, not "defaul meta: { requestId: 'req-maestro-address' }, } as unknown as DaemonRequest; - const response = await runTypedMaestroReplay({ - request: req, - session: createReplaySession(SCOPED_ADDRESS, path.join(root, 'daemon.log'), sessionStore), - invoke: async () => ({ ok: true, data: {} }), - }); + const response = await runTypedMaestroReplay( + replayCommandForTest({ + req, + sessionName: SCOPED_ADDRESS, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }), + ); expect(response.ok).toBe(false); if (response.ok) return; diff --git a/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts b/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts index 92a15b4289..60e938db8f 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-classification-fixtures.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; -import { computeTargetEvidence } from '../../../session-target-evidence.ts'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { diff --git a/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts b/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts index 7b3b3d4291..d70cd0a310 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-target-classification.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { computeTargetEvidence } from '../../../session-target-evidence.ts'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; import { buildSelectorChainForNode, resolveRecordedTarget } from '@agent-device/selectors'; import { resolvePressRecordingTarget } from '@agent-device/selectors/press-retarget'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; diff --git a/src/daemon/replay/internal/__tests__/session-test-command-trust.test.ts b/src/daemon/replay/internal/__tests__/session-test-command-trust.test.ts index 38f103c314..c86186054c 100644 --- a/src/daemon/replay/internal/__tests__/session-test-command-trust.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-command-trust.test.ts @@ -6,7 +6,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { SessionStore } from '../../../session-store.ts'; -import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import { + createReplaySession, + replayDaemonDependencies, +} from '../../../handlers/session-replay-command.ts'; import { runReplayTestCommand } from '../../index.ts'; import type { ReplayCommand, ReplayTestCommand } from '../command-types.ts'; import { replayScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; @@ -26,7 +29,7 @@ beforeEach(() => { capturedCommands.length = 0; }); -async function forwardedCommands(publicNetworkOnly?: boolean): Promise { +async function forwardedCommands(publicNetworkOnly?: true): Promise { const root = mkdtempForTestSync('agent-device-test-command-trust-'); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'context platform=ios\nopen "Demo"\n'); @@ -48,6 +51,7 @@ async function forwardedCommands(publicNetworkOnly?: boolean): Promise createReplaySession(sessionName, logPath, sessionStore), invoke: (async () => ({ ok: true as const, data: {} })) as DaemonInvokeFn, + dependencies: replayDaemonDependencies, cleanupSession: async () => {}, ...(publicNetworkOnly === undefined ? {} : { publicNetworkOnly }), }; @@ -64,7 +68,7 @@ test('test command forwards untrusted replay trust to each attempt', async () => }); test('test command forwards trusted replay trust to each attempt', async () => { - const commands = await forwardedCommands(false); + const commands = await forwardedCommands(); expect(commands).toHaveLength(1); - expect(commands[0]?.publicNetworkOnly).toBe(false); + expect(commands[0]?.publicNetworkOnly).toBeUndefined(); }); diff --git a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts index 27cfd9e70d..7c098a8615 100644 --- a/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-flag-policy.test.ts @@ -9,7 +9,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import { SessionStore } from '../../../session-store.ts'; -import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import { + createReplaySession, + replayCommandEnvelope, + replayDaemonDependencies, + replayInvoke, +} from '../../../handlers/session-replay-command.ts'; import { runReplayTestCommand } from '../../index.ts'; import type { ReplayTestCommand } from '../command-types.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; @@ -40,11 +45,12 @@ function testCommand( invoke: DaemonInvokeFn, ): ReplayTestCommand { return { - request: req, + ...replayCommandEnvelope(req), session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), createSession: (sessionName, logPath) => createReplaySession(sessionName, logPath, sessionStore), - invoke, + invoke: replayInvoke(invoke, req), + dependencies: replayDaemonDependencies, cleanupSession: async () => {}, }; } diff --git a/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts index 032c3eb148..5c9a7f400a 100644 --- a/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-suite-command-video.test.ts @@ -19,7 +19,12 @@ import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; import { createReplayTestVideoOwner } from '../../../handlers/session-replay-video-owner.ts'; -import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import { + createReplaySession, + replayCommandEnvelope, + replayDaemonDependencies, + replayInvoke, +} from '../../../handlers/session-replay-command.ts'; const recordRuntimeMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), @@ -272,24 +277,24 @@ test('test finalizes replay video exactly once when cancellation arrives after s if (!video) throw new Error('Expected replay video owner'); const responsePromise = runReplayTestCommand({ - request, + ...replayCommandEnvelope(request), session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), createSession: (sessionName, logPath) => createReplaySession(sessionName, logPath, sessionStore), video, cleanupSession: async () => {}, - invoke: async (nestedReq) => { + dependencies: replayDaemonDependencies, + invoke: replayInvoke(async (nestedReq) => { nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { const provisionalSession = makeIosSession(nestedReq.session); sessionStore.set(nestedReq.session, provisionalSession); - const hookResponse = - await nestedReq.internal?.openLifecycle?.beforeDispatch?.(provisionalSession); + const hookResponse = await nestedReq.internal?.openLifecycle?.beforeDispatch?.(); if (hookResponse && !hookResponse.ok) return hookResponse; events.push('open:dispatch'); } return { ok: true, data: { session: nestedReq.session } }; - }, + }, request), }); await vi.advanceTimersByTimeAsync(4_000); const response = await responsePromise; diff --git a/src/daemon/replay/internal/command-types.ts b/src/daemon/replay/internal/command-types.ts index 32ff054340..5b765daa4f 100644 --- a/src/daemon/replay/internal/command-types.ts +++ b/src/daemon/replay/internal/command-types.ts @@ -1,25 +1,110 @@ -import type { ReplayObservationAuthorityBinder } from '@agent-device/contracts/replay'; -import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -import type { DaemonInvokeFn, DaemonRequest } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { ReplayDivergenceResume, ReplayRepairHint } from '@agent-device/contracts/divergence'; import type { - ReplaySessionMutationStore as ReplaySessionMutationStoreCapability, - ReplaySessionStore as ReplaySessionStoreCapability, -} from '../../session-replay-coordinator.ts'; -import { type DaemonResponse } from '@agent-device/kernel/contracts'; + ReplayDispatchOptions, + ReplayObservationAuthorityBinder, +} from '@agent-device/contracts/replay'; +import type { SessionAction, SessionScope } from '@agent-device/contracts/session'; +import type { SnapshotDiagnosticsState } from '@agent-device/contracts/capture'; +import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; +import type { DaemonResponse, SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SnapshotState } from '@agent-device/kernel/snapshot'; -export type ReplaySessionStore = ReplaySessionStoreCapability; -export type ReplaySessionMutationStore = ReplaySessionMutationStoreCapability; /** - * The replay side of the ref-publication owner: the operational capture it reads, and a binder for - * the daemon-side authority that records a capture and publishes exactly its own projection. The - * session-store pair the authority is drawn from stays in the daemon. + * The slice of the daemon's live session record replay reads. The daemon passes its full + * `SessionState`; replay never names the record itself, so the port depends on these fields and + * nothing else the daemon keeps on a session. + */ +export type ReplaySessionState = Readonly<{ + name: string; + device: DeviceInfo; + appBundleId?: string; + actions: SessionAction[]; + trace?: Readonly<{ outPath: string }>; + /** Read for presence only: an active recording means replay video must not start another. */ + screenRecording?: object; + snapshotDiagnostics?: Pick; +}>; + +export type ReplaySessionStore = Readonly<{ + get: () => ReplaySessionState | undefined; + lookup: () => Readonly<{ address: string; session: ReplaySessionState }> | undefined; + getRuntimeHints: () => SessionRuntimeHints | undefined; + ensureSessionDir: () => string; + /** Rejects the request when its device/platform selectors contradict the session's binding. */ + assertSelectorMatches: (flags: DaemonWireRequest['flags']) => void; + /** + * The runtime hints an `open` dispatched from this session would run with: the request's own, + * the session's persisted ones, and the platform defaults the daemon owns. + */ + resolveOpenRuntimeHints: (params: { + request: DaemonWireRequest; + device?: DeviceInfo; + platform?: 'ios' | 'android'; + }) => SessionRuntimeHints | undefined; +}>; + +/** + * The replay side of the ref-publication owner: the operational capture it reads, a binder for + * the daemon-side authority that records a capture and publishes exactly its own projection, and + * the daemon's snapshot capture over this session. The session-store pair the authority is drawn + * from stays in the daemon. */ export type ReplaySessionObservation = Readonly<{ - get: () => SessionState | undefined; + get: () => ReplaySessionState | undefined; bindAuthority: ReplayObservationAuthorityBinder; + capture: (params: { + flags: DaemonWireRequest['flags']; + logPath: string; + }) => Promise<{ snapshot: SnapshotState }>; }>; +/** Immutable read projection of the repair-transaction fields the coordinator's writers touch. */ +export type ReplaySessionView = Readonly<{ + repairBoundary: number | undefined; + pendingRecordAndHeal: + | Readonly<{ expectedFrom: number; actionsCountAtDivergence: number }> + | undefined; + /** The session's script-publication state, as arming reads it: kind, target, persisted force. */ + scriptPublication: Readonly<{ + kind: 'none' | 'authoring' | 'repair'; + status: string | undefined; + targetPath: string | undefined; + targetForce: boolean; + }>; +}>; + +export type ReplayResumeStamper = Readonly<{ + sessionExists(): boolean; + stampCorrectiveWatermark(params: { + resume: ReplayDivergenceResume; + repairHint: ReplayRepairHint; + failedIndex: number; + actions: SessionAction[]; + }): void; +}>; + +/** + * The daemon-owned gateway one replay request reaches the repair transaction and the corrective + * resume watermark through. The daemon constructs it over its locked session; replay only calls. + */ +export type ReplayCoordinator = { + view(): ReplaySessionView | undefined; + armStep(params: { + saveScript: boolean | string; + force: boolean | undefined; + sourcePath: string; + firstArm: boolean; + }): void; + demoteForRerunIfArmed(): void; + markCompleteIfArmed(): void; + markSessionHeldIfArmed(response: DaemonResponse): DaemonResponse; + clearTombstone(): void; + clearCorrectiveWatermarkIfExpected(expectedFrom: number | undefined): void; + readonly resumeStamper: ReplayResumeStamper; +}; + export type ReplayTestSessionFactory = (sessionName: string, logPath: string) => ReplaySession; export type ReplaySession = Readonly<{ @@ -27,30 +112,46 @@ export type ReplaySession = Readonly<{ name: string; logPath: string; store: ReplaySessionStore; - /** Bound repair writes; replay internals never receive an unbound SessionStore setter. */ - mutationStore: ReplaySessionMutationStore; - /** Bound observation writes used only by the existing ref-publication owner. */ + /** Bound observation reads and writes used by the ref-publication owner and divergence capture. */ observationStore: ReplaySessionObservation; + /** The repair-transaction gateway the daemon bound over this session. */ + coordinator: ReplayCoordinator; +}>; + +export type ReplayDispatchRequest = DaemonWireRequest & + Readonly<{ dispatch?: ReplayDispatchOptions }>; + +export type ReplayInvoke = (request: ReplayDispatchRequest) => Promise; + +/** Daemon policy replay consults but does not own. */ +export type ReplayDaemonDependencies = Readonly<{ + /** The isolation scope a request opens sessions under. Throws when the request is inadmissible. */ + resolveSessionScope: (request: DaemonWireRequest) => SessionScope; }>; export type ReplayCommand = Readonly<{ - request: DaemonRequest; - session: ReplaySession; - invoke: DaemonInvokeFn; - tracePath?: string; - onStep?: ReplayTestAttemptStepSink; + request: DaemonWireRequest; /** - * True when the request reached the daemon only over its public HTTP surface. The handler draws - * it from the request's daemon-private half and hands it here so the Maestro engine decides - * script trust from an explicit input instead of reading `req.internal`. Absent means a local + * True when the request reached the daemon over its public HTTP surface only: flow scripts are + * then untrusted, `runScript` HTTP calls may not reach private addresses, and the Maestro engine + * decides script trust from this input instead of reading `req.internal`. Absent means a local * caller, which trusts its own scripts. */ - publicNetworkOnly?: boolean; + publicNetworkOnly?: true; + /** The isolation scope the daemon already resolved for this request, when it did. */ + resolvedSessionScope?: SessionScope; + /** Dispatch options every request this command issues carries, before its own. */ + dispatch?: ReplayDispatchOptions; + session: ReplaySession; + invoke: ReplayInvoke; + dependencies: ReplayDaemonDependencies; + tracePath?: string; + onStep?: ReplayTestAttemptStepSink; }>; type ReplayRequestContext = Readonly<{ - token: DaemonRequest['token']; - meta: DaemonRequest['meta']; + token: DaemonWireRequest['token']; + meta: DaemonWireRequest['meta']; }>; export type ReplayRecordVideoRequest = Readonly< diff --git a/src/daemon/replay/internal/native-command.ts b/src/daemon/replay/internal/native-command.ts index b51c987e7f..6dcfd19c9f 100644 --- a/src/daemon/replay/internal/native-command.ts +++ b/src/daemon/replay/internal/native-command.ts @@ -1,15 +1,10 @@ import { asAppError } from '@agent-device/kernel/errors'; -import type { SessionState } from '../../session-state.ts'; import { runAdReplay } from '@agent-device/ad-replay'; import type { SnapshotTimingSample } from '@agent-device/contracts/capture'; import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import { isMaestroYamlPath, maestroBackendRequiredMessage } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import { - createReplayCoordinator, - type ReplayCoordinator, -} from '../../session-replay-coordinator.ts'; import { createAdReplayStepRuntime, type ReplayStepContext, @@ -20,7 +15,12 @@ import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, } from '../../replay-script-source.ts'; import { prepareReplaySession } from './session-replay-runtime-session.ts'; -import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; +import type { + ReplayCommand, + ReplayCoordinator, + ReplaySessionState, + ReplaySessionStore, +} from './command-types.ts'; import { errorResponse, type DaemonResponse } from '@agent-device/kernel/contracts'; /** @@ -57,7 +57,7 @@ import { errorResponse, type DaemonResponse } from '@agent-device/kernel/contrac export async function runReplayCommand(command: ReplayCommand): Promise { const { request: req, - session: { name: sessionName, logPath, store: sessionStore, mutationStore, observationStore }, + session: { name: sessionName, logPath, store: sessionStore, observationStore, coordinator }, tracePath, onStep, invoke, @@ -76,9 +76,6 @@ export async function runReplayCommand(command: ReplayCommand): Promise(); - // #1478 P4b: the one locked coordinator this request reaches the repair - // transaction and resume watermark through. - const coordinator = createReplayCoordinator({ sessionStore, mutationStore }); try { resolved = bundle.entry; if (isMaestroYamlPath(resolved) && req.flags?.replayBackend !== 'maestro') { @@ -99,6 +96,7 @@ export async function runReplayCommand(command: ReplayCommand): Promise; +type ReplayBaseRequest = Omit; /** * #1555 review P1 (second pass, "move variable semantics/planning behind the @@ -27,7 +29,8 @@ type ReplayBaseRequest = Omit; * text. */ export async function invokeReplayAction(params: { - req: DaemonRequest; + /** The plan's request, carrying the dispatch options every step inherits (guards included). */ + req: ReplayDispatchRequest; sessionName: string; action: SessionAction; resolved: SessionAction; @@ -37,7 +40,10 @@ export async function invokeReplayAction(params: { /** Resolved source file when it differs from `filePath` (a `runFlow` include's path). */ sourcePath?: string; tracePath?: string; - invoke: DaemonInvokeFn; + invoke: ReplayInvoke; + /** The isolation scope the daemon already resolved for the request, when it did. */ + resolvedSessionScope: SessionScope | undefined; + dependencies: ReplayDaemonDependencies; }): Promise { const { req, @@ -50,6 +56,8 @@ export async function invokeReplayAction(params: { sourcePath, tracePath, invoke, + resolvedSessionScope, + dependencies, } = params; const startedAt = Date.now(); appendReplayTraceEvent(tracePath, { @@ -75,6 +83,8 @@ export async function invokeReplayAction(params: { resolved, sourceAction: action, invoke, + resolvedSessionScope, + dependencies, }); } catch (error) { // Only an expected AppError dispatch failure (e.g. a selector-miss) gets @@ -125,13 +135,16 @@ function withReplayFailureSource( } async function invokeResolvedReplayAction(params: { - req: DaemonRequest; + req: ReplayDispatchRequest; sessionName: string; resolved: SessionAction; sourceAction: SessionAction; - invoke: DaemonInvokeFn; + invoke: ReplayInvoke; + resolvedSessionScope: SessionScope | undefined; + dependencies: ReplayDaemonDependencies; }): Promise { - const { req, sessionName, resolved, sourceAction, invoke } = params; + const { req, sessionName, resolved, sourceAction, invoke, resolvedSessionScope, dependencies } = + params; const flags = buildReplayActionFlags(req.flags, resolved.flags); const recordedInputVariable = sourceAction.command === 'fill' @@ -151,13 +164,11 @@ async function invokeResolvedReplayAction(params: { // `session-action-recorder.ts`) reads it to keep an authored // `get`/`is`/`find`/`snapshot` step in its own healed script while still // excluding an interactive diagnostic read of the same command. - internal: { - ...req.internal, + dispatch: { + ...req.dispatch, replayPlanStep: true, ...(resolved.command === 'open' - ? { - resolvedSessionScope: req.internal?.resolvedSessionScope ?? resolveSessionScope(req), - } + ? { resolvedSessionScope: resolvedSessionScope ?? dependencies.resolveSessionScope(req) } : {}), }, }; @@ -167,7 +178,7 @@ async function invokeResolvedReplayAction(params: { function buildReplayInteractionRequest( baseReq: ReplayBaseRequest, action: SessionAction, -): DaemonRequest { +): ReplayDispatchRequest { const positionals = action.positionals ?? []; if (action.command === 'gesture') { return { diff --git a/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts b/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts index 5ee0fa7319..ad9ea95306 100644 --- a/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts +++ b/src/daemon/replay/internal/session-replay-dispatch-narrowing.ts @@ -1,4 +1,4 @@ -import type { DaemonRequest } from '../../daemon-request.ts'; +import type { ReplayDispatchRequest } from './command-types.ts'; import type { AdReplayDispatchGuard, AdReplayDispatchOutcome, @@ -39,12 +39,12 @@ import { type DaemonResponse } from '@agent-device/kernel/contracts'; * 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. */ +/** Threads a pre-action identity guard into the dispatch options the interaction layer reads for its own resolution — a no-op when no guard applies. */ export function applyReplayDispatchGuard( - replayReq: DaemonRequest, + replayReq: ReplayDispatchRequest, guard: AdReplayDispatchGuard | undefined, -): DaemonRequest { - const guardInternal = +): ReplayDispatchRequest { + const guardDispatch = guard?.kind === 'target' ? { replayTargetGuard: guard.guard.expected } : guard?.kind === 'targets' @@ -57,8 +57,8 @@ export function applyReplayDispatchGuard( : guard?.kind === 'landmark' ? { replayLandmarkGuard: guard.landmark } : undefined; - return guardInternal - ? { ...replayReq, internal: { ...replayReq.internal, ...guardInternal } } + return guardDispatch + ? { ...replayReq, dispatch: { ...replayReq.dispatch, ...guardDispatch } } : replayReq; } diff --git a/src/daemon/replay/internal/session-replay-divergence.ts b/src/daemon/replay/internal/session-replay-divergence.ts index 43b1d5a52b..3dde9a1b59 100644 --- a/src/daemon/replay/internal/session-replay-divergence.ts +++ b/src/daemon/replay/internal/session-replay-divergence.ts @@ -7,7 +7,6 @@ import { displayLabel, formatRole } from '@agent-device/capture-kit/snapshot-lin 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 { collectReplaySelectorCandidates } from './session-replay-heal.ts'; import { buildSelectorCandidates, resolveReplaySuggestionCandidate } from '@agent-device/selectors'; import { collectSettleChromeRefs } from '@agent-device/capture-kit/snapshot-chrome'; @@ -17,13 +16,18 @@ import { computeReplayRepairHint, type ReplayRepairHintCapture, } from './session-replay-repair-hint.ts'; -import type { ReplaySessionObservation, ReplaySessionStore } from './command-types.ts'; -import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; +import type { + ReplayResumeStamper, + ReplaySessionObservation, + ReplaySessionState, + ReplaySessionStore, +} from './command-types.ts'; + import type { ReplayObservationEvidence } from '@agent-device/contracts/replay'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; -import type { SessionState } from '../../session-state.ts'; + import { type ReplayDivergence, type ReplayDivergenceScreen, @@ -52,7 +56,7 @@ export async function buildReplayFailureDivergence(params: { index: number; sourcePath: string; sourceLine: number; - session: SessionState | undefined; + session: ReplaySessionState | undefined; sessionStore: ReplaySessionStore; observationStore: ReplaySessionObservation; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ @@ -240,7 +244,7 @@ const DIVERGENCE_CAPTURE_RETRY_DEADLINE_MS = 12_000; const DIVERGENCE_CAPTURE_RETRY_DELAYS_MS = [300, 500, 800, 1200, 2000, 3000, 4000] as const; export async function captureDivergenceObservation(params: { - session: SessionState; + session: ReplaySessionState; observationStore: ReplaySessionObservation; logPath: string; action: ReplayReportAction; @@ -293,19 +297,14 @@ type DivergenceCaptureAttempt = { }; async function captureDivergenceObservationAttempt(params: { - session: SessionState; + session: ReplaySessionState; observationStore: ReplaySessionObservation; logPath: string; flags: CommandFlags; }): Promise { const { session, observationStore, logPath, flags } = params; try { - const capture = await captureSnapshot({ - device: session.device, - session, - flags, - logPath, - }); + const capture = await observationStore.capture({ flags, logPath }); const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { @@ -514,7 +513,7 @@ function buildReplayDivergenceScreenRefs( */ function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction; - session: SessionState; + session: ReplaySessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; }): ReplayDivergenceSuggestion[] { @@ -554,7 +553,7 @@ type RankedSuggestion = { function rankSuggestionCandidates(params: { candidates: string[]; nodes: SnapshotNode[]; - session: SessionState; + session: ReplaySessionState; action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; @@ -583,7 +582,7 @@ function rankSuggestionCandidates(params: { function resolveSuggestionCandidate(params: { candidate: string; nodes: SnapshotNode[]; - session: SessionState; + session: ReplaySessionState; action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; @@ -613,7 +612,7 @@ export function buildReplayDivergenceSuggestionForNode(params: { node: SnapshotNode; /** The record-time tree the node came from, for #1269 non-unique-id demotion in the chain. */ nodes: readonly SnapshotNode[]; - session: SessionState; + session: ReplaySessionState; action: ReplayReportAction; basis: ReplayDivergenceSuggestionBasis; sanitize: DivergenceFieldSanitizer; diff --git a/src/daemon/replay/internal/session-replay-heal.ts b/src/daemon/replay/internal/session-replay-heal.ts index e1d58fc14d..4ec9cea1de 100644 --- a/src/daemon/replay/internal/session-replay-heal.ts +++ b/src/daemon/replay/internal/session-replay-heal.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { readSelectorExpression } from '@agent-device/selectors'; import { isTouchTargetCommand } from '@agent-device/ad-script'; @@ -91,3 +92,10 @@ function isFiniteNumberString(value: string | undefined): boolean { if (value === undefined || value.trim() === '') return false; return Number.isFinite(Number(value)); } + +/** `flows/login.ad` -> `flows/login.healed.ad`, beside the original (R6). */ +export function healedScriptSiblingPath(sourcePath: string): string { + const dir = path.dirname(sourcePath); + const base = path.basename(sourcePath, path.extname(sourcePath)); + return path.join(dir, `${base}.healed.ad`); +} diff --git a/src/daemon/replay/internal/session-replay-maestro-failure.ts b/src/daemon/replay/internal/session-replay-maestro-failure.ts index 814a8690f2..5e0a766e0e 100644 --- a/src/daemon/replay/internal/session-replay-maestro-failure.ts +++ b/src/daemon/replay/internal/session-replay-maestro-failure.ts @@ -10,9 +10,12 @@ import { } from '@agent-device/ad-replay/divergence'; import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import type { DaemonRequest } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; -import type { ReplaySessionObservation, ReplaySessionStore } from './command-types.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { + ReplaySessionObservation, + ReplaySessionState, + ReplaySessionStore, +} from './command-types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; import { @@ -44,7 +47,7 @@ export type MaestroFailureReportProjection = { export function buildTypedMaestroFailureReportProjection( failure: MaestroFailedAction, - req: DaemonRequest, + req: DaemonWireRequest, ): MaestroFailureReportProjection { const progress = { command: failure.action, @@ -66,7 +69,7 @@ export async function buildTypedMaestroFailureResponse(params: { readonly error: DaemonError; readonly failure: MaestroFailedAction; readonly replayPath: string; - readonly req: DaemonRequest; + readonly req: DaemonWireRequest; readonly sessionStore: ReplaySessionStore; readonly observationStore: ReplaySessionObservation; readonly logPath: string; @@ -161,7 +164,7 @@ export async function buildTypedMaestroFailureResponse(params: { function collectTypedMaestroSuggestions(params: { failure: MaestroFailedAction; action: MaestroFailureReportAction; - session: SessionState; + session: ReplaySessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; }) { diff --git a/src/daemon/replay/internal/session-replay-maestro-request.ts b/src/daemon/replay/internal/session-replay-maestro-request.ts index 9c0656c97c..d687a93fc3 100644 --- a/src/daemon/replay/internal/session-replay-maestro-request.ts +++ b/src/daemon/replay/internal/session-replay-maestro-request.ts @@ -2,25 +2,23 @@ import type { MaestroDaemonDispatchOptions, MaestroDaemonOperationRequest, } from '@agent-device/maestro/daemon-runtime-port'; +import type { ReplayDispatchOptions } from '@agent-device/contracts/replay'; import { stripUndefined } from '@agent-device/kernel/record'; -import type { DaemonRequest } from '../../daemon-request.ts'; - -type DaemonRequestInternal = NonNullable; +import type { ReplayDispatchRequest } from './command-types.ts'; /** * The daemon half of the Maestro runtime port. The port projects a flow step onto one public * command plus the dispatch options it needs honored; this keeps the replay request's own - * envelope (token, session, metadata, runtime hints, request-private state), replaces the command - * it carried, and folds those options into `internal`, the one key the transport never accepts - * from a client. + * envelope (token, session, metadata, runtime hints), replaces the command it carried, and folds + * those options into the dispatch bag the daemon turns into request-private state. */ -export function maestroOperationDaemonRequest( - replay: DaemonRequest, +export function maestroOperationDispatchRequest( + replay: ReplayDispatchRequest, operation: MaestroDaemonOperationRequest, -): DaemonRequest { - const internal = stripUndefined({ - ...replay.internal, - ...dispatchInternal(operation.dispatch), +): ReplayDispatchRequest { + const dispatch = stripUndefined({ + ...replay.dispatch, + ...maestroDispatchOptions(operation.dispatch), }); return stripUndefined({ ...replay, @@ -28,17 +26,17 @@ export function maestroOperationDaemonRequest( positionals: operation.positionals, input: operation.input, flags: operation.flags, - internal: Object.keys(internal).length > 0 ? internal : undefined, + dispatch: Object.keys(dispatch).length > 0 ? dispatch : undefined, }); } /** - * Every dispatch option the port may set, named against the daemon's own field. A key the port - * adds without a daemon counterpart fails here, as does a daemon field the mapping forgets. + * Every dispatch option the Maestro port may set, named against the replay dispatch key. A key + * the port adds without a counterpart fails here, as does a key the mapping forgets. */ -function dispatchInternal( +function maestroDispatchOptions( dispatch: MaestroDaemonDispatchOptions | undefined, -): Pick { +): Pick { return stripUndefined({ closeAppOnly: dispatch?.closeAppOnly, observationOnly: dispatch?.observationOnly, diff --git a/src/daemon/replay/internal/session-replay-maestro-runtime.ts b/src/daemon/replay/internal/session-replay-maestro-runtime.ts index 780fe4f5d7..67804c353b 100644 --- a/src/daemon/replay/internal/session-replay-maestro-runtime.ts +++ b/src/daemon/replay/internal/session-replay-maestro-runtime.ts @@ -1,4 +1,4 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; +import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; import { setTimeout as sleep } from 'node:timers/promises'; import { executeMaestroFlow, @@ -17,24 +17,25 @@ import { } from '@agent-device/ad-script'; import { createDaemonMaestroRuntimePort } from '@agent-device/maestro/daemon-runtime-port'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DaemonInvokeFn, DaemonRequest } from '../../daemon-request.ts'; -import { assertSessionSelectorMatches } from '../../session-selector.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; import { createMaestroReplayObserver } from './session-replay-maestro-observer.ts'; -import { maestroOperationDaemonRequest } from './session-replay-maestro-request.ts'; +import { maestroOperationDispatchRequest } from './session-replay-maestro-request.ts'; import { buildTypedMaestroReplayErrorResponse, buildTypedMaestroSuccessResponse, } from './session-replay-maestro-response.ts'; -import { resolveEffectiveOpenRuntimeHints } from '../../session-runtime.ts'; -import { buildMaestroReplayTargetDeviceResolutionOptions } from '../../replay-device-selection.ts'; +import { buildMaestroReplayTargetDeviceResolutionOptions } from './replay-script-selection.ts'; import { readReplayScriptSourceFile, REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE, } from '../../replay-script-source.ts'; import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; -import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; -import { errorResponse, type DaemonResponse } from '@agent-device/kernel/contracts'; +import type { ReplayCommand, ReplayDispatchRequest, ReplaySessionStore } from './command-types.ts'; +import { + errorResponse, + type DaemonResponse, + type SessionRuntimeHints, +} from '@agent-device/kernel/contracts'; type TypedMaestroReplayState = { snapshotStart: number; @@ -57,7 +58,7 @@ type TypedMaestroReplayContext = { device?: DeviceInfo; platform: Extract; target: string; - runtimeHints: ReturnType; + runtimeHints: SessionRuntimeHints | undefined; defaults: Record; env: Record; signal: AbortSignal | undefined; @@ -106,15 +107,13 @@ async function executeTypedMaestroReplay( session: { store: sessionStore }, tracePath, onStep, - invoke, } = command; const context = await prepareTypedMaestroReplay({ command, bundle }); // evalScript runs via node:vm, which is not a security sandbox; only trust it // for flows that did not arrive over the daemon's remote HTTP surface. const publicNetworkOnly = command.publicNetworkOnly === true; const port = createMaestroReplayPort({ - req, - invoke, + command, device: context.device, platform: context.platform, runtimeHints: context.runtimeHints, @@ -171,7 +170,7 @@ async function prepareTypedMaestroReplay( // conflict must tell the caller to close or reuse. const sessionRef = sessionStore.lookup(); const session = sessionRef?.session; - if (sessionRef) assertSessionSelectorMatches(sessionRef, req.flags); + if (sessionRef) sessionStore.assertSelectorMatches(req.flags); const binding = await resolveMaestroReplayBinding({ req, sessionStore, @@ -196,7 +195,7 @@ async function prepareTypedMaestroReplay( } async function resolveMaestroReplayBinding(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; sessionName: string; session: ReturnType; @@ -234,7 +233,7 @@ async function resolveMaestroReplayBinding(params: { async function completeMaestroRuntimeBinding( params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; sessionName: string; flow: MaestroFlow; @@ -260,29 +259,20 @@ async function completeMaestroRuntimeBinding( } function resolveReplayRuntimeHints(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; sessionName: string; device?: DeviceInfo; platform?: Extract; -}): ReturnType { - return resolveEffectiveOpenRuntimeHints({ - req: params.req, - sessionStore: { - getRuntimeHints: (requestedSessionName) => - requestedSessionName === params.sessionName - ? params.sessionStore.getRuntimeHints() - : undefined, - }, - sessionName: params.sessionName, +}): SessionRuntimeHints | undefined { + return params.sessionStore.resolveOpenRuntimeHints({ + request: params.req, device: params.device, platform: params.platform, }); } -function requiresDeviceRuntimeDefaults( - runtimeHints: ReturnType, -): boolean { +function requiresDeviceRuntimeDefaults(runtimeHints: SessionRuntimeHints | undefined): boolean { return ( runtimeHints?.metroPort !== undefined && runtimeHints.metroHost === undefined && @@ -291,7 +281,7 @@ function requiresDeviceRuntimeDefaults( } function buildTypedMaestroDefaults(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionName: string; filePath: string; platform: Extract; @@ -309,7 +299,7 @@ function buildTypedMaestroDefaults(params: { }; } -function buildTypedMaestroEnv(req: DaemonRequest): Record { +function buildTypedMaestroEnv(req: DaemonWireRequest): Record { return { ...collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), ...parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), @@ -317,18 +307,22 @@ function buildTypedMaestroEnv(req: DaemonRequest): Record { } function createMaestroReplayPort(params: { - req: DaemonRequest; - invoke: DaemonInvokeFn; + command: ReplayCommand; device: DeviceInfo | undefined; platform: Extract; - runtimeHints: ReturnType; + runtimeHints: SessionRuntimeHints | undefined; sourcePath: string; publicNetworkOnly: boolean; }) { - const { req, invoke, device, platform, runtimeHints, sourcePath, publicNetworkOnly } = params; - const replay = { ...req, runtime: runtimeHints }; + const { command, device, platform, runtimeHints, sourcePath, publicNetworkOnly } = params; + const { request: req, invoke } = command; + const replay: ReplayDispatchRequest = { + ...req, + runtime: runtimeHints, + ...(command.dispatch ? { dispatch: command.dispatch } : {}), + }; return createDaemonMaestroRuntimePort({ - invoke: (operation) => invoke(maestroOperationDaemonRequest(replay, operation)), + invoke: (operation) => invoke(maestroOperationDispatchRequest(replay, operation)), flags: maestroRuntimeDeviceFlags(device, platform, req.flags), publicNetworkOnly, platform, @@ -399,7 +393,7 @@ function unresolvedIosMaestroFlags( } function resolveMaestroPlatform( - req: DaemonRequest, + req: DaemonWireRequest, sessionDevice: DeviceInfo | undefined, ): Extract { const platform = req.flags?.platform; @@ -412,7 +406,10 @@ function resolveMaestroPlatform( ); } -function resolveMaestroTarget(req: DaemonRequest, sessionDevice: DeviceInfo | undefined): string { +function resolveMaestroTarget( + req: DaemonWireRequest, + sessionDevice: DeviceInfo | undefined, +): string { return typeof req.flags?.target === 'string' ? req.flags.target : (sessionDevice?.target ?? 'mobile'); diff --git a/src/daemon/replay/internal/session-replay-repair-hint.ts b/src/daemon/replay/internal/session-replay-repair-hint.ts index 81fc7260a4..7b3759894b 100644 --- a/src/daemon/replay/internal/session-replay-repair-hint.ts +++ b/src/daemon/replay/internal/session-replay-repair-hint.ts @@ -16,7 +16,7 @@ * Lives in the daemon zone because the container-presence test * below is a genuine structural containment check over `parentIndex` — the * same tree-walking machinery decision 3's own identity-set filter uses - * (`buildAncestryChain`/`computeScrollRegionKey`, `session-target-evidence.ts`) + * (`buildAncestryChain`/`computeScrollRegionKey`, `@agent-device/selectors/target-evidence`) * — not a flat identity-string search. */ @@ -24,7 +24,10 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; import { matchesAncestryPrefix, buildAncestryChain, buildIndexMap } from '@agent-device/ad-script'; import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/contracts/replay'; -import { computeScrollRegionKey, scrollRegionKeysEqual } from '../../session-target-evidence.ts'; +import { + computeScrollRegionKey, + scrollRegionKeysEqual, +} from '@agent-device/selectors/target-evidence'; export type ReplayRepairHintCapture = | { state: 'available'; nodes: SnapshotNode[] } diff --git a/src/daemon/replay/internal/session-replay-resume.ts b/src/daemon/replay/internal/session-replay-resume.ts index 0ba5a8cb21..fd738e6ec5 100644 --- a/src/daemon/replay/internal/session-replay-resume.ts +++ b/src/daemon/replay/internal/session-replay-resume.ts @@ -1,6 +1,6 @@ import type { SessionAction } from '@agent-device/contracts/session'; import type { ReplayDivergenceResume, ReplayRepairHint } from '@agent-device/contracts/divergence'; -import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; +import type { ReplayResumeStamper } from './command-types.ts'; /** * Builds the `resume` object and, through the caller's `ReplayResumeStamper` — the narrow diff --git a/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts index 55b702290f..4ed1e8e66f 100644 --- a/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts +++ b/src/daemon/replay/internal/session-replay-runtime-engine-adapter.ts @@ -1,5 +1,13 @@ -import type { SessionAction } from '@agent-device/contracts/session'; -import type { DaemonInvokeFn, DaemonRequest } from '../../daemon-request.ts'; +import type { SessionAction, SessionScope } from '@agent-device/contracts/session'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { + ReplayCoordinator, + ReplayDaemonDependencies, + ReplayDispatchRequest, + ReplayInvoke, + ReplaySessionObservation, + ReplaySessionStore, +} from './command-types.ts'; import { readReplaySelectorDisplayValue } from '@agent-device/selectors'; import { type DaemonResponse, @@ -8,7 +16,7 @@ import { } 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 { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; @@ -30,7 +38,6 @@ import { type TargetBindingDivergenceContext, } from './session-replay-target-verification.ts'; import type { ReplayTestAttemptStepSink } from '@agent-device/replay-test'; -import type { ReplaySessionObservation, ReplaySessionStore } from './command-types.ts'; /** * #1555 P5 (decomposition): the daemon's `AdReplayStepRuntime` adapter — extracted verbatim out @@ -92,7 +99,7 @@ import type { ReplaySessionObservation, ReplaySessionStore } from './command-typ */ export function createAdReplayStepRuntime(params: { ctx: ReplayStepContext; - req: DaemonRequest; + req: DaemonWireRequest; /** * The run's ONE artifact ledger, owned by `runReplayCommand`. `dispatchStep` * is its only writer, and returns its contents for the engine to thread as a @@ -218,6 +225,8 @@ export function createAdReplayStepRuntime(params: { step: index + 1, tracePath: ctx.actionTracePath, invoke: ctx.invoke, + resolvedSessionScope: ctx.resolvedSessionScope, + dependencies: ctx.dependencies, }); lastResponse = response; // The run's one artifact ledger: this step's entries are written into @@ -326,7 +335,7 @@ export function createAdReplayStepRuntime(params: { * or reads a scope value itself. */ export type ReplayStepContext = { - replayReq: DaemonRequest; + replayReq: ReplayDispatchRequest; sessionName: string; sessionStore: ReplaySessionStore; observationStore: ReplaySessionObservation; @@ -338,10 +347,13 @@ export type ReplayStepContext = { planDigest: string; actionTracePath: string | undefined; responseLevel: ResponseLevel | undefined; - invoke: DaemonInvokeFn; + invoke: ReplayInvoke; signal: AbortSignal | undefined; /** #1478 P4b: the one locked gateway to this request's repair transaction. */ coordinator: ReplayCoordinator; + /** The isolation scope the daemon already resolved for this request, when it did. */ + resolvedSessionScope: SessionScope | undefined; + dependencies: ReplayDaemonDependencies; }; /** @@ -365,7 +377,7 @@ function asFailedReplayStepResponse( async function buildReplayActionFailure( ctx: ReplayStepContext, - req: DaemonRequest, + req: DaemonWireRequest, action: SessionAction, index: number, response: Extract, diff --git a/src/daemon/replay/internal/session-replay-runtime-failure.ts b/src/daemon/replay/internal/session-replay-runtime-failure.ts index cc27b2c957..f5e951fedb 100644 --- a/src/daemon/replay/internal/session-replay-runtime-failure.ts +++ b/src/daemon/replay/internal/session-replay-runtime-failure.ts @@ -5,15 +5,18 @@ import { type SnapshotDiagnosticsSummary, type SnapshotTimingSample, } from '@agent-device/contracts/capture'; -import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; -import type { DaemonRequest } from '../../daemon-request.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { buildReplayFailureDivergence } from './session-replay-divergence.ts'; import { buildReplayDivergenceFailureResponse, hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import type { ReplaySessionObservation, ReplaySessionStore } from './command-types.ts'; +import type { + ReplayResumeStamper, + ReplaySessionObservation, + ReplaySessionStore, +} from './command-types.ts'; import { type DaemonResponse } from '@agent-device/kernel/contracts'; export async function withReplayFailureDiagnostics(params: { @@ -27,7 +30,7 @@ export async function withReplayFailureDiagnostics(params: { 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: readonly AdReplayScrubValue[]; - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; observationStore: ReplaySessionObservation; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ @@ -53,7 +56,7 @@ async function withReplayFailureContext(params: { 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: readonly AdReplayScrubValue[]; - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; observationStore: ReplaySessionObservation; /** #1478 P4b: the request's bound resume-stamping capability — never a second-constructed coordinator. */ diff --git a/src/daemon/replay/internal/session-replay-runtime-plan.ts b/src/daemon/replay/internal/session-replay-runtime-plan.ts index 1804d4b4dc..9c9a7db3dc 100644 --- a/src/daemon/replay/internal/session-replay-runtime-plan.ts +++ b/src/daemon/replay/internal/session-replay-runtime-plan.ts @@ -1,10 +1,14 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import type { CommandFlags } from '@agent-device/contracts/command'; -import type { DaemonRequest } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; -import type { ReplayCoordinator } from '../../session-replay-coordinator.ts'; -import type { ReplayCommand, ReplaySessionStore } from './command-types.ts'; -import { buildReplayScriptPlatformFlags } from '../../replay-device-selection.ts'; +import type { ReplayDispatchOptions } from '@agent-device/contracts/replay'; +import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; +import type { + ReplayCommand, + ReplayCoordinator, + ReplayDispatchRequest, + ReplaySessionState, + ReplaySessionStore, +} from './command-types.ts'; +import { buildReplayScriptPlatformFlags } from './replay-script-selection.ts'; import { inspectAdReplay, type AdReplayManifest, @@ -63,12 +67,12 @@ export async function routeMaestroReplay(params: { } export type PreparedReplayPlan = { - replayReq: DaemonRequest; + replayReq: ReplayDispatchRequest; actions: SessionAction[]; actionLines: number[]; actionSourcePaths: (string | undefined)[] | undefined; planDigest: string; - preEntrySession: SessionState | undefined; + preEntrySession: ReplaySessionState | undefined; entryIndex: number; /** * `${VAR}` scope INPUTS — plain data, never a built `ReplayVarScope` @@ -81,7 +85,7 @@ export type PreparedReplayPlan = { }; export function prepareReplayPlan(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionName: string; sessionStore: ReplaySessionStore; tracePath: string | undefined; @@ -89,12 +93,15 @@ export function prepareReplayPlan(params: { /** The entry script's text, taken from the request's replay script source bundle (#1802). */ script: string; coordinator: ReplayCoordinator; + /** Dispatch options the command carries; every plan step inherits them. */ + dispatch: ReplayDispatchOptions | undefined; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { - const { req, sessionName, sessionStore, tracePath, resolved, script, coordinator } = params; + const { req, sessionName, sessionStore, tracePath, resolved, script, coordinator, dispatch } = + params; const backendRejection = validateReplayBackendFlag(req); if (backendRejection) return { ok: false, response: backendRejection }; - const { manifest, replayReq } = inspectReplayPlanManifest(req, script); + const { manifest, replayReq } = inspectReplayPlanManifest(req, script, dispatch); const { metadata, actions, actionLines, actionSourcePaths, planDigest } = manifest; const preEntrySession = sessionStore.get(); const entryIndexResult = resolveReplayPlanEntryIndex({ @@ -138,7 +145,7 @@ export function prepareReplayPlan(params: { * Maestro-format request to `runTypedMaestroReplay` above; only a * stray/unknown value reaches this branch. */ -function validateReplayBackendFlag(req: DaemonRequest): DaemonResponse | undefined { +function validateReplayBackendFlag(req: DaemonWireRequest): DaemonResponse | undefined { if (req.flags?.replayBackend && req.flags.replayBackend !== 'maestro') { return errorResponse( 'INVALID_ARGS', @@ -156,25 +163,30 @@ function validateReplayBackendFlag(req: DaemonRequest): DaemonResponse | undefin * `readEffectiveReplayPlanDigestMetadata(replayReq.flags)`. */ function inspectReplayPlanManifest( - req: DaemonRequest, + req: DaemonWireRequest, script: string, -): { manifest: AdReplayManifest; replayReq: DaemonRequest } { + dispatch: ReplayDispatchOptions | undefined, +): { manifest: AdReplayManifest; replayReq: ReplayDispatchRequest } { const manifest = inspectAdReplay(script, { platform: req.flags?.platform, target: req.flags?.target, }); const replayReq = applyReplayMetadata( - { ...req, flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions) }, + { + ...req, + flags: buildReplayScriptPlatformFlags(req.flags, manifest.actions), + ...(dispatch ? { dispatch } : {}), + }, manifest.metadata, ); return { manifest, replayReq }; } function resolveReplayPlanEntryIndex(params: { - req: DaemonRequest; + req: DaemonWireRequest; coordinator: ReplayCoordinator; manifest: AdReplayManifest; - preEntrySession: SessionState | undefined; + preEntrySession: ReplaySessionState | undefined; }): { ok: true; value: number } | { ok: false; response: DaemonResponse } { const { req, coordinator, manifest, preEntrySession } = params; const entryIndex = manifest.resolveEntryIndex({ @@ -190,9 +202,9 @@ function resolveReplayPlanEntryIndex(params: { } function applyReplayMetadata( - req: DaemonRequest, + req: DaemonWireRequest, metadata: AdReplayManifest['metadata'], -): DaemonRequest { +): DaemonWireRequest { if (!metadata.platform && !metadata.target) return req; return { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) }; } @@ -206,8 +218,8 @@ function applyReplayMetadata( * P1); this function stops at collecting the plain data. */ function buildPreparedReplayVarSources(params: { - req: DaemonRequest; - replayReq: DaemonRequest; + req: DaemonWireRequest; + replayReq: ReplayDispatchRequest; sessionName: string; resolved: string; metadata: AdReplayManifest['metadata']; diff --git a/src/daemon/replay/internal/session-replay-runtime-session.ts b/src/daemon/replay/internal/session-replay-runtime-session.ts index 976bf3b7ce..b08890a16e 100644 --- a/src/daemon/replay/internal/session-replay-runtime-session.ts +++ b/src/daemon/replay/internal/session-replay-runtime-session.ts @@ -1,17 +1,8 @@ import fs from 'node:fs'; -import type { DaemonRequest } from '../../daemon-request.ts'; -import type { ReplaySessionStore } from './command-types.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { ReplayCoordinator, ReplaySessionStore, ReplaySessionView } from './command-types.ts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; -import { - NO_SCRIPT_PUBLICATION, - scriptTargetForce, - scriptTargetPath, - type SessionScriptPublicationState, -} from '../../session-script-publication-state.ts'; -import { - healedScriptSiblingPath, - type ReplayCoordinator, -} from '../../session-replay-coordinator.ts'; +import { healedScriptSiblingPath } from './session-replay-heal.ts'; import { errorResponse, noActiveSessionError, @@ -27,8 +18,15 @@ import { * EEXIST preflight, and the actual arming closure). */ +const NO_SCRIPT_PUBLICATION_VIEW: ReplaySessionView['scriptPublication'] = { + kind: 'none', + status: undefined, + targetPath: undefined, + targetForce: false, +}; + export function prepareReplaySession(params: { - req: DaemonRequest; + req: DaemonWireRequest; entryIndex: number; sessionStore: ReplaySessionStore; sourcePath: string; @@ -66,7 +64,7 @@ function validateReplaySessionEntry(params: { function rejectSaveScriptArming(params: { saveScript: boolean | string | undefined; force: boolean | undefined; - preRunState: SessionScriptPublicationState; + preRunState: ReplaySessionView['scriptPublication']; sourcePath: string; }): DaemonResponse | undefined { const { saveScript, force, preRunState, sourcePath } = params; @@ -79,25 +77,24 @@ function rejectSaveScriptArming(params: { return preflightSaveScriptTarget({ saveScript, liveForce: force, - persistedForce: scriptTargetForce(preRunState) || undefined, + persistedForce: preRunState.targetForce || undefined, sourcePath, - existingSaveScriptPath: scriptTargetPath(preRunState), + existingSaveScriptPath: preRunState.targetPath, }); } function prepareSaveScriptSession(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: ReplaySessionStore; sourcePath: string; coordinator: ReplayCoordinator; }): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, sessionStore, sourcePath, coordinator } = params; - const preRunSession = sessionStore.get(); + const { req, sourcePath, coordinator } = params; const { saveScript, force } = req.flags ?? {}; const rejection = rejectSaveScriptArming({ saveScript, force, - preRunState: preRunSession?.scriptPublication ?? NO_SCRIPT_PUBLICATION, + preRunState: coordinator.view()?.scriptPublication ?? NO_SCRIPT_PUBLICATION_VIEW, sourcePath, }); if (rejection) return { ok: false, response: rejection }; @@ -115,7 +112,7 @@ function prepareSaveScriptSession(params: { } function consumeReplayResumeState(params: { - req: DaemonRequest; + req: DaemonWireRequest; coordinator: ReplayCoordinator; }): void { const { req, coordinator } = params; diff --git a/src/daemon/replay/internal/session-replay-target-classification.ts b/src/daemon/replay/internal/session-replay-target-classification.ts index 6be7ae5d18..af0b1833cc 100644 --- a/src/daemon/replay/internal/session-replay-target-classification.ts +++ b/src/daemon/replay/internal/session-replay-target-classification.ts @@ -48,7 +48,7 @@ import { computeScrollRegionKey, scrollRegionKeysEqual, orderByViewportPosition, -} from '../../session-target-evidence.ts'; +} from '@agent-device/selectors/target-evidence'; import { resolveRecordedTarget } from '@agent-device/selectors'; import { resolveUnverifiedWrapperControl } from '@agent-device/selectors/interaction-targeting'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; diff --git a/src/daemon/replay/internal/session-replay-target-verification.ts b/src/daemon/replay/internal/session-replay-target-verification.ts index 9e4a1211aa..56c050359c 100644 --- a/src/daemon/replay/internal/session-replay-target-verification.ts +++ b/src/daemon/replay/internal/session-replay-target-verification.ts @@ -30,9 +30,8 @@ import { } from '@agent-device/contracts/replay'; import { resolveTargetIdentityVerification } from '@agent-device/command-registry/registry'; import { parseWaitPositionals } from '@agent-device/command-registry/wait-positionals'; -import type { SessionState } from '../../session-state.ts'; -import type { ReplayResumeStamper } from '../../session-replay-coordinator.ts'; -import { boundedLocalIdentity } from '../../session-target-evidence.ts'; + +import { boundedLocalIdentity } from '@agent-device/selectors/target-evidence'; import { buildDivergenceScreen, captureDivergenceObservation, @@ -41,7 +40,12 @@ import { type DivergenceObservation, } from './session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from './session-replay-divergence-publication.ts'; -import type { ReplaySessionObservation, ReplaySessionStore } from './command-types.ts'; +import type { + ReplayResumeStamper, + ReplaySessionObservation, + ReplaySessionState, + ReplaySessionStore, +} from './command-types.ts'; import { computeReplayRepairHint, type ReplayRepairHintCapture, @@ -269,7 +273,7 @@ export function buildTargetBindingFailureResponse( } async function captureFreshObservation(params: { - session: SessionState | undefined; + session: ReplaySessionState | undefined; observationStore: ReplaySessionObservation; logPath: string; action: SessionAction; @@ -295,7 +299,7 @@ async function captureFreshObservation(params: { export async function buildRecordedUnverifiableFailureResponse( context: TargetBindingDivergenceContext, params: { - session: SessionState | undefined; + session: ReplaySessionState | undefined; observationStore: ReplaySessionObservation; logPath: string; action: SessionAction; @@ -331,7 +335,7 @@ export async function buildPostDispatchTargetBindingFailureResponse( context: TargetBindingDivergenceContext, evidence: AdReplayTargetBindingEvidence, params: { - session: SessionState | undefined; + session: ReplaySessionState | undefined; observationStore: ReplaySessionObservation; logPath: string; action: SessionAction; diff --git a/src/daemon/replay/internal/session-replay-vars.ts b/src/daemon/replay/internal/session-replay-vars.ts index 2fc328e711..602896298c 100644 --- a/src/daemon/replay/internal/session-replay-vars.ts +++ b/src/daemon/replay/internal/session-replay-vars.ts @@ -1,9 +1,9 @@ import path from 'node:path'; -import type { DaemonRequest } from '../../daemon-request.ts'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; export function buildReplayBuiltinVars(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionName: string; metadata: ReplayScriptMetadata; resolvedPath: string; @@ -24,12 +24,12 @@ export function buildReplayBuiltinVars(params: { return builtins; } -function resolveReplayFilename(req: DaemonRequest, resolvedPath: string): string { +function resolveReplayFilename(req: DaemonWireRequest, resolvedPath: string): string { const cwd = req.meta?.cwd ?? process.cwd(); return path.relative(cwd, resolvedPath) || resolvedPath; } -function resolveReplayDeviceId(flags: NonNullable): unknown { +function resolveReplayDeviceId(flags: NonNullable): unknown { return typeof flags.serial === 'string' ? flags.serial : flags.udid; } diff --git a/src/daemon/replay/internal/session-replay-video-recording.ts b/src/daemon/replay/internal/session-replay-video-recording.ts index 27dc67619a..875a28d35b 100644 --- a/src/daemon/replay/internal/session-replay-video-recording.ts +++ b/src/daemon/replay/internal/session-replay-video-recording.ts @@ -1,5 +1,6 @@ import path from 'node:path'; -import type { DaemonOpenLifecycle, DaemonRequest } from '../../daemon-request.ts'; +import type { ReplayDispatchOptions } from '@agent-device/contracts/replay'; +import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { sleep } from '@agent-device/host-kit/retry'; @@ -20,7 +21,7 @@ const REPLAY_TEST_VIDEO_RECORDING_TAIL_MS = 3_000; export function buildReplayTestVideoOpenLifecycle( params: ReplayTestVideoRecordingParams, -): DaemonOpenLifecycle | undefined { +): NonNullable | undefined { if (params.req.flags?.recordVideo !== true) return undefined; return { beforeDispatch: async () => await startReplayTestVideoRecordingIfReady(params), @@ -28,7 +29,7 @@ export function buildReplayTestVideoOpenLifecycle( } type ReplayTestVideoRecordingParams = { - req: DaemonRequest; + req: DaemonWireRequest; sessionName: string; sessionStore: ReplaySessionStore; artifactsDir: string | undefined; @@ -136,7 +137,7 @@ function appendVideoTimingEvent( } type ReplayVideoRecordRequestParams = Readonly<{ - req: DaemonRequest; + req: DaemonWireRequest; sessionName: string; }> & ({ phase: 'start'; outputPath: string } | { phase: 'stop' }); diff --git a/src/daemon/replay/internal/session-test-infrastructure.ts b/src/daemon/replay/internal/session-test-infrastructure.ts index da33cca801..cfdc6a7fec 100644 --- a/src/daemon/replay/internal/session-test-infrastructure.ts +++ b/src/daemon/replay/internal/session-test-infrastructure.ts @@ -1,6 +1,6 @@ import { isInfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; import type { ReplaySuiteTestResult } from '@agent-device/contracts/replay'; -import { isDeviceClaimConflictReason } from '../../device-claim-conflict.ts'; +import { isDeviceClaimConflictReason } from '@agent-device/contracts/device'; import { type DaemonResponse } from '@agent-device/kernel/contracts'; const REPLAY_INFRASTRUCTURE_FAILURE_MESSAGE_PATTERNS = [ diff --git a/src/daemon/replay/internal/test-command.ts b/src/daemon/replay/internal/test-command.ts index 7b27f56157..c96bec9ba1 100644 --- a/src/daemon/replay/internal/test-command.ts +++ b/src/daemon/replay/internal/test-command.ts @@ -2,13 +2,16 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { CommandFlags } from '@agent-device/contracts/command'; -import type { ReplaySuiteResult, ReplayScriptSourceBundle } from '@agent-device/contracts/replay'; +import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; +import type { + ReplayDispatchOptions, + ReplaySuiteResult, + ReplayScriptSourceBundle, +} from '@agent-device/contracts/replay'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../replay-script-source.ts'; import type { ReplayScriptMetadata } from '@agent-device/ad-script'; -import type { DaemonRequest } from '../../daemon-request.ts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; -import type { ReplayTestCommand } from './command-types.ts'; +import type { ReplayCommand, ReplayTestCommand } from './command-types.ts'; import { runReplayTestSuite, type ReplayTestBindAttemptCancellation, @@ -228,25 +231,20 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) : undefined; const replayResponse = await runReplayCommand({ - request: { - ...req, - command: 'replay', - session: testSessionName, - positionals: [filePath], - flags: nestedFlags, - meta: { - ...(req.meta ?? {}), - ...(requestId ? { requestId } : {}), + ...nestedReplayCommand(command, { + request: { + ...req, + command: 'replay', + session: testSessionName, + positionals: [filePath], + flags: nestedFlags, + meta: { + ...(req.meta ?? {}), + ...(requestId ? { requestId } : {}), + }, }, - ...(req.internal || openLifecycle - ? { - internal: { - ...(req.internal ?? {}), - ...(openLifecycle ? { openLifecycle } : {}), - }, - } - : {}), - }, + openLifecycle, + }), session: attemptSession, tracePath, onStep, @@ -316,7 +314,7 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< */ export function attachRemoteReplayTestArtifacts( data: ReplaySuiteResult, - req: DaemonRequest, + req: DaemonWireRequest, ): DaemonResponseData { const clientRoot = req.meta?.clientArtifactPaths?.artifactsDir; const daemonRoot = data.artifactsDir; @@ -369,25 +367,30 @@ export function attachRemoteReplayTestArtifacts( }; } -/** - * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). - * - * `replayBackend` is deliberately not carried across: it selects an engine, and it has already - * been applied here when building the source-discovery and shard-target capabilities. - */ /** * #1802: a `test` request states the script sources its suite runs, because the daemon opens no * caller path. Absent entirely means a client too old to send them; it is rejected as a typed * `AppError` so it travels the same translation-failure path the shard/flag rejections already * take, rather than adding a second refusal shape to the handler. */ -function requireReplayTestScriptSources(req: DaemonRequest): readonly ReplayScriptSourceBundle[] { +function requireReplayTestScriptSources( + req: DaemonWireRequest, +): readonly ReplayScriptSourceBundle[] { const sources = req.flags?.replayScriptSources; if (!sources) throw new AppError('INVALID_ARGS', REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE); return sources; } -function toReplayTestSuiteRequest(req: DaemonRequest, sessionName: string): ReplayTestSuiteRequest { +/** + * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). + * + * `replayBackend` is deliberately not carried across: it selects an engine, and it has already + * been applied here when building the source-discovery and shard-target capabilities. + */ +function toReplayTestSuiteRequest( + req: DaemonWireRequest, + sessionName: string, +): ReplayTestSuiteRequest { const flags = req.flags ?? {}; const cwd = req.meta?.cwd; const artifactsDir = stringFlag(flags.artifactsDir); @@ -405,6 +408,34 @@ function toReplayTestSuiteRequest(req: DaemonRequest, sessionName: string): Repl }; } +/** + * One attempt's replay command inherits the suite command's admission facts and dispatch options; + * the video open-lifecycle hook, when recording, rides beside the inherited options. + */ +function nestedReplayCommand( + command: ReplayTestCommand, + attempt: { + request: DaemonWireRequest; + openLifecycle: NonNullable | undefined; + }, +): Pick< + ReplayCommand, + 'request' | 'publicNetworkOnly' | 'resolvedSessionScope' | 'dispatch' | 'dependencies' +> { + const { publicNetworkOnly, resolvedSessionScope, dispatch, dependencies } = command; + const nestedDispatch = + dispatch || attempt.openLifecycle + ? { ...dispatch, ...(attempt.openLifecycle ? { openLifecycle: attempt.openLifecycle } : {}) } + : undefined; + return { + request: attempt.request, + ...(publicNetworkOnly ? { publicNetworkOnly } : {}), + ...(resolvedSessionScope ? { resolvedSessionScope } : {}), + ...(nestedDispatch ? { dispatch: nestedDispatch } : {}), + dependencies, + }; +} + function numberFlag(value: unknown): number | undefined { return typeof value === 'number' ? value : undefined; } diff --git a/src/daemon/request-binding.ts b/src/daemon/request-binding.ts index d8c0ef622b..cff557a635 100644 --- a/src/daemon/request-binding.ts +++ b/src/daemon/request-binding.ts @@ -1,7 +1,7 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { hasDeviceSelectionInput } from './device-selector-intent.ts'; import { applyRequestLockPolicy } from './request-lock-policy.ts'; -import { buildOpenTargetDeviceResolutionOptions } from './open-device-selection.ts'; +import { buildOpenTargetDeviceResolutionOptions } from '@agent-device/device-selection/open-target'; import { buildReplayTargetDeviceResolution } from './replay-device-selection.ts'; import type { SessionStore } from './session-store.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; diff --git a/src/daemon/request-platform-provider-context.ts b/src/daemon/request-platform-provider-context.ts index b67ad5d3bb..8e2b824894 100644 --- a/src/daemon/request-platform-provider-context.ts +++ b/src/daemon/request-platform-provider-context.ts @@ -1,7 +1,7 @@ import type { PlatformProviderRequestContext } from '@agent-device/contracts/platform-providers'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { hasDeviceSelectionInput, hasExplicitDeviceSelector } from './device-selector-intent.ts'; -import { buildOpenTargetDeviceResolutionOptions } from './open-device-selection.ts'; +import { buildOpenTargetDeviceResolutionOptions } from '@agent-device/device-selection/open-target'; import { resolveProviderDeviceResolutionIntent } from './daemon-command-registry.ts'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index a34173d5f5..a4c5ca31c4 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -54,7 +54,7 @@ import { import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { isWebSession } from './web-session-names.ts'; -import { inferFillText } from './action-utils.ts'; +import { inferFillText } from '@agent-device/ad-script'; import { createPlatformRequestScope } from './platform-request-scope.ts'; import { createOwnerScopedDeviceClaimReconciler } from './device-claim-owner-recovery.ts'; import { diff --git a/src/daemon/selector-recording.ts b/src/daemon/selector-recording.ts index 292d9a5ea7..b03679e242 100644 --- a/src/daemon/selector-recording.ts +++ b/src/daemon/selector-recording.ts @@ -9,7 +9,7 @@ import { computeTargetEvidence, type RecordedTargetCapture, type TargetEvidenceMode, -} from './session-target-evidence.ts'; +} from '@agent-device/selectors/target-evidence'; export function buildFindRecordResult( result: Record, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 2490c1604d..a1a9ca2b60 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -26,7 +26,7 @@ import { toDaemonFindData, toDaemonGetData, } from './selector-recording.ts'; -import type { RecordedTargetCapture } from './session-target-evidence.ts'; +import type { RecordedTargetCapture } from '@agent-device/selectors/target-evidence'; import { withSystemSurfaceDisclosure } from './system-surface-disclosure.ts'; import { createBoundSelectorRuntime, diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 7b9a3f3e60..e79e70f92a 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -10,12 +10,12 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { timingSafeStringEqual } from '@agent-device/host-kit/transport'; import type { CommandRpcParams, + DaemonInstallSource, JsonRpcId, JsonRpcRequestEnvelope, LeaseBackend, } from '@agent-device/kernel/contracts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -import type { DaemonInstallSource } from '../daemon-request-wire.ts'; import type { DaemonInvokeFn, DaemonRequest } from '../daemon-request.ts'; import { normalizeTenantId } from '../config.ts'; import { diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 8e5d524313..b60131d39d 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -7,8 +7,8 @@ import type { SessionRuntimeHints, SessionState } from './session-state.ts'; import { applyRecordedSaveScriptFlags } from './session-script-publication-capability.ts'; import { repairSessionBoundary } from './session-replay-transaction.ts'; import type { MultiTargetAnnotationV1, TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import { inferFillText } from './action-utils.ts'; import { + inferFillText, recordedInputPlaceholder, validateRecordedInputVariableName, } from '@agent-device/ad-script'; @@ -19,7 +19,7 @@ import { parameterizeTargetEvidenceEcho, targetEvidenceCarriesAnyLiteral, } from '@agent-device/selectors/parameterized-recorded-fill'; -import type { TargetEvidenceMode } from './session-target-evidence.ts'; +import type { TargetEvidenceMode } from '@agent-device/selectors/target-evidence'; export type RecordActionEntry = { command: string; diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 83f9d25bf0..1be19c529e 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -8,10 +8,10 @@ import { type DeviceSelectionResult, } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; -import type { SessionSurface } from '@agent-device/contracts/session'; +import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionScope, SessionState } from '../../session-state.ts'; +import type { SessionState } from '../../session-state.ts'; import { abortAuthoringOnSecondOpen, armAuthoringOnOpen, @@ -353,7 +353,7 @@ async function prepareOpenDispatchSession(params: { if (!beforeDispatch) return { type: 'session', session: existingSession }; const provisionalSession = createProvisionalOpenDispatchSession(params); sessionStore.set(sessionName, provisionalSession); - const lifecycleResponse = await beforeDispatch(provisionalSession); + const lifecycleResponse = await beforeDispatch(); if (lifecycleResponse && !lifecycleResponse.ok) return { type: 'response', response: lifecycleResponse }; return { type: 'session', session: sessionStore.get(sessionName) ?? provisionalSession }; diff --git a/src/daemon/session-lifecycle/internal/session-open-surface.ts b/src/daemon/session-lifecycle/internal/session-open-surface.ts index e45de9b360..18a7d7914e 100644 --- a/src/daemon/session-lifecycle/internal/session-open-surface.ts +++ b/src/daemon/session-lifecycle/internal/session-open-surface.ts @@ -1,11 +1,11 @@ -import type { SessionSurface } from '@agent-device/contracts/session'; +import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; import { isIosFamily, isSerialAddressablePlatform, publicPlatformString, type DeviceInfo, } from '@agent-device/kernel/device'; -import type { SessionRuntimeHints, SessionScope, SessionState } from '../../session-state.ts'; +import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; import { successText } from '@agent-device/kernel/success-text'; import type { StartupPerfSample } from './session-startup-metrics.ts'; import type { DeviceSelectionResult } from '@agent-device/device-selection/device-selection-resolver'; diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index bac430ccd9..badb991d29 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -12,7 +12,7 @@ import type { SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { refreshSessionDeviceIfNeeded } from '../../session-device-resolution.ts'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; -import { buildOpenTargetDeviceResolutionOptions } from '../../open-device-selection.ts'; +import { buildOpenTargetDeviceResolutionOptions } from '@agent-device/device-selection/open-target'; import { invalidOpenArgs, prepareOpenCommandDetails, diff --git a/src/daemon/session-replay-coordinator.ts b/src/daemon/session-replay-coordinator.ts index 4a9e17d00f..c99895c212 100644 --- a/src/daemon/session-replay-coordinator.ts +++ b/src/daemon/session-replay-coordinator.ts @@ -1,9 +1,5 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import path from 'node:path'; -import { - type ReplayDivergenceResume, - type ReplayRepairHint, -} from '@agent-device/contracts/divergence'; +import type { ReplayDivergenceResume, ReplayRepairHint } from '@agent-device/contracts/divergence'; import { readReplayDivergenceResume } from '@agent-device/ad-replay/divergence'; import type { DaemonResponse } from './daemon-request.ts'; import type { SessionRuntimeHints, SessionState } from './session-state.ts'; @@ -14,6 +10,18 @@ import { repairSessionBoundary, resetRepairCompletionForRerun, } from './session-replay-transaction.ts'; +import { + NO_SCRIPT_PUBLICATION, + scriptTargetForce, + scriptTargetPath, + type SessionScriptPublicationState, +} from './session-script-publication-state.ts'; +import { + healedScriptSiblingPath, + type ReplayCoordinator, + type ReplayResumeStamper, + type ReplaySessionView, +} from './replay/index.ts'; /** * `ReplayCoordinator` (#1478 P4b): the single daemon-owned gateway a native `.ad` replay request @@ -34,66 +42,6 @@ import { * P4b PR description for the ownership split. */ -/** Immutable read projection of the repair-transaction fields this coordinator's writers touch. */ -export type ReplaySessionView = Readonly<{ - /** The R6 boundary watermark, or `undefined` when the session carries no repair transaction. */ - repairBoundary: number | undefined; - /** #1262's corrective-resume watermark, or `undefined` when none is pending. */ - pendingRecordAndHeal: - | Readonly<{ expectedFrom: number; actionsCountAtDivergence: number }> - | undefined; -}>; - -/** - * The one capability the divergence-report chain (`session-replay-resume.ts`, - * `session-replay-divergence.ts`, `session-replay-target-verification.ts`) needs from a - * `ReplayCoordinator`, bound to the SAME instance the owning replay command call - * created — never a second construction from a bare session name. Carries no `SessionStore` - * and cannot name or reacquire a different session. - */ -export type ReplayResumeStamper = Readonly<{ - /** Whether the request's session currently exists (gates the empty-tail `alternateFrom`). */ - sessionExists(): boolean; - /** Stamps the corrective-resume watermark for a divergence; a no-op before step 1 creates the session. */ - stampCorrectiveWatermark(params: { - resume: ReplayDivergenceResume; - repairHint: ReplayRepairHint; - failedIndex: number; - actions: SessionAction[]; - }): void; -}>; - -export type ReplayCoordinator = { - /** Reads the request's current session state, or `undefined` before step 1 creates it. */ - view(): ReplaySessionView | undefined; - - /** ADR 0012 R1/R6: arms (or re-arms, per step) the repair transaction. A no-op before step 1 creates the session. */ - armStep(params: { - saveScript: boolean | string; - force: boolean | undefined; - sourcePath: string; - firstArm: boolean; - }): void; - - /** ADR 0012 R2: demotes a completed transaction back to `armed` for a `--from` rerun; a no-op outside repair. */ - demoteForRerunIfArmed(): void; - - /** ADR 0012 C2: flips an armed repair transaction to `complete`; a no-op outside repair. */ - markCompleteIfArmed(): void; - - /** ADR 0012 R7 (C1): stamps `resume.repairSessionHeld` on a divergence whose transaction is uncommitted. */ - markSessionHeldIfArmed(response: DaemonResponse): DaemonResponse; - - /** ADR 0012 R7 (C5a): clears this session key's reap tombstone. */ - clearTombstone(): void; - - /** Retires the watermark once a `--from` matching `expectedFrom` has actually been entered. */ - clearCorrectiveWatermarkIfExpected(expectedFrom: number | undefined): void; - - /** The narrow capability threaded into the divergence-report chain. */ - readonly resumeStamper: ReplayResumeStamper; -}; - export type ReplaySessionStore = Readonly<{ get: () => Readonly | undefined; lookup: () => @@ -134,6 +82,9 @@ export function createReplayCoordinator(params: { return { repairBoundary: repairSessionBoundary(session), pendingRecordAndHeal: session.pendingRecordAndHeal, + scriptPublication: projectScriptPublication( + session.scriptPublication ?? NO_SCRIPT_PUBLICATION, + ), }; }, @@ -189,11 +140,16 @@ export function createReplayCoordinator(params: { }; } -/** `flows/login.ad` -> `flows/login.healed.ad`, beside the original (R6). */ -export function healedScriptSiblingPath(sourcePath: string): string { - const dir = path.dirname(sourcePath); - const base = path.basename(sourcePath, path.extname(sourcePath)); - return path.join(dir, `${base}.healed.ad`); +/** The publication state as replay's arming preflight reads it: kind, status, target, force. */ +function projectScriptPublication( + state: SessionScriptPublicationState, +): ReplaySessionView['scriptPublication'] { + return { + kind: state.kind, + status: state.kind === 'none' ? undefined : state.status, + targetPath: scriptTargetPath(state), + targetForce: scriptTargetForce(state), + }; } /** diff --git a/src/daemon/session-routing.ts b/src/daemon/session-routing.ts index 0904f57cd1..e76e97cf95 100644 --- a/src/daemon/session-routing.ts +++ b/src/daemon/session-routing.ts @@ -1,4 +1,4 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; +import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -9,7 +9,8 @@ import { publicPlatformString, } from '@agent-device/kernel/device'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionRef, SessionScope, SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionScope } from '@agent-device/contracts/session'; import { SessionStore } from './session-store.ts'; import { listSessionSelectorConflicts, type SessionSelectorConflict } from './session-selector.ts'; @@ -58,7 +59,7 @@ export function resolveImplicitSessionScope( return scope.kind === 'cwd' ? scope : undefined; } -export function resolveSessionScope(req: DaemonRequest): SessionScope { +export function resolveSessionScope(req: DaemonWireRequest): SessionScope { if (req.meta?.sessionIsolation === 'tenant' || req.flags?.sessionIsolation === 'tenant') { const tenantId = req.meta?.tenantId; if (!tenantId) { @@ -102,7 +103,7 @@ export function isImplicitSessionScopeConflict(req: DaemonRequest, session: Sess return session.sessionScope.id !== scope.id; } -export function hasExplicitSessionFlag(req: DaemonRequest): boolean { +export function hasExplicitSessionFlag(req: DaemonWireRequest): boolean { if (req.meta?.sessionExplicit === true) return true; const value = (req.flags as CommandFlags | undefined)?.session; return typeof value === 'string' && value.trim().length > 0; diff --git a/src/daemon/session-runtime.ts b/src/daemon/session-runtime.ts index 1fef38140f..52fb6669df 100644 --- a/src/daemon/session-runtime.ts +++ b/src/daemon/session-runtime.ts @@ -1,4 +1,4 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; +import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; import { type RuntimeHintValues, hasRuntimeTransportHintValues, @@ -10,7 +10,6 @@ import { isSessionRuntimePlatform, } from '@agent-device/kernel/contracts'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; -import type { DaemonRequest } from './daemon-request.ts'; import type { SessionRuntimeHints, SessionState } from './session-state.ts'; import { SessionStore } from './session-store.ts'; import { trimRuntimeValue } from '@agent-device/host-kit/runtime-transport-hints'; @@ -236,7 +235,7 @@ function resolveSessionRuntimeHints( } function resolveOpenRuntimeHints(params: { - req: DaemonRequest; + req: DaemonWireRequest; sessionStore: SessionRuntimeHintsStore; sessionName: string; device?: DeviceInfo; diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index c0be3e59d9..3c410ca358 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -3,10 +3,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { publicPlatformString } from '@agent-device/kernel/device'; import { dragGesturePayloadFromPositionals } from '@agent-device/contracts/gesture-normalization'; -import { inferFillText } from './action-utils.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; import { + inferFillText, appendScriptSeriesFlags, formatPortableActionLine, formatScriptArg, diff --git a/src/daemon/session-state.ts b/src/daemon/session-state.ts index 7a3c13d87d..d7ef7e626d 100644 --- a/src/daemon/session-state.ts +++ b/src/daemon/session-state.ts @@ -8,7 +8,7 @@ import type { PerfProfileHandoff, } from '@agent-device/contracts/perf-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; -import type { SessionAction, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionAction, SessionScope, SessionSurface } from '@agent-device/contracts/session'; import type { LeaseBackend, SessionRuntimeHints as PublicSessionRuntimeHints, @@ -25,7 +25,7 @@ import type { SessionScriptPublicationState } from './session-script-publication * `ref-frame.ts` owns the `refFrame` slot whose value is declared in `ref-frame-slot.ts`. * * Nothing here belongs on the wire. The request half of a dispatch lives in `daemon-request.ts`, - * and its public-only shape in `daemon-request-wire.ts`. + * and its public-only shape is `DaemonWireRequest` in `@agent-device/contracts/command`. */ export type SessionRuntimeHints = PublicSessionRuntimeHints; @@ -117,12 +117,6 @@ export type SessionRef = { session: SessionState; }; -export type SessionScope = - | { kind: 'cwd'; id: string } - | { kind: 'tenant'; id: string } - | { kind: 'named-local' } - | { kind: 'global-default' }; - export type SessionState = { name: string; sessionScope?: SessionScope; diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index f1ae7b2671..cbaabbfe90 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -31,7 +31,8 @@ import { type SnapshotRuntimeRouteParams, } from './snapshot-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from './daemon-request.ts'; -import type { SessionScope, SessionState } from './session-state.ts'; +import type { SessionState } from './session-state.ts'; +import type { SessionScope } from '@agent-device/contracts/session'; export type SnapshotRuntimeRecord = | { kind: 'snapshot'; nodes: number; truncated: boolean | undefined } diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 884e94ac7a..0322551409 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,7 +1,8 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionScope, SessionState } from './session-state.ts'; +import type { SessionState } from './session-state.ts'; +import type { SessionScope } from '@agent-device/contracts/session'; import { isActiveProviderDevice } from './provider-device-admission.ts'; import { SessionStore } from './session-store.ts';