diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 3b30ec85a0..8362cac3be 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -23,7 +23,7 @@ The registries and tests named there are the living source of truth. 1. **`--update` never rewrites.** The candidate machinery of heal only ranks the bounded `suggestions` list inside a divergence report. - The order is identity components, then the same `scrollRegion`, then document order. + The order is identity components, then the same `scrollRegion` for a verified `target-v1` annotation, then document order. The list is deduplicated by node. No unattended repair path exists. 2. **Every element resolution discloses how it resolved.** @@ -98,7 +98,7 @@ They do not act unattended. 1. Candidates that satisfy more identity components rank first. A recorded-id match precedes a role+label match. A role+label match precedes a label-only match. -2. Among equal candidates, candidates in the same `scrollRegion` as recorded rank before candidates in other regions. +2. For a verified `target-v1` annotation, equally strong candidates in the recorded `scrollRegion` rank before candidates in other regions. 3. Document order resolves the remaining ties. Suggestions are deduplicated by node. diff --git a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 007e13dfb8..38c0987e78 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -23,7 +23,7 @@ import { resolveSessionScope } from '../../sessions/session-routing.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; -import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/internal/session-close.ts'; const TEST_ROOT = mkdtempForTestSync('agent-device-request-execution-scope-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); diff --git a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index 86cd71791d..e36a15f3e7 100644 --- a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -73,3 +73,78 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis expect(divergence.suggestions[0]?.ref).toBe('e1'); expect(divergence.suggestions[0]?.basis).toBe('id'); }); + +test.each(['region', 'absent', 'none', 'unverifiable'] as const)( + 'suggestions rank identity, recorded region, then document order (evidence: %s)', + async (evidence) => { + const root = mkdtempForTestSync('agent-device-replay-suggest-region-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + const button = (index: number, parentIndex: number, label: string) => ({ + index, + parentIndex, + depth: 1, + type: 'Button', + label, + rect: { x: 0, y: index * 10, width: 100, height: 44 }, + hittable: true, + }); + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { index: 0, depth: 0, type: 'ScrollView', identifier: 'other' }, + button(1, 0, 'Other'), + { ...button(2, 0, 'Strong'), identifier: 'strong' }, + { index: 3, depth: 0, type: 'ScrollView', identifier: 'recorded' }, + button(4, 3, 'First'), + button(5, 3, 'Second'), + { ...button(6, 0, 'Outside'), parentIndex: undefined, depth: 0 }, + ], + truncated: false, + backend: 'xctest', + }); + const action = { + ts: 0, + command: 'click', + positionals: ['label="Other"'], + flags: {}, + result: { + selectorChain: ['label="Second"', 'label="First"', 'id="strong"', 'label="Outside"'], + }, + ...(evidence !== 'absent' + ? { + targetEvidence: { + role: 'button', + label: 'Original', + ancestry: [], + sibling: 0, + viewportOrder: 0, + ...(evidence === 'region' || evidence === 'unverifiable' + ? { scrollRegion: { role: 'scrollview', id: 'recorded' } } + : {}), + verification: + evidence === 'unverifiable' ? ('unverifiable' as const) : ('verified' as const), + }, + } + : {}), + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + ...replayDivergenceForTest(sessionStore, 'default'), + logPath: path.join(root, 'daemon.log'), + responseLevel: 'full', + planActions: [action], + planDigest: 'test-plan-digest', + }); + expect(divergence.suggestions.map(({ label }) => label)).toEqual( + evidence === 'region' + ? ['Strong', 'First', 'Second', 'Other', 'Outside'] + : evidence === 'none' + ? ['Strong', 'Outside', 'Other', 'First', 'Second'] + : ['Strong', 'Other', 'First', 'Second', 'Outside'], + ); + }, +); diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts index efec07e900..7812c140a4 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts @@ -443,6 +443,65 @@ test('typed Maestro text matching an identifier reports id basis', async () => { expect(divergence.suggestions).toEqual([expect.objectContaining({ basis: 'id' })]); }); +test('typed Maestro failure ranks by identity and document order across scroll regions', async () => { + const command = { + kind: 'tapOn' as const, + source: { path: '/flows/actions.yaml', line: 4 }, + target: { space: 'target' as const, selector: { text: 'save.*' } }, + } satisfies Extract; + const response = await buildFailureResponse(command, [ + { + index: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + { + index: 1, + parentIndex: 0, + identifier: 'actions', + type: 'ScrollView', + rect: { x: 0, y: 0, width: 402, height: 300 }, + }, + { + index: 2, + parentIndex: 1, + type: 'Button', + label: 'Save in list', + rect: { x: 16, y: 40, width: 140, height: 44 }, + hittable: true, + }, + { + index: 3, + parentIndex: 0, + type: 'Button', + label: 'Save outside', + rect: { x: 16, y: 350, width: 140, height: 44 }, + hittable: true, + }, + { + index: 4, + parentIndex: 0, + type: 'Button', + identifier: 'save-primary', + label: 'Confirm', + rect: { x: 16, y: 410, width: 140, height: 44 }, + hittable: true, + }, + ]); + const divergence = response.error.details?.divergence as { + screen: { state: string }; + suggestions: Array<{ basis: string; label: string }>; + }; + + expect(response.error.code).toBe('REPLAY_DIVERGENCE'); + expect(divergence.screen.state).toBe('available'); + expect(divergence.suggestions.map(({ basis, label }) => [basis, label])).toEqual([ + ['id', 'Confirm'], + ['label', 'Save in list'], + ['label', 'Save outside'], + ]); +}); + test('typed Maestro suggestions retain total count before the five-entry cap', async () => { const command = { kind: 'tapOn' as const, diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 9787199b53..661387946c 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -45,7 +45,7 @@ vi.mock('../../../core/snapshot-interactor-capture.ts', () => ({ import fs from 'node:fs'; import path from 'node:path'; import { runReplayForTest } from '../replay-runtime/replay-command-fixture.ts'; -import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; import { captureSnapshotWithInteractor } from '../../../core/snapshot-interactor-capture.ts'; diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts index 88e1ea6c2e..30795c302c 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts @@ -7,7 +7,7 @@ import { makeRepairCompleteSession, repairPublication, } from '../../../__tests__/support/session-factories.ts'; -import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts index 1c7604a3a0..b39b9f0c65 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts @@ -51,10 +51,8 @@ import { } from '@agent-device/daemon-contracts/device-claims'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; import { SessionStore } from '../../../core/session-store.ts'; -import { - handleSessionCloseCommands as handleProductionCloseCommand, - handleSessionOpenCommands as handleProductionOpenCommand, -} from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; +import { handleSessionOpenCommands as handleProductionOpenCommand } from '../../session-lifecycle/internal/session-open.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeAuthoringSession } from '../../../__tests__/support/session-factories.ts'; import { AppError } from '@agent-device/kernel/errors'; diff --git a/packages/daemon/src/sessions/handlers/session-replay-command.ts b/packages/daemon/src/sessions/handlers/session-replay-command.ts index 12833f230a..ee22ac21bf 100644 --- a/packages/daemon/src/sessions/handlers/session-replay-command.ts +++ b/packages/daemon/src/sessions/handlers/session-replay-command.ts @@ -26,7 +26,7 @@ import { assertSessionSelectorMatches } from '../../core/session-selector.ts'; import { resolveSessionScope } from '../session-routing.ts'; import { resolveEffectiveOpenRuntimeHints } from '../session-runtime.ts'; import { captureSnapshot } from '../../core/snapshot-capture.ts'; -import { handleSessionCloseCommands } from '../session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../session-lifecycle/internal/session-close.ts'; import { onDelegatingLedger } from '../../core/request-ended-leases.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; import type { SessionCommandHandler } from './session-command-input.ts'; diff --git a/packages/daemon/src/sessions/handlers/session.ts b/packages/daemon/src/sessions/handlers/session.ts index 94a8461f2d..176e226d55 100644 --- a/packages/daemon/src/sessions/handlers/session.ts +++ b/packages/daemon/src/sessions/handlers/session.ts @@ -7,15 +7,13 @@ import { } from '../../execution/handlers/session-selector-dispatch.ts'; import { handleSessionAppDeploymentCommand } from './session-app-deployment-route.ts'; import { runBatchCommands } from './session-batch.ts'; -import { - handleSessionCloseCommands, - handleSessionInventoryCommands, - handleSessionOpenCommands, -} from '../session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../session-lifecycle/internal/session-close.ts'; +import { handleSessionInventoryCommands } from '../session-lifecycle/internal/inventory.ts'; +import { handleSessionOpenCommands } from '../session-lifecycle/internal/session-open.ts'; import { handleBootCommand } from './session-boot.ts'; import { handleShutdownCommand } from './session-shutdown.ts'; import { handleAppStateCommand } from './session-appstate.ts'; -import { handleSessionObservabilityCommands } from '../session-observability/index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability/internal/session-observability.ts'; import { handleReplayCommand, handleReplayTestCommand } from './session-replay-command.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; import { handleSessionClipboardCommand } from '../../execution/handlers/session-clipboard.ts'; @@ -26,12 +24,10 @@ import type { SessionCommandInput, SessionCommandParams, } from './session-command-input.ts'; -import type { - SessionCloseCommandInput, - SessionInventoryCommandInput, - SessionOpenCommandInput, -} from '../session-lifecycle/index.ts'; -import type { SessionObservabilityCommandInput } from '../session-observability/index.ts'; +import type { SessionCloseCommandInput } from '../session-lifecycle/internal/session-close.ts'; +import type { SessionInventoryCommandInput } from '../session-lifecycle/internal/inventory.ts'; +import type { SessionOpenCommandInput } from '../session-lifecycle/internal/session-open.ts'; +import type { SessionObservabilityCommandInput } from '../session-observability/internal/session-observability.ts'; import type { DescriptorSessionRouteCommandName } from '@agent-device/command-registry/registry'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; diff --git a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts index 98377819ed..b977f6a7c8 100644 --- a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts @@ -14,22 +14,28 @@ vi.mock('@agent-device/device-selection/device-inventory-context', async (import return { ...actual, listDeviceInventory: vi.fn(async () => []) }; }); -vi.mock('../index.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../internal/session-close.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleSessionCloseCommands: vi.fn(actual.handleSessionCloseCommands) }; +}); + +vi.mock('../internal/inventory.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - handleSessionCloseCommands: vi.fn(actual.handleSessionCloseCommands), handleSessionInventoryCommands: vi.fn(actual.handleSessionInventoryCommands), - handleSessionOpenCommands: vi.fn(actual.handleSessionOpenCommands), }; }); +vi.mock('../internal/session-open.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleSessionOpenCommands: vi.fn(actual.handleSessionOpenCommands) }; +}); + import { handleSessionCommands } from '../../handlers/session.ts'; -import { - handleSessionCloseCommands, - handleSessionInventoryCommands, - handleSessionOpenCommands, -} from '../index.ts'; +import { handleSessionCloseCommands } from '../internal/session-close.ts'; +import { handleSessionInventoryCommands } from '../internal/inventory.ts'; +import { handleSessionOpenCommands } from '../internal/session-open.ts'; const mockHandleSessionCloseCommands = vi.mocked(handleSessionCloseCommands); const mockHandleSessionInventoryCommands = vi.mocked(handleSessionInventoryCommands); @@ -102,7 +108,7 @@ test('inventory commands retain their route responses and typed failures', async } }); -test('open routes only its lifecycle input through the public facade', async () => { +test('open routes only its lifecycle input through its owner', async () => { const expected: DaemonResponse = { ok: true, data: { session: 'default' } }; mockHandleSessionOpenCommands.mockImplementationOnce(async () => expected); @@ -129,7 +135,7 @@ test('open routes only its lifecycle input through the public facade', async () }); }); -test('close routes only its lifecycle input through the public facade', async () => { +test('close routes only its lifecycle input through its owner', async () => { const expected: DaemonResponse = { ok: true, data: { session: 'default' } }; mockHandleSessionCloseCommands.mockImplementationOnce(async () => expected); diff --git a/packages/daemon/src/sessions/session-lifecycle/index.ts b/packages/daemon/src/sessions/session-lifecycle/index.ts deleted file mode 100644 index 51914a9cfb..0000000000 --- a/packages/daemon/src/sessions/session-lifecycle/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { handleSessionCloseCommands } from './internal/session-close.ts'; -export type { SessionCloseCommandInput } from './internal/session-close.ts'; -export { handleSessionInventoryCommands } from './internal/inventory.ts'; -export type { SessionInventoryCommandInput } from './internal/inventory.ts'; -export { handleSessionOpenCommands } from './internal/session-open.ts'; -export type { SessionOpenCommandInput } from './internal/session-open.ts'; diff --git a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts index acbd73af4d..858dca2149 100644 --- a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts @@ -5,8 +5,8 @@ import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; -vi.mock('../index.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../internal/session-observability.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, handleSessionObservabilityCommands: vi.fn(actual.handleSessionObservabilityCommands), @@ -14,7 +14,7 @@ vi.mock('../index.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../../handlers/session.ts'; -import { handleSessionObservabilityCommands } from '../index.ts'; +import { handleSessionObservabilityCommands } from '../internal/session-observability.ts'; const mockHandleSessionObservabilityCommands = vi.mocked(handleSessionObservabilityCommands); diff --git a/packages/daemon/src/sessions/session-observability/index.ts b/packages/daemon/src/sessions/session-observability/index.ts deleted file mode 100644 index a584f4b975..0000000000 --- a/packages/daemon/src/sessions/session-observability/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { handleSessionObservabilityCommands } from './internal/session-observability.ts'; -export type { SessionObservabilityCommandInput } from './internal/session-observability.ts'; diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts index c1f58a43bf..f4b69b5078 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts @@ -26,7 +26,7 @@ import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { audioProbeDurableResource } from '@agent-device/capture-kit/audio-probe-session-resource'; import type { SessionStore } from '../../../../core/session-store.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; import { encodeHostAudioProbeDescriptor } from '@agent-device/capture-kit/audio-probe-descriptor-fixtures'; async function runAudio( diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts index 02b6af5e6c..20852df66a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { makeAndroidSession } from '../../../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; import { createNetworkRuntime, emptyAppLogResult, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts index 9002f14e07..372f12de2a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -25,7 +25,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../../../core/request-runtime-binding.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; const available = Object.freeze({ available: true as const }); const unavailable = Object.freeze({ diff --git a/packages/replay-port/src/daemon-port/repair-hint.ts b/packages/replay-port/src/daemon-port/repair-hint.ts index bfddf9bc21..e428edf62c 100644 --- a/packages/replay-port/src/daemon-port/repair-hint.ts +++ b/packages/replay-port/src/daemon-port/repair-hint.ts @@ -15,7 +15,7 @@ * * The container-presence test below is a genuine structural containment check over `parentIndex` — the * same tree-walking machinery decision 3's own identity-set filter uses - * (`buildAncestryChain`/`computeScrollRegionKey`, `@agent-device/selectors/target-evidence`) + * (`buildAncestryChain`/`isInScrollRegion`, `@agent-device/selectors/target-evidence`) * — not a flat identity-string search. */ @@ -23,10 +23,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; import { matchesAncestryPrefix, buildAncestryChain, buildIndexMap } from '@agent-device/ad-script'; import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/contracts/replay'; -import { - computeScrollRegionKey, - scrollRegionKeysEqual, -} from '@agent-device/selectors/target-evidence'; +import { isInScrollRegion } from '@agent-device/selectors/target-evidence'; export type ReplayRepairHintCapture = | { state: 'available'; nodes: SnapshotNode[] } @@ -107,7 +104,7 @@ function isScrollRegionPresent( // Some node's OWN nearest scrollable ancestor (walked via parentIndex) // resolves to the recorded region: the identified region still contains a // descendant. - return nodes.some((node) => scrollRegionKeysEqual(computeScrollRegionKey(node, byIndex), region)); + return nodes.some((node) => isInScrollRegion(node, byIndex, region)); } /** diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index 7878e59232..ef1f317e33 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -11,7 +11,12 @@ import { collectReplaySelectorCandidates } from '@agent-device/replay-port/sessi import { buildSelectorCandidates, resolveReplaySuggestionCandidate } from '@agent-device/selectors'; import { collectSettleChromeRefs } from '@agent-device/capture-kit/snapshot-chrome'; import { buildAndPersistReplayDivergenceResume } from '@agent-device/replay-port/session-replay-resume'; -import { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; +import { + buildIndexMap, + formatDivergenceActionLabel, + isTouchTargetCommand, +} from '@agent-device/ad-script'; +import { isInScrollRegion } from '@agent-device/selectors/target-evidence'; import { computeReplayRepairHint, type ReplayRepairHintCapture, @@ -508,8 +513,7 @@ function buildReplayDivergenceScreenRefs( /** * Decision 1's candidate machinery reused READ-ONLY over the shared capture. * Ranking: identity-component strength (id > role+label > label > other), - * then document order; the same-scrollRegion tier awaits decision 3's - * recorded evidence (migration step 4). + * then recorded scroll region for a verified `target-v1` annotation, then document order. */ function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction; @@ -576,7 +580,20 @@ function rankSuggestionCandidates(params: { if (!entry) continue; entries.push(entry); } - return rankAndDedupeReplaySuggestions(entries).map((entry) => entry.suggestion); + let recordedRegionNodeIndices: Set | undefined; + const targetEvidence = action.targetEvidence; + if (targetEvidence?.verification === 'verified') { + const byIndex = buildIndexMap(nodes); + recordedRegionNodeIndices = new Set(); + for (const { nodeIndex } of entries) { + const node = byIndex.get(nodeIndex); + if (node && isInScrollRegion(node, byIndex, targetEvidence.scrollRegion)) { + recordedRegionNodeIndices.add(nodeIndex); + } + } + } + const ranked = rankAndDedupeReplaySuggestions(entries, recordedRegionNodeIndices); + return ranked.map((entry) => entry.suggestion); } function resolveSuggestionCandidate(params: { diff --git a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts index 3a20d53ebb..4f388cd231 100644 --- a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts +++ b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts @@ -12,7 +12,7 @@ export function rankAndDedupeReplaySuggestions< readonly nodeIndex: number; readonly basis: ReplayDivergenceSuggestionBasis; }, ->(entries: Iterable): T[] { +>(entries: Iterable, recordedRegionNodeIndices: ReadonlySet = new Set()): T[] { const byNode = new Map(); for (const entry of entries) { const existing = byNode.get(entry.nodeIndex); @@ -22,7 +22,10 @@ export function rankAndDedupeReplaySuggestions< } return [...byNode.values()].sort( (left, right) => - basisRank(left.basis) - basisRank(right.basis) || left.nodeIndex - right.nodeIndex, + basisRank(left.basis) - basisRank(right.basis) || + Number(recordedRegionNodeIndices.has(right.nodeIndex)) - + Number(recordedRegionNodeIndices.has(left.nodeIndex)) || + left.nodeIndex - right.nodeIndex, ); } diff --git a/packages/replay-port/src/daemon-port/target-classification.ts b/packages/replay-port/src/daemon-port/target-classification.ts index 641afd0a7e..8073a916d7 100644 --- a/packages/replay-port/src/daemon-port/target-classification.ts +++ b/packages/replay-port/src/daemon-port/target-classification.ts @@ -51,8 +51,7 @@ import { import { boundedLocalIdentity, computeSiblingOrdinal, - computeScrollRegionKey, - scrollRegionKeysEqual, + isInScrollRegion, orderByViewportPosition, } from '@agent-device/selectors/target-evidence'; import { resolveRecordedTarget } from '@agent-device/selectors'; @@ -122,7 +121,7 @@ export function classifyReplayTarget(params: { (candidate) => computeSiblingOrdinal(nodes, candidate) === recorded.sibling, ); const regionMembers = identitySet.filter((candidate) => - scrollRegionKeysEqual(computeScrollRegionKey(candidate, byIndex), recorded.scrollRegion), + isInScrollRegion(candidate, byIndex, recorded.scrollRegion), ); const orderedRegion = orderByViewportPosition(regionMembers); const viewportCandidateRef = orderedRegion[recorded.viewportOrder]?.ref; diff --git a/packages/selectors/src/target-evidence.ts b/packages/selectors/src/target-evidence.ts index a1a35810b8..f9072ac64b 100644 --- a/packages/selectors/src/target-evidence.ts +++ b/packages/selectors/src/target-evidence.ts @@ -182,7 +182,7 @@ export function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: Snap } /** Decision 3 record-time write step 4: nearest scrollable ancestor's local identity, or `undefined` for *none*. */ -export function computeScrollRegionKey( +function computeScrollRegionKey( node: SnapshotNode, byIndex: Map, ): TargetScrollRegion | undefined { @@ -199,7 +199,7 @@ export function computeScrollRegionKey( }; } -export function scrollRegionKeysEqual( +function scrollRegionKeysEqual( a: TargetScrollRegion | undefined, b: TargetScrollRegion | undefined, ): boolean { @@ -208,6 +208,14 @@ export function scrollRegionKeysEqual( return matchesLocalIdentity(a, b); } +export function isInScrollRegion( + node: SnapshotNode, + byIndex: Map, + region: TargetScrollRegion | undefined, +): boolean { + return scrollRegionKeysEqual(computeScrollRegionKey(node, byIndex), region); +} + function boundedRect(node: SnapshotNode): TargetAnnotationV1['rect'] { const rect = node.rect; if (!rect) return undefined; @@ -247,9 +255,7 @@ function computeDisambiguationDomain(params: { const regionMembers = identitySet.length > 0 - ? identitySet.filter((candidate) => - scrollRegionKeysEqual(computeScrollRegionKey(candidate, byIndex), scrollRegion), - ) + ? identitySet.filter((candidate) => isInScrollRegion(candidate, byIndex, scrollRegion)) : undefined; const orderedRegion = regionMembers ? orderByViewportPosition(regionMembers) : []; diff --git a/scripts/layering/architecture-ownership.test.ts b/scripts/layering/architecture-ownership.test.ts index 0effdc9656..086631136e 100644 --- a/scripts/layering/architecture-ownership.test.ts +++ b/scripts/layering/architecture-ownership.test.ts @@ -6,8 +6,8 @@ import { ARCHITECTURE_OWNERSHIP, INTERACTION_RETIRED_HANDLER_PATHS, matchesDeclaredRoot, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, } from './architecture-ownership.ts'; import { readNamedExports } from './facade-exports.ts'; import { resolveImportEdges } from './model.ts'; @@ -61,16 +61,16 @@ test('logical module façades pin their named command surfaces', () => { } }); -test('session lifecycle retires its handler-owned helper paths', () => { +test('session lifecycle retires its handler helpers and barrel paths', () => { const tracked = new Set(listTrackedTypeScriptFiles(repoRoot)); - for (const retiredPath of SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS) { + for (const retiredPath of SESSION_LIFECYCLE_RETIRED_PATHS) { assert.equal(tracked.has(retiredPath), false, `retired path was restored: ${retiredPath}`); } }); -test('session observability retires its handler-owned paths', () => { +test('session observability retires its handler and barrel paths', () => { const tracked = new Set(listTrackedTypeScriptFiles(repoRoot)); - for (const retiredPath of SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS) { + for (const retiredPath of SESSION_OBSERVABILITY_RETIRED_PATHS) { assert.equal(tracked.has(retiredPath), false, `retired path was restored: ${retiredPath}`); } }); diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 93338ce70f..cd368e4687 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -21,27 +21,33 @@ export type LogicalModulePolicy = Readonly<{ */ forbiddenTargetShapes?: readonly ((file: string) => boolean)[]; internalForbiddenTargetShapes?: readonly ((file: string) => boolean)[]; - facade?: FacadeDeclaration; + facades?: readonly FacadeDeclaration[]; }>; -const DAEMON_SESSION_LIFECYCLE_FACADE = { - root: 'packages/daemon/src/sessions/session-lifecycle/index.ts', - exports: [ - 'SessionCloseCommandInput', - 'SessionInventoryCommandInput', - 'SessionOpenCommandInput', - 'handleSessionCloseCommands', - 'handleSessionInventoryCommands', - 'handleSessionOpenCommands', - ], -} as const; +const DAEMON_SESSION_LIFECYCLE_FACADES = [ + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', + exports: ['SessionCloseCommandInput', 'handleSessionCloseCommands'], + }, + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', + exports: ['SessionInventoryCommandInput', 'handleSessionInventoryCommands'], + }, + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts', + exports: ['SessionOpenCommandInput', 'handleSessionOpenCommands'], + }, +] as const; -const DAEMON_SESSION_OBSERVABILITY_FACADE = { - root: 'packages/daemon/src/sessions/session-observability/index.ts', - exports: ['SessionObservabilityCommandInput', 'handleSessionObservabilityCommands'], -} as const; +const DAEMON_SESSION_OBSERVABILITY_FACADES = [ + { + root: 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', + exports: ['SessionObservabilityCommandInput', 'handleSessionObservabilityCommands'], + }, +] as const; -export const SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS = [ +export const SESSION_LIFECYCLE_RETIRED_PATHS = [ + 'packages/daemon/src/sessions/session-lifecycle/index.ts', 'packages/daemon/src/handlers/session-device-utils.ts', 'packages/daemon/src/handlers/session-runtime-admission.ts', 'packages/daemon/src/handlers/session-open.ts', @@ -98,7 +104,8 @@ const DAEMON_INTERACTION_FACADE = { ], } as const; -export const SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS = [ +export const SESSION_OBSERVABILITY_RETIRED_PATHS = [ + 'packages/daemon/src/sessions/session-observability/index.ts', 'packages/daemon/src/handlers/session-observability.ts', 'packages/daemon/src/handlers/session-perf-runtime.ts', 'packages/daemon/src/handlers/session-network.ts', @@ -164,27 +171,27 @@ export const LOGICAL_MODULE_POLICIES = [ roots: ['packages/daemon/src/sessions/session-lifecycle/'], forbiddenTargetRoots: [], forbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_SESSION_LIFECYCLE_FACADE, + facades: DAEMON_SESSION_LIFECYCLE_FACADES, }, { name: 'daemon-interaction', roots: ['packages/daemon/src/execution/interaction/'], forbiddenTargetRoots: [], internalForbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_INTERACTION_FACADE, + facades: [DAEMON_INTERACTION_FACADE], }, { name: 'daemon-session-observability', roots: ['packages/daemon/src/sessions/session-observability/'], forbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_SESSION_OBSERVABILITY_FACADE, + facades: DAEMON_SESSION_OBSERVABILITY_FACADES, }, ] as const satisfies readonly LogicalModulePolicy[]; export const ARCHITECTURE_OWNERSHIP = { logicalModules: LOGICAL_MODULE_POLICIES, facades: LOGICAL_MODULE_POLICIES.flatMap((module) => - module.facade ? [{ name: module.name, ...module.facade }] : [], + (module.facades ?? []).map((facade) => ({ name: module.name, ...facade })), ), vocabulary: [ { diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 411b279be2..84a1024f75 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -197,7 +197,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { new Map([ [ 'packages/replay-port/src/daemon-port/test-command.ts', - "import { handleSessionCloseCommands } from '../../../../packages/daemon/src/sessions/session-lifecycle/index.ts';", + "import { handleSessionCloseCommands } from '../../../../packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts';", ], [ 'packages/replay-port/src/daemon-port/native-command.ts', @@ -208,7 +208,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { "import { runReplayCommand } from '../../../replay-port/src/daemon-port/native-command.ts';", ], [ - 'packages/daemon/src/sessions/session-lifecycle/index.ts', + 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', 'export function handleSessionCloseCommands() {}', ], ['packages/daemon/src/core/session-store.ts', 'export class SessionStore {}'], @@ -226,7 +226,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { { file: 'packages/replay-port/src/daemon-port/test-command.ts', message: - 'replay-port must not import packages/daemon/src/sessions/session-lifecycle/index.ts', + 'replay-port must not import packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', }, { file: 'packages/replay-port/src/daemon-port/native-command.ts', @@ -240,25 +240,21 @@ test('replay-port rejects daemon source imports and engine back edges', () => { ); }); -test('session lifecycle rejects handler deep imports in both directions', () => { +test('session lifecycle rejects handler crossings and private helper imports', () => { const edges = resolveImportEdges( new Map([ [ 'packages/daemon/src/sessions/handlers/session.ts', - "import { handleSessionInventoryCommands } from '../session-lifecycle/internal/inventory.ts';\nexport function handleSessionCommands() {}", + "import { prepareOpenSession } from '../session-lifecycle/internal/session-open-prepare.ts';\nexport function handleSessionCommands() {}", ], [ - 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', - "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionInventoryCommands() {}", + 'packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts', + "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function prepareOpenSession() {}", ], [ 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', "import { handleSessionCommands } from '../../handlers/session.ts';", ], - [ - 'packages/daemon/src/sessions/session-lifecycle/index.ts', - 'export function handleSessionInventoryCommands() {}', - ], ]), ); @@ -278,10 +274,10 @@ test('session lifecycle rejects handler deep imports in both directions', () => file: 'packages/daemon/src/sessions/handlers/session.ts', line: 1, message: - "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-lifecycle's internal tree (packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts)", + "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-lifecycle's internal tree (packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts)", }, { - file: 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', + file: 'packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts', line: 1, message: 'daemon-session-lifecycle must not import packages/daemon/src/sessions/handlers/session.ts', @@ -394,19 +390,19 @@ test('interaction rejects handler crossings and deep imports around its facade', ); }); -test('session observability rejects handler deep imports in both directions', () => { +test('session observability rejects handler crossings and private helper imports', () => { const edges = resolveImportEdges( new Map([ [ 'packages/daemon/src/sessions/handlers/session.ts', - "import { handleSessionObservabilityCommands } from '../session-observability/internal/session-observability.ts';\nexport function handleSessionCommands() {}", + "import { handleAudioCommand } from '../session-observability/internal/session-audio.ts';\nexport function handleSessionCommands() {}", ], [ - 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', - "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionObservabilityCommands() {}", + 'packages/daemon/src/sessions/session-observability/internal/session-audio.ts', + "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleAudioCommand() {}", ], [ - 'packages/daemon/src/sessions/session-observability/index.ts', + 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', 'export function handleSessionObservabilityCommands() {}', ], ]), @@ -428,10 +424,10 @@ test('session observability rejects handler deep imports in both directions', () file: 'packages/daemon/src/sessions/handlers/session.ts', line: 1, message: - "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-observability's internal tree (packages/daemon/src/sessions/session-observability/internal/session-observability.ts)", + "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-observability's internal tree (packages/daemon/src/sessions/session-observability/internal/session-audio.ts)", }, { - file: 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', + file: 'packages/daemon/src/sessions/session-observability/internal/session-audio.ts', line: 1, message: 'daemon-session-observability must not import packages/daemon/src/sessions/handlers/session.ts', @@ -686,3 +682,39 @@ for (const file of [ }); } } + +test('session command owners accept direct imports without an internal barrel', () => { + const sources = new Map(); + for (const [family, owner, symbol] of [ + ['session-lifecycle', 'session-close', 'handleSessionCloseCommands'], + ['session-lifecycle', 'session-open', 'handleSessionOpenCommands'], + ['session-lifecycle', 'inventory', 'handleSessionInventoryCommands'], + ['session-observability', 'session-observability', 'handleSessionObservabilityCommands'], + ]) { + sources.set( + `packages/daemon/src/sessions/${family}/internal/${owner}.ts`, + `export function ${symbol}() {}`, + ); + sources.set( + `packages/daemon/src/sessions/handlers/${owner}.ts`, + `import { ${symbol} } from '../${family}/internal/${owner}.ts';`, + ); + } + assert.deepEqual( + checkDaemonModularityRatchets(resolveImportEdges(sources), REFERENCE, REFERENCE), + [], + ); +}); + +test('session command families reject restored internal barrels', () => { + const lifecycle = 'packages/daemon/src/sessions/session-lifecycle/index.ts'; + const observability = 'packages/daemon/src/sessions/session-observability/index.ts'; + assert.deepEqual( + checkRetiredSessionLifecyclePaths([lifecycle]).map(({ file }) => file), + [lifecycle], + ); + assert.deepEqual( + checkRetiredSessionObservabilityPaths([observability]).map(({ file }) => file), + [observability], + ); +}); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 204ac41d0c..1dee838d6b 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -4,8 +4,8 @@ import { INTERACTION_RETIRED_HANDLER_PATHS, LOGICAL_MODULE_POLICIES, matchesDeclaredRoot, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, SNAPSHOT_EXECUTION_RETIRED_HANDLER_PATHS, type LogicalModulePolicy, } from './architecture-ownership.ts'; @@ -63,7 +63,7 @@ export function checkRetiredSessionLifecyclePaths( ): LayeringViolation[] { return checkRetiredHandlerPaths( sourceFiles, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, /^packages\/daemon\/src\/handlers\/session-(?:open|close)(?:-[^/]+)?\.ts$/, 'session lifecycle', ); @@ -74,7 +74,7 @@ export function checkRetiredSessionObservabilityPaths( ): LayeringViolation[] { return checkRetiredHandlerPaths( sourceFiles, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, /^packages\/daemon\/src\/handlers\/session-(?:observability|perf|logs|events|network|audio)(?:-[^/]+)?\.ts$/, 'session observability', ); @@ -252,7 +252,8 @@ function checkLogicalModuleImports(edges: readonly ResolvedImportEdge[]): Layeri if ( targetModule && sourceModule !== targetModule && - isInsideInternalTree(edge.target, targetModule.roots) + isInsideInternalTree(edge.target, targetModule.roots) && + !targetModule.facades?.some(({ root }) => edge.target === root) ) { violations.push({ rule: 'R10 daemon-modularity', diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts index 2af4e0a8dc..6607d9ee7d 100644 --- a/scripts/layering/session-authority-overlay.test.ts +++ b/scripts/layering/session-authority-overlay.test.ts @@ -129,6 +129,13 @@ test('R75 fails a handler file that gains a shape or authority edge beyond the m assert.equal(authority.rule, SESSION_AUTHORITY_OVERLAY_RULE); assert.match(shape.message, /new handler-owned SessionState shape edge/); assert.match(authority.message, /new handler-owned SessionStore authority edge/); + assert.match(shape.message, /owning module's facade or a named semantic query/); + assert.match(authority.message, /owning module's facade or a named store operation/); + for (const { message } of [shape, authority]) { + assert.match(message, /ADR 0022's authority invariant/); + assert.match(message, /R75 in scripts\/layering\/check\.ts/); + assert.doesNotMatch(message, /accept the growth/); + } }); test('R75 passes when the handler-owned sets hold or shrink', () => { diff --git a/scripts/layering/session-authority-overlay.ts b/scripts/layering/session-authority-overlay.ts index 2225edab10..8d2724553e 100644 --- a/scripts/layering/session-authority-overlay.ts +++ b/scripts/layering/session-authority-overlay.ts @@ -94,8 +94,8 @@ export function checkSessionAuthorityOverlay( line: 1, message: 'new handler-owned SessionState shape edge: the merge-base holds no such import. ' + - "Route the read through the owning module's facade or a named semantic query, or " + - "accept the growth at ADR 0022's authority overlay explicitly.", + "Route the read through the owning module's facade or a named semantic query. " + + "If the boundary change is intended, change ADR 0022's authority invariant and R75 in scripts/layering/check.ts together.", }); } for (const file of scopedMeasured.authorityFiles) { @@ -106,8 +106,8 @@ export function checkSessionAuthorityOverlay( line: 1, message: 'new handler-owned SessionStore authority edge: the merge-base holds no such import. ' + - "Route the operation through the owning module's facade or a named store operation, " + - "or accept the growth at ADR 0022's authority overlay explicitly.", + "Route the operation through the owning module's facade or a named store operation. " + + "If the boundary change is intended, change ADR 0022's authority invariant and R75 in scripts/layering/check.ts together.", }); } return violations; diff --git a/src/client/lease-client.test.ts b/src/client/lease-client.test.ts index 5156365970..7907addb4a 100644 --- a/src/client/lease-client.test.ts +++ b/src/client/lease-client.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { createAgentDeviceClient } from '../agent-device-client.ts'; import { HUMAN_CONTROL_HOLD, HUMAN_CONTROL_SCOPE } from './human-control.fixtures.ts'; import type { DaemonRequest } from '@agent-device/kernel/contracts'; @@ -128,3 +128,42 @@ test('allocate sends the lease id named on the call, never the configured one', assert.equal(requests[1]?.meta?.leaseId, '0123456789abcdef'); assert.equal(lease.leaseId, 'abcdef0123456789'); }); + +for (const operation of ['allocate', 'heartbeat'] as const) { + const validLease = { + leaseId: 'lease-a', + tenantId: 'tenant-a', + runId: 'run-a', + backend: 'ios-instance', + }; + const invalidLeases: unknown[] = [undefined, null, [], 'invalid']; + for (const field of ['leaseId', 'tenantId', 'runId', 'backend'] as const) { + for (const value of [undefined, null, '', 42]) { + invalidLeases.push({ ...validLease, [field]: value }); + } + } + for (const lease of invalidLeases) { + test(`${operation} rejects an invalid lease (${JSON.stringify(lease)}) with structured diagnostics`, async () => { + const data = { lease }; + const client = createAgentDeviceClient( + {}, + { + transport: async () => ({ ok: true, data }), + }, + ); + await assert.rejects( + client.leases[operation]({ tenant: 'tenant-a', runId: 'run-a', leaseId: 'lease-a' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { reason: 'INVALID_LEASE_RESPONSE', response: data }); + const normalized = normalizeError(error); + assert.equal(normalized.code, 'COMMAND_FAILED'); + assert.equal(normalized.details?.reason, 'INVALID_LEASE_RESPONSE'); + assert.deepEqual(normalized.details?.response, data); + return true; + }, + ); + }); + } +} diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts index bdb5ad0d87..2603546685 100644 --- a/src/client/lease-client.ts +++ b/src/client/lease-client.ts @@ -75,14 +75,21 @@ function normalizeHumanControlHold(value: unknown): HumanControlHold { function normalizeLease(data: Record): Lease { const rawLease = data.lease; - if (!isRecord(rawLease)) { - throw new Error('Invalid lease response from daemon'); + const leaseId = isRecord(rawLease) ? readOptionalString(rawLease, 'leaseId') : undefined; + const tenantId = isRecord(rawLease) ? readOptionalString(rawLease, 'tenantId') : undefined; + const runId = isRecord(rawLease) ? readOptionalString(rawLease, 'runId') : undefined; + const backend = isRecord(rawLease) ? readOptionalString(rawLease, 'backend') : undefined; + if (!isRecord(rawLease) || !leaseId || !tenantId || !runId || !backend) { + throw new AppError('COMMAND_FAILED', 'Invalid lease response from daemon', { + reason: 'INVALID_LEASE_RESPONSE', + response: data, + }); } return { - leaseId: readRequiredString(rawLease, 'leaseId'), - tenantId: readRequiredString(rawLease, 'tenantId'), - runId: readRequiredString(rawLease, 'runId'), - backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + leaseId, + tenantId, + runId, + backend: backend as Lease['backend'], leaseProvider: readOptionalString(rawLease, 'leaseProvider'), clientId: readOptionalString(rawLease, 'clientId'), deviceKey: readOptionalString(rawLease, 'deviceKey'), diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 7400b7a5e3..c5fa91262d 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -300,6 +300,9 @@ Domain client methods: - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +If the daemon returns a missing or non-object lease, or a lease without a non-empty string for `leaseId`, `tenantId`, `runId`, or `backend`, `client.leases.allocate()` and `client.leases.heartbeat()` reject with `COMMAND_FAILED` and `details.reason: "INVALID_LEASE_RESPONSE"`. +The original daemon response is available in `error.details.response`. + `client.devices.list()` returns `AgentDeviceDevice` entries. Their optional `model` and `osVersion` fields describe the hardware and OS when discovery reports them; see [Device discovery](/docs/commands#device-discovery) for the sources. `client.capture.snapshot()` carries an optional `viewport: { width, height }` beside `nodes`: the box those rects are measured in, in the same coordinate space and orientation. Scale and clip against it instead of inferring the screen from the largest rect. When no box was measured, the field is absent rather than zero; see [Coordinates and viewport](/docs/snapshots#coordinates-and-viewport) for what each producer answers with. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 497177f3dd..36557aed6d 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -324,7 +324,7 @@ A failing `replay`/`test` step returns a structured `REPLAY_DIVERGENCE` error. T - **`step`** — the 1-based plan index and its source file/line (through Maestro `runFlow` includes). - **`screen`** — a fresh post-failure snapshot digest with actionable refs, or `unavailable` with a reason/hint when capture failed or was sparse (never a stale tree). -- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. +- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. For a verified `target-v1` annotation, equally strong candidates in the recorded scroll region rank first; document order breaks remaining ties. - **`resume`** — whether and how to continue without re-running the script from the top. ```jsonc