From 3bf786f5f82302de5494f9a9e494bbf050f9ee35 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 19 Aug 2026 11:56:19 +0200 Subject: [PATCH] refactor(daemon): one capture-input builder and one admit-then-bind step Behaviour-neutral. No descriptor changes platform execution, the cutover table is untouched, and no contract surface is added. - buildRuntimeCaptureInput moves to its own module so every request-bound capture consumer builds CaptureSnapshotInput one way. - The admit-then-bind sequence in the snapshot/diff resolver becomes one named step, ready for the selector units' second caller. - handlers/find.ts splits into focused target-capture and match-resolution concepts (600 -> 346 lines); behaviour unchanged. --- .../help-conformance-sample-producers.ts | 2 +- src/daemon/handlers/find-match-resolution.ts | 189 +++++++++++++ src/daemon/handlers/find-target-capture.ts | 77 +++++ src/daemon/handlers/find.ts | 266 +----------------- src/daemon/snapshot-runtime-binding.ts | 123 ++++---- src/daemon/snapshot-runtime-capture-input.ts | 53 ++++ 6 files changed, 389 insertions(+), 321 deletions(-) create mode 100644 src/daemon/handlers/find-match-resolution.ts create mode 100644 src/daemon/handlers/find-target-capture.ts create mode 100644 src/daemon/snapshot-runtime-capture-input.ts diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 336cc3b22..52c2b913b 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -19,7 +19,7 @@ import { interactionCliOutputFormatters } from '../../src/commands/interaction/o import { snapshotCliOutput } from '../../src/commands/capture/output.ts'; import { openCliOutput } from '../../src/commands/management/output.ts'; import { NEVER_SETTLED_HINT } from '../../src/commands/interaction/runtime/settle.ts'; -import { buildAmbiguousMatchError } from '../../src/daemon/handlers/find.ts'; +import { buildAmbiguousMatchError } from '../../src/daemon/handlers/find-match-resolution.ts'; import { refMutationAdmissionResponse } from '../../src/daemon/handlers/interaction-ref-policy.ts'; import { buildDeviceInUseBySessionError } from '../../src/daemon/handlers/session-open.ts'; import { buildDeviceClaimConflictError } from '../../src/daemon/device-claim-conflict.ts'; diff --git a/src/daemon/handlers/find-match-resolution.ts b/src/daemon/handlers/find-match-resolution.ts new file mode 100644 index 000000000..0aa569f64 --- /dev/null +++ b/src/daemon/handlers/find-match-resolution.ts @@ -0,0 +1,189 @@ +import { + findBestMatchesByLocator, + type FindLocator, + type SelectorResolutionPolicy, +} from '@agent-device/selectors'; +import { listSelectorPipelineMatches } from '../../core/selector-pipeline.ts'; +import { SELECTOR_PIPELINE_POLICIES } from '../../core/selector-pipeline-policy.ts'; +import { centerOfRect, type SnapshotState } from '@agent-device/kernel/snapshot'; +import { + isRootInteractionContainer, + resolveActionableTouchResolution, +} from '../../core/interaction-targeting.ts'; +import { formatSnapshotLine } from '../../snapshot/snapshot-lines.ts'; +import type { ElementMatchCandidateDetails } from '../../utils/error-candidates.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { errorResponse } from './response.ts'; + +export type FindMatchResult = + | { ok: true; node: SnapshotState['nodes'][number] } + | { ok: false; response: DaemonResponse }; + +function assertRejectsCandidates(policy: SelectorResolutionPolicy): void { + if (policy.ambiguity !== 'reject-candidates') { + throw new Error(`find's resolution policy must reject candidates, got "${policy.ambiguity}"`); + } +} + +export function resolveFindMatch(params: { + nodes: SnapshotState['nodes']; + locator: FindLocator; + query: string; + selectorExpression: string | null; + flags: DaemonRequest['flags']; + platform: SessionState['device']['platform']; +}): FindMatchResult { + const { nodes, locator, query, selectorExpression, flags, platform } = params; + const pipeline = SELECTOR_PIPELINE_POLICIES.findAct; + const rooted = nodes.filter((node) => !isRootInteractionContainer(node, nodes[0])); + // #1625: selector-shaped and text-shaped queries share ONE ambiguity + // contract — multiple matches reject with candidates unless --first/--last + // explicitly opts into positional narrowing. Selectors used to take the + // first match silently, which was exactly the mis-binding path the error's + // own recovery advice ("use a selector") pointed agents at. + const policy = pipeline.resolution; + let matches: SnapshotState['nodes']; + if (selectorExpression) { + // The `reject-candidates` door: the row's candidacy stage runs inside, and + // the whole candidate set comes back for find to rank and narrow. + matches = + listSelectorPipelineMatches(pipeline, rooted, selectorExpression, { platform }).list + ?.matchedNodes ?? []; + } else { + // Fuzzy text scoring, not a selector chain: this branch brings its own + // matcher and reads the row's rect requirement. The row still governs the + // target it produces — occlusion and promotion run on it below, in the + // same node stages the selector branch reaches. + matches = findBestMatchesByLocator(rooted, locator, query, { + requireRect: policy.requireRect, + }).matches; + } + matches = preferOnscreenMatches(matches, nodes); + + if (matches.length > 1) { + // The row says candidates reject unless the caller narrowed explicitly; + // assert that rather than assuming, so a future row edit cannot silently + // turn this into first-match. + assertRejectsCandidates(policy); + const narrowed = narrowMultipleMatches(matches, flags); + if (!narrowed) { + return { ok: false, response: buildAmbiguousMatchError(matches, locator, query) }; + } + matches = narrowed; + } + + const node = matches[0] ?? null; + if (!node) { + return { + ok: false, + response: errorResponse('COMMAND_FAILED', 'find did not match any element'), + }; + } + return { ok: true, node }; +} + +function narrowMultipleMatches( + matches: SnapshotState['nodes'], + flags: DaemonRequest['flags'], +): SnapshotState['nodes'] | null { + if (flags?.findFirst) return [matches[0]!]; + if (flags?.findLast) return [matches[matches.length - 1]!]; + return null; +} + +function preferOnscreenMatches( + matches: SnapshotState['nodes'], + nodes: SnapshotState['nodes'], +): SnapshotState['nodes'] { + const viewport = nodes[0]?.rect; + if (!viewport) return matches; + const onscreen = matches.filter((node) => { + if (!node.rect) return false; + const center = centerOfRect(node.rect); + return ( + center.x >= viewport.x && + center.x <= viewport.x + viewport.width && + center.y >= viewport.y && + center.y <= viewport.y + viewport.height + ); + }); + return rankInteractiveMatches(onscreen.length > 0 ? onscreen : matches, nodes); +} + +function rankInteractiveMatches( + matches: SnapshotState['nodes'], + nodes: SnapshotState['nodes'], +): SnapshotState['nodes'] { + if (matches.length < 2) return matches; + return matches + .map((node, index) => ({ node, index, score: interactiveMatchScore(node, nodes) })) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return rectArea(left.node) - rectArea(right.node) || left.index - right.index; + }) + .map((entry) => entry.node); +} + +function interactiveMatchScore( + node: SnapshotState['nodes'][number], + nodes: SnapshotState['nodes'], +): number { + const resolution = resolveActionableTouchResolution(nodes, node); + if (resolution.reason === 'covered') return 0; + const resolved = resolvedTouchScore(resolution, nodes[0]); + if (resolved > 0) return resolved; + if (node.hittable && node.rect && !isRootInteractionContainer(node, nodes[0])) return 3; + return node.rect ? 1 : 0; +} + +function resolvedTouchScore( + resolution: ReturnType, + root: SnapshotState['nodes'][number] | undefined, +): number { + if (!resolution.node.rect) return 0; + if (resolution.reason === 'semantic-target' || resolution.reason === 'same-rect-descendant') { + return 4; + } + if ( + resolution.reason === 'hittable-ancestor' && + !isRootInteractionContainer(resolution.node, root) + ) { + return 2; + } + return 0; +} + +function rectArea(node: SnapshotState['nodes'][number]): number { + return node.rect ? node.rect.width * node.rect.height : Number.POSITIVE_INFINITY; +} +// #1597: an agent reading an ambiguous-match error must be able to act on the +// right @ref immediately, without a follow-up snapshot round trip. Candidate +// lines reuse the exact snapshot-line renderer (`formatSnapshotLine`) so a +// candidate reads identically to its row in `snapshot -i` output: ref, role, +// label/identifier. Capped at AMBIGUOUS_MATCH_CANDIDATE_LIMIT to bound the +// error payload — `matches` (the true total) is what a "+N more" marker is +// computed from at render time (src/utils/error-candidates.ts). +// Module-local: no consumer outside this file needs the raw cap, only the +// already-capped `candidates` array on the response. +const AMBIGUOUS_MATCH_CANDIDATE_LIMIT = 5; + +// Exported as the single AMBIGUOUS_MATCH producer so the help-benchmark +// sample parity test renders the exact error this handler returns; a message +// change here fails that gate instead of drifting past it. +export function buildAmbiguousMatchError( + matches: SnapshotState['nodes'], + locator: FindLocator, + query: string, +): DaemonResponse { + const candidateDetails: ElementMatchCandidateDetails = { + matches: matches.length, + candidates: matches + .slice(0, AMBIGUOUS_MATCH_CANDIDATE_LIMIT) + .map((candidate) => formatSnapshotLine(candidate, 0, false)), + }; + return errorResponse( + 'AMBIGUOUS_MATCH', + `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`, + { locator, query, ...candidateDetails }, + ); +} diff --git a/src/daemon/handlers/find-target-capture.ts b/src/daemon/handlers/find-target-capture.ts new file mode 100644 index 000000000..d378f9b50 --- /dev/null +++ b/src/daemon/handlers/find-target-capture.ts @@ -0,0 +1,77 @@ +import type { FindLocator } from '@agent-device/selectors'; +import type { SnapshotQualityVerdict, SnapshotState } from '@agent-device/kernel/snapshot'; +import { createSelectorCaptureRuntime } from '../selector-capture-runtime.ts'; +import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { errorResponse } from './response.ts'; + +/** The tree a mutating find resolves its target against, plus what the capture disclosed. */ +export type FindTargetTree = { + nodes: SnapshotState['nodes']; + snapshotQuality?: SnapshotQualityVerdict; + systemSurfaceOnly?: boolean; +}; + +/** + * Find's target capture. A mutating find (click/fill/focus/type) resolves its target from its + * own capture rather than the read-only selector runtime's, with find's two sparse-recovery + * policies and none of the selector read cache tiers — one question, kept out of the route. + */ +export function createFindTargetCapture( + params: Readonly<{ + device: SessionState['device']; + session: SessionState; + req: DaemonRequest; + logPath: string; + locator: FindLocator; + query: string; + sessionStore: SessionStore; + sessionName: string; + }>, +): () => Promise { + const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; + const captureRuntime = createSelectorCaptureRuntime({ + device, + session, + sessionStore, + sessionName, + req, + logPath, + }); + return async () => { + // Interaction targets need the full interactive tree so duplicate labels can + // be resolved against viewport visibility before an off-screen subtree wins. + const { snapshot } = await captureRuntime.capture({ + flags: { + ...req.flags, + snapshotInteractiveOnly: true, + }, + recovery: { + legacyIosSparse: { + query, + shouldScope: shouldScopeFind(locator), + }, + sparseVerdictQueryScope: { + query, + shouldScope: shouldScopeFind(locator), + }, + }, + }); + return { + nodes: snapshot.nodes, + snapshotQuality: snapshot.snapshotQuality, + systemSurfaceOnly: snapshot.systemSurfaceOnly, + }; + }; +} + +export function sparseFindSnapshotResponse(verdict: SnapshotQualityVerdict): DaemonResponse { + return errorResponse('COMMAND_FAILED', 'find could not read the current accessibility tree', { + reason: verdict.reason, + hint: 'The snapshot quality verdict is sparse. Use screenshot as visual truth, navigate with coordinates if needed, then retry find after reaching a readable screen.', + }); +} + +function shouldScopeFind(locator: FindLocator): boolean { + return locator !== 'role'; +} diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 942fb850f..89184d80a 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -1,48 +1,28 @@ import { dispatchCommand } from '../../core/dispatch.ts'; import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction'; import { - findBestMatchesByLocator, isReadOnlyFindAction, checkFindArgs, parseFindSelectorExpression, type FindLocator, - type SelectorResolutionPolicy, } from '@agent-device/selectors'; -import { - listSelectorPipelineMatches, - runNodePipelineStages, -} from '../../core/selector-pipeline.ts'; +import { runNodePipelineStages } from '../../core/selector-pipeline.ts'; import { SELECTOR_PIPELINE_POLICIES } from '../../core/selector-pipeline-policy.ts'; -import { - centerOfRect, - type SnapshotQualityVerdict, - type SnapshotState, -} from '@agent-device/kernel/snapshot'; +import { centerOfRect, type SnapshotState } from '@agent-device/kernel/snapshot'; 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'; -import { - isRootInteractionContainer, - resolveActionableTouchResolution, -} from '../../core/interaction-targeting.ts'; -import { formatSnapshotLine } from '../../snapshot/snapshot-lines.ts'; -import type { ElementMatchCandidateDetails } from '../../utils/error-candidates.ts'; import { readCommandMessage, successText } from '../../utils/success-text.ts'; import { errorResponse, noActiveSessionError } from './response.ts'; import { withSystemSurfaceDisclosure } from './system-surface-disclosure.ts'; import { recordSessionAction } from './handler-utils.ts'; import { stripInternalInteractionFlags } from '../interaction-outcome-policy.ts'; +import { resolveFindMatch } from './find-match-resolution.ts'; import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; -import { createSelectorCaptureRuntime } from '../selector-capture-runtime.ts'; +import { createFindTargetCapture, sparseFindSnapshotResponse } from './find-target-capture.ts'; import { isSparseSnapshotQualityVerdict } from '../../snapshot-quality/verdict.ts'; -function assertRejectsCandidates(policy: SelectorResolutionPolicy): void { - if (policy.ambiguity !== 'reject-candidates') { - throw new Error(`find's resolution policy must reject candidates, got "${policy.ambiguity}"`); - } -} - type FindContext = { req: DaemonRequest; sessionName: string; @@ -71,10 +51,6 @@ type ResolvedMatch = { occludedNode?: SnapshotState['nodes'][number]; }; -type FindMatchResult = - | { ok: true; node: SnapshotState['nodes'][number] } - | { ok: false; response: DaemonResponse }; - export async function handleFindCommands(params: { req: DaemonRequest; sessionName: string; @@ -116,7 +92,7 @@ export async function handleFindCommands(params: { if (!session) return noActiveSessionError(); const device = session.device; const selectorExpression = parseFindSelectorExpression(locator, query); - const fetchNodes = createFindNodeFetcher({ + const readTargetTree = createFindTargetCapture({ device, session, req, @@ -141,7 +117,7 @@ export async function handleFindCommands(params: { publicFlags: publicFindFlags(req.flags), }; - const snapshotResult = await fetchNodes(); + const snapshotResult = await readTargetTree(); if (isSparseSnapshotQualityVerdict(snapshotResult.snapshotQuality)) { return sparseFindSnapshotResponse(snapshotResult.snapshotQuality); } @@ -201,200 +177,6 @@ async function dispatchFindAction( // --- Per-action handlers --- -type FindSnapshotResult = { - nodes: SnapshotState['nodes']; - snapshotQuality?: SnapshotQualityVerdict; - systemSurfaceOnly?: boolean; -}; - -type FindNodeFetcher = () => Promise; - -function createFindNodeFetcher(params: { - device: SessionState['device']; - session: SessionState; - req: DaemonRequest; - logPath: string; - locator: FindLocator; - query: string; - sessionStore: SessionStore; - sessionName: string; -}): FindNodeFetcher { - const { device, session, req, logPath, locator, query } = params; - const { sessionStore, sessionName } = params; - const captureRuntime = createSelectorCaptureRuntime({ - device, - session, - sessionStore, - sessionName, - req, - logPath, - }); - return async () => { - // Interaction targets need the full interactive tree so duplicate labels can - // be resolved against viewport visibility before an off-screen subtree wins. - const { snapshot } = await captureRuntime.capture({ - flags: { - ...req.flags, - snapshotInteractiveOnly: true, - }, - recovery: { - legacyIosSparse: { - query, - shouldScope: shouldScopeFind(locator), - }, - sparseVerdictQueryScope: { - query, - shouldScope: shouldScopeFind(locator), - }, - }, - }); - return { - nodes: snapshot.nodes, - snapshotQuality: snapshot.snapshotQuality, - systemSurfaceOnly: snapshot.systemSurfaceOnly, - }; - }; -} - -function sparseFindSnapshotResponse(verdict: SnapshotQualityVerdict): DaemonResponse { - return errorResponse('COMMAND_FAILED', 'find could not read the current accessibility tree', { - reason: verdict.reason, - hint: 'The snapshot quality verdict is sparse. Use screenshot as visual truth, navigate with coordinates if needed, then retry find after reaching a readable screen.', - }); -} - -function resolveFindMatch(params: { - nodes: SnapshotState['nodes']; - locator: FindLocator; - query: string; - selectorExpression: string | null; - flags: DaemonRequest['flags']; - platform: SessionState['device']['platform']; -}): FindMatchResult { - const { nodes, locator, query, selectorExpression, flags, platform } = params; - const pipeline = SELECTOR_PIPELINE_POLICIES.findAct; - const rooted = nodes.filter((node) => !isRootInteractionContainer(node, nodes[0])); - // #1625: selector-shaped and text-shaped queries share ONE ambiguity - // contract — multiple matches reject with candidates unless --first/--last - // explicitly opts into positional narrowing. Selectors used to take the - // first match silently, which was exactly the mis-binding path the error's - // own recovery advice ("use a selector") pointed agents at. - const policy = pipeline.resolution; - let matches: SnapshotState['nodes']; - if (selectorExpression) { - // The `reject-candidates` door: the row's candidacy stage runs inside, and - // the whole candidate set comes back for find to rank and narrow. - matches = - listSelectorPipelineMatches(pipeline, rooted, selectorExpression, { platform }).list - ?.matchedNodes ?? []; - } else { - // Fuzzy text scoring, not a selector chain: this branch brings its own - // matcher and reads the row's rect requirement. The row still governs the - // target it produces — occlusion and promotion run on it below, in the - // same node stages the selector branch reaches. - matches = findBestMatchesByLocator(rooted, locator, query, { - requireRect: policy.requireRect, - }).matches; - } - matches = preferOnscreenMatches(matches, nodes); - - if (matches.length > 1) { - // The row says candidates reject unless the caller narrowed explicitly; - // assert that rather than assuming, so a future row edit cannot silently - // turn this into first-match. - assertRejectsCandidates(policy); - const narrowed = narrowMultipleMatches(matches, flags); - if (!narrowed) { - return { ok: false, response: buildAmbiguousMatchError(matches, locator, query) }; - } - matches = narrowed; - } - - const node = matches[0] ?? null; - if (!node) { - return { - ok: false, - response: errorResponse('COMMAND_FAILED', 'find did not match any element'), - }; - } - return { ok: true, node }; -} - -function narrowMultipleMatches( - matches: SnapshotState['nodes'], - flags: DaemonRequest['flags'], -): SnapshotState['nodes'] | null { - if (flags?.findFirst) return [matches[0]!]; - if (flags?.findLast) return [matches[matches.length - 1]!]; - return null; -} - -function preferOnscreenMatches( - matches: SnapshotState['nodes'], - nodes: SnapshotState['nodes'], -): SnapshotState['nodes'] { - const viewport = nodes[0]?.rect; - if (!viewport) return matches; - const onscreen = matches.filter((node) => { - if (!node.rect) return false; - const center = centerOfRect(node.rect); - return ( - center.x >= viewport.x && - center.x <= viewport.x + viewport.width && - center.y >= viewport.y && - center.y <= viewport.y + viewport.height - ); - }); - return rankInteractiveMatches(onscreen.length > 0 ? onscreen : matches, nodes); -} - -function rankInteractiveMatches( - matches: SnapshotState['nodes'], - nodes: SnapshotState['nodes'], -): SnapshotState['nodes'] { - if (matches.length < 2) return matches; - return matches - .map((node, index) => ({ node, index, score: interactiveMatchScore(node, nodes) })) - .sort((left, right) => { - if (right.score !== left.score) return right.score - left.score; - return rectArea(left.node) - rectArea(right.node) || left.index - right.index; - }) - .map((entry) => entry.node); -} - -function interactiveMatchScore( - node: SnapshotState['nodes'][number], - nodes: SnapshotState['nodes'], -): number { - const resolution = resolveActionableTouchResolution(nodes, node); - if (resolution.reason === 'covered') return 0; - const resolved = resolvedTouchScore(resolution, nodes[0]); - if (resolved > 0) return resolved; - if (node.hittable && node.rect && !isRootInteractionContainer(node, nodes[0])) return 3; - return node.rect ? 1 : 0; -} - -function resolvedTouchScore( - resolution: ReturnType, - root: SnapshotState['nodes'][number] | undefined, -): number { - if (!resolution.node.rect) return 0; - if (resolution.reason === 'semantic-target' || resolution.reason === 'same-rect-descendant') { - return 4; - } - if ( - resolution.reason === 'hittable-ancestor' && - !isRootInteractionContainer(resolution.node, root) - ) { - return 2; - } - return 0; -} - -function rectArea(node: SnapshotState['nodes'][number]): number { - return node.rect ? node.rect.width * node.rect.height : Number.POSITIVE_INFINITY; -} - /** * #1654: hand the interaction leaf the node this find already resolved, so the * leaf stops resolving `match.ref` a second time. @@ -562,39 +344,3 @@ function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string function publicFindFlags(flags: DaemonRequest['flags']): Record { return { ...(stripInternalInteractionFlags(flags) ?? {}) }; } - -// #1597: an agent reading an ambiguous-match error must be able to act on the -// right @ref immediately, without a follow-up snapshot round trip. Candidate -// lines reuse the exact snapshot-line renderer (`formatSnapshotLine`) so a -// candidate reads identically to its row in `snapshot -i` output: ref, role, -// label/identifier. Capped at AMBIGUOUS_MATCH_CANDIDATE_LIMIT to bound the -// error payload — `matches` (the true total) is what a "+N more" marker is -// computed from at render time (src/utils/error-candidates.ts). -// Module-local: no consumer outside this file needs the raw cap, only the -// already-capped `candidates` array on the response. -const AMBIGUOUS_MATCH_CANDIDATE_LIMIT = 5; - -// Exported as the single AMBIGUOUS_MATCH producer so the help-benchmark -// sample parity test renders the exact error this handler returns; a message -// change here fails that gate instead of drifting past it. -export function buildAmbiguousMatchError( - matches: SnapshotState['nodes'], - locator: FindLocator, - query: string, -): DaemonResponse { - const candidateDetails: ElementMatchCandidateDetails = { - matches: matches.length, - candidates: matches - .slice(0, AMBIGUOUS_MATCH_CANDIDATE_LIMIT) - .map((candidate) => formatSnapshotLine(candidate, 0, false)), - }; - return errorResponse( - 'AMBIGUOUS_MATCH', - `find matched ${matches.length} elements for ${locator} "${query}". Use a more specific locator or selector.`, - { locator, query, ...candidateDetails }, - ); -} - -function shouldScopeFind(locator: FindLocator): boolean { - return locator !== 'role'; -} diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 3d9815537..5c135e9d2 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -7,7 +7,7 @@ import { type SnapshotRuntimePlan, } from '@agent-device/contracts/platform'; import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; -import { contextFromFlags } from './context.ts'; +import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -41,20 +41,31 @@ type ResolvedSnapshotCaptureRuntime = }> | Readonly<{ ok: false; response: DaemonResponse }>; -/** Resolves one plan, inspects its owner facts once, then returns one bound capture closure. */ -export async function resolveBoundSnapshotCaptureRuntime( - params: SnapshotRuntimeRouteParams, - command: 'snapshot' | 'diff', -): Promise { - const { req, sessionName, sessionStore } = params; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); - if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; +/** A capture operation parametrized by intent, so a polling caller can capture repeatedly + * under the one binding it was admitted for. */ +type BoundSnapshotCapture = (input: CaptureSnapshotInput) => Promise; - const plan = resolveSnapshotRuntimePlan({ - customActions: req.flags?.snapshotCustomActions === true, - hasActiveApp: session?.appBundleId !== undefined, - }); +type AdmittedSnapshotCapture = + | Readonly<{ ok: true; capture: BoundSnapshotCapture }> + | Readonly<{ ok: false; response: DaemonResponse }>; + +/** + * The ONE admit-then-bind path for every request-bound snapshot capture (ADR 0019 §9): + * side-effect-free facts inspection, refusal before any binding, then exactly one bind on + * the admitted device. `snapshot`/`diff` supply the four-way custom-actions plan and the + * selector family the active-app plan; a new consumer supplies a plan and a command name. + */ +async function admitAndBindSnapshotCapture( + params: Readonly<{ + command: string; + device: SessionState['device']; + session: SessionState | undefined; + plan: SnapshotRuntimePlan; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; + }>, +): Promise { + const { command, device, session, plan } = params; const admission = await admitRuntimePlan({ device, plan, inspectFacts: params.inspectFacts }); if (!admission.admitted) { return { @@ -68,15 +79,49 @@ export async function resolveBoundSnapshotCaptureRuntime( }), }; } - const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, admission); - const captureInput = buildRuntimeCaptureInput(params, session, resolvedScope.scope); + return Object.freeze({ + ok: true, + capture: async (input: CaptureSnapshotInput) => await runtime.captureSnapshot(input), + }); +} + +/** Resolves one plan, inspects its owner facts once, then returns one bound capture closure. */ +export async function resolveBoundSnapshotCaptureRuntime( + params: SnapshotRuntimeRouteParams, + command: 'snapshot' | 'diff', +): Promise { + const { req, sessionName, sessionStore } = params; + const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); + if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; + + const bound = await admitAndBindSnapshotCapture({ + command, + device, + session, + plan: resolveSnapshotRuntimePlan({ + customActions: req.flags?.snapshotCustomActions === true, + hasActiveApp: session?.appBundleId !== undefined, + }), + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (!bound.ok) return bound; + + const captureInput = buildRuntimeCaptureInput({ + flags: req.flags, + logPath: params.logPath, + meta: req.meta, + session, + snapshotScope: resolvedScope.scope, + }); return Object.freeze({ ok: true, session, device, snapshotScope: resolvedScope.scope, - captureSnapshot: async () => await runtime.captureSnapshot(captureInput), + captureSnapshot: async () => await bound.capture(captureInput), }); } @@ -145,7 +190,7 @@ type SnapshotPlanUnavailableParams = { fact: RuntimeOperationFact; session: SessionState | undefined; device: SessionState['device']; - command: 'snapshot' | 'diff'; + command: string; }; async function snapshotPlanUnavailableResponse( @@ -187,45 +232,3 @@ function snapshotCustomActionsUnavailableResponse( }, ); } - -function buildRuntimeCaptureInput( - params: Readonly<{ req: DaemonRequest; logPath: string }>, - session: SessionState | undefined, - snapshotScope: string | undefined, -): CaptureSnapshotInput { - const { req, logPath } = params; - const flags = req.flags ?? {}; - const { appBundleId, trace, surface } = session ?? {}; - const { requestId } = req.meta ?? {}; - const context = contextFromFlags( - logPath, - flags, - appBundleId, - trace?.outPath, - requestId, - req.meta, - ); - return { - options: { - appBundleId, - interactiveOnly: flags.snapshotInteractiveOnly, - preferredBackend: flags.snapshotPreferredBackend, - depth: flags.snapshotDepth, - scope: snapshotScope, - raw: flags.snapshotRaw, - customActions: flags.snapshotCustomActions, - includeHiddenContentHints: flags.snapshotIncludeHiddenContentHints, - surface, - }, - execution: { - requestId: context.requestId, - verbose: context.verbose, - logPath: context.logPath, - traceLogPath: context.traceLogPath, - iosXctestrunFile: context.iosXctestrunFile, - iosXctestDerivedDataPath: context.iosXctestDerivedDataPath, - iosXctestEnvDir: context.iosXctestEnvDir, - runnerLeaseContext: context.runnerLeaseContext, - }, - }; -} diff --git a/src/daemon/snapshot-runtime-capture-input.ts b/src/daemon/snapshot-runtime-capture-input.ts new file mode 100644 index 000000000..d89688b96 --- /dev/null +++ b/src/daemon/snapshot-runtime-capture-input.ts @@ -0,0 +1,53 @@ +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { CaptureSnapshotInput } from '@agent-device/contracts/platform'; +import { contextFromFlags } from './context.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +/** + * The one place a daemon request becomes neutral capture intent. `snapshot` and `diff` build + * theirs here; a repeated-capture consumer builds one per capture from its own effective flags + * and scope. One builder is what stops those shapes drifting on which flag reaches the platform. + */ +export function buildRuntimeCaptureInput( + params: Readonly<{ + flags: CommandFlags | undefined; + logPath: string; + meta?: DaemonRequest['meta']; + session: SessionState | undefined; + snapshotScope: string | undefined; + }>, +): CaptureSnapshotInput { + const { flags, logPath, meta, session, snapshotScope } = params; + const { appBundleId, trace, surface } = session ?? {}; + const context = contextFromFlags( + logPath, + flags, + appBundleId, + trace?.outPath, + meta?.requestId, + meta, + ); + return { + options: { + appBundleId, + interactiveOnly: flags?.snapshotInteractiveOnly, + preferredBackend: flags?.snapshotPreferredBackend, + depth: flags?.snapshotDepth, + scope: snapshotScope, + raw: flags?.snapshotRaw, + customActions: flags?.snapshotCustomActions, + includeHiddenContentHints: flags?.snapshotIncludeHiddenContentHints, + surface, + }, + execution: { + requestId: context.requestId, + verbose: context.verbose, + logPath: context.logPath, + traceLogPath: context.traceLogPath, + iosXctestrunFile: context.iosXctestrunFile, + iosXctestDerivedDataPath: context.iosXctestDerivedDataPath, + iosXctestEnvDir: context.iosXctestEnvDir, + runnerLeaseContext: context.runnerLeaseContext, + }, + }; +}