diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb9b8a37..f7f1929b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Breaking (ADR 0014, session ref-frame lifetime): a mutation through an `@ref` now expires the session's ref frame, so a later ref mutation without a fresh observation fails closed with a typed `details.reason` (`ref_frame_expired`, `ref_generation_mismatch`, `plain_ref_requires_complete_frame`, or `ref_not_issued`) instead of acting on a possibly-navigated screen. A ref-oriented sequence that performs several mutations must re-`snapshot` between them, consume an honestly issued `--settle` ref in pinned `@eN~s` form, or use selectors. Enforcement applies on every platform, not just iOS. Legacy hand-written `.ad` scripts that reuse several bare refs from one snapshot must capture between mutations or use selectors. +- Ref reads resolve against the authorized ref frame's source tree, so an internal read-only capture (including Android freshness) can no longer retarget an admitted `@ref` by positional coincidence. Read-only ref consumers keep the structured staleness warning while the frame retains the ref's evidence. + ## 0.15.0 - Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps. diff --git a/CONTEXT.md b/CONTEXT.md index edaeac265..eb105b57c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,9 @@ - Coverage manifest: `CONTRACT_COVERAGE` export beside each interaction contract test file claiming which matrix cells it proves; the coverage gate requires every enforced/delegated cell to be claimed and rejects overclaims of waived cells. - Delegation-on-error: a fast path falling back to the runtime path on semantic failure shapes. It closes failure-side guarantee cells only — never success-path parity. - Ref generation pin: optional `~s` suffix on an @ref carrying the snapshot generation it was minted from. Accepted as input everywhere, emitted by no tree output (snapshot token budget), auto-appended by the MCP layer, stripped and ignored by replay. +- Ref frame (ADR 0014): the session's single authorization namespace for mutation `@ref`s, kept separate from the latest operational observation (`session.snapshot`). It owns a frozen epoch (the `refsGeneration` the client received), an immutable source tree, a lifecycle state (`active`/`expired`), and an issuance scope (`all` for a complete snapshot, or the bounded set of ref bodies a partial publication emitted). Owned solely by `src/daemon/ref-frame.ts`. A complete snapshot activates an `all` frame; `find`/settled diff/replay divergence activate a bounded partial frame that supersedes the prior one; internal read captures never activate or reindex it. +- Frame expiry seam (ADR 0014): every mutating leaf calls `expireRefFrame` synchronously, immediately before the device op that may change element identity (after all pre-action guards), so a post-dispatch failure still leaves the frame expired — there is no success-only rollback. Ref resolution binds `@eN` against the frame's source tree, so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; a fresh capture's coordinates are adopted only when its node's local identity matches. +- Mutation admission (ADR 0014): a ref mutation is admitted only against an active frame whose epoch and issuance scope authorize the ref (`admitRefMutation`, order-sensitive reasons `ref_frame_expired` → `ref_generation_mismatch` → `plain_ref_requires_complete_frame` → `ref_not_issued`). Rejections carry `details.reason` and name the lifetime failure. A ref-oriented sequence that performs several mutations must re-observe (snapshot), consume an honestly issued settled ref in pinned form, or use selectors. Read-only ref consumers stay fail-open with a staleness warning while the frame retains the ref's evidence. - Settled observation: opt-in (`--settle`) post-action payload on press/click/fill/longpress — the quiet-window stable loop re-captures until the UI settles, and the response carries the diff vs the pre-action tree (changed lines only, added lines with fresh refs, `refsGeneration` when the settled tree was stored). Best-effort: never fails the action; `settled: false` plus a hint on never-quiet content. - Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch. - Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes. @@ -136,8 +139,9 @@ the observable freshness and failure semantics below before any runtime refactor direct miss may fall back to the snapshot selector path, but ambiguous matches and runner errors must surface instead of silently falling back. `get text` uses direct native selectors only for simple `id` selectors because label/text/value reads need snapshot disambiguation. -- Regular selector reads remain capture-backed. `@ref` reads resolve against stored session - snapshots; selector `get`/`is`/`find`/`wait` capture through the backend. `find` and `wait` +- Regular selector reads remain capture-backed. `@ref`s resolve against the authorized ref frame's + source tree (ADR 0014), not whatever now sits at that index in a newer observation; selector + `get`/`is`/`find`/`wait` capture through the backend. `find` and `wait` polling must bypass the 750 ms snapshot cache. The cache is also bypassed while Android freshness recovery or post-gesture stabilization is active. - Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index d9b794031..361c1ce00 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -463,11 +463,12 @@ every `screen` ref with `refsGeneration` before returning it, including on the e client callers receive the unpinned refs and generation already present in the daemon error. No caller gets a text-only divergence that loses its repair data. -> **Proposed ADR 0014 amendment:** if ADR 0014 is accepted and implemented, replay divergence -> `screen.refs` becomes a partial ref publication. MCP keeps auto-pinning those refs; CLI text renders -> `@eN~s`; JSON and Node.js callers pair each plain ref with the response-level -> generation before mutation. Until that migration lands, the current unpinned contract above remains -> accepted. The implementation must update this note and its contract tests in the same change. +> **ADR 0014 amendment (accepted, implemented):** replay divergence `screen.refs` is now a partial ref +> publication — it activates a bounded partial ref frame authorizing exactly the divergence screen's refs +> (`markSessionPartialRefsIssued`). MCP auto-pins those refs; CLI text renders `@eN~s`; +> JSON and Node.js callers pair each plain ref with the response-level generation before mutation. Because +> the frame is partial, a mutation through a divergence ref requires the pinned form; a plain ref there +> reports `plain_ref_requires_complete_frame`. `--from N` is a `replay`-only flag. `test` must reject it as `INVALID_ARGS`; test shares replay execution but must remain a full, deterministic suite run. `N` is a 1-based index into the fully expanded diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index ccf99946d..15254111c 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -2,7 +2,7 @@ ## Status -Proposed +Accepted ## Context @@ -35,10 +35,9 @@ remain compatible with selector-based replay, and add no automatic capture or pe ## Decision -The terms introduced below describe the proposed target model. They do not replace the current domain -language in `CONTEXT.md` while this ADR is Proposed and the source still implements the snapshot/stale -marker model. The implementation change that establishes these concepts must promote the accepted -terms into `CONTEXT.md`; documentation must not present the target model as current behavior early. +The terms introduced below are now the implemented model; the ref-frame vocabulary is promoted into +`CONTEXT.md`. The coarse `snapshotRefsStale` marker still backs read-only staleness warnings and is +removed once per-platform enforcement is confirmed by live evidence (migration step 8). ### Ref frames are separate from operational observations @@ -69,15 +68,15 @@ compatibility. ### Frame transitions -| Event | Frame transition | Plain refs | Pinned refs | -| --- | --- | --- | --- | -| Internal observation that returns no refs | None | Unchanged | Unchanged | -| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches | -| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch | -| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected | -| Additional effects while already expired | Idempotent | Rejected | Rejected | -| Sparse, failed, or unusable capture | None | Unchanged | Unchanged | -| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today | +| Event | Frame transition | Plain refs | Pinned refs | +| ----------------------------------------- | -------------------------------------- | -------------------------- | ------------------------------------------------ | +| Internal observation that returns no refs | None | Unchanged | Unchanged | +| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches | +| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch | +| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected | +| Additional effects while already expired | Idempotent | Rejected | Rejected | +| Sparse, failed, or unusable capture | None | Unchanged | Unchanged | +| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today | A complete activation normally accompanies a command result that exposes the complete ref namespace for the stored frame, including an interactive or intentionally scoped snapshot. An intentionally @@ -210,9 +209,7 @@ The command descriptor's daemon facet declares a request policy: ```ts type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated'; -type DaemonRefFrameEffect = - | RefFrameEffect - | ((request: DaemonRequest) => RefFrameEffect); +type DaemonRefFrameEffect = RefFrameEffect | ((request: DaemonRequest) => RefFrameEffect); ``` The daemon registry exposes a named resolver. Every daemon command is classified, but the @@ -440,6 +437,27 @@ Each step lands green and independently useful: evidence; promote the implemented vocabulary into `CONTEXT.md`; then remove the superseded coarse stale marker. +Implementation status: steps 1–7 have landed — the ref-frame module and observation split (1), the +complete daemon classification and gate (2), the pre-side-effect seam at every leaf (3), correct +complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4), +Android freshness decoupled from positional ref authorization (5), the cross-platform contract and +provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7). +Fresh live evidence has exercised nearly every production seam — Apple runtime-ref, direct/native +selector, generation-pin, generic, and lifecycle paths; Android helper freshness (including proven +non-retarget) and Android existing-session relaunch; and a real provider-backed interaction plus +provider-backed lifecycle operation (AWS Device Farm, `backend: webdriver`: a fresh ref succeeded, an +immediate stale ref was rejected before dispatch with the shared typed fields, an `open --relaunch` +lifecycle mutation expired the frame, and a fresh observation restored authorization). ONE seam — +Android blocking-dialog recovery — was NOT live-exercised: the repo harness exposes no deterministic +app-owned ANR trigger, and no reproducible control exists to raise the system dialog on demand. The +team accepted shipping without a live run for it: its transition and abort logic are covered by fixture +regressions (`android-system-dialog-ref-frame.test.ts` proves recovery expires the frame before its +tap; `interaction-android-recovery-abort.test.ts` proves an outstanding ref action then aborts with the +shared `ref_frame_expired` rejection and no dispatch), the seam is enforced in code identically to the +verified paths, and it is recorded here as a documented, accepted evidence gap rather than an open +release blocker. Every other enabled seam is proven on hardware, so step 8's removal of the superseded +coarse `snapshotRefsStale` marker is unblocked. + PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker case before this full lifecycle is implemented; it does not own the architecture migration. diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f08b038ba..2d7eb46c0 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -903,6 +903,24 @@ test('client capture.snapshot preserves visibility metadata from daemon response }); }); +test('client capture.snapshot preserves refsGeneration from daemon responses (ADR 0014)', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'Go' }], + truncated: false, + refsGeneration: 752890, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot(); + + // Node.js callers must retain the response-level generation to pin a plain ref + // (`@e1~s752890`) before a mutation. + assert.equal(result.refsGeneration, 752890); +}); + test('client capture.snapshot preserves snapshot quality annotation from daemon responses', async () => { const snapshotQuality = { state: 'recovered', diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 0806d0139..6c7f7af09 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -501,6 +501,7 @@ function optionalSnapshotResponseFields( | 'warnings' | 'snapshotQuality' | 'snapshotDiagnostics' + | 'refsGeneration' > > { const visibility = readObject(data.visibility); @@ -511,6 +512,9 @@ function optionalSnapshotResponseFields( ...readSerializedSnapshotCaptureAnnotations(data), ...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}), ...(snapshotDiagnostics ? { snapshotDiagnostics } : {}), + // ADR 0014: keep the response-level ref-frame generation on Node.js results + // so callers can pin refs (`@e12~s`) before a mutation. + ...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}), }; } diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 5ee50a08d..338baf409 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -450,6 +450,12 @@ export type CaptureSnapshotResult = { unchanged?: SnapshotUnchanged; snapshotDiagnostics?: SnapshotDiagnosticsSummary; identifiers: AgentDeviceIdentifiers; + /** + * ADR 0014: the response-level ref-frame epoch the plain node refs were minted + * from. A ref-issuing snapshot carries it ONCE (nodes stay plain `@e12` for the + * token budget); pair a ref with it (`@e12~s`) before a mutation. + */ + refsGeneration?: number; } & PublicSnapshotCaptureAnnotations; export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { diff --git a/src/commands/interaction/output.ts b/src/commands/interaction/output.ts index 8324f9e74..99f40c478 100644 --- a/src/commands/interaction/output.ts +++ b/src/commands/interaction/output.ts @@ -14,6 +14,17 @@ function getCliOutput(params: { result: CommandRequestResult; format?: string }) return defaultCommandCliOutput(data); } +// ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy +// `@eN~s` form so a human CLI caller can paste it into the next +// mutation without a separate pin step. A mutating result carries no +// `refsGeneration`, so its acted ref is never pinned. +function pinnedRefText(ref: unknown, refsGeneration: unknown): string | undefined { + if (typeof ref !== 'string' || ref.length === 0) return undefined; + if (typeof refsGeneration !== 'number') return undefined; + const body = ref.startsWith('@') ? ref.slice(1) : ref; + return `@${body}~s${refsGeneration}`; +} + function findCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; // Interactive find actions (click/fill/focus/type) carry the same success message as @@ -21,6 +32,9 @@ function findCliOutput(result: CommandRequestResult): CliOutput { const message = readCommandMessage(data); if (message) return { data, text: message }; if (typeof data.text === 'string') return { data, text: data.text }; + // A read-only find that returns a reusable ref renders it pinned (ADR 0014). + const pinned = pinnedRefText(data.ref, data.refsGeneration); + if (pinned) return { data, text: `Found: ${pinned}` }; if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; return defaultCommandCliOutput(data); @@ -64,6 +78,7 @@ type SettleTextView = { }; tail?: Array<{ ref?: string; role?: string; label?: string }>; tailTruncated?: boolean; + refsGeneration?: number; }; /** @@ -100,7 +115,10 @@ function formatSettleTailLines(view: SettleTextView): string[] { const lines = [`unchanged interactive (${tail.length}):`]; for (const entry of tail) { const label = entry.label ? ` "${entry.label}"` : ''; - lines.push(`= @${entry.ref ?? ''} [${entry.role ?? ''}]${label}`); + // ADR 0014: the settled tail refs are reusable, so render them pinned when + // the settle response carried its generation. + const ref = pinnedRefText(entry.ref, view.refsGeneration) ?? `@${entry.ref ?? ''}`; + lines.push(`= ${ref} [${entry.role ?? ''}]${label}`); } if (view.tailTruncated) { lines.push('… more interactive elements not shown, use snapshot -i'); diff --git a/src/commands/interaction/runtime/resolution.test.ts b/src/commands/interaction/runtime/resolution.test.ts index fccb0025a..72e60e9c1 100644 --- a/src/commands/interaction/runtime/resolution.test.ts +++ b/src/commands/interaction/runtime/resolution.test.ts @@ -412,7 +412,7 @@ test('runtime interactions reject unsupported macOS desktop and menubar surfaces assert.equal(pressed, true); }); -test('runtime ref interactions refresh the snapshot when a stored ref has no usable rect', async () => { +test('runtime ref interactions fail closed when the authorized ref has no usable bounds (ADR 0014)', async () => { const staleSnapshot = makeSnapshotState([ { index: 0, @@ -422,25 +422,30 @@ test('runtime ref interactions refresh the snapshot when a stored ref has no usa hittable: true, }, ]); - const freshSnapshot = selectorSnapshot(); const calls: Point[] = []; let captures = 0; const device = createInteractionDevice(staleSnapshot, { captureSnapshot: async () => { captures += 1; - return { snapshot: freshSnapshot }; + return { snapshot: selectorSnapshot() }; }, tap: async (_context, point) => { calls.push(point); }, }); - const result = await device.interactions.click(ref('@e1'), { session: 'default' }); - - assert.equal(captures, 1); - assert.deepEqual(calls, [{ x: 60, y: 40 }]); - assert.equal(result.kind, 'ref'); - assert.equal(result.node?.rect?.width, 100); + // ADR 0014: the authorized frame's @e1 has no usable rect, so it FAILS rather + // than recapturing and accepting the same index from a newer tree by + // positional coincidence. + await assert.rejects( + () => device.interactions.click(ref('@e1'), { session: 'default' }), + (error: unknown) => { + assert.match((error as Error).message, /Ref @e1 not found or has no bounds/); + return true; + }, + ); + assert.equal(captures, 0); + assert.deepEqual(calls, []); }); test('tryResolveRefNode discloses exact for a resolved ref and label-fallback for label recovery', () => { diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 4323ff56c..0c4fa85e4 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -2,7 +2,11 @@ import { AppError } from '../../../kernel/errors.ts'; import type { Point, SnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../../../kernel/snapshot.ts'; import { resolveRectCenter } from '../../../utils/rect-center.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import type { + AgentDeviceRuntime, + CommandContext, + CommandSessionRecord, +} from '../../../runtime-contract.ts'; import { parseSelectorChain } from '../../../selectors/parse.ts'; import { formatSelectorFailure, @@ -545,28 +549,71 @@ async function resolveSnapshotForRef( const sessionName = options.session ?? 'default'; const session = await runtime.sessions.get(sessionName); if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.'); - if (!session.snapshot) { + // ADR 0014: a ref resolves against the AUTHORIZED frame tree, not the latest + // operational observation. They share the capture object until a read-only + // capture (e.g. Android freshness) advances `snapshot` alone; from then on the + // frame tree is the identity authority for `@eN`. + const frameTree = session.refFrameSnapshot ?? session.snapshot; + if (!frameTree) { throw new AppError('INVALID_ARGS', 'No snapshot in session. Run snapshot first.'); } const fallbackLabel = target.fallbackLabel ?? ''; - const stored = tryResolveRefNode(session.snapshot.nodes, target.ref, { - fallbackLabel, - }); - if (stored) { - return { snapshot: session.snapshot, resolved: stored }; - } - - const capture = await captureInteractionSnapshot(runtime, options, true); - const refreshed = tryResolveRefNode(capture.snapshot.nodes, target.ref, { + const authorized = tryResolveRefNode(frameTree.nodes, target.ref, { fallbackLabel, }); - if (!refreshed) { + // ADR 0014: missing authorized-frame evidence FAILS. It must not fall through + // to a fresh capture and accept the same ref body from a newer tree — that is + // exactly the positional-coincidence retarget the frame model forbids. A stale + // read is observable and recoverable; a stale mutation can act on the wrong + // element. The caller re-observes (snapshot) or uses a selector. + if (!authorized) { throw new AppError('COMMAND_FAILED', `Ref ${target.ref} not found or has no bounds`, { hint: STALE_REF_HINT, }); } - return { ...capture, resolved: refreshed }; + return reconcileFreshObservation({ + session, + frameTree, + target, + fallbackLabel, + authorized, + }); +} + +/** + * ADR 0014 step 5: decouple Android freshness from ref authorization. The frame + * tree names WHICH node `@eN` authorizes. When a freshness (or other read-only) + * capture has advanced the operational observation past the frame, adopt the + * observation's node — its fresh on-screen coordinates — ONLY when its local + * identity still matches the authorized node. That covers the legitimate case of + * an element that merely moved. If the identity differs (a different element now + * sits at that index) or the ref is absent from the observation, keep the + * authorized frame node so a positional coincidence cannot retarget the action. + */ +function reconcileFreshObservation(params: { + session: CommandSessionRecord; + frameTree: SnapshotState; + target: Extract; + fallbackLabel: string; + authorized: ResolvedRefNode; +}): CapturedSnapshot & { resolved: ResolvedRefNode } { + const { session, frameTree, target, fallbackLabel, authorized } = params; + const observation = session.snapshot; + if (!observation || observation === frameTree) { + return { snapshot: frameTree, resolved: authorized }; + } + const observed = tryResolveRefNode(observation.nodes, target.ref, { fallbackLabel }); + if ( + observed && + localIdentitiesEqual( + readNodeLocalIdentity(authorized.node), + readNodeLocalIdentity(observed.node), + ) + ) { + return { snapshot: observation, resolved: observed }; + } + return { snapshot: frameTree, resolved: authorized }; } /** The runtime-ref resolver: `exact` for a resolved `@ref`, `label-fallback` for trailing-label recovery. */ diff --git a/src/contracts/result-serialization.test.ts b/src/contracts/result-serialization.test.ts index 0b8b56ebc..6044ecff1 100644 --- a/src/contracts/result-serialization.test.ts +++ b/src/contracts/result-serialization.test.ts @@ -131,6 +131,19 @@ test('serializeSnapshotResult includes Android backend metadata', () => { }); }); +test('serializeSnapshotResult preserves the response-level refsGeneration (ADR 0014)', () => { + const data = serializeSnapshotResult({ + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'Go' }], + truncated: false, + refsGeneration: 752890, + identifiers: { session: 'qa' }, + } as Parameters[0]); + + assert.equal(data.refsGeneration, 752890); + // The node tree stays plain — the generation rides once at the response level. + assert.equal((data.nodes as Array<{ ref?: string }>)[0]?.ref, 'e1'); +}); + test('serializeSnapshotResult maps capture quality annotation to public snapshotQuality', () => { const snapshotQuality = { state: 'healthy', diff --git a/src/contracts/result-serialization.ts b/src/contracts/result-serialization.ts index 8ce644810..df38a8943 100644 --- a/src/contracts/result-serialization.ts +++ b/src/contracts/result-serialization.ts @@ -182,6 +182,9 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record` before a mutation. + ...(result.refsGeneration !== undefined ? { refsGeneration: result.refsGeneration } : {}), }; } diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 636d4c869..a21667575 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -32,10 +32,11 @@ const DAEMON_FUNCTION_TRAITS = [ // Public commands that intentionally have no daemon route — they live only in the // capability/batch tables, so the daemon registry has never covered them. -const UNROUTED_PUBLIC_COMMANDS = new Set([ - PUBLIC_COMMANDS.appSwitcher, - PUBLIC_COMMANDS.installFromSource, -]); +// `install-from-source` projects to the daemon via the `install_source` internal +// command (its daemon writer), never by its own name, so the daemon registry has +// never covered it. (`app-switcher` gained a daemon facet under ADR 0014 so its +// device mutation could be classified, so it is no longer unrouted.) +const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); // Public commands that intentionally carry no capability entry — pure control-plane // or always-admitted commands, so the capability matrix has never covered them. diff --git a/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts new file mode 100644 index 000000000..37a14577a --- /dev/null +++ b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; +import { + DAEMON_COMMAND_DESCRIPTORS, + resolveRefFrameEffect, + type RefFrameEffect, +} from '../../../daemon/daemon-command-registry.ts'; +import type { DaemonRequest } from '../../../daemon/types.ts'; + +// ADR 0014 migration step 2: the ref-frame-effect classification is an +// honesty/completeness guard. Every command that reaches a session-owning daemon +// leaf must classify how it relates to the authorized ref frame, so a device +// mutation cannot be added on a path that silently leaves stale refs admissible. + +function makeRequest(command: string, positionals: string[] = []): DaemonRequest { + return { command, token: 'gate-token', session: 'gate-session', positionals, flags: {} }; +} + +/** + * Public commands that never reach a session-owning daemon leaf by their own + * name, so they carry no ref-frame classification. `install-from-source`'s + * daemon writer sends the `install_source` internal command (which IS + * classified), so the daemon never receives `install-from-source` itself. This + * is the ONLY unclassified public command: if it grows, a new daemon-projected + * mutation may be slipping past the gate — add its facet, do not extend this + * list without proving the command cannot mutate through the daemon. + */ +const NON_DAEMON_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); + +test('every daemon-projected command classifies a ref-frame effect', () => { + for (const descriptor of DAEMON_COMMAND_DESCRIPTORS) { + assert.ok( + descriptor.refFrameEffect !== undefined, + `daemon command ${descriptor.command} declares no refFrameEffect (ADR 0014)`, + ); + } +}); + +test('every public command is classified or explicitly non-daemon', () => { + const daemonCommands = new Set( + DAEMON_COMMAND_DESCRIPTORS.filter((d) => d.refFrameEffect !== undefined).map((d) => d.command), + ); + for (const command of Object.values(PUBLIC_COMMANDS)) { + if (NON_DAEMON_PUBLIC_COMMANDS.has(command)) continue; + assert.ok( + daemonCommands.has(command), + `public command ${command} has no ref-frame classification and is not in the ` + + `explicit non-daemon allowlist — a daemon-projected mutation may be unclassified`, + ); + } +}); + +test('app-switcher no longer bypasses classification (ADR 0014 escape hatch)', () => { + // app-switcher reaches the generic daemon leaf and mutates the device. Before + // ADR 0014 it had no daemon facet and relied on the registry's generic + // fallback, so it could not be classified. It must now resolve to a concrete + // mutating effect. + assert.equal(resolveRefFrameEffect(makeRequest('app-switcher')), 'may-invalidate'); +}); + +test('resolver commands classify per selected subaction', () => { + // keyboard: status/get probes preserve; dismiss/enter/return mutate. enter and + // return dispatch a real return key, so they must NOT be preserve. + assert.equal(resolveRefFrameEffect(makeRequest('keyboard')), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['status'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['get'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['dismiss'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['enter'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['return'])), 'may-invalidate'); + // alert: get/wait read, accept/dismiss mutate. + assert.equal(resolveRefFrameEffect(makeRequest('alert')), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['get'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['wait'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['accept'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['dismiss'])), 'may-invalidate'); +}); + +test('representative literal classifications resolve as declared', () => { + const cases: Array<[string, RefFrameEffect]> = [ + ['snapshot', 'preserve'], + ['diff', 'preserve'], + ['get', 'preserve'], + ['is', 'preserve'], + ['screenshot', 'preserve'], + ['clipboard', 'preserve'], + ['press', 'may-invalidate'], + ['click', 'may-invalidate'], + ['fill', 'may-invalidate'], + ['type', 'may-invalidate'], + ['scroll', 'may-invalidate'], + ['back', 'may-invalidate'], + ['open', 'may-invalidate'], + ['viewport', 'may-invalidate'], + ['batch', 'delegated'], + ['replay', 'delegated'], + ['test', 'delegated'], + ]; + for (const [command, expected] of cases) { + assert.equal(resolveRefFrameEffect(makeRequest(command)), expected, `${command} effect`); + } +}); + +test('non-daemon commands resolve to no effect', () => { + // A command the daemon never receives by name has no classification. + assert.equal(resolveRefFrameEffect(makeRequest('install-from-source')), undefined); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c9bff82a4..58a1d77a4 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1,5 +1,8 @@ import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; +// Type-only back-edge, erased at runtime (same pattern as derive.ts importing +// DaemonCommandDescriptor); no runtime import cycle. +import type { RefFrameEffect } from '../../daemon/daemon-command-registry.ts'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; import { DEFAULT_TIMEOUT_POLICY, @@ -82,6 +85,28 @@ const isShardedTestRequest = (req: DaemonRequest): boolean => req.command === 'test' && (typeof req.flags?.shardAll === 'number' || typeof req.flags?.shardSplit === 'number'); +// ADR 0014 request-sensitive ref-frame resolvers. The action is the leading +// positional (see keyboard/alert daemon writers in src/commands/system/index.ts +// and src/commands/capture/alert.ts). Only the read-only status probes preserve +// the frame; every mutating subaction crosses a device side effect. +// +// keyboard actions are status/get/dismiss/enter/return (src/commands/system/ +// runtime/system.ts): status/get inspect, while dismiss hides the keyboard and +// enter/return dispatch a real return key. Anything other than a read is +// classified may-invalidate (the honest superset for unknown subactions). +const KEYBOARD_READ_ONLY_ACTIONS = new Set(['status', 'get']); +const keyboardRefFrameEffect = (req: DaemonRequest): RefFrameEffect => + KEYBOARD_READ_ONLY_ACTIONS.has((req.positionals?.[0] ?? 'status').toLowerCase()) + ? 'preserve' + : 'may-invalidate'; + +// alert actions are get/wait/accept/dismiss: get/wait read, accept/dismiss act. +const ALERT_READ_ONLY_ACTIONS = new Set(['get', 'wait']); +const alertRefFrameEffect = (req: DaemonRequest): RefFrameEffect => + ALERT_READ_ONLY_ACTIONS.has((req.positionals?.[0] ?? 'get').toLowerCase()) + ? 'preserve' + : 'may-invalidate'; + // --------------------------------------------------------------------------- // Capability matrices — platform/kind buckets, copied VERBATIM from // src/core/capabilities.ts (BASE_COMMAND_CAPABILITY_MATRIX). @@ -208,7 +233,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_allocate', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseAllocate' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -216,7 +241,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_heartbeat', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseHeartbeat' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -224,7 +249,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_release', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseRelease' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -232,7 +257,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'artifacts', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/artifacts.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -244,7 +269,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/session-inventory.ts'] as const } : {}), catalog: { group: 'internal', key: 'sessionList' }, - daemon: { route: 'session', sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'inventory', + ...REQUEST_EXECUTION_EXEMPT, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -254,6 +284,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, @@ -267,6 +298,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, @@ -281,6 +313,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, allowSessionlessDefaultDevice: allowAnyDeviceSessionless, @@ -295,6 +328,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, @@ -307,7 +341,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'boot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, @@ -320,7 +354,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'shutdown', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, capability: { apple: { simulator: true }, android: { emulator: true }, @@ -333,7 +367,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'appstate', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appState' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'state' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, @@ -341,7 +375,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'perf', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/perf/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -350,7 +384,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'logs', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -361,6 +395,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'observability', allowInvalidRecording: true, ...REQUEST_EXECUTION_EXEMPT, @@ -372,7 +407,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'network', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -381,7 +416,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'audio', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: { emulator: true }, @@ -396,6 +431,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, }, @@ -409,6 +445,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, }, @@ -423,7 +460,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/session-runtime-command.ts'] as const } : {}), catalog: { group: 'internal' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -431,7 +468,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'clipboard', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', replayScopedAction: true }, + daemon: { route: 'session', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -445,7 +482,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'keyboard', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'session', + refFrameEffect: keyboardRefFrameEffect, + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -459,7 +501,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'install', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -468,7 +510,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'reinstall', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -479,7 +521,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } : {}), catalog: { group: 'internal', key: 'installSource' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: false, }, @@ -489,7 +531,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } : {}), catalog: { group: 'internal', key: 'releaseMaterializedPaths' }, - daemon: { route: 'session', ...REQUEST_EXECUTION_EXEMPT }, + daemon: { route: 'session', refFrameEffect: 'preserve', ...REQUEST_EXECUTION_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -497,7 +539,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'push', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: { simulator: true }, @@ -511,7 +553,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'trigger-app-event', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -521,7 +563,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'open', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', allowSessionlessDefaultDevice: allowAnyDeviceSessionless }, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + allowSessionlessDefaultDevice: allowAnyDeviceSessionless, + }, dispatch: {}, capability: APP_RUNTIME_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -531,7 +577,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'prepare', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/prepare.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { budget: { source: 'flag' }, @@ -545,7 +591,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'batch', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/batch/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'delegated' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -553,7 +599,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'close', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', allowInvalidRecording: true }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', allowInvalidRecording: true }, dispatch: {}, capability: APP_RUNTIME_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -565,7 +611,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'snapshot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/snapshot.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, // First Apple snapshot on a device can sit behind runner startup; --timeout @@ -577,7 +623,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'diff', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/diff.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -586,7 +632,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'wait', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/wait.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, // The wait budget travels as a positional, not a flag; parse it the same // way the daemon will so the request envelope extends past it (#1075). @@ -600,7 +646,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'alert', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect, replayScopedAction: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, @@ -613,7 +659,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'settings', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -629,7 +675,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'react-native', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, - daemon: { route: 'reactNative', replayScopedAction: true }, + daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate', replayScopedAction: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -640,6 +686,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'recordTrace', + refFrameEffect: 'preserve', replayScopedAction: true, allowInvalidRecording: true, allowSessionlessDefaultDevice: isRecordingStartRequest, @@ -652,7 +699,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'trace', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'recordTrace' }, + daemon: { route: 'recordTrace', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, @@ -660,7 +707,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'find', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'find', replayScopedAction: true }, + daemon: { route: 'find', refFrameEffect: 'may-invalidate', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, batchable: true, @@ -677,7 +724,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'click', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('click'), postActionObservation: postActionObservation('click'), @@ -688,7 +740,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'fill', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('fill'), @@ -700,7 +757,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'longpress', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public', key: 'longPress' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: { @@ -717,7 +779,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'press', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('press'), @@ -729,7 +796,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'type', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('type'), @@ -739,7 +811,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'get', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('get'), batchable: true, @@ -756,7 +828,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'is', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('is'), batchable: true, @@ -767,7 +839,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'back', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -777,7 +854,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'gesture', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -786,7 +868,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'home', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -800,7 +887,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'tv-remote', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -814,7 +906,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'orientation', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -828,7 +925,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'scroll', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -838,7 +940,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'swipe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -847,7 +954,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'focus', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -857,7 +968,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'screenshot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/screenshot.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true }, + daemon: { route: 'generic', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -867,7 +978,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'viewport', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/viewport.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -878,6 +989,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'app-switcher', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, + // ADR 0014: app-switcher previously reached the generic daemon leaf via the + // registry's generic fallback with no daemon facet, so it could not be + // classified. Add the facet (route unchanged) so its device mutation is + // covered by the completeness gate; this is the escape hatch the ADR calls + // out, not a new specialized route. + daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts new file mode 100644 index 000000000..ecac49847 --- /dev/null +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -0,0 +1,44 @@ +import { test, expect, vi } from 'vitest'; + +// ADR 0014: Android blocking-dialog recovery is itself device-mutating, so its +// recovery tap must cross the side-effect seam and expire the ref frame. +vi.mock('../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); +vi.mock('../../platforms/android/adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { snapshotAndroid } from '../../platforms/android/snapshot.ts'; +import { runAndroidAdb } from '../../platforms/android/adb.ts'; +import { recoverAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; +import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; + +test('android blocking-dialog recovery expires the ref frame before its recovery tap', async () => { + const dialog = { + index: 0, + type: 'TextView', + label: "App isn't responding", + rect: { x: 0, y: 0, width: 300, height: 60 }, + }; + const closeApp = { + index: 1, + type: 'Button', + label: 'Close app', + hittable: true, + rect: { x: 20, y: 200, width: 120, height: 44 }, + }; + // First read: the blocking dialog is present (drives recovery). Post-tap poll: + // the dialog is gone, so recovery completes without sleeping through retries. + vi.mocked(snapshotAndroid) + .mockResolvedValueOnce({ nodes: [dialog, closeApp] } as never) + .mockResolvedValue({ nodes: [] } as never); + vi.mocked(runAndroidAdb).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' } as never); + + const session = makeAndroidSession('anr-recovery'); + session.recording = { outPath: '/tmp/anr.mp4', startedAt: 0 } as never; + expect(session.refFrameState).toBeUndefined(); // active + + const result = await recoverAndroidBlockingSystemDialog({ session }); + + // The recovery tap was dispatched, and the frame is expired as a result. + expect(vi.mocked(runAndroidAdb)).toHaveBeenCalled(); + expect(session.refFrameState).toBe('expired'); + expect(result.status).not.toBe('absent'); +}); diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts new file mode 100644 index 000000000..50266e1c5 --- /dev/null +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + activateCompleteRefFrame, + admitRefMutation, + expireRefFrame, + refFrameEpoch, + refFrameScope, + refFrameState, + type RefFrameAdmission, +} from '../ref-frame.ts'; +import type { SessionState } from '../types.ts'; + +function session(overrides: Partial = {}): SessionState { + return { + name: 'ref-frame-test', + device: { platform: 'ios', kind: 'simulator', id: 'sim', name: 'iPhone' }, + createdAt: 0, + ...overrides, + } as SessionState; +} + +function reason(admission: RefFrameAdmission): string | undefined { + return admission.admitted ? undefined : admission.reason; +} + +test('defaults: no frame fields set reads as active / all / undefined epoch', () => { + const s = session(); + assert.equal(refFrameState(s), 'active'); + assert.equal(refFrameScope(s), 'all'); + assert.equal(refFrameEpoch(s), undefined); +}); + +test('complete active frame admits a plain ref', () => { + const s = session({ snapshotGeneration: 42 }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined }), { + admitted: true, + }); +}); + +test('complete active frame admits a pinned ref at the current epoch', () => { + const s = session({ snapshotGeneration: 42 }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { + admitted: true, + }); +}); + +test('a pinned ref at another epoch is a generation mismatch', () => { + const s = session({ snapshotGeneration: 43 }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_generation_mismatch', + ); +}); + +test('an expired frame rejects every ref, and expiry wins over a matching pin', () => { + const s = session({ snapshotGeneration: 42, refFrameState: 'expired' }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), + 'ref_frame_expired', + ); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_frame_expired', + ); +}); + +test('a partial frame rejects a plain ref: it requires a complete frame', () => { + const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), + 'plain_ref_requires_complete_frame', + ); +}); + +test('a partial frame admits only pinned refs it issued, at the current epoch', () => { + const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { + admitted: true, + }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e2', mintedGeneration: 42 })), + 'ref_not_issued', + ); +}); + +test('generation mismatch is evaluated before issuance scope', () => { + // A pin at the wrong epoch is a mismatch even if the body was in scope. + const s = session({ snapshotGeneration: 43, refFrameScope: new Set(['e1']) }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_generation_mismatch', + ); +}); + +test('expireRefFrame is idempotent and rejects all refs while expired', () => { + const s = session({ snapshotGeneration: 42 }); + expireRefFrame(s); + assert.equal(refFrameState(s), 'expired'); + expireRefFrame(s); // idempotent + assert.equal(refFrameState(s), 'expired'); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_frame_expired', + ); +}); + +test('activateCompleteRefFrame re-authorizes a complete frame after expiry', () => { + const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + expireRefFrame(s); + activateCompleteRefFrame(s); + assert.equal(refFrameState(s), 'active'); + assert.equal(refFrameScope(s), 'all'); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e9', mintedGeneration: undefined }), { + admitted: true, + }); +}); + +test('expireRefFrame clears scoped-snapshot lineage at the seam (ADR 0014)', () => { + const scopeTree = { + nodes: [], + createdAt: 0, + backend: 'xctest', + } as unknown as SessionState['snapshot']; + const s = session({ snapshotGeneration: 42, snapshotScopeSource: scopeTree }); + expireRefFrame(s); + // A mutation breaks the consecutive `snapshot -s @ref` chain, so a later + // repeated scoped snapshot cannot borrow stale lineage across the side effect. + assert.equal(s.snapshotScopeSource, undefined); +}); diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index 71bb11a66..57e3b670c 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest'; import type { SnapshotState } from '../../kernel/snapshot.ts'; import type { SessionState } from '../types.ts'; import { + markSessionPartialRefsIssued, resolveRefStalenessWarning, setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING, @@ -97,3 +98,24 @@ test('resolveRefStalenessWarning treats a missing stored generation as s0', () = ); expect(resolveRefStalenessWarning({ session, ref: '@e2', mintedGeneration: 0 })).toBeUndefined(); }); + +test('markSessionPartialRefsIssued: an empty result leaves all state untouched (ADR 0014)', () => { + const session = makeSession(); + // A useful prior frame exists. + session.snapshotRefsStale = true; + session.refFrameState = 'active'; + session.refFrameScope = new Set(['e1']); + session.refFrameGeneration = 7; + + // An empty partial publication (no refs) must not supersede that authority or + // even clear the coarse marker. + markSessionPartialRefsIssued(session, []); + expect(session.snapshotRefsStale).toBe(true); + expect(session.refFrameScope).toEqual(new Set(['e1'])); + expect(session.refFrameGeneration).toBe(7); + + // A non-empty result supersedes with exactly its bodies. + markSessionPartialRefsIssued(session, ['@e5~s7', 'e6']); + expect(session.snapshotRefsStale).toBe(false); + expect(session.refFrameScope).toEqual(new Set(['e5', 'e6'])); +}); diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 71e0742c4..c02d675a3 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -11,6 +11,7 @@ import { AppError } from '../kernel/errors.ts'; import { centerOfRect, attachRefs, type SnapshotNode } from '../kernel/snapshot.ts'; import { sleep } from '../utils/timeouts.ts'; import { pruneGroupNodes } from '../snapshot/snapshot-processing.ts'; +import { expireRefFrame } from './ref-frame.ts'; import type { SessionState } from './types.ts'; const ANDROID_BLOCKING_MODAL_PATTERN = /\bis(?:n(?:'|'|')?t| not)\s+responding\b/i; @@ -275,6 +276,11 @@ async function tapAndroidDialogButton( return { ok: false, exitCode: 1, stdout: '', stderr: 'button has no rect' }; } const { x, y } = centerOfRect(button.rect); + // ADR 0014 side-effect seam: blocking-dialog recovery is itself device + // mutating. Expire the frame before the recovery tap (the first recovery side + // effect), even when invoked from apparent readiness work, so a ref action + // cannot continue against the recovered UI. + expireRefFrame(session); const result = await runAndroidAdb( session.device, ['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index fc96fa7aa..4aa1d328c 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -7,10 +7,38 @@ export type { DaemonCommandRoute } from './request-handler-chain.ts'; export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'replay'; +/** + * ADR 0014 session ref-frame lifetime. Declares how a daemon command relates to + * the session's authorized ref frame: + * - `preserve`: no successful path changes device-visible element identity, so + * the frame carries through untouched (snapshots, reads, inventory, ...); + * - `may-invalidate`: some successful path crosses a device side effect, so the + * leaf must expire the frame at its side-effect seam when that path runs; + * - `delegated`: an orchestrator (batch/replay/test) whose nested leaves own + * their own transitions — the outer command never expires a frame itself. + * + * This classification is an honesty/completeness guard, NOT the transition site: + * a `may-invalidate` command still calls the ref-frame module only when its + * mutating path is selected. The completeness gate + * (`__tests__/ref-frame-effect.test.ts`) fails if a daemon-projected command + * omits this classification. + */ +export type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated'; + +/** + * Request-sensitive form of {@link RefFrameEffect}. Commands whose subactions + * differ (keyboard `status` vs `dismiss`, alert `get`/`wait` vs + * `accept`/`dismiss`) use the resolver form instead of pretending all + * subcommands behave alike. Mirrors the existing `(req) => boolean` closure + * traits below. + */ +export type DaemonRefFrameEffect = RefFrameEffect | ((req: DaemonRequest) => RefFrameEffect); + export type DaemonCommandDescriptor = { command: string; route: DaemonCommandRoute; sessionKind?: SessionCommandKind; + refFrameEffect?: DaemonRefFrameEffect; leaseAdmissionExempt?: boolean; sessionExecutionLockExempt?: boolean; selectorValidationExempt?: boolean; @@ -86,6 +114,18 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } +/** + * ADR 0014: the ref-frame effect a request resolves to, honoring the + * request-sensitive resolver form. Returns `undefined` for commands with no + * daemon descriptor (never daemon-projected) — the completeness gate ensures + * every daemon-projected command declares an effect, so `undefined` here means + * the command does not reach a session-owning daemon leaf. + */ +export function resolveRefFrameEffect(req: DaemonRequest): RefFrameEffect | undefined { + const effect = getDaemonCommandDescriptor(req.command)?.refFrameEffect; + return typeof effect === 'function' ? effect(req) : effect; +} + export function resolveProviderDeviceResolutionIntent( req: DaemonRequest, params: { hasExistingSession: boolean; hasExplicitDeviceSelector: boolean }, diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 2cbe13e2f..d80ebb56e 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -75,6 +75,24 @@ async function runFindClickScenario(options: { return { response: response!, invokeCalls, session }; } +test('mutating find focus crosses the ADR 0014 side-effect seam and expires the ref frame', async () => { + // find focus/type dispatch the device command directly (they do NOT re-enter + // the interaction leaf like find click/fill), so the seam must live in find. + const node = { + index: 0, + type: 'Button', + label: 'Save', + hittable: true, + rect: { x: 10, y: 20, width: 100, height: 40 }, + }; + const { response, session } = await runFindClickScenario({ + positionals: ['Save', 'focus'], + nodes: [node], + }); + expect(response.ok).toBe(true); + expect(session.refFrameState).toBe('expired'); +}); + test('handleFindCommands click returns deterministic metadata across locator variants', async () => { const hittableParentNoRect = { index: 0, type: 'View', hittable: true, depth: 0 }; const nonHittableChildWithRect = { @@ -93,8 +111,9 @@ test('handleFindCommands click returns deterministic metadata across locator var positionals: ['Increment', 'click'], nodes: [hittableParentNoRect, nonHittableChildWithRect], invoke: async () => ({ platformSpecificRef: 'XCUIElementTypeView' }), - // refsGeneration rides every ref-issuing find response (#1076 versioned refs). - expectedKeys: ['locator', 'message', 'query', 'ref', 'refsGeneration', 'x', 'y'], + // ADR 0014: a mutating find (click) omits refsGeneration — its ref is + // diagnostic pre-action identity, never a pinnable issued ref. + expectedKeys: ['locator', 'message', 'query', 'ref', 'x', 'y'], expectedLocator: 'any', expectedQuery: 'Increment', expectedCoordinates: { x: 100, y: 50 }, @@ -759,7 +778,7 @@ test('handleFindCommands click re-issues a fresh ref and clears the stale-refs m expect(storedSession.snapshotRefsStale).toBe(false); }); -test('handleFindCommands click carries refsGeneration for the freshly stored tree (#1076 versioned refs)', async () => { +test('handleFindCommands click omits refsGeneration — a mutating find never issues a pinnable ref (ADR 0014)', async () => { const sessionName = 'default'; const session = makeSession(sessionName); // Two earlier tree replacements happened in this session. @@ -781,10 +800,12 @@ test('handleFindCommands click carries refsGeneration for the freshly stored tre }); expect(response.ok).toBe(true); - // The find capture replaced the stored tree (generation 3) and the response - // returns a ref minted from it, so it reports that generation ONCE. + // The find capture still replaced the stored tree (generation 3)… expect(storedSession.snapshotGeneration).toBe(3); if (response.ok) { - expect((response.data as Record).refsGeneration).toBe(3); + // …but a mutating find must NOT report refsGeneration: its acted ref is + // diagnostic pre-action identity, so MCP cannot pin and reuse it after the + // action. + expect((response.data as Record).refsGeneration).toBeUndefined(); } }); diff --git a/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts b/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts new file mode 100644 index 000000000..7c5a8d07c --- /dev/null +++ b/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts @@ -0,0 +1,96 @@ +import { test, expect, vi, beforeEach } from 'vitest'; + +// ADR 0014 (evidence #13): Android blocking-dialog recovery is device-mutating +// and expires the frame at its own seam. A ref action admitted against the +// pre-recovery frame must ABORT after recovery mutates — it cannot continue +// against the recovered UI — and the rejection must use the SHARED admission +// shape (reason + ref + currentGeneration + scope), not a bespoke error. + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; +}); + +vi.mock('../interaction-snapshot.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureSnapshotForSession: vi.fn(async () => ({ nodes: [], createdAt: 0, backend: 'android' })), + }; +}); + +// Simulate recovery: the before-command readiness check taps a blocking dialog +// (a device mutation) and reports `recovered`, expiring the frame. +vi.mock('../../android-system-dialog.ts', async (importOriginal) => { + const actual = await importOriginal(); + const { expireRefFrame } = await import('../../ref-frame.ts'); + return { + ...actual, + ensureAndroidBlockingSystemDialogReady: vi.fn( + async (params: { session: import('../../types.ts').SessionState; phase: string }) => { + if (params.phase === 'before-command') { + expireRefFrame(params.session); + return { status: 'recovered', warning: 'Recovered from a blocking system dialog' }; + } + return { status: 'clear' }; + }, + ), + }; +}); + +import { handleInteractionCommands } from '../interaction.ts'; +import { dispatchCommand } from '../../../core/dispatch.ts'; +import { attachRefs } from '../../../kernel/snapshot.ts'; +import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; + +const mockDispatch = vi.mocked(dispatchCommand); +const contextFromFlags = () => ({}); + +beforeEach(() => { + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue({}); +}); + +test('a ref action aborts with the shared ref_frame_expired rejection after Android dialog recovery mutates', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-recovery-abort'; + const session = makeAndroidSession(sessionName); + session.snapshot = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 0, y: 0, width: 80, height: 80 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android', + }; + session.snapshotGeneration = 900; + // A freshly issued complete frame is active. + sessionStore.set(sessionName, session); + + const response = await handleInteractionCommands({ + req: { token: 't', session: sessionName, command: 'press', positionals: ['@e1'], flags: {} }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + // The SHARED admission rejection shape — not a bespoke recovery error. + const details = response.error.details as Record | undefined; + expect(details?.reason).toBe('ref_frame_expired'); + expect(details?.ref).toBe('@e1'); + expect(details?.currentGeneration).toBe(900); + expect(details?.scope).toBe('all'); + } + // The outstanding ref action never dispatched a press against the recovered UI. + expect(mockDispatch.mock.calls.some((call) => call[1] === 'press')).toBe(false); +}); diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index 0e707ed55..d5a54ad2d 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -260,13 +260,18 @@ test('press --settle on a removals-only diff attaches the unchanged interactive expect(settle.refsGeneration).toBe(session.snapshotGeneration); }); -test('press --settle rejects a stale iOS ref before dispatch or observation', async () => { +test('press --settle rejects an expired-frame ref before dispatch or observation', async () => { const sessionStore = makeSessionStore(); const sessionName = 'settle-stale-ref'; const session = seedSession(sessionName, sessionStore); + // ADR 0014: a device action since the snapshot expired the ref frame; the + // coarse marker rides along but the frame lifecycle is what rejects. session.snapshotRefsStale = true; + session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockRejectedValue( + new Error('dispatch should not be called for an expired-frame ref'), + ); const response = await handleInteractionCommands({ req: { @@ -284,7 +289,8 @@ test('press --settle rejects a stale iOS ref before dispatch or observation', as expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('Ref @e2 not found or has no bounds'); + expect(response.error.message).toMatch(/expired ref frame/); + expect(response.error.details?.reason).toBe('ref_frame_expired'); expect(String(response.error.details?.hint)).toMatch(/refs were issued/); } expect(mockCaptureSnapshotForSession).not.toHaveBeenCalled(); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 6d8842233..cd1667943 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -6,7 +6,12 @@ import type { CommandFlags } from '../../../core/dispatch.ts'; import { attachRefs, type SnapshotBackend } from '../../../kernel/snapshot.ts'; import { AppError } from '../../../kernel/errors.ts'; import { buildSnapshotState } from '../snapshot-capture.ts'; -import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts'; +import { + markSessionSnapshotRefsIssued, + setSessionSnapshot, + STALE_SNAPSHOT_REFS_WARNING, +} from '../../session-snapshot.ts'; +import { activateCompleteRefFrame, expireRefFrame } from '../../ref-frame.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession, @@ -1438,7 +1443,7 @@ test('longpress @ref resolves the target and dispatches coordinate longpress', a expect(sessionStore.get(sessionName)?.actions[0]?.command).toBe('longpress'); }); -test('press @ref refreshes stale stored refs and syncs the daemon session snapshot', async () => { +test('press @ref fails closed when the authorized ref has no usable bounds (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-refresh'; const session = makeSession(sessionName); @@ -1457,24 +1462,9 @@ test('press @ref refreshes stale stored refs and syncs the daemon session snapsh }; sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { - nodes: [ - { - index: 0, - type: 'XCUIElementTypeButton', - label: 'Continue', - rect: { x: 10, y: 20, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'xctest', - }; - } - return { pressed: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on missing frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -1489,18 +1479,15 @@ test('press @ref refreshes stale stored refs and syncs the daemon session snapsh contextFromFlags, }); - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.x).toBe(60); - expect(response.data?.y).toBe(40); + // ADR 0014: the authorized frame's @e1 has no usable rect, so the ref FAILS + // rather than recapturing and accepting the same index from a newer tree by + // positional coincidence. No fresh capture, no dispatch. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); } - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press']); - expect(sessionStore.get(sessionName)?.snapshot?.nodes[0]?.rect).toEqual({ - x: 10, - y: 20, - width: 100, - height: 40, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press @ref refreshes Android snapshot when freshness tracking is active', async () => { @@ -1576,6 +1563,78 @@ test('press @ref refreshes Android snapshot when freshness tracking is active', }); }); +test('ADR 0014: Android freshness cannot retarget an admitted ref by positional coincidence', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-fresh-ref-no-retarget'; + const session = makeAndroidSession(sessionName); + const frameTree = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 0, y: 0, width: 40, height: 40 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android' as const, + comparisonSafe: true, + }; + session.snapshot = frameTree; + // ADR 0014: the authorized frame tree names WHICH node @e1 authorizes. + session.refFrameTree = frameTree; + session.androidSnapshotFreshness = { + action: 'press', + markedAt: Date.now(), + baselineCount: 1, + routeComparable: false, + }; + sessionStore.set(sessionName, session); + + // The freshness refresh returns a DIFFERENT element at @e1's index — after + // navigation the button at that position is now "Cancel", not "Continue". + mockDispatch.mockImplementation(async (_device, command, args) => { + if (command === 'snapshot') { + return { + nodes: [ + { + index: 0, + type: 'android.widget.Button', + label: 'Cancel', + rect: { x: 100, y: 200, width: 80, height: 40 }, + enabled: true, + hittable: true, + }, + ], + backend: 'android', + }; + } + return { pressed: true, args }; + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['@e1'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(true); + // The identity at @e1 changed (Continue -> Cancel), so the refreshed + // observation must NOT redefine the target: the press stays on the frame + // node's coordinates (center of {0,0,40,40}), never the fresh (140, 220). + expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press']); + expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['20', '20']); +}); + test('press @ref falls back to cached Android ref when freshness refresh fails', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-fresh-ref-refresh-failure'; @@ -2438,7 +2497,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio } }); -test('press @ref refreshes snapshot when stored ref bounds are invalid', async () => { +test('press @ref fails closed when stored ref bounds are invalid (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2465,26 +2524,9 @@ test('press @ref refreshes snapshot when stored ref bounds are invalid', async ( }; sessionStore.set(sessionName, session); - let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command, _positionals) => { - if (command === 'snapshot') { - snapshotCalls += 1; - return { - nodes: [ - { - index: 0, - type: 'android.widget.TextView', - label: 'My App', - rect: { x: 20, y: 40, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } - return { pressed: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on unusable frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -2499,17 +2541,14 @@ test('press @ref refreshes snapshot when stored ref bounds are invalid', async ( contextFromFlags, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(snapshotCalls).toBe(1); - const pressCalls = mockDispatch.mock.calls.filter((c) => c[1] === 'press'); - expect(pressCalls.length).toBe(1); - expect(pressCalls[0]?.[2]).toEqual(['70', '60']); - if (response?.ok) { - expect(response.data?.x).toBe(70); - expect(response.data?.y).toBe(60); - expect(response.data?.ref).toBe('e1'); + // ADR 0014: the authorized frame's @e1 has an unusable rect (NaN), so it FAILS + // rather than recapturing and accepting the same index from a newer tree. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); } + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press @ref fails fast when the target is off-screen', async () => { @@ -2563,7 +2602,7 @@ test('press @ref fails fast when the target is off-screen', async () => { } }); -test('press @ref fallback label is used after refresh when ref bounds remain invalid', async () => { +test('press @ref with a trailing label recovers within the authorized frame (no positional recapture)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2574,16 +2613,27 @@ test('press @ref fallback label is used after refresh when ref bounds remain inv kind: 'emulator', booted: true, }; + // @e1's rect is unusable, but the SAME frame tree carries another node with + // the trailing label at usable bounds. ADR 0014 label recovery resolves within + // the authorized frame — never by recapturing a fresh tree. session.snapshot = { nodes: attachRefs([ { index: 0, type: 'android.widget.TextView', - label: 'My App', + label: 'Different', rect: { x: 20, y: 40, width: Number.NaN, height: 40 }, enabled: true, hittable: true, }, + { + index: 1, + type: 'android.widget.TextView', + label: 'My App', + rect: { x: 100, y: 200, width: 80, height: 40 }, + enabled: true, + hittable: true, + }, ]), createdAt: Date.now(), backend: 'android', @@ -2591,29 +2641,7 @@ test('press @ref fallback label is used after refresh when ref bounds remain inv sessionStore.set(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { - nodes: [ - { - index: 0, - type: 'android.widget.TextView', - label: 'Different', - rect: { x: 20, y: 40, width: Number.NaN, height: 40 }, - enabled: true, - hittable: true, - }, - { - index: 1, - type: 'android.widget.TextView', - label: 'My App', - rect: { x: 100, y: 200, width: 80, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } + if (command === 'snapshot') throw new Error('no positional recapture: recovery stays in-frame'); return { pressed: true }; }); @@ -2696,7 +2724,7 @@ test('fill @ref fails fast when the target is off-screen', async () => { } }); -test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () => { +test('fill @ref fails closed when stored ref bounds are invalid (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2723,26 +2751,9 @@ test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () }; sessionStore.set(sessionName, session); - let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - snapshotCalls += 1; - return { - nodes: [ - { - index: 0, - type: 'android.widget.EditText', - label: 'Email', - rect: { x: 20, y: 40, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } - return { filled: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on unusable frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -2757,19 +2768,14 @@ test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () contextFromFlags, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(snapshotCalls).toBe(1); - const fillCalls = mockDispatch.mock.calls.filter((c) => c[1] === 'fill'); - expect(fillCalls.length).toBe(1); - expect(fillCalls[0]?.[2]).toEqual(['70', '60', 'hello@example.com']); - expect((fillCalls[0]?.[4] as Record | undefined)?.delayMs).toBe(25); - - const stored = sessionStore.get(sessionName); - const result = (stored?.actions[0]?.result ?? {}) as Record; - expect(result.ref).toBe('e1'); - expect(result.x).toBe(70); - expect(result.y).toBe(60); + // ADR 0014: the authorized frame's @e1 has an unusable rect, so the fill FAILS + // rather than recapturing a fresh tree and filling the same index. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); + } + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press coordinates does not treat extra trailing args as selector', async () => { @@ -3282,17 +3288,98 @@ test('press selector then press @ref rejects refs that outlived the stored snaps expect(selectorPress.data?.warning).toBeUndefined(); expect(sessionStore.get(sessionName)?.snapshotRefsStale).toBe(true); + // ADR 0014: the selector press crossed the side-effect seam and expired the + // frame, so the ref that outlived it is rejected before dispatch. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); const dispatchCallsBeforeStaleRef = mockDispatch.mock.calls.length; const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(false); if (refPress && !refPress.ok) { expect(refPress.error.code).toBe('COMMAND_FAILED'); - expect(refPress.error.message).toBe('Ref @e1 not found or has no bounds'); + expect(refPress.error.message).toMatch(/expired ref frame/); + expect(refPress.error.details?.reason).toBe('ref_frame_expired'); expect(refPress.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); } expect(mockDispatch).toHaveBeenCalledTimes(dispatchCallsBeforeStaleRef); }); +test('a ref press crosses the ADR 0014 side-effect seam and expires the ref frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'seam-expiry'; + const session = makeStaleRefSession(sessionName); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // A freshly issued frame is active (undefined === active). + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + + const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); + expect(press?.ok).toBe(true); + // The transition is wired at the leaf seam. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // ADR 0014: a PARTIAL publication (find/settle/divergence, via + // markSessionSnapshotRefsIssued) clears the coarse marker but must NOT + // re-authorize the complete frame — only a complete snapshot does. + markSessionSnapshotRefsIssued(sessionStore.get(sessionName)!); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); +}); + +test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) until a fresh observation re-authorizes', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'seam-sequence'; + const session = makeStaleRefSession(sessionName); + session.snapshotGeneration = 500; + // A complete snapshot issued the frame at generation 500. + activateCompleteRefFrame(session); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // `snapshot -> press @e1`: admitted; crosses the seam and expires the frame. + const first = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); + expect(first?.ok).toBe(true); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // `-> press @e2`: the unobserved second mutation rejects (bare). + const bare = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); + expect(bare?.ok).toBe(false); + if (bare && !bare.ok) expect(bare.error.details?.reason).toBe('ref_frame_expired'); + + // A pin at the same epoch also rejects — expiry is evaluated before the pin. + const pinned = await runInteraction(sessionStore, sessionName, 'press', ['@e2~s500']); + expect(pinned?.ok).toBe(false); + if (pinned && !pinned.ok) expect(pinned.error.details?.reason).toBe('ref_frame_expired'); + + // `-> snapshot -> press @e2`: a fresh complete frame re-authorizes. + activateCompleteRefFrame(sessionStore.get(sessionName)!); + const reobserved = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); + expect(reobserved?.ok).toBe(true); +}); + +test('direct iOS selector click crosses the ADR 0014 fused seam and expires the ref frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'direct-ios-seam'; + const session = makeStaleRefSession(sessionName); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // click + a simple selector on a non-recording iOS session takes the direct + // iOS selector fast path (no daemon-tree resolution). + const click = await runInteraction(sessionStore, sessionName, 'click', ['label=Continue']); + expect(click?.ok).toBe(true); + const tookDirectPath = mockDispatch.mock.calls.some( + (call) => (call[4] as { directElementSelector?: unknown })?.directElementSelector !== undefined, + ); + expect(tookDirectPath).toBe(true); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); +}); + test('press @ref directly after refs were issued does not warn', async () => { const sessionStore = makeSessionStore(); const sessionName = 'fresh-ref-no-warning'; @@ -3320,11 +3407,16 @@ test('re-issuing refs clears the stale marker so press @ref does not warn', asyn ]); expect(selectorPress?.ok).toBe(true); expect(sessionStore.get(sessionName)?.snapshotRefsStale).toBe(true); + // The selector press expired the frame (ADR 0014 seam). + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - // Simulate the snapshot command re-issuing refs (its handler clears the - // marker through buildNextSnapshotSession; covered in snapshot-handler tests). + // Simulate the snapshot command re-issuing the complete ref namespace: it + // clears the marker AND re-activates a complete frame (through + // buildNextSnapshotSession; covered in snapshot-handler tests). Clearing the + // coarse marker alone would leave the frame expired. const stored = sessionStore.get(sessionName)!; stored.snapshotRefsStale = false; + activateCompleteRefFrame(stored); sessionStore.set(sessionName, stored); const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); @@ -3334,7 +3426,7 @@ test('re-issuing refs clears the stale marker so press @ref does not warn', asyn } }); -test('fill @ref rejects while refs are stale', async () => { +test('fill @ref rejects after a device action expired the frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-fill'; const session = makeStaleRefSession(sessionName); @@ -3352,9 +3444,14 @@ test('fill @ref rejects while refs are stale', async () => { createdAt: Date.now(), backend: 'xctest', }; + // ADR 0014: an unobserved device action expired the frame; the coarse marker + // rides along and supplies the hint. session.snapshotRefsStale = true; + expireRefFrame(session); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockRejectedValue( + new Error('dispatch should not be called for an expired-frame ref'), + ); const response = await runInteraction(sessionStore, sessionName, 'fill', [ '@e1', @@ -3363,7 +3460,8 @@ test('fill @ref rejects while refs are stale', async () => { expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('Ref @e1 not found or has no bounds'); + expect(response.error.message).toMatch(/expired ref frame/); + expect(response.error.details?.reason).toBe('ref_frame_expired'); expect(response.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); } expect(mockDispatch).not.toHaveBeenCalled(); @@ -3484,22 +3582,29 @@ test('get text with a pinned stale ref gets the precise warning', async () => { } }); -test('a plain stale ref rejects with the coarse #1093 hint, never the pinned text', async () => { +test('ADR 0014: a read-only capture that set the coarse marker does not invalidate a mutation ref', async () => { + // Evidence #6: an internal read-only capture advances the operational + // observation (and the coarse marker) but does NOT expire the frame, so a + // plain ref from the still-active frame is admitted and dispatches — the old + // coarse-marker mutation block was the ADR's false positive. const sessionStore = makeSessionStore(); const sessionName = 'plain-ref-coarse'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 7; session.snapshotRefsStale = true; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockResolvedValue({ pressed: true }); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); + expect(response?.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalled(); + // Crossing the seam expired the frame, so a SECOND plain ref is now rejected. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + const second = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); + expect(second?.ok).toBe(false); + if (second && !second.ok) { + expect(second.error.details?.reason).toBe('ref_frame_expired'); } - expect(mockDispatch).not.toHaveBeenCalled(); }); test('a malformed generation suffix is INVALID_ARGS with the ref grammar hint', async () => { diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 51a59bfd2..068bb984a 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -77,6 +77,9 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste }); expect(response?.ok).toBe(true); + // ADR 0014 side-effect seam: overlay dismissal taps the device, so it expires + // the ref frame. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); expect(mockDispatchCommand).toHaveBeenCalledWith( expect.objectContaining({ platform: 'apple' }), 'press', diff --git a/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts index ea6e2f352..d2c344ae0 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts @@ -155,6 +155,55 @@ test('appstate fails when iOS session has no tracked app', async () => { } }); +test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves the frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'kb'; + const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(device); + mockDispatch.mockResolvedValue({}); + const logPath = path.join(os.tmpdir(), 'daemon.log'); + + // dismiss mutates the device → frame expires. + sessionStore.set(sessionName, makeSession(sessionName, device)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['dismiss'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // status is a read-only probe → frame preserved (undefined === active). + sessionStore.set(sessionName, makeSession(sessionName, device)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['status'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); +}); + test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 11f37a6d2..b07641567 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -57,6 +57,51 @@ test('open --relaunch closes and reopens active session app', async () => { expect(calls[1]).toEqual({ command: 'open', positionals: ['com.example.app'] }); }); +test('open --relaunch leaves the old frame expired when the close dispatch fails after dispatch (ADR 0014)', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'com.example.app', + snapshotGeneration: 400, + }); + // A freshly issued frame is active before the relaunch. + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + + // The relaunch close dispatches and then fails/times out AFTER the app may + // already have been torn down. + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'close') throw new Error('adb: close timed out after dispatch'); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }).catch(() => null); + + // Whether the failure surfaced as an error response or a throw, the existing + // session's frame was expired BEFORE the close dispatch and stays expired — a + // post-dispatch close failure never restores it (there is no rollback). + expect(response?.ok ?? false).toBe(false); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); +}); + test('open --relaunch on iOS stops runner before close/open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index ff337f037..cf773bcc3 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -4,6 +4,7 @@ import type { RawSnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { expireRefFrame } from '../../ref-frame.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +50,35 @@ test('snapshot resolves @ref scope with the stored source after scoped output re expect(sessionStore.get(sessionName)?.snapshotScopeSource?.nodes[2]?.ref).toBe('e3'); }); +test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref cannot borrow it (ADR 0014)', async () => { + const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); + const sessionName = 'android-ref-scope-broken-by-mutation'; + const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); + sessionStore.set(sessionName, session); + + mockDispatch.mockResolvedValue({ + nodes: scopedScriptErrorNodes(), + truncated: false, + backend: 'android', + }); + + // First scoped snapshot establishes the lineage and reindexes the stored refs + // (the reduced output no longer contains @e3). + const first = await requestScopedSnapshot(sessionName, sessionStore, '@e3'); + expect(first?.ok).toBe(true); + expect(sessionStore.get(sessionName)?.snapshotScopeSource?.nodes[2]?.ref).toBe('e3'); + + // A device mutation crosses the side-effect seam and clears the lineage. + expireRefFrame(sessionStore.get(sessionName)!); + expect(sessionStore.get(sessionName)?.snapshotScopeSource).toBeUndefined(); + + // The repeated `snapshot -s @e3` can no longer borrow the stale lineage: the + // reindexed stored tree has no @e3, so it fails closed rather than resolving a + // different subtree. + const second = await requestScopedSnapshot(sessionName, sessionStore, '@e3'); + expect(second?.ok).toBe(false); +}); + test('empty @ref-scoped snapshot output does not replace the stored session snapshot', async () => { const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-empty-scope-preserve'; diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 6ff98ab12..1f6616a39 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -7,6 +7,7 @@ import { type FindLocator, } from '../../selectors/find.ts'; import { centerOfRect, type SnapshotState } from '../../kernel/snapshot.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; @@ -165,6 +166,21 @@ export async function handleFindCommands(params: { sessionStore.set(sessionName, session); } + return dispatchFindAction(ctx, match, action, value); +} + +/** + * Run the selected find action and, for a READ-ONLY action only, attach the + * issued `refsGeneration`. A mutating find (click/fill/focus/type) returns + * `data.ref` solely as diagnostic pre-action identity (ADR 0014) — it must omit + * `refsGeneration` so MCP cannot pin and reuse it after the action. + */ +async function dispatchFindAction( + ctx: FindContext, + match: ResolvedMatch, + action: string, + value: string | undefined, +): Promise { const actionHandlers: Record Promise> = { exists: () => handleFindExists(ctx), get_text: () => handleFindGetText(ctx, match), @@ -177,11 +193,12 @@ export async function handleFindCommands(params: { const handler = actionHandlers[action]; if (!handler) return null; - // Re-read the session AFTER the handler: internal click/fill sub-invocations - // may have replaced the stored tree again (Android freshness refresh), and - // the reported generation must describe the tree the response's ref resolves - // against at response time. - return attachIssuedRefsGeneration(await handler(), () => sessionStore.get(sessionName)); + const result = await handler(); + if (!isReadOnlyFindAction(action)) return result; + // Re-read the session AFTER the handler: the read capture may have replaced the + // stored tree, and the reported generation must describe the tree the + // response's ref resolves against at response time. + return attachIssuedRefsGeneration(result, () => ctx.sessionStore.get(ctx.sessionName)); } /** @@ -526,6 +543,7 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< command: 'click', positionals: [match.ref], flags: match.actionFlags, + internal: { findResolvedTarget: true }, }); if (!response.ok) return response; const matchCoords = match.resolvedNode.rect @@ -568,6 +586,7 @@ async function handleFindFill( command: 'fill', positionals: [match.ref, value], flags: match.actionFlags, + internal: { findResolvedTarget: true }, }); if (!response.ok) return response; recordSessionAction( @@ -599,6 +618,9 @@ async function handleFindType( } const focusResponse = await dispatchFocusForFindMatch(ctx, match); if (!focusResponse.ok) return focusResponse; + // The focus above already crossed the seam; expiry is idempotent, but keep it + // explicit at the type dispatch so it does not rely on the focus-first order. + if (session) expireRefFrame(session); const response = await dispatchCommand(device, 'type', [value], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }); @@ -617,6 +639,10 @@ async function dispatchFocusForFindMatch( if (!coords) { return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); } + // ADR 0014 side-effect seam: mutating find focus/type dispatch the device + // command directly (they do not re-enter the interaction leaf), so expire the + // frame here before the device op. Pre-seam guards above preserve the frame. + if (session) expireRefFrame(session); const response = await dispatchCommand( device, 'focus', diff --git a/src/daemon/handlers/interaction-ref-policy.ts b/src/daemon/handlers/interaction-ref-policy.ts index 704a106e5..1b734223f 100644 --- a/src/daemon/handlers/interaction-ref-policy.ts +++ b/src/daemon/handlers/interaction-ref-policy.ts @@ -1,20 +1,68 @@ -import { publicPlatformString } from '../../kernel/device.ts'; +import { + admitRefMutation, + refFrameEpoch, + refFrameScope, + type RefFrameRejectReason, +} from '../ref-frame.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; -/** Mutating through a ref from an older client-visible tree is never safe on iOS. */ -export function staleIosRefGuardResponse(params: { +/** + * ADR 0014 mutation-admission enforcement. A ref-targeting mutation is admitted + * only against an active frame whose epoch and issuance scope authorize the ref + * (`admitRefMutation`). This runs BEFORE resolution and dispatch, on EVERY + * supported platform — a device side effect from an earlier command expires the + * frame at its seam, so the next ref mutation is rejected before it can act on a + * possibly-navigated screen. + * + * Returns `null` when the mutation is admitted, or a typed failure whose + * `details.reason` distinguishes "capture a complete snapshot" from "use the + * emitted pinned ref". The message names the actual lifetime failure rather than + * claiming the ref was missing or lacked bounds. + */ +export function refMutationAdmissionResponse(params: { session: SessionState; ref: string; + mintedGeneration: number | undefined; + /** + * The precise staleness diagnostic the caller already resolved + * (`resolveRefStalenessWarning`). Used as the failure hint when present — for + * a pinned generation mismatch it names the exact minted-vs-current + * generations — otherwise a generic actionable hint is attached. + */ staleRefsWarning: string | undefined; }): DaemonResponse | null { - if ( - params.staleRefsWarning === undefined || - publicPlatformString(params.session.device) !== 'ios' - ) { - return null; - } - return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, { - hint: params.staleRefsWarning, + const refBody = params.ref.startsWith('@') ? params.ref.slice(1) : params.ref; + const admission = admitRefMutation({ + session: params.session, + refBody, + mintedGeneration: params.mintedGeneration, + }); + if (admission.admitted) return null; + + const scope = refFrameScope(params.session); + return errorResponse('COMMAND_FAILED', rejectionMessage(admission.reason, params.ref), { + reason: admission.reason, + ref: params.ref, + currentGeneration: refFrameEpoch(params.session), + scope: scope === 'all' ? 'all' : Array.from(scope), + ...(params.mintedGeneration !== undefined ? { mintedGeneration: params.mintedGeneration } : {}), + hint: params.staleRefsWarning ?? REJECTION_HINT, }); } + +const REJECTION_HINT = + 'Capture a fresh interactive snapshot (snapshot -i) or use a stable selector, then retry.'; + +function rejectionMessage(reason: RefFrameRejectReason, ref: string): string { + switch (reason) { + case 'ref_frame_expired': + return `Ref ${ref} belongs to an expired ref frame — a device action since the snapshot invalidated it`; + case 'ref_generation_mismatch': + return `Ref ${ref} was minted from a superseded snapshot generation`; + case 'plain_ref_requires_complete_frame': + return `Ref ${ref} needs a complete snapshot — the current frame only authorizes its emitted refs`; + case 'ref_not_issued': + return `Ref ${ref} was not issued by the current ref frame`; + } +} diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index 50d83044f..13c9c6a55 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -13,6 +13,7 @@ import { createAgentDevice } from '../../runtime.ts'; import { AppError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import type { InteractionHandlerParams } from './interaction-common.ts'; import type { CaptureSnapshotForSession } from './interaction-snapshot.ts'; import { createDaemonRuntimePolicy } from '../runtime-policy.ts'; @@ -34,7 +35,13 @@ export function createInteractionRuntime( sessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, getSession: () => session, - recordOptions: { includeSnapshot: true }, + recordOptions: { + includeSnapshot: true, + // ADR 0014: a mutating find's internal dispatch already re-resolved its + // target by locator against the fresh capture, so it resolves against the + // observation, not the authorized frame tree. + omitRefFrameSnapshot: params.req.internal?.findResolvedTarget === true, + }, setRecord: (record) => { if (!record.snapshot) return; setSessionSnapshot(session, record.snapshot); @@ -70,8 +77,11 @@ function createInteractionBackend( session.device, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - tap: async (_context, point): Promise => - toBackendActionResult( + tap: async (_context, point): Promise => { + // ADR 0014 side-effect seam: the point is resolved; expire the ref frame + // synchronously before dispatching so a later step cannot reuse it. + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'press', @@ -79,15 +89,18 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, tapTarget: webProvider?.clickRef ? async (_context, target): Promise => { + expireRefFrame(session); await webProvider.clickRef?.(target.ref); return { ref: stripAtPrefix(target.ref) }; } : undefined, - fill: async (_context, point, text): Promise => - toBackendActionResult( + fill: async (_context, point, text): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'fill', @@ -95,9 +108,11 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, fillTarget: webProvider?.fillRef ? async (_context, target, text, options): Promise => { + expireRefFrame(session); await webProvider.fillRef?.(target.ref, text, options); return { ref: stripAtPrefix(target.ref), @@ -106,8 +121,9 @@ function createInteractionBackend( }; } : undefined, - longPress: async (_context, point, options): Promise => - toBackendActionResult( + longPress: async (_context, point, options): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'longpress', @@ -119,17 +135,21 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), - performGesture: async (_context, plan): Promise => - toBackendActionResult( + ); + }, + performGesture: async (_context, plan): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchGesturePlan( session.device, plan, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), - typeText: async (_context, text): Promise => - toBackendActionResult( + ); + }, + typeText: async (_context, text): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'type', @@ -137,7 +157,8 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, }; } diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 881f32659..49b2d7e95 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -20,7 +20,7 @@ import { asAppError, normalizeError } from '../../kernel/errors.ts'; import type { ReplayTargetGuardDenotation } from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; -import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; +import { markSessionPartialRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; import { buildInteractionResponseData, type InteractionResponsePayloads, @@ -64,7 +64,8 @@ import { ensureAndroidBlockingSystemDialogReady, type AndroidBlockingDialogReadinessResult, } from '../android-system-dialog.ts'; -import { staleIosRefGuardResponse } from './interaction-ref-policy.ts'; +import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; +import { expireRefFrame } from '../ref-frame.ts'; export async function handleTouchInteractionCommands( params: InteractionHandlerParams & { @@ -153,12 +154,15 @@ async function dispatchTargetedTouchViaRuntime( req.flags, ); if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const staleRefResponse = staleIosRefGuardResponse({ - session, - ref: parsedTarget.target.ref, - staleRefsWarning, - }); - if (staleRefResponse) return staleRefResponse; + const admissionResponse = req.internal?.findResolvedTarget + ? null + : refMutationAdmissionResponse({ + session, + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + }); + if (admissionResponse) return admissionResponse; androidFreshnessBaseline = await refreshAndroidRefSnapshotIfFreshnessActive(params, session); } // ADR 0012 step 4: a guarded replay dispatch must resolve through the @@ -181,6 +185,14 @@ async function dispatchTargetedTouchViaRuntime( return await dispatchRuntimeInteraction(params, { androidFreshnessBaseline, + refContext: + parsedTarget.target.kind === 'ref' && req.internal?.findResolvedTarget !== true + ? { + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + } + : undefined, run: async (runtime) => await runTargetedTouchInteraction({ runtime, @@ -315,10 +327,23 @@ function settleRefsGenerationIssue( result: PressCommandResult | FillCommandResult | LongPressCommandResult, ): number | undefined { if (!result.settle?.diff) return undefined; - markSessionSnapshotRefsIssued(session); + // ADR 0014: a settled diff publishes the refs it exposed, so it activates a + // PARTIAL frame authorizing exactly those bodies (not the whole tree). + markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(result.settle)); return session.snapshotGeneration; } +/** The reusable refs a settled diff exposed: added diff lines, `refs`, `tail`. */ +function collectSettleIssuedRefBodies(settle: NonNullable): string[] { + const bodies: string[] = []; + for (const line of settle.diff?.lines ?? []) { + if (line.ref) bodies.push(line.ref); + } + for (const entry of settle.refs ?? []) bodies.push(entry.ref); + for (const entry of settle.tail ?? []) bodies.push(entry.ref); + return bodies; +} + function readLongPressResultDuration(result: TargetedTouchResult): number | undefined { return 'durationMs' in result ? result.durationMs : undefined; } @@ -400,6 +425,11 @@ async function dispatchDirectIosSelectorInteraction(params: { fallbackPhase, } = params; const actionStartedAt = Date.now(); + // ADR 0014 side-effect seam: the direct iOS selector path fuses its final + // status/target check and mutation into one runner request and consumes no + // ref, so dispatching that fused request is the conservative seam. A later + // not-found/timeout is post-seam and does not restore the frame. + expireRefFrame(session); try { const data = (await dispatchCommand(session.device, command, positionals, handlerParams.req.flags?.out, { @@ -571,6 +601,14 @@ async function dispatchFillViaRuntime( if (directResponse) return directResponse; return await dispatchRuntimeInteraction(params, { + refContext: + parsedTarget.target.kind === 'ref' && req.internal?.findResolvedTarget !== true + ? { + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + } + : undefined, run: async (runtime) => await runtime.interactions.fill(parsedTarget.target, parsedTarget.text, { session: sessionName, @@ -612,12 +650,15 @@ async function prepareFillRefTarget( }); const invalidRefFlagsResponse = params.refSnapshotFlagGuardResponse('fill', params.req.flags); if (invalidRefFlagsResponse) return { response: invalidRefFlagsResponse, staleRefsWarning }; - const staleRefResponse = staleIosRefGuardResponse({ - session, - ref: target.ref, - staleRefsWarning, - }); - if (staleRefResponse) return { response: staleRefResponse, staleRefsWarning }; + const admissionResponse = params.req.internal?.findResolvedTarget + ? null + : refMutationAdmissionResponse({ + session, + ref: target.ref, + mintedGeneration: refGeneration, + staleRefsWarning, + }); + if (admissionResponse) return { response: admissionResponse, staleRefsWarning }; await refreshAndroidRefSnapshotIfFreshnessActive(params, session); return { staleRefsWarning }; } @@ -705,6 +746,12 @@ async function dispatchRuntimeInteraction< }, options: { androidFreshnessBaseline?: SessionState['snapshot']; + /** + * Present when the action targets a `@ref`: if Android dialog recovery + * expires the frame before dispatch, the action aborts through the shared + * admission rejection built from this context. + */ + refContext?: RefAdmissionContext; run(runtime: ReturnType): Promise; afterRun?(result: TResult): Promise; buildPayloads( @@ -717,15 +764,18 @@ async function dispatchRuntimeInteraction< const runtime = createInteractionRuntime(params); const actionStartedAt = Date.now(); try { - const { readiness, runtimeResult } = await runWithAndroidDialogReadinessCheck( + const outcome = await runWithAndroidDialogReadinessCheck( session, params.req.command, + { refContext: options.refContext }, async () => { const result = await options.run(runtime); await options.afterRun?.(result); return result; }, ); + if (outcome.aborted) return outcome.response; + const { readiness, runtimeResult } = outcome; const actionFinishedAt = Date.now(); const { result, responseData, recordedTarget } = await options.buildPayloads(runtimeResult); if (readiness.status === 'recovered') { @@ -759,29 +809,57 @@ async function dispatchRuntimeInteraction< } } +type RefAdmissionContext = { + ref: string; + mintedGeneration: number | undefined; + staleRefsWarning: string | undefined; +}; + +type ReadinessOutcome = + | { aborted: true; response: DaemonResponse } + | { + aborted: false; + readiness: AndroidBlockingDialogReadinessResult; + runtimeResult: TResult; + }; + async function runWithAndroidDialogReadinessCheck( session: SessionState, command: string, + options: { refContext: RefAdmissionContext | undefined }, run: () => Promise, -): Promise<{ - readiness: AndroidBlockingDialogReadinessResult; - runtimeResult: TResult; -}> { +): Promise> { if (session.lease?.leaseProvider) { - return { readiness: { status: 'clear' }, runtimeResult: await run() }; + return { aborted: false, readiness: { status: 'clear' }, runtimeResult: await run() }; } const readiness = await ensureAndroidBlockingSystemDialogReady({ session, command, phase: 'before-command', }); + // ADR 0014: blocking-dialog recovery is itself device-mutating and expires the + // frame at its own seam. A ref action admitted against the pre-recovery frame + // must NOT continue against the recovered UI — abort it through the SHARED + // admission rejection so the failure shape (reason, ref, currentGeneration, + // scope, mintedGeneration, hint) is identical to every other expired-frame + // rejection across platforms. Selector/coordinate actions carry no refContext + // and re-resolve and continue under their own policy. + if (options.refContext && readiness.status === 'recovered') { + const abort = refMutationAdmissionResponse({ + session, + ref: options.refContext.ref, + mintedGeneration: options.refContext.mintedGeneration, + staleRefsWarning: options.refContext.staleRefsWarning, + }); + if (abort) return { aborted: true, response: abort }; + } const runtimeResult = await run(); await ensureAndroidBlockingSystemDialogReady({ session, command, phase: 'after-command', }); - return { readiness, runtimeResult }; + return { aborted: false, readiness, runtimeResult }; } async function refreshAndroidRefSnapshotIfFreshnessActive( diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 5db0adc28..773b377fb 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -16,6 +16,7 @@ import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts'; import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts'; export async function handleReactNativeCommands( @@ -109,6 +110,9 @@ async function dismissReactNativeOverlayTarget( ): Promise { const { req, sessionStore } = params; const actionStartedAt = Date.now(); + // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; + // the target is already resolved, so expire the frame before the press. + expireRefFrame(session); const data = (await dispatchCommand( session.device, diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index ec4214cdc..2aea79e71 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -21,6 +21,7 @@ import { settleIosSimulator, } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { recordSessionAction } from './handler-utils.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; @@ -271,6 +272,10 @@ async function dispatchTargetedPlatformClose(params: { } } try { + // ADR 0014 side-effect seam: close mutates the device. The frame expires + // here for uniformity, though a successful close deletes the whole session + // (and its frame) in handleCloseCommand's finally, so nothing is restored. + expireRefFrame(session); await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), }); diff --git a/src/daemon/handlers/session-deploy.ts b/src/daemon/handlers/session-deploy.ts index 246bc2f44..031fc6d03 100644 --- a/src/daemon/handlers/session-deploy.ts +++ b/src/daemon/handlers/session-deploy.ts @@ -9,6 +9,7 @@ import { resolveDeployResultTarget } from '../../contracts/result-serialization. import { withSuccessText } from '../../utils/success-text.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; export type ReinstallOps = { ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; @@ -154,6 +155,10 @@ export async function handleAppDeployCommand(params: { const unsupported = requireCommandSupported(command, device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam: install/reinstall replace the app binary and + // can replace the visible surface; expire the frame before the deploy op. + if (session) expireRefFrame(session); + const result = isIosFamily(device) ? buildIosDeployResult(app, appPath, await deployOps.ios(device, app, appPath)) : buildAndroidDeployResult(app, appPath, await deployOps.android(device, app, appPath)); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index f699063bb..e2ba1cc40 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,6 +43,7 @@ import { validateResolvedOpenRequest, } from './session-open-prepare.ts'; import { errorResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { buildSessionRecoveryHint } from '../session-recovery-hints.ts'; import { isImplicitSessionScopeConflict, @@ -254,6 +255,11 @@ async function completeOpenCommand(params: { isIosSimulator(device) && req.flags?.clearAppState !== true; if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { + // ADR 0014 side-effect seam: the relaunch close is the FIRST device dispatch + // against the existing session. Expire its frame before awaiting the close, + // so a close timeout/failure that may already have torn the app down still + // leaves the old frame expired rather than active. + if (existingSession) expireRefFrame(existingSession); const closeTarget = sessionAppBundleId ?? openTarget; const closeStartedAtMs = Date.now(); await relaunchCloseApp({ @@ -298,6 +304,10 @@ async function completeOpenCommand(params: { return provisionalSession.response; } const openDispatchSession = provisionalSession.session ?? existingSession; + // ADR 0014 side-effect seam: open/relaunch against an existing session replaces + // its visible surface. Expire that session's frame before the first + // close/launch dispatch; a fresh first open has no prior frame to expire. + if (openDispatchSession) expireRefFrame(openDispatchSession); await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, sessionAppBundleId), ...(collapseSimulatorRelaunch ? { terminateRunningApp: true } : {}), diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index a2ef6dc2f..276ca35a8 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; -import { markSessionSnapshotRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; +import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { redactDiagnosticData } from '../../kernel/redaction.ts'; @@ -230,7 +230,20 @@ export async function captureDivergenceObservation(params: { }; } setSessionSnapshot(session, snapshot); - markSessionSnapshotRefsIssued(session); + // ADR 0014: the divergence screen digest publishes only its capped, + // non-covered, non-chrome refs — the same set `buildReplayDivergenceScreenRefs` + // renders. Activate a PARTIAL frame authorizing exactly those bodies. + const chromeRefs = collectSettleChromeRefs(snapshot.nodes, session.appBundleId); + const digestBodies = snapshot.nodes + .filter( + (node) => + node.ref !== undefined && + node.interactionBlocked !== 'covered' && + !chromeRefs.has(node.ref), + ) + .slice(0, SCREEN_REF_CAPTURE_LIMIT) + .map((node) => node.ref as string); + markSessionPartialRefsIssued(session, digestBodies); sessionStore.set(sessionName, session); return { state: 'available', diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 93e3d9f17..af383f2a1 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -37,7 +37,8 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { handleDoctorCommand } from './session-doctor.ts'; -import { getSessionCommandKind } from '../daemon-command-registry.ts'; +import { getSessionCommandKind, resolveRefFrameEffect } from '../daemon-command-registry.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { PREPARE_REQUEST_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; import { Deadline } from '../../utils/retry.ts'; @@ -202,6 +203,14 @@ async function runSessionOrSelectorDispatch(params: { const unsupported = requireCommandSupported(command, device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam for session/selector-route leaves (keyboard + // dismiss/enter/return, push, trigger-app-event). Expire the frame before the + // dispatch when the classification says this request mutates; keyboard + // status/get resolve to `preserve` and leave the frame untouched. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } + const result = await dispatchCommand(device, command, positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }); diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index a0a3986d1..f3e44c327 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -15,6 +15,8 @@ import { SessionStore } from '../session-store.ts'; import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; +import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; type HandleAlertCommandParams = { @@ -48,6 +50,13 @@ export async function handleAlertCommand( })(); const unsupported = requireCommandSupported('alert', device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait + // are read-only. The alert resolver returns `may-invalidate` only for the + // acting subactions, so this covers both the Android and native accept/dismiss + // mutations without touching the read paths. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } if (device.platform === 'android') { const timeoutMs = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; return recordAlertResponse( diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 980102332..079f273b5 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -10,6 +10,7 @@ import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; type ParsedSettingsArgs = { setting: string; @@ -100,6 +101,9 @@ export async function handleSettingsCommand( : setting === 'location' && state === 'set' ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] : [setting, state, appBundleId ?? '']; + // ADR 0014 side-effect seam: a settings mutation changes device state; expire + // the frame before the dispatch (settings is always classified may-invalidate). + if (session) expireRefFrame(session); const data = await dispatchCommand(device, 'settings', positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), }); diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts new file mode 100644 index 000000000..b63f7d0e5 --- /dev/null +++ b/src/daemon/ref-frame.ts @@ -0,0 +1,138 @@ +import type { SessionState } from './types.ts'; + +/** + * ADR 0014 session ref-frame lifetime — the authorization model for mutation + * refs, kept distinct from the latest operational observation (`session.snapshot`). + * + * A session owns at most one **ref frame**: the namespace whose refs a caller + * may use to target a mutation. This module is the single owner of the frame's + * transitions and of the admission decision. It is introduced behind the + * existing `snapshotGeneration` (the frame epoch) and `snapshotRefsStale` (the + * coarse client-stale marker) fields, which keep their wire-visible names + * (`refsGeneration`, the `@e12~s42` pin grammar) for compatibility. + * + * The frame is expired at the device side-effect seam, carries a non-`all` + * issuance scope after a partial publication, and its admission matrix is + * enforced fail-closed on every platform before dispatch (ADR 0014 steps 3–7). + * The wire names `refsGeneration` and the `@e12~s42` pin grammar are unchanged; + * the coarse `snapshotRefsStale` marker still backs read-only staleness warnings + * until migration step 8 removes it. + */ + +/** + * Issuance scope of the current frame: `all` for a complete namespace (a full + * interactive snapshot), or the bounded set of ref bodies a partial publication + * (`find`, settled diff, replay divergence) actually returned. + */ +export type RefFrameScope = 'all' | ReadonlySet; + +/** Lifecycle state of the current frame. */ +export type RefFrameState = 'active' | 'expired'; + +/** + * Typed admission-failure reasons, evaluated in this order so the caller can + * distinguish "capture a complete snapshot" from "use the emitted pinned ref". + */ +export type RefFrameRejectReason = + | 'ref_frame_expired' + | 'ref_generation_mismatch' + | 'plain_ref_requires_complete_frame' + | 'ref_not_issued'; + +export type RefFrameAdmission = + | { admitted: true } + | { admitted: false; reason: RefFrameRejectReason }; + +/** + * The frame epoch exposed to clients as `refsGeneration`. Frozen at issuance + * (`refFrameGeneration`) so a later read-only capture that advances the + * observation counter (`snapshotGeneration`) does not shift the epoch a valid + * pin is compared against. Falls back to `snapshotGeneration` for pre-frame + * sessions. + */ +export function refFrameEpoch(session: SessionState): number | undefined { + return session.refFrameGeneration ?? session.snapshotGeneration; +} + +/** + * Expire the current frame at a device side-effect seam (ADR 0014). Idempotent: + * additional effects while already expired are a no-op. Call this SYNCHRONOUSLY, + * immediately before awaiting the operation that may change device-visible + * element identity, so that a post-dispatch failure (timeout, connection loss, + * ambiguous error) still leaves the frame expired — there is no success-only + * rollback. + * + * Crossing the seam also clears the scoped-snapshot lineage (`snapshotScopeSource`, + * ADR 0014): a mutation breaks the consecutive `snapshot -s @ref` chain, so a + * later repeated scoped snapshot cannot borrow stale lineage across a device + * side effect. + */ +export function expireRefFrame(session: SessionState): void { + session.refFrameState = 'expired'; + session.snapshotScopeSource = undefined; +} + +/** + * Re-authorize a complete frame with scope `all` (ADR 0014). This is the only + * transition that restores plain-ref mutation after an expiry, and it is + * reserved for a COMPLETE namespace publication (the snapshot command). Partial + * publications (`find`, settled diffs, replay divergence) and internal read + * captures never call it, so a partial result cannot restore broad authority. + * + * Retains the just-published tree (`session.snapshot`) as the frame's immutable + * source tree by SHARED reference — no deep copy (ADR 0014 performance). A later + * read-only capture advances `session.snapshot` without disturbing this tree, so + * a ref keeps resolving against the namespace that authorized it. + */ +export function activateCompleteRefFrame(session: SessionState): void { + session.refFrameState = 'active'; + session.refFrameScope = undefined; + session.refFrameTree = session.snapshot; + session.refFrameGeneration = session.snapshotGeneration; +} + +export function refFrameState(session: SessionState): RefFrameState { + return session.refFrameState ?? 'active'; +} + +export function refFrameScope(session: SessionState): RefFrameScope { + return session.refFrameScope ?? 'all'; +} + +/** + * The ADR 0014 mutation-admission matrix, evaluated in reason order. Pure over + * the session's frame fields; it does not itself read the operational + * observation, so an internal read capture cannot admit or reject a mutation by + * positional coincidence. + * + * `refBody` is the plain ref body (no `@`, no `~s` suffix). `mintedGeneration` + * is the generation carried by a pinned input (`@e12~s42`), or `undefined` for a + * plain ref. + */ +export function admitRefMutation(params: { + session: SessionState; + refBody: string; + mintedGeneration: number | undefined; +}): RefFrameAdmission { + const { session, refBody, mintedGeneration } = params; + + if (refFrameState(session) === 'expired') { + return { admitted: false, reason: 'ref_frame_expired' }; + } + + if (mintedGeneration !== undefined && mintedGeneration !== refFrameEpoch(session)) { + return { admitted: false, reason: 'ref_generation_mismatch' }; + } + + const scope = refFrameScope(session); + if (scope !== 'all') { + if (mintedGeneration === undefined) { + return { admitted: false, reason: 'plain_ref_requires_complete_frame' }; + } + if (!scope.has(refBody)) { + return { admitted: false, reason: 'ref_not_issued' }; + } + } + + return { admitted: true }; +} diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index d43ffcd93..359b77add 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -24,7 +24,11 @@ import { } from './recording-gestures.ts'; import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; import { normalizeError } from '../kernel/errors.ts'; -import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; +import { expireRefFrame } from './ref-frame.ts'; +import { + resolveRefFrameEffect, + shouldGuardAndroidBlockingDialog, +} from './daemon-command-registry.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { assertSupportedScreenshotPixelDensity, @@ -59,6 +63,14 @@ export async function dispatchGenericCommand(params: { ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), surface: session.surface, }; + // ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/ + // tv-remote/app-switcher/viewport/focus, ...). The daemon effect classification + // is the honesty guard that decides which of these mutate; expire the frame + // before dispatching so a later ref cannot reuse it. Read-only generic leaves + // (screenshot) are classified `preserve` and leave the frame untouched. + if (resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } let data = await executeGenericPlatformCommand({ session, sessionName: params.sessionName, diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 5ac613d6d..32591e984 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -4,6 +4,13 @@ import type { SessionState } from './types.ts'; export type RuntimeSessionRecordOptions = { includeSnapshot?: boolean; + /** + * ADR 0014: omit the authorized frame tree so `@ref` resolution binds against + * the latest observation instead. Set for a mutating `find`'s internal leaf + * dispatch, whose ref was just re-resolved by locator against the find's fresh + * capture — the frame model does not govern that internal re-resolution. + */ + omitRefFrameSnapshot?: boolean; metadata?: Record; }; @@ -17,7 +24,17 @@ function toRuntimeSessionRecord( name, appBundleId: session.appBundleId, appName: session.appName, - ...(options.includeSnapshot === true ? { snapshot: session.snapshot } : {}), + ...(options.includeSnapshot === true + ? { + snapshot: session.snapshot, + // ADR 0014: expose the authorized frame tree so ref resolution binds a + // `@eN` to the node the caller was authorized against, not to whatever + // now sits at that index in a newer observation. + ...(session.refFrameTree && options.omitRefFrameSnapshot !== true + ? { refFrameSnapshot: session.refFrameTree } + : {}), + } + : {}), metadata: { surface: session.surface, ...(options.metadata ?? {}), diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 3bb47dc27..36406af6e 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -6,7 +6,7 @@ import { runAppleRunnerCommand } from '../platforms/apple/core/runner/runner-cli import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { errorResponse, requireCommandSupported } from './handlers/response.ts'; -import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from './session-snapshot.ts'; +import { markSessionPartialRefsIssued, resolveRefStalenessWarning } from './session-snapshot.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; import { parseFindArgs, type FindAction } from '../selectors/find.ts'; import { splitIsSelectorArgs } from '../selectors/index.ts'; @@ -111,7 +111,9 @@ export async function dispatchFindReadOnlyViaRuntime( if (typeof data.ref === 'string') { const session = params.sessionStore.get(params.sessionName); if (session) { - markSessionSnapshotRefsIssued(session); + // ADR 0014: a read-only find publishes exactly its one returned ref, so + // it activates a PARTIAL frame authorizing only that ref body. + markSessionPartialRefsIssued(session, [data.ref]); params.sessionStore.set(params.sessionName, session); if (session.snapshotGeneration !== undefined) { return { ...data, refsGeneration: session.snapshotGeneration }; diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 57d20f020..c45e6f72d 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -1,5 +1,6 @@ import { randomInt } from 'node:crypto'; import type { SnapshotState } from '../kernel/snapshot.ts'; +import { refFrameEpoch } from './ref-frame.ts'; import type { SessionState } from './types.ts'; /** @@ -70,11 +71,60 @@ export function nextSnapshotGeneration(current: number | undefined): number { return current === undefined ? randomInt(100_000, 1_000_000) : current + 1; } -/** The response being returned hands the stored snapshot's refs to the client. */ +/** + * The response being returned hands the stored snapshot's refs to the client. + * + * ADR 0014: this clears the coarse client marker but must NOT re-authorize a + * complete frame — restoring broad `all`-scope mutation authority from a partial + * result is the ADR hole. Only a complete namespace (the snapshot command, via + * `buildNextSnapshotSession`) re-activates a complete frame. A partial + * publication that wants to authorize its bounded ref set calls + * {@link markSessionPartialRefsIssued} instead. + */ export function markSessionSnapshotRefsIssued(session: SessionState): void { session.snapshotRefsStale = false; } +/** Plain ref body: strip a leading `@` and any `~s` generation suffix. */ +function normalizeRefBody(ref: string): string { + const withoutAt = ref.startsWith('@') ? ref.slice(1) : ref; + const suffix = withoutAt.indexOf('~'); + return suffix === -1 ? withoutAt : withoutAt.slice(0, suffix); +} + +/** + * ADR 0014 partial issuance: a `find`, settled diff, or replay divergence screen + * publishes only the refs it actually returned. It activates a PARTIAL frame that + * authorizes ONLY those ref bodies (scope = the emitted set) at the current + * epoch — a plain ref then requires a complete frame, and a pinned ref outside + * the set is rejected. An empty partial result does not supersede existing + * authority (it leaves the frame untouched), so a useful prior frame survives. + */ +export function markSessionPartialRefsIssued(session: SessionState, refs: Iterable): void { + const scope = new Set(); + for (const ref of refs) { + const body = normalizeRefBody(ref); + if (body.length > 0) scope.add(body); + } + // ADR 0014: an empty partial result does not supersede existing authority — it + // leaves ALL session state untouched, including the coarse marker. Build the + // scope before touching anything so a no-ref result is a true no-op. + if (scope.size === 0) return; + session.snapshotRefsStale = false; + session.refFrameState = 'active'; + session.refFrameScope = scope; + // ADR 0014: retain the tree this partial result published from as the frame's + // immutable source (shared reference — the caller already stored it via + // setSessionSnapshot). Interaction guards and replay identity can depend on + // ancestors, siblings, and viewport outside the emitted subset, so the whole + // tree is kept while the issuance set bounds authority. + session.refFrameTree = session.snapshot; + // Freeze the epoch the client is handed (the response-level refsGeneration), + // so a later read-only capture that bumps the observation counter cannot + // invalidate a correct pin from this frame. + session.refFrameGeneration = session.snapshotGeneration; +} + /** * Warning for a ref pinned to a generation (`@e12~s3`) that no longer matches * the stored tree's generation. Unlike STALE_SNAPSHOT_REFS_WARNING it is @@ -109,7 +159,10 @@ export function resolveRefStalenessWarning(params: { }): string | undefined { const { session, ref, mintedGeneration } = params; if (mintedGeneration !== undefined) { - const currentGeneration = session?.snapshotGeneration ?? 0; + // ADR 0014: compare against the FRAME epoch (frozen at issuance), not the + // observation counter — a read-only capture that bumped `snapshotGeneration` + // must not make a valid pin from the issuing frame look stale. + const currentGeneration = session ? (refFrameEpoch(session) ?? 0) : 0; if (mintedGeneration === currentGeneration) return undefined; return buildPinnedStaleRefWarning({ ref, mintedGeneration, currentGeneration }); } diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 50bb759b7..ebd39d59c 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -19,6 +19,7 @@ import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { summarizeSnapshotDiagnostics } from '../snapshot-diagnostics.ts'; import { nextSnapshotGeneration } from './session-snapshot.ts'; +import { activateCompleteRefFrame } from './ref-frame.ts'; export async function dispatchSnapshotViaRuntime(params: { req: DaemonRequest; @@ -264,6 +265,7 @@ function buildNextSnapshotSession(params: { nextSession.snapshotGeneration = keepCurrentSnapshot ? current?.snapshotGeneration : nextSnapshotGeneration(current?.snapshotGeneration); + reactivateCompleteFrameIfIssuing(nextSession, keepCurrentSnapshot, params.issuesRefsToClient); if (record.appName) nextSession.appName = record.appName; return nextSession; } @@ -282,6 +284,21 @@ function shouldKeepCurrentSnapshot( ); } +// ADR 0014: only a snapshot command hands the client the complete ref namespace, +// so only it re-authorizes a complete frame (recovering usability after a +// side-effect expiry). A diff (summary response) or a kept tree preserves the +// prior authorization state buildSnapshotSession carried over; partial +// publications (find/settled/divergence) never reach this path. +function reactivateCompleteFrameIfIssuing( + session: SessionState, + keepCurrentSnapshot: boolean, + issuesRefsToClient: boolean, +): void { + if (!keepCurrentSnapshot && issuesRefsToClient) { + activateCompleteRefFrame(session); + } +} + function resolveNextSnapshotScopeSource(params: { current: SessionState | undefined; keepCurrentSnapshot: boolean; diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 3af693400..61e5648fb 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,6 +18,9 @@ import type { RecordingScope } from '../contracts/recording-scope.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../kernel/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; +// Type-only import; erased at runtime. ref-frame.ts imports SessionState from +// here, so this back-edge must stay type-only to avoid a runtime cycle. +import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; import type { ReplayTargetGuardDenotation } from '../replay/target-identity-node.ts'; import type { AppLogFailure, AppLogState } from './app-log-process.ts'; @@ -68,6 +71,14 @@ type DaemonRequestInternal = { * different same-identity duplicate. */ replayTargetGuard?: ReplayTargetGuardDenotation; + /** + * ADR 0014: set when a mutating `find` re-enters the interaction leaf with the + * ref it just resolved by locator against a fresh capture. That ref is find's + * own diagnostic identity, not a client-supplied ref subject to frame + * lifetime, so the leaf skips ref-frame admission (it still crosses the + * side-effect seam and expires the frame). + */ + findResolvedTarget?: boolean; }; export type DaemonRequest = Omit & { @@ -234,6 +245,44 @@ export type SessionState = { snapshotGeneration?: number; /** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */ snapshotScopeSource?: SnapshotState; + /** + * ADR 0014 ref-frame lifecycle state. Undefined is treated as `active`. A + * device side effect transitions the frame to `expired` (wired at the + * side-effect seam in a later migration step); an expired frame admits no ref + * mutation. Managed only through `src/daemon/ref-frame.ts`. + */ + refFrameState?: RefFrameState; + /** + * ADR 0014 issuance scope of the current ref frame. Undefined is treated as + * `all` (a complete namespace). A bounded set names the ref bodies a partial + * publication (`find`, settled diff, replay divergence) actually issued, which + * are the only bodies a pinned mutation may target. Managed only through + * `src/daemon/ref-frame.ts`. + */ + refFrameScope?: RefFrameScope; + /** + * ADR 0014 immutable source tree of the current ref frame: the tree that + * minted the frame's refs, retained so a ref resolves to the node the caller + * was authorized against — never to a different element by positional + * coincidence in a newer operational observation (`snapshot`). Shares the + * capture object with `snapshot` at activation (no deep copy); an Android + * freshness or other read-only capture advances `snapshot` WITHOUT touching + * this, so the two intentionally diverge. Managed only through + * `src/daemon/ref-frame.ts` and the partial-issuance writer. Undefined falls + * back to `snapshot` (pre-frame sessions). + */ + refFrameTree?: SnapshotState; + /** + * ADR 0014 ref-frame epoch, frozen at the generation the frame was issued at + * (the `refsGeneration` the client received). A later read-only capture + * advances `snapshotGeneration` (the observation counter) WITHOUT reissuing + * refs, so admission and pin comparisons use this frame-pinned epoch — a + * correct pin from the issuing frame is not falsely rejected because an + * intervening read bumped the observation counter. Undefined falls back to + * `snapshotGeneration`. Managed only through `src/daemon/ref-frame.ts` and the + * partial-issuance writer. + */ + refFrameGeneration?: number; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: AndroidSnapshotFreshness; diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index d7d60b232..1b4a7e487 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -219,6 +219,12 @@ function mergeIssuedRefPins( const record = asOptionalRecord(result); const refsGeneration = record?.refsGeneration; if (record === undefined || typeof refsGeneration !== 'number') { + // ADR 0014: a MUTATING find returns its acted ref as diagnostic pre-action + // identity WITHOUT `refsGeneration` — it is explicitly non-issuing and must + // leave remembered pins untouched (forwarding the old pin on a later ref is + // how the daemon produces a precise stale rejection). Only a snapshot that + // genuinely issued no generation clears the scope. + if (name === 'find') return; refPinsByScope.delete(scopeKey); return; } diff --git a/src/runtime-contract.ts b/src/runtime-contract.ts index d15ca3736..e90938941 100644 --- a/src/runtime-contract.ts +++ b/src/runtime-contract.ts @@ -16,6 +16,14 @@ export type CommandSessionRecord = { appName?: string; backendSessionId?: string; snapshot?: SnapshotState; + /** + * ADR 0014 authorized ref-frame source tree. When present it is the tree a + * ref (`@eN`) resolves against for identity, so a newer operational + * observation in `snapshot` (e.g. an Android freshness capture) cannot + * retarget an already-authorized ref by positional coincidence. Absent for + * pre-frame sessions and non-daemon runtimes, which resolve against `snapshot`. + */ + refFrameSnapshot?: SnapshotState; metadata?: Record; }; diff --git a/test/integration/provider-scenarios/android-find.test.ts b/test/integration/provider-scenarios/android-find.test.ts index 67fb1160e..79adeb2d1 100644 --- a/test/integration/provider-scenarios/android-find.test.ts +++ b/test/integration/provider-scenarios/android-find.test.ts @@ -45,6 +45,12 @@ test('Provider-backed integration Android find flow covers refs, wait, ambiguity assertString(attrsRef, 'find attrs ref'); assert.match(attrsRef, /^@e\d+$/); assert.equal((attrs.node as { label?: string } | undefined)?.label, 'Apps'); + // ADR 0014: a read-only find partially publishes its returned ref, so it is + // consumed in pinned form (`@eN~s`) — a plain ref would require a + // complete frame. + const attrsGeneration = (attrs as { refsGeneration?: number }).refsGeneration; + assert.equal(typeof attrsGeneration, 'number'); + const attrsPinnedRef = `${attrsRef}~s${attrsGeneration}`; const exists = await client.interactions.find({ locator: 'label', @@ -54,7 +60,7 @@ test('Provider-backed integration Android find flow covers refs, wait, ambiguity }); assert.equal(exists.found, true); - const pressFoundRef = await client.interactions.press({ ref: attrsRef, ...selection }); + const pressFoundRef = await client.interactions.press({ ref: attrsPinnedRef, ...selection }); assert.equal(pressFoundRef.x, 88); assert.equal(pressFoundRef.y, 151); diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 857b295d1..b5964c4c5 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1033,6 +1033,9 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( const appSwitcher = await client.command.appSwitcher(selection); assert.equal(appSwitcher.action, 'app-switcher'); + // ADR 0014: rotate + app-switcher expired the frame, so re-observe before + // acting through a ref again (the mock re-captures the same tree). + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const press = await client.interactions.press({ ref: `@${apps.ref}`, ...selection }); assert.equal(press.x, 88); assert.equal(press.y, 151); @@ -1049,6 +1052,8 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( assert.equal(heldPress.holdMs, 5); assert.equal(heldPress.jitterPx, 1); + // ADR 0014: the press + held coordinate press expired the frame — re-observe. + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const click = await client.interactions.click({ ref: `@${apps.ref}`, ...selection }); assert.equal(click.x, 88); assert.equal(click.y, 151); @@ -1070,6 +1075,8 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( false, ); + // ADR 0014: the ref click expired the frame — re-observe before filling. + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const fill = await client.interactions.fill({ ref: `@${search.ref}`, text: 'Display', @@ -1162,6 +1169,10 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( [ 'snapshot -i', 'press @e2 Apps --count 2 --interval-ms 1', + // ADR 0014: the ref press expired the frame, so the script re-observes + // before mutating through another ref (a legacy multi-ref-from-one-snapshot + // script would now fail closed). + 'snapshot -i', 'fill @e3 Search "Network"', 'get text @e3 Search', '', @@ -1172,7 +1183,7 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( update: true, ...selection, }); - assert.equal(updateReplay.replayed, 4); + assert.equal(updateReplay.replayed, 5); assert.equal(updateReplay.healed, 0); const replayEnvPath = path.join(tempRoot, 'settings-env.ad'); diff --git a/test/integration/provider-scenarios/linux-desktop.test.ts b/test/integration/provider-scenarios/linux-desktop.test.ts index 0e882586c..8e2d56e60 100644 --- a/test/integration/provider-scenarios/linux-desktop.test.ts +++ b/test/integration/provider-scenarios/linux-desktop.test.ts @@ -136,6 +136,13 @@ test('Provider-backed integration Linux desktop flow uses semantic desktop and i to: { x: 30, y: 40 }, }, }, + { + // ADR 0014: the earlier ref press (and the coordinate gestures) + // expired the frame, so re-observe before the next ref mutation. + name: 're-observe before the next ref mutation', + command: 'snapshot', + flags: { snapshotInteractiveOnly: true }, + }, { name: 'fill snapshot ref', command: 'fill', diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index a56769a0d..435c50344 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -224,9 +224,12 @@ test('Provider-backed integration press --settle returns the settled diff and fr // The settled tree is never serialized into the response. assert.equal(pressData.nodes, undefined); - // The diff's ref acts directly on the stored settled tree — no fresh - // snapshot round trip and no stale-refs warning. - const followUp = await daemon.callCommand('press', ['@e2'], {}); + // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its + // emitted ref, so the follow-up consumes it in pinned form — acting + // directly on the stored settled tree with no fresh snapshot round trip + // and no stale-refs warning. The plain `@e2` would require a complete + // frame. + const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 200); @@ -387,8 +390,9 @@ test('Provider-backed integration modal-dismiss press --settle attaches the unch assert.equal(typeof settle.refsGeneration, 'number'); // The tail's ref acts directly on the stored settled tree, same as an - // added-line ref would. - const followUp = await daemon.callCommand('press', ['@e3'], {}); + // added-line ref would — consumed in pinned form from the partial frame + // (ADR 0014). + const followUp = await daemon.callCommand('press', [`@e3~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 200); @@ -720,7 +724,7 @@ test('Provider-backed integration fill --settle summoning the keyboard still att assert.equal(settle.tailTruncated, undefined); assert.equal(typeof settle.refsGeneration, 'number'); - const followUp = await daemon.callCommand('press', ['@e17'], {}); + const followUp = await daemon.callCommand('press', [`@e17~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 201); diff --git a/test/integration/provider-scenarios/stale-ref-warning.test.ts b/test/integration/provider-scenarios/stale-ref-warning.test.ts index 9448025b2..8aed00e08 100644 --- a/test/integration/provider-scenarios/stale-ref-warning.test.ts +++ b/test/integration/provider-scenarios/stale-ref-warning.test.ts @@ -127,7 +127,11 @@ test('Provider-backed integration iOS @refs reject after a selector press replac assert.equal(selectorData.warning, undefined); const stalePress = await daemon.callCommand('press', ['@e2'], {}); - assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e2 not found or has no bounds/); + assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e2 belongs to an expired ref frame/); + assert.equal( + (stalePress.json?.error?.data?.details as Record)?.reason, + 'ref_frame_expired', + ); const refresh = await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true, @@ -184,7 +188,11 @@ test('Provider-backed iOS press rejects a stale ref after navigation', async () assertRpcOk(await daemon.callCommand('press', ['label=Back'], {})); const stalePress = await daemon.callCommand('press', ['@e3'], {}); - assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e3 not found or has no bounds/); + assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e3 belongs to an expired ref frame/); + assert.equal( + (stalePress.json?.error?.data?.details as Record)?.reason, + 'ref_frame_expired', + ); runnerTranscript.assertComplete(); }, ); diff --git a/test/integration/provider-scenarios/versioned-refs.test.ts b/test/integration/provider-scenarios/versioned-refs.test.ts index f2b5ba383..def8178cb 100644 --- a/test/integration/provider-scenarios/versioned-refs.test.ts +++ b/test/integration/provider-scenarios/versioned-refs.test.ts @@ -66,23 +66,17 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { test('Provider-backed integration rejects stale pinned @refs and accepts current pins', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs at the seeded generation g1 + // snapshot -i: issues the complete frame at the seeded generation g1 snapshotEntry(), - // press label=Continue: selector resolution capture replaces the stored - // tree (g1+1) without issuing refs - snapshotEntry(), - tapEntry(200, 322), - // press @e2~s{g1}: reject the outlived ref before any runner command - // press @e2~s{g1+1}: pinned to the CURRENT generation — clean + // press @e2~s{g1}: pinned to the CURRENT frame — admitted, taps Cancel, + // and crosses the seam so the frame expires tapEntry(200, 422), - // find Cancel click: capture replaces the tree AGAIN (g1+2) and issues - // only the found ref at the new generation + // snapshot -i: re-issues a fresh complete frame at g2 (= g1+1) snapshotEntry(), + // press @e2~s{g1}: an outlived pin against the active g2 frame — rejected + // with a precise generation mismatch before any runner command + // press @e2~s{g2}: pinned to the current frame — admitted, taps Cancel tapEntry(200, 422), - // press @e1~s{g1+1}: a PRE-find pin — the find must not bless it (the - // #1076 hole): reject before any runner command - // press @e1~s{g1+2}: pinned to the post-find generation — clean - tapEntry(200, 322), ]); const appleRunnerProvider = createAppleRunnerProviderFromTranscript( runnerTranscript, @@ -120,47 +114,37 @@ test('Provider-backed integration rejects stale pinned @refs and accepts current assert.ok(nodes.length > 0); assert.ok(nodes.every((node) => node.ref === undefined || !node.ref.includes('~'))); - const selectorPress = await daemon.callCommand('press', ['label=Continue'], {}); - const selectorData = assertRpcOk(selectorPress); - assert.equal(selectorData.warning, undefined); + // A pin to the current frame is admitted; the tap crosses the seam. + const pinnedCurrent = await daemon.callCommand('press', [`@e2~s${g1}`], {}); + assert.equal(assertRpcOk(pinnedCurrent).warning, undefined); + + // ADR 0014: the mutation expired the frame, so a fresh observation is + // required before another ref mutation. + const refresh = await daemon.callCommand('snapshot', [], { + snapshotInteractiveOnly: true, + }); + const g2 = assertRpcOk(refresh).refsGeneration as number; + assert.equal(g2, g1 + 1); + // The outlived pin is rejected precisely against the active g2 frame. const pinnedStale = await daemon.callCommand('press', [`@e2~s${g1}`], {}); const pinnedStaleData = assertRpcError( pinnedStale, 'COMMAND_FAILED', - /Ref @e2 not found or has no bounds/, + /Ref @e2 was minted from a superseded snapshot generation/, ); + const pinnedStaleDetails = pinnedStaleData.details as Record; + assert.equal(pinnedStaleDetails.reason, 'ref_generation_mismatch'); + assert.equal(pinnedStaleDetails.mintedGeneration, g1); + assert.equal(pinnedStaleDetails.currentGeneration, g2); assert.equal( pinnedStaleData.hint, - `Ref @e2 was minted from snapshot s${g1} but the session tree is now s${g1 + 1} — re-run snapshot -i.`, - ); - - const pinnedCurrent = await daemon.callCommand('press', [`@e2~s${g1 + 1}`], {}); - const pinnedCurrentData = assertRpcOk(pinnedCurrent); - assert.equal(pinnedCurrentData.warning, undefined); - - // The blessing flow: find replaces the tree and issues its ref at the - // NEW generation… - const find = await daemon.callCommand('find', ['Cancel', 'click'], {}); - const findData = assertRpcOk(find); - assert.equal(findData.refsGeneration, g1 + 2); - - // …but a ref pinned BEFORE the find is still rejected precisely — the - // find response must not silently re-bless it. - const preFindPin = await daemon.callCommand('press', [`@e1~s${g1 + 1}`], {}); - const preFindPinData = assertRpcError( - preFindPin, - 'COMMAND_FAILED', - /Ref @e1 not found or has no bounds/, - ); - assert.equal( - preFindPinData.hint, - `Ref @e1 was minted from snapshot s${g1 + 1} but the session tree is now s${g1 + 2} — re-run snapshot -i.`, + `Ref @e2 was minted from snapshot s${g1} but the session tree is now s${g2} — re-run snapshot -i.`, ); - const postFindPin = await daemon.callCommand('press', [`@e1~s${g1 + 2}`], {}); - const postFindPinData = assertRpcOk(postFindPin); - assert.equal(postFindPinData.warning, undefined); + // A pin to the current generation is admitted. + const pinnedFresh = await daemon.callCommand('press', [`@e2~s${g2}`], {}); + assert.equal(assertRpcOk(pinnedFresh).warning, undefined); runnerTranscript.assertComplete(); }, diff --git a/test/integration/provider-scenarios/web-desktop.test.ts b/test/integration/provider-scenarios/web-desktop.test.ts index f84d348ae..e3970f1aa 100644 --- a/test/integration/provider-scenarios/web-desktop.test.ts +++ b/test/integration/provider-scenarios/web-desktop.test.ts @@ -94,6 +94,13 @@ test('Provider-backed integration web desktop flow uses semantic web provider ca assert.equal(data?.message, 'Tapped @e4'); }, }, + { + // ADR 0014: the ref click above expired the frame, so a fresh + // observation is required before the next ref mutation. + name: 're-observe before the next ref mutation', + command: 'snapshot', + flags: { snapshotInteractiveOnly: true }, + }, { name: 'fill email ref', command: 'fill', diff --git a/test/output-economy/output-economy.baseline.json b/test/output-economy/output-economy.baseline.json index 8ccec93bf..5e575e2f4 100644 --- a/test/output-economy/output-economy.baseline.json +++ b/test/output-economy/output-economy.baseline.json @@ -42,7 +42,7 @@ "shape": "$.message:string|$.ref:string|$.settle.captures:number|$.settle.diff.summary.additions:number|$.settle.diff.summary.removals:number|$.settle.diff.summary.unchanged:number|$.settle.diff.summary:object|$.settle.diff:object|$.settle.quietMs:number|$.settle.refs:array|$.settle.refsGeneration:number|$.settle.refs[].ref:string|$.settle.refs[]:object|$.settle.settled:boolean|$.settle.timeoutMs:number|$.settle.waitedMs:number|$.settle:object|$.x:number|$.y:number|$:object" }, "settle-tail.default.text": { - "bytes": 180, + "bytes": 188, "lines": 7, "refs": 4, "hints": 0, @@ -175,7 +175,7 @@ "shape": "text" }, "workflow.mutation-tail.cli.text": { - "bytes": 165, + "bytes": 173, "lines": 6, "refs": 3, "hints": 0, diff --git a/test/output-economy/output-economy.waivers.json b/test/output-economy/output-economy.waivers.json index 0967ef424..903f2df98 100644 --- a/test/output-economy/output-economy.waivers.json +++ b/test/output-economy/output-economy.waivers.json @@ -1 +1,10 @@ -{} +{ + "settle-tail.default.text": { + "bytes": 188, + "reason": "ADR 0014: a settled diff's reusable tail refs render in ready-to-copy pinned @eN~s form so a human CLI caller can paste them straight into the next mutation (+8 bytes for the two tail pins). The response-level refsGeneration stays the token-economical authority for JSON and MCP; only human-CLI text carries the pins." + }, + "workflow.mutation-tail.cli.text": { + "bytes": 173, + "reason": "ADR 0014: the routine-workflow mutation-tail step renders the same pinned tail refs in human-CLI text (+8 bytes). See settle-tail.default.text." + } +} diff --git a/test/output-economy/routine-workflow.ts b/test/output-economy/routine-workflow.ts index d646f1364..8fedc04f2 100644 --- a/test/output-economy/routine-workflow.ts +++ b/test/output-economy/routine-workflow.ts @@ -259,7 +259,14 @@ function workflowSamples(steps: WorkflowStep[]): Record { function refsInSample(sample: EconomySample): string[] { const serialized = 'text' in sample ? sample.text : JSON.stringify(sample.data); - return serialized.match(REF_TOKEN_PATTERN) ?? []; + // ADR 0014: a partial CLI result may render a ready-to-copy pinned ref + // (`@e7~s14`). That still surfaces the plain target `@e7` — an agent pasting + // the pinned form is targeting the same body — so normalize the pin away when + // deciding which refs a response surfaced. + return (serialized.match(REF_TOKEN_PATTERN) ?? []).map((ref) => { + const suffix = ref.indexOf('~'); + return suffix === -1 ? ref : ref.slice(0, suffix); + }); } function measureRoutineWorkflow(