diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 73404d9b79..4afe421d08 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -185,13 +185,13 @@ test.each([ { outcome: 'unreadable', step: 'url-owner' }, ], [ - 'the accept rejects', + 'the accept rejects and the confirmation remains', port(async () => CONFIRMATION, { acceptAlert: async () => { throw spawnTimeout(); }, }), - { outcome: 'unreadable', step: 'alert-accept' }, + { outcome: 'unanswered', reason: 'alert-still-present' }, ], ])('an answer attempt where %s reports %j', async (_case, { port: device }, expected) => { await expect(answerLaunchConfirmation(device)).resolves.toEqual(expected); @@ -412,3 +412,34 @@ test('a runner that cannot be resolved reports an unreadable attempt', async () ), ).resolves.toEqual({ outcome: 'unreadable', step: 'runner' }); }); + +test('a confirmed persistent prompt reports its observed outcome without an accept-failure diagnostic', async () => { + vi.mocked(emitDiagnostic).mockClear(); + const { port: device } = port(async () => CONFIRMATION, { + acceptAlert: async () => { + throw spawnTimeout(); + }, + }); + await expect(answerLaunchConfirmation(device)).resolves.toEqual({ + outcome: 'unanswered', + reason: 'alert-still-present', + }); + expect(emitDiagnostic).toHaveBeenCalledExactlyOnceWith({ + level: 'debug', + phase: 'ios_launch_confirmation_unanswered', + data: { reason: 'alert-still-present' }, + }); +}); + +test('a matching launch title with other buttons is left unanswered on the first read', async () => { + const { port: device, acceptAlert } = port(async () => ({ + ...CONFIRMATION, + items: ['Cancel', 'Continue'], + })); + await expect(answerLaunchConfirmation(device)).resolves.toMatchObject({ + outcome: 'unanswered', + reason: 'alert-unrecognized', + }); + expect(device.readAlert).toHaveBeenCalledOnce(); + expect(acceptAlert).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/launch-confirmation.ts b/packages/platform-apple/src/launch-confirmation.ts index fe1fbd3c85..f0a6aaf917 100644 --- a/packages/platform-apple/src/launch-confirmation.ts +++ b/packages/platform-apple/src/launch-confirmation.ts @@ -9,17 +9,19 @@ import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts'; export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app'; /** - * How one launch-confirmation answer attempt ended. Two endings mean the launch URL left the - * caller's hands and only that caller knows whether it landed: `accepted`, and the `unreadable` - * whose step is `alert-accept`, where the accept was attempted and its outcome never arrived. Every - * other ending leaves the launch exactly as it was — `absent` found no prompt, `unanswered` found a - * prompt this answer must not accept, and any other `unreadable` step failed before one. A caller - * acts on these outcomes and its own proof about the process, never on text. + * How one launch-confirmation answer attempt ended. `accepted` verifies dismissal; an unreadable + * `alert-accept` has an unknown outcome. `alert-still-present` proves the recognized prompt remains + * after an accept failed. Other endings precede acceptance: no prompt, an unrecognized prompt, an + * unresolved URL owner, or an unreadable prerequisite. A caller acts on these typed outcomes and + * its own proof about the process, never on error text. */ export type LaunchConfirmationAttempt = | Readonly<{ outcome: 'accepted' }> | Readonly<{ outcome: 'absent' }> - | Readonly<{ outcome: 'unanswered'; reason: 'alert-unrecognized' | 'url-owner-unresolved' }> + | Readonly<{ + outcome: 'unanswered'; + reason: 'alert-unrecognized' | 'url-owner-unresolved' | 'alert-still-present'; + }> | Readonly<{ outcome: 'unreadable'; step: LaunchConfirmationStep }>; /** A step of the answer that failed, keyed for diagnostics and tests. */ @@ -55,7 +57,7 @@ export type LaunchConfirmationPort = Readonly<{ }>; /** - * Answers a launch confirmation. The title only recognizes the confirmation; the app it opens is + * Answers a launch confirmation. The title and buttons recognize the confirmation; the app it opens is * the URL scheme's owner. An owner that is the session app is accepted; any other owner is never * accepted and fails the open, because accepting would hand it the launch URL. Every other ending * is reported as its typed attempt so the settle can decide what the launch still needs. Each step @@ -66,15 +68,16 @@ export async function answerLaunchConfirmation( ): Promise { const read = await readAlert(port); if ('outcome' in read) return read; - if (!read.alert) return { outcome: 'absent' }; - if (!isLaunchConfirmation(read.alert)) { + const alert = read.alert; + if (!alert) return { outcome: 'absent' }; + if (!isLaunchConfirmation(alert)) { emitDiagnostic({ level: 'warn', phase: 'ios_launch_confirmation_unanswered', data: { reason: 'alert-unrecognized', - title: read.alert['message'], - buttons: read.alert['items'], + title: alert['message'], + buttons: alert['items'], }, }); return { outcome: 'unanswered', reason: 'alert-unrecognized' }; @@ -93,7 +96,27 @@ export async function answerLaunchConfirmation( hint: `iOS is asking whether to open ${owner.owner}. Answer it with alert accept or alert dismiss, and pass a launch URL whose scheme belongs to the session app.`, }); } - return await port.acceptAlert().then(accepted, unreadable('alert-accept')); + return await port + .acceptAlert() + .then(accepted, async (error: unknown) => await verifyFailedAcceptance(port, alert, error)); +} + +async function verifyFailedAcceptance( + port: LaunchConfirmationPort, + original: Record, + error: unknown, +): Promise { + const read = await readAlert(port); + if ('outcome' in read) return unreadable('alert-accept')(error); + if ( + read.alert && + isLaunchConfirmation(read.alert) && + read.alert['message'] === original['message'] + ) { + reportUnanswered('alert-still-present', {}); + return { outcome: 'unanswered', reason: 'alert-still-present' }; + } + return unreadable('alert-accept')(error); } async function readAlert( @@ -114,7 +137,15 @@ function accepted(): LaunchConfirmationAttempt { function isLaunchConfirmation(alert: Record): boolean { const title = alert['message']; - return typeof title === 'string' && LAUNCH_CONFIRMATION_TITLE.test(title); + const buttons = alert['items']; + return ( + typeof title === 'string' && + LAUNCH_CONFIRMATION_TITLE.test(title) && + Array.isArray(buttons) && + buttons.length === 2 && + buttons.includes('Cancel') && + buttons.includes('Open') + ); } /** A failed step leaves the open unanswered; the failure is reported, not thrown. */ diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 5b97340676..b3bbd612e0 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -781,8 +781,13 @@ test.each([ test('an accept that dies with the runner session hands the URL over again and reads once more', async () => { let accepts = 0; + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT) + .mockResolvedValue(CONFIRMATION); const { lifecycle, events } = launchUrlSimulator( - async () => CONFIRMATION, + readAlert, [UNOBSERVABLE, UNOBSERVABLE, OBSERVABLE], { acceptAlert: async () => { @@ -804,6 +809,7 @@ test('an accept that dies with the runner session hands the URL over again and r 'observe unobservable', 'alert get', 'alert accept', + 'alert get', 'observe unobservable', 'open', 'alert get', @@ -812,6 +818,84 @@ test('an accept that dies with the runner session hands the URL over again and r ]); }); +test.each([ + ['still coming up', COMING_UP], + ['observable', OBSERVABLE], + ['not running', UNOBSERVABLE], +])( + 'a failed accept rejects a persistent launch prompt while the app is %s', + async (_name, afterAnswer) => { + const { lifecycle, interactor, events } = launchUrlSimulator( + async () => CONFIRMATION, + [BRIDGE_CIRCUIT, afterAnswer], + { + acceptAlert: async () => { + throw new AppError('COMMAND_FAILED', 'alert accept exhausted its deadline', { + runnerErrorCode: 'ALERT_DEADLINE_EXCEEDED', + }); + }, + }, + ); + + await expect(lifecycle.openApplication(launchUrlInput())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'launch_confirmation_unanswered', appBundleId: 'com.example.app' }, + }); + expect(interactor.acceptAlert).toHaveBeenCalledOnce(); + expect(interactor.readAlert).toHaveBeenCalledTimes(2); + expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ + `open ${LAUNCH_URL}`, + ]); + }, +); + +test.each([ + { + name: 'absent', + readAfterFailure: async () => { + throw alertNotFound(); + }, + }, + { + name: 'unreadable', + readAfterFailure: async () => { + throw SPAWN_TIMEOUT; + }, + }, + { + name: 'replaced by the same title with unrelated buttons', + readAfterFailure: async () => ({ message: CONFIRMATION.message, items: ['Allow', 'Deny'] }), + }, + { + name: 'replaced by another launch confirmation', + readAfterFailure: async () => ({ ...CONFIRMATION, message: 'Open in “Other App”?' }), + }, +])( + 'a failed accept with its prompt $name preserves launch-transition policy', + async ({ readAfterFailure }) => { + const readAlert = vi.fn(readAfterFailure).mockResolvedValueOnce(CONFIRMATION); + const { lifecycle, interactor, events } = launchUrlSimulator( + readAlert, + [BRIDGE_CIRCUIT, COMING_UP], + { + acceptAlert: async () => { + throw SPAWN_TIMEOUT; + }, + }, + ); + + const outcome = await lifecycle.openApplication(launchUrlInput()); + + expect(outcome.launchConfirmation).toBeUndefined(); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(interactor.acceptAlert).toHaveBeenCalledOnce(); + expect(interactor.readAlert).toHaveBeenCalledTimes(2); + expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([ + `open ${LAUNCH_URL}`, + ]); + }, +); + test('an accept leaving a launch-transition window open stays green and re-hands nothing', async () => { const { lifecycle, events } = launchUrlSimulator( async () => CONFIRMATION, @@ -834,8 +918,14 @@ test('an accept leaving a launch-transition window open stays green and re-hands }); test('a launch URL handed over twice that still leaves no process fails the open', async () => { + const readAlert = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT) + .mockResolvedValueOnce(CONFIRMATION) + .mockRejectedValueOnce(SPAWN_TIMEOUT); const { lifecycle, events } = launchUrlSimulator( - async () => CONFIRMATION, + readAlert, Array.from({ length: 3 }, () => UNOBSERVABLE), { acceptAlert: async () => { diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index da868b02b9..acb3a7ac97 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -16,7 +16,7 @@ import { } from './snapshot-observability.ts'; import type { LaunchConfirmationAttempt } from './launch-confirmation.ts'; -/** Why an open failed because its launch URL never reached the app it names. */ +/** Why an open failed because its launch URL has not completed for the app it names. */ const LAUNCH_CONFIRMATION_UNANSWERED_REASON = 'launch_confirmation_unanswered'; const POST_OPEN_SETTLE_MS = 300; @@ -32,7 +32,7 @@ const LAUNCH_CONFIRMATION_ROUNDS = 2; type HeldLaunchOutcome = Readonly<{ observed: LaunchObservation | undefined; launchConfirmation?: LaunchConfirmation; - /** The launch was proven to have no process after every round this open can spend on it. */ + /** The launch has no process, or its recognized confirmation remains visible. */ unanswered: boolean; }>; @@ -41,6 +41,7 @@ type HeldLaunchRound = Readonly<{ launchConfirmation?: LaunchConfirmation; /** Whether an accept has run or been attempted; only an accept can change what the bridge sees. */ acceptAttempted: boolean; + confirmationStillPresent: boolean; }>; export type MutableOpenTiming = { @@ -117,7 +118,8 @@ export function releaseSpeculativeRunner( * proven not running, the accept died with the runner session that raised it or the device dropped a * held URL, and the URL is handed over again and read once more. A launch still proven not running * afterwards fails the open with `launch_confirmation_unanswered` rather than returning green to a - * session whose every later command then fails `app is not running`. A read that found no prompt, an + * session whose every later command then fails `app is not running`. A recognized prompt still + * visible after a failed accept also fails the open, even if the app is running. A read that found no prompt, an * unrecognized prompt and an unresolved URL owner all leave the open as green as it was, with only * the one extra runner command spent. */ @@ -150,11 +152,11 @@ export async function settleAppleOpen( if (held.unanswered) { throw new AppError( 'COMMAND_FAILED', - `The launch URL was handed to the Simulator but ${input.appBundleId} never came up.`, + `The Simulator has not completed the launch URL for ${input.appBundleId}.`, { reason: LAUNCH_CONFIRMATION_UNANSWERED_REASON, appBundleId: input.appBundleId, - hint: 'iOS may still be holding an "Open in" prompt, or the answer died with the runner session that raised it. Answer it with alert accept, or re-run open with the same --launch-url.', + hint: 'iOS may still be holding an "Open in" prompt, or the answer died with the runner session that raised it. Inspect the current alert before deciding on another action.', }, ); } @@ -191,20 +193,27 @@ async function answerHeldLaunch( observe: () => Promise, observed: LaunchObservation | undefined, ): Promise { - let round: HeldLaunchRound = { observed, acceptAttempted: false }; + let round: HeldLaunchRound = { + observed, + acceptAttempted: false, + confirmationStillPresent: false, + }; for (let roundIndex = 0; roundIndex < LAUNCH_CONFIRMATION_ROUNDS; roundIndex += 1) { round = await answerConfirmationOnce(answer, observe, round); const lastRound = roundIndex === LAUNCH_CONFIRMATION_ROUNDS - 1; - if (lastRound || !acceptLeftTheUrlInFlight(round) || !redispatchLaunchUrl) break; + if ( + round.confirmationStillPresent || + lastRound || + !acceptLeftTheUrlInFlight(round) || + !redispatchLaunchUrl + ) + break; await redispatchLaunchUrl(); } - // A second hand-off and another read later, a launch with no process is one this open cannot - // complete. An expired launch-transition window leaves the open green: it proves only that the - // app was still coming up. return { observed: round.observed, launchConfirmation: round.launchConfirmation, - unanswered: acceptLeftTheUrlInFlight(round), + unanswered: acceptLeftTheUrlInFlight(round) || round.confirmationStillPresent, }; } @@ -214,15 +223,27 @@ async function answerConfirmationOnce( round: HeldLaunchRound, ): Promise { const attempt = await answer(); + const confirmationStillPresent = + attempt.outcome === 'unanswered' && attempt.reason === 'alert-still-present'; const acceptAttempted = round.acceptAttempted || acceptWasAttempted(attempt); const launchConfirmation = attempt.outcome === 'accepted' ? ('accepted' as const) : round.launchConfirmation; if (!acceptAttempted) { // Only an accept changes the device, so a read that found no prompt, an unrecognized prompt or // an unresolved URL owner leaves the launch as the verdict before it already described it. - return { observed: round.observed, launchConfirmation, acceptAttempted }; + return { + observed: round.observed, + launchConfirmation, + acceptAttempted, + confirmationStillPresent, + }; } - return { observed: await observe(), launchConfirmation, acceptAttempted }; + return { + observed: await observe(), + launchConfirmation, + acceptAttempted, + confirmationStillPresent, + }; } /** @@ -236,6 +257,7 @@ function acceptLeftTheUrlInFlight(round: HeldLaunchRound): boolean { function acceptWasAttempted(attempt: LaunchConfirmationAttempt): boolean { return ( attempt.outcome === 'accepted' || + (attempt.outcome === 'unanswered' && attempt.reason === 'alert-still-present') || (attempt.outcome === 'unreadable' && attempt.step === 'alert-accept') ); } diff --git a/packages/replay-port/src/daemon-port/command-types.ts b/packages/replay-port/src/daemon-port/command-types.ts index e34948b0f2..f9214bf21e 100644 --- a/packages/replay-port/src/daemon-port/command-types.ts +++ b/packages/replay-port/src/daemon-port/command-types.ts @@ -54,11 +54,13 @@ export type ReplaySessionStore = Readonly<{ */ export type ReplaySessionObservation = Readonly<{ get: () => ReplaySessionState | undefined; - bindAuthority: ReplayObservationAuthorityBinder; - capture: (params: { - flags: DaemonWireRequest['flags']; - logPath: string; - }) => Promise<{ snapshot: SnapshotState }>; + bindAuthority: (signal?: AbortSignal) => ReturnType & + Readonly<{ + capture: (params: { + flags: DaemonWireRequest['flags']; + logPath: string; + }) => Promise<{ snapshot: SnapshotState }>; + }>; }>; /** Immutable read projection of the repair-transaction fields the coordinator's writers touch. */ diff --git a/packages/replay-port/src/daemon-port/replay-session-binding.ts b/packages/replay-port/src/daemon-port/replay-session-binding.ts index d7f2d66108..51345c93fc 100644 --- a/packages/replay-port/src/daemon-port/replay-session-binding.ts +++ b/packages/replay-port/src/daemon-port/replay-session-binding.ts @@ -1,4 +1,3 @@ -import type { ReplayObservationAuthorityBinder } from '@agent-device/contracts/replay'; import type { ReplayCoordinator, ReplaySession, @@ -26,8 +25,7 @@ export type ReplaySessionPolicy = Readonly<{ createCoordinator: () => ReplayCoordinator; assertSelectorMatches: ReplaySessionStore['assertSelectorMatches']; resolveOpenRuntimeHints: ReplaySessionStore['resolveOpenRuntimeHints']; - bindAuthority: ReplayObservationAuthorityBinder; - capture: ReplaySessionObservation['capture']; + bindAuthority: ReplaySessionObservation['bindAuthority']; }>; /** @@ -53,7 +51,6 @@ export function bindReplaySession( observationStore: { get: container.get, bindAuthority: policy.bindAuthority, - capture: policy.capture, }, coordinator: policy.createCoordinator(), }; 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 f24caa1d61..6551d8fd67 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -256,10 +256,11 @@ export async function captureDivergenceObservation(params: { // shrinks the retry budget rather than getting a free `DEADLINE_MS` on top // of however long it took. const deadline = Date.now() + DIVERGENCE_CAPTURE_RETRY_DEADLINE_MS; + const observation = observationStore.bindAuthority(); let attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -272,7 +273,7 @@ export async function captureDivergenceObservation(params: { await sleep(Math.min(delayMs, remainingMs)); attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -298,13 +299,13 @@ type DivergenceCaptureAttempt = { async function captureDivergenceObservationAttempt(params: { session: ReplaySessionState; - observationStore: ReplaySessionObservation; + observation: ReturnType; logPath: string; flags: CommandFlags; }): Promise { - const { session, observationStore, logPath, flags } = params; + const { session, observation, logPath, flags } = params; try { - const capture = await observationStore.capture({ flags, logPath }); + const capture = await observation.capture({ flags, logPath }); const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { @@ -316,8 +317,7 @@ async function captureDivergenceObservationAttempt(params: { retryable: true, }; } - const observationAuthority = observationStore.bindAuthority(); - const stored = observationAuthority.store(snapshot); + const stored = observation.store(snapshot); return { observation: { state: 'available', diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index b13653694f..fa44cc254a 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -96,8 +96,8 @@ export const SESSION_STATE_FIELD_OWNERS: Readonlyplaceholder registry is populated and consulted only at the diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 77bd427dfb..b18a127b62 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -195,7 +195,7 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url/--launch-url set this session\'s Metro/debug runtime hints as part of open itself (applied to the app\'s dev-server prefs and recorded as the session\'s dev-server binding), so a fresh session has them before its first reload instead of needing a throwaway reload-first call just to seed hints; a later plain metro reload in the same session reuses whichever of these were set. A fresh open without these flags clears any leftover binding from a previous same-name session; close also clears it. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url/--launch-url set this session\'s Metro/debug runtime hints as part of open itself (applied to the app\'s dev-server prefs and recorded as the session\'s dev-server binding), so a fresh session has them before its first reload instead of needing a throwaway reload-first call just to seed hints; a later plain metro reload in the same session reuses whichever of these were set. A fresh open without these flags clears any leftover binding from a previous same-name session; close also clears it. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: "Metro and debug runtime hints given here are recorded as the session's dev-server binding, so a later reload reuses them; a fresh open without them clears any binding left by a previous same-name session.", }, diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 5ae2c10d04..afddd3d5f5 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -13,7 +13,7 @@ import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; // #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, @@ -85,19 +85,19 @@ type SettlePayload = { const captureObservations: Array<{ postGestureStabilizationPending: boolean }> = []; async function emulateCaptureSnapshotForSession( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, options: { interactiveOnly: boolean }, ) { captureObservations.push({ - postGestureStabilizationPending: session.postGestureStabilization !== undefined, + postGestureStabilizationPending: + sessionStore.requireCurrent(ref).postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; const snapshotData = (await mockDispatch('snapshot')) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); return snapshot; } @@ -187,7 +187,7 @@ async function dispatchGeneric(params: { }; return await dispatchGenericCommand({ req, - session: params.session, + ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, logPath: '', sessionStore: params.sessionStore, @@ -210,8 +210,7 @@ beforeEach(() => { mockCaptureSnapshotForSession.mockImplementation( (...args: Parameters) => { const [ref, flags, sessionStore, _contextFromFlags, options] = args; - const session = sessionStore.requireCurrent(ref); - return emulateCaptureSnapshotForSession(session, flags, sessionStore, options); + return emulateCaptureSnapshotForSession(ref, flags, sessionStore, options); }, ); }); @@ -550,32 +549,31 @@ test('scroll without --settle takes no observation captures and issues no refs', expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); }); -test('a settle observation that cannot build a runtime degrades instead of failing the action', async () => { +test('an ended generic lifetime is refused before dispatch or settle construction', async () => { const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-evicted'; - // The session the router handed us is no longer in the store — evicted - // between dispatch and observation. Building the settle runtime throws - // SESSION_NOT_FOUND, and the observation is best-effort: the scroll already - // happened, so the response keeps its result and simply carries no settle. - const session = makeIosSession(sessionName); - setSessionSnapshot( - session, - buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), - ); - activateCompleteRefFrame(session); + seedSession(sessionName, sessionStore); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.retire(ref); mockCommandDispatch([AFTER_NODES]); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - session, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - expect(data.settle).toBeUndefined(); + await expect( + dispatchGenericCommand({ + req: { + token: 't', + session: sessionName, + command: 'scroll', + positionals: ['down'], + flags: SETTLE_FLAGS, + }, + ref, + sessionName, + sessionStore, + logPath: '', + contextFromFlags, + executePlatformCommand: platformExecution, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(mockDispatch).not.toHaveBeenCalled(); expect(captureObservations).toEqual([]); }); @@ -620,3 +618,91 @@ test('an orphaned --settle-quiet is rejected before the command dispatches', asy expect(response.error?.code).toBe('INVALID_ARGS'); expect(response.error?.message).toContain('--settle-quiet'); }); + +test('scroll stays successful when its lifetime retires during optional settle observation', async () => { + const store = makeSessionStore(); + const name = 'generic-settle-retired'; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + mockCommandDispatch([AFTER_NODES]); + mockCaptureSnapshotForSession.mockImplementationOnce( + async (boundRef, flags, sessionStore, _context, options) => { + const observed = await emulateCaptureSnapshotForSession( + boundRef, + flags, + sessionStore, + options, + ); + queueMicrotask(() => { + store.retire(ref); + store.publish(name, makeIosSession(name)); + }); + return observed; + }, + ); + const response = await dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + expect(expectOkData(response).settle).toBeUndefined(); + expect(store.get(name)?.snapshot).toBeUndefined(); + expect(store.get(name)?.actions).toEqual([]); +}); + +for (const change of ['rebuild', 'retire'] as const) { + test(`held generic settle capture respects its lifetime after ${change}`, async () => { + const store = makeSessionStore(); + const name = `generic-held-settle-${change}`; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + mockDispatch.mockImplementation(async (command) => { + if (command !== 'snapshot') return {}; + entered(); + await held; + return snapshotPayload({ nodes: AFTER_NODES }); + }); + const pending = dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + try { + await reached; + const trace = { outPath: 'latest-trace', startedAt: 1 }; + const successor = makeIosSession(name); + if (change === 'rebuild') store.update(ref, { trace }); + else { + store.retire(ref); + store.publish(name, successor); + } + release(); + const data = expectOkData(await pending); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).trace).toBe(trace); + expect(store.requireCurrent(ref).snapshot?.nodes[1]?.label).toBe('Load more'); + } else { + expect(data.settle).toBeUndefined(); + expect(store.get(name)).toBe(successor); + expect(successor.snapshot).toBeUndefined(); + } + } finally { + release(); + await pending; + } + }); +} diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 29167cfafc..d798548777 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -42,11 +42,11 @@ function scenario() { const prior = snapshot('e1', 'Previously published'); setSessionSnapshot(session, prior); markSessionPartialRefsIssued(session, ['e1']); - sessionStore.set(sessionName, session); + const ref = sessionStore.publish(sessionName, session); const captured = snapshot('e2', 'Internal capture'); const authority = bindAuthority(sessionStore, sessionName); const stored = authority.store(captured); - return { sessionStore, sessionName, session, prior, captured, authority, ...stored }; + return { sessionStore, sessionName, ref, session, prior, captured, authority, ...stored }; } function publishCurrent(input: ReturnType, signal?: AbortSignal) { @@ -59,17 +59,8 @@ function publishCurrent(input: ReturnType, signal?: AbortSignal function bindAuthority(sessionStore: SessionStore, sessionName: string, signal?: AbortSignal) { return bindInternalObservationAuthority({ - sessionStore: { - get: () => sessionStore.get(sessionName), - update: (mutate) => { - const session = sessionStore.get(sessionName); - if (!session) return false; - mutate(session); - sessionStore.set(sessionName, session); - return true; - }, - }, - sessionName, + sessionStore, + ref: sessionStore.lookup(sessionName), ...(signal ? { signal } : {}), }); } @@ -173,12 +164,12 @@ test('runtime revision invalidates evidence even when the ref frame was already test('session close invalidates capture evidence', () => { const input = scenario(); - input.sessionStore.delete(input.sessionName); + input.sessionStore.retire(input.ref); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); // Even restoring the exact same session object cannot revive evidence that // a stale finalization attempt already consumed. - input.sessionStore.set(input.sessionName, input.session); + input.sessionStore.publish(input.sessionName, input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); @@ -186,7 +177,8 @@ test('session close invalidates capture evidence', () => { test('same-name session replacement cannot inherit capture evidence', () => { const input = scenario(); const replacement = makeIosSession(input.sessionName, { appBundleId: 'com.example.app' }); - input.sessionStore.set(input.sessionName, replacement); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, replacement); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameTree(replacement)).toBeUndefined(); @@ -214,3 +206,25 @@ test('generation and ref projection must match the exact captured tree', () => { expect(refFrameScope(wrongGeneration.session)).toEqual(new Set(['e1'])); expect(refFrameScope(wrongRef.session)).toEqual(new Set(['e1'])); }); + +test('a same-lifetime rebuild preserves evidence and publishes into the latest record', () => { + const input = scenario(); + const current = input.sessionStore.update(input.ref, { appName: 'Rebuilt app' }); + expect(publishCurrent(input)).toEqual({ + published: true, + refsGeneration: input.refsGeneration, + refCount: 1, + }); + expect(current.appName).toBe('Rebuilt app'); + expect(refFrameState(current)).toBe('active'); + expect(refFrameScope(current)).toEqual(new Set(['e2'])); + expect(refFrameTree(current)).toBe(input.captured); +}); + +test('reusing the same record in a new lifetime cannot adopt unconsumed evidence', () => { + const input = scenario(); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, input.session); + expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); +}); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts index a5a4cfd878..8ace66f2be 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '@agent-device/replay-port/session-replay-divergence'; @@ -29,7 +30,10 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { }); const mockDispatchCommand = legacyDispatchCapture; -beforeEach(() => resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor))); +beforeEach(() => { + resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor)); + vi.mocked(sleep).mockReset().mockResolvedValue(undefined); +}); // #1385 P2: the retry deadline is a DELAY-ONLY budget, not a per-attempt // capture timeout — this loop does not itself bound how long a single @@ -82,3 +86,110 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr vi.useRealTimers(); } }); + +test.each(['rebuild', 'retire'] as const)( + 'divergence capture binds observation authority before awaiting the native capture: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let captured!: (value: Record) => void; + let started!: () => void; + const capturing = new Promise((resolve) => { + started = resolve; + }); + mockDispatchCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + captured = resolve; + started(); + }), + ); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await capturing; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + captured({ nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }] }); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + if (change === 'rebuild') { + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); + +test.each(['rebuild', 'retire'] as const)( + 'capture retries retain their original lifetime through backoff: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-retry-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let sleeping!: () => void; + let resume!: () => void; + const backoff = new Promise((resolve) => { + sleeping = resolve; + }); + vi.mocked(sleep).mockImplementationOnce( + () => + new Promise((resolve) => { + resume = resolve; + sleeping(); + }), + ); + mockDispatchCommand + .mockResolvedValueOnce({ + nodes: [], + quality: { state: 'sparse', backend: 'tree' }, + }) + .mockResolvedValueOnce({ + nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }], + }); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + retryLaunchRace: true, + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await backoff; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + resume(); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + if (change === 'rebuild') { + expect(mockDispatchCommand).toHaveBeenCalledTimes(2); + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(mockDispatchCommand).toHaveBeenCalledTimes(1); + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts index 1f9e7d44f2..617aabcd7e 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts @@ -114,7 +114,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent presses the blessed @ref (record-and-heal): recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts index 04dd93bed5..f6e7b0883d 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts @@ -153,7 +153,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent performs the corrective press (blessed @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -280,7 +280,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // --- Agent performs the step's intent as a recorded action (blessed // @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -408,7 +408,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Agent presses the actual (renamed) control via a blessed @ref, // recorded live — the record-and-heal-shaped repair for path (a) from // #1262 ("selector binds the wrong node on the right screen"). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -659,7 +659,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin expect(divergence.resume.from).toBe(3); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts index d5870193a2..2a00c0e8ac 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; /** * ADR 0012 decision 6: `replay --save-script` arming (R1), the repair-run @@ -134,7 +135,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land expect(divergence.resume.from).toBe(3); // --- Agent performs the corrective action live (recorded). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -381,13 +382,13 @@ test('a --no-record state-fix action never enters session.actions', () => { // Agent fixes app state with --no-record, then performs the real corrective // action (recorded). - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['100', '200'], flags: { noRecord: true }, result: {}, }); - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -578,7 +579,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett expect(divergence.resume.from).toBe(2); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 1daa91397f..e998f0bcf0 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * #1271 stage 2 (ADR 0012 amendment): repair-segment default exclusion of * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the @@ -193,14 +194,14 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent explores mid-repair: a couple of diagnostic reads to locate // the renamed control. Both are observation-only and the session is // repair-armed, so per the default exclusion neither is appended. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'is', positionals: ['visible', 'id="save-v2"'], flags: {}, @@ -212,7 +213,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent performs the corrective press (blessed @ref), recorded live — // a mutating action is never observation-only, so it is unaffected. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -295,7 +296,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste // --- The agent's correction for the diverged step IS itself a read (the // wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced // into the heal with `--record` since it would otherwise be excluded. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e7'], flags: { record: true }, @@ -374,14 +375,14 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h // --- The agent ONLY inspects — never performs a corrective action. Both // reads are excluded from `session.actions`, so nothing was recorded in // this repair segment. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'find', positionals: ['id', 'save-v2', 'exists'], flags: {}, @@ -418,7 +419,7 @@ test('non-repair authoring recording is unchanged: a read in a fresh `open --sav session.scriptPublication = authoringPublication('armed'); expect(session.scriptPublication.kind).not.toBe('repair'); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 83d6e03d60..4f2192f2ea 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction `--force`/`--overwrite` semantics (#1258): a * `--save-script` target that already exists is refused at arm time, before any step runs, unless @@ -244,7 +245,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // The agent's corrective press (blessed @ref), recorded live. const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -317,7 +318,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi expect(sessionTargetPath(session)).toBe(targetA); expect(sessionTargetForce(session)).toBe(true); // The agent's corrective press. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 3136669a5f..a8841a4ffe 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -158,7 +158,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- Agent performs the corrective press (blessed @ref), recorded live. --- const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts new file mode 100644 index 0000000000..a3c8f2806b --- /dev/null +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { + makeIosSession, + authoringPublication, +} from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; + +test('recording replay fixtures use the selected scoped journal without a public-name slot', async () => { + const store = makeSessionStore(); + const address = 'cwd:recording-fixture:default'; + const ref = store.publish( + address, + makeIosSession('default', { + scriptPublication: authoringPublication('armed'), + }), + ); + const invoke = makeRecordingReplayInvoke({ sessionStore: store, sessionName: address }); + expect( + await invoke({ + token: 'test', + session: 'default', + command: 'click', + positionals: ['1', '2'], + flags: {}, + }), + ).toMatchObject({ ok: true }); + await store.flushEvents(); + expect(store.requireCurrent(ref).actions).toHaveLength(1); + expect(store.readEvents(address).events).toHaveLength(1); + expect(store.lookup('default')).toBeUndefined(); + expect(store.readEvents('default').events).toEqual([]); +}); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index bfdc799b69..40af9dc39e 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; /** * Shared fixtures for the ADR 0012 decision 6 repair-loop tests. The mock @@ -59,7 +60,7 @@ export function makeRecordingReplayInvoke(config: RecordingReplayInvokeConfig): } const session = resolveInvokeSession(config, req); const evidence = isSessionRecording(session) ? config.evidence?.(req) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session, config.sessionName), { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 6516da75b3..51f3f78847 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -289,7 +289,7 @@ test('an owner-armed session still records its target and publishes its script', // What `open`/`close --save-script` do once past the seam: arm the session, // then publish at teardown. Unchanged by the ingress rejection. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'open', positionals: ['Example'], flags: { saveScript: target }, diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index d90131a822..5173d75ad5 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -54,7 +54,6 @@ test('selector capture cache is keyed by scoped presentation options', async () const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -228,7 +227,6 @@ function proofRuntime(params: { const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(params.sessionName), device: session.device, - session, sessionStore, sessionName: params.sessionName, consumedSnapshot, @@ -322,7 +320,6 @@ function makeCaptureRuntime(sessionName: string) { const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -356,7 +353,6 @@ test('a held selector capture updates the matching rebuilt record without restor const runtime = createSelectorCaptureRuntime({ ref, device: ref.session.device, - session: ref.session, sessionStore, sessionName: address, capture: boundCapture, @@ -377,3 +373,48 @@ test('a held selector capture updates the matching rebuilt record without restor await running.catch(() => {}); } }); + +test('selector capture and sparse recovery both use current same-lifetime app metadata', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-current-metadata'); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.update(ref, { appBundleId: 'before-first-capture' }); + boundCapture + .mockImplementationOnce(async () => { + sessionStore.update(ref, { appBundleId: 'before-recovery-capture' }); + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Application' }], + }; + }) + .mockResolvedValueOnce({ + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Recovered' }], + }); + const result = await runtime.capture({ + flags: { snapshotInteractiveOnly: true }, + recovery: { legacyIosSparse: { query: 'Search', shouldScope: false } }, + }); + expect(result.snapshot.nodes[0]?.label).toBe('Recovered'); + expect(boundCapture.mock.calls.map(([input]) => input.options?.appBundleId)).toEqual([ + 'before-first-capture', + 'before-recovery-capture', + ]); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('before-recovery-capture'); +}); + +test('a retired selector runtime refuses even a reusable cached capture without touching its successor', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-retired-cache'); + const ref = sessionStore.lookup(sessionName)!; + await runtime.capture({ flags: {} }); + sessionStore.retire(ref); + const successor = sessionStore.publish(sessionName, ref.session); + await expect(runtime.capture({ flags: {} })).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(boundCapture).toHaveBeenCalledOnce(); + expect(sessionStore.requireCurrent(successor)).toBe(ref.session); +}); diff --git a/src/daemon/__tests__/selector-recording.test.ts b/src/daemon/__tests__/selector-recording.test.ts index ea319a2ff7..77825b263b 100644 --- a/src/daemon/__tests__/selector-recording.test.ts +++ b/src/daemon/__tests__/selector-recording.test.ts @@ -48,10 +48,10 @@ test('a repair-armed session excludes get/is/find by default but keeps recording }); store.set('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['wait']); }); @@ -73,9 +73,9 @@ test('a repair-armed session still records get/is/find dispatched as replay plan }); store.set('default', session); - recordIfSession(store, 'default', planStepReq('get'), {}); - recordIfSession(store, 'default', planStepReq('is'), {}); - recordIfSession(store, 'default', planStepReq('find'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('get'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('is'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('find'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -92,9 +92,9 @@ test('--record forces get/is/find through even while repair-armed', () => { }); store.set('default', session); - recordIfSession(store, 'default', req('get', { record: true }), {}); - recordIfSession(store, 'default', req('is', { record: true }), {}); - recordIfSession(store, 'default', req('find', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('find', { record: true }), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -105,10 +105,10 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () expect(session.scriptPublication).toBeUndefined(); store.set('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual([ 'get', @@ -122,7 +122,7 @@ test('wait absent records positionals without target-v1 annotation', () => { const store = makeStore(); store.set('default', makeIosSession('default')); - recordIfSession(store, 'default', waitAbsentReq(), { waitedMs: 0 }); + recordIfSession(store, store.lookup('default'), waitAbsentReq(), { waitedMs: 0 }); expect(store.get('default')!.actions[0]).toMatchObject({ command: 'wait', @@ -131,3 +131,21 @@ test('wait absent records positionals without target-v1 annotation', () => { }); expect(store.get('default')!.actions[0]?.targetEvidence).toBeUndefined(); }); + +test('sessionless recording cannot acquire a newly published address', () => { + const store = makeStore(); + const admitted = store.lookup('default'); + const session = makeIosSession('default'); + store.publish('default', session); + recordIfSession(store, admitted, req('wait'), { waitedMs: 100 }); + expect(session.actions).toEqual([]); +}); + +test('optional recording cannot adopt a successor lifetime', () => { + const store = makeStore(); + const retired = store.publish('default', makeIosSession('default')); + store.retire(retired); + const successor = store.publish('default', makeIosSession('default')); + expect(() => recordIfSession(store, retired, req('wait'), { completed: true })).not.toThrow(); + expect(store.requireCurrent(successor).actions).toEqual([]); +}); diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index bf16d25932..dafdd1af84 100644 --- a/src/daemon/__tests__/selector-runtime.test.ts +++ b/src/daemon/__tests__/selector-runtime.test.ts @@ -1,9 +1,23 @@ -import { beforeEach, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; import type { SessionState } from '../session-state.ts'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { + snapshotRuntimeOperationFacts, + type SnapshotResult, +} from '@agent-device/contracts/snapshot-runtime'; +import { createUnavailableRuntimeFactsForTest } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeStoredSession } from '../../__tests__/test-utils/session-factories.ts'; +import { dispatchGetViaRuntime } from '../selector-runtime.ts'; const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), @@ -19,6 +33,109 @@ function makeSession(): SessionState { return { name: 'default', device: IOS_SIMULATOR, createdAt: Date.now(), actions: [] }; } +test.each(['rebuild', 'retire'] as const)( + 'get text records in its captured lifetime after a held native read across %s', + async (transition) => { + const store = makeSessionStore(); + const address = 'cwd:held-get:default'; + const device = { + platform: 'web', + id: 'web', + name: 'Web', + kind: 'device', + booted: true, + } as const; + const ref = store.publish(address, makeStoredSession('default', { device })); + const owner = localRuntimeOwner('web'); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const available = { available: true } as const; + const facts = { + ...base, + operations: { + ...base.operations, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: available, + withoutActiveApp: available, + }), + readTextAtPoint: available, + }, + }; + let startRead!: () => void; + let releaseRead!: () => void; + const reading = new Promise((resolve) => { + startRead = resolve; + }); + const released = new Promise((resolve) => { + releaseRead = resolve; + }); + const capture = async (): Promise => ({ + backend: 'web', + producer: 'agent-browser', + nodes: [ + { index: 0, type: 'Window', rect: { x: 0, y: 0, width: 400, height: 800 } }, + { + index: 1, + parentIndex: 0, + type: 'TextField', + label: 'Input', + rect: { x: 20, y: 20, width: 80, height: 30 }, + hittable: true, + }, + ], + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + captureSnapshot: capture, + captureSnapshotWithCustomActions: capture, + captureSnapshotWithoutActiveApp: capture, + readTextAtPoint: async () => { + startRead(); + await released; + return { status: 'read', text: 'Native value' }; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = dispatchGetViaRuntime({ + req: { + token: 't', + session: 'default', + command: 'get', + positionals: ['text', 'label="Input"'], + flags: {}, + }, + sessionName: address, + sessionStore: store, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await reading; + let current = ref.session; + if (transition === 'rebuild') + current = store.update(ref, { actions: [], appName: 'Rebuilt during read' }); + else { + store.retire(ref); + current = store.publish(address, makeStoredSession('default', { device })).session; + } + releaseRead(); + const response = await running; + if (transition === 'rebuild') { + expect(response).toMatchObject({ ok: true, data: { text: 'Native value' } }); + expect(current.actions.map((action) => action.command)).toEqual(['get']); + expect(current.appName).toBe('Rebuilt during read'); + expect(ref.session.actions).toEqual([]); + } else { + expect(response).toMatchObject({ ok: true, data: { text: 'Native value' } }); + expect(ref.session.actions).toEqual([]); + expect(current.actions).toEqual([]); + } + }, +); + async function withRunner(operation: () => Promise): Promise { return await withAppleRunnerProvider( mockRunAppleRunnerCommand, diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index c7b6fa2458..41c9ee2002 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'vitest'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import type { SettleObservation } from '@agent-device/contracts/interaction'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { SessionState } from '../session-state.ts'; import { markSessionPartialRefsIssued, + issueSettleRefs, resolveRefStalenessWarning, setSessionSnapshot, setCommandSnapshot, @@ -179,12 +182,14 @@ test('a ref pinned before a diff keeps resolving: the diff advances the counter, // `diff` replaces the stored tree, so lineage advances the counter — but it passes // `issuesRefsToClient: false`, so it never reactivates the frame. const afterDiff: SessionState = { ...session }; + const diffSnapshot = makeSnapshot(); setCommandSnapshot(afterDiff, { - snapshot: afterDiff.snapshot!, + snapshot: diffSnapshot, scopeSource: undefined, keptCurrentSnapshot: false, previousGeneration: session.snapshotGeneration, }); + expect(afterDiff.snapshot).toBe(diffSnapshot); expect(afterDiff.snapshotGeneration).not.toBe(session.snapshotGeneration); expect(refFrameEpoch(afterDiff)).toBe(issuedAt); @@ -220,3 +225,36 @@ test('keeping the current snapshot leaves the counter alone', () => { }); expect(session.snapshotGeneration).toBe(before); }); + +for (const retire of [false, true]) { + test(`settle ref issuance checks lifetime before publishing (${retire ? 'retired' : 'live'})`, () => { + const store = makeSessionStore(); + const session = makeSession(); + setSessionSnapshot(session, makeSnapshot()); + const ref = store.publish('cwd:settle:default', session); + const priorFrame = refFrame(session); + if (retire) { + store.retire(ref); + store.publish(ref.address, session); + } + const observation: SettleObservation = { + settled: true, + waitedMs: 1, + captures: 2, + quietMs: 1, + timeoutMs: 20, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'new button', ref: 'e1' }], + }, + }; + const generation = issueSettleRefs(ref, store, observation); + if (retire) { + expect(generation).toBeUndefined(); + expect(refFrame(session)).toBe(priorFrame); + } else { + expect(generation).toBe(session.snapshotGeneration); + expect(refFrameScope(session)).toEqual(new Set(['e1'])); + } + }); +} diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index ef1315fa6d..80ca833207 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -149,6 +149,55 @@ test('script writes use the latest matching record and refuse a retired lifetime assert.equal(successor.session.actions.length, 0); }); +test('action recording uses the latest matching record and its scoped journal', async () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + const publicSlot = store.publish('default', makeSession('default')); + const current = store.update(ref, { actions: [] }); + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true }, + }); + await store.flushEvents(); + assert.deepEqual( + current.actions.map((action) => action.command), + ['click'], + ); + assert.equal(ref.session.actions.length, 0); + assert.equal(ref.session.scriptPublication, undefined); + assert.equal(current.scriptPublication?.kind, 'authoring'); + assert.equal(publicSlot.session.actions.length, 0); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + assert.equal(store.readEvents(ADDRESS).events[0]?.session, ADDRESS); +}); + +test.each([{}, { noRecord: true }])( + 'retired action recording refuses before flags or journal writes, flags=%j', + async (flags) => { + const store = makeSessionStore(); + const session = makeSession('default'); + const ref = store.publish(ADDRESS, session); + store.retire(ref); + const successor = store.publish(ADDRESS, session); + assert.throws( + () => + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true, ...flags }, + }), + ended, + ); + await store.flushEvents(); + assert.equal(store.requireCurrent(successor), session); + assert.equal(session.actions.length, 0); + assert.equal(session.scriptPublication, undefined); + assert.equal(fs.existsSync(store.resolveEventLogPath(ADDRESS)), false); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + }, +); + test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => { const store = makeSessionStore(); const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 0e00cfef09..0455a280cb 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -65,7 +65,7 @@ function recordOpen( flags: RecordActionEntry['flags'] = { platform: 'ios', saveScript: true }, runtime?: RecordActionEntry['runtime'], ): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'open', positionals: ['Settings'], flags, @@ -75,7 +75,7 @@ function recordOpen( } function recordClose(store: SessionStore, session: SessionState): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'close', positionals: [], flags: { platform: 'ios' }, @@ -143,7 +143,7 @@ test('parameterized fill publication writes only the placeholder to target and t const fixture = makeFixture('agent-device-session-log-parameterized-fill-'); const secret = 'publication-only-live-value-1348'; recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['id="password"', secret], flags: { platform: 'ios', recordAs: 'PASSWORD' }, @@ -167,7 +167,7 @@ test('parameterized fill publication writes only the placeholder to target and t test('recordAction writes a paged session event log', async () => { const { store, session } = makeFixture('agent-device-session-events-'); recordOpen(store, session, { platform: 'ios' }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['@14', 'Checkout'], flags: { platform: 'ios' }, @@ -189,7 +189,7 @@ test('recordAction writes a paged session event log', async () => { test('recordAction event log redacts typed text and its length from display positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'fill', positionals: ['@14', 'super-secret-token'], flags: {}, @@ -213,25 +213,25 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a const eventPayload = '{"token":"event-secret-token"}'; const futurePayload = 'future-secret-token'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'clipboard', positionals: ['write', clipboardText], flags: {}, result: { action: 'write', textLength: Array.from(clipboardText).length }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'push', positionals: ['com.example.app', pushPayload], flags: {}, result: { message: 'Pushed notification to com.example.app' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'trigger-app-event', positionals: ['checkout', eventPayload], flags: {}, result: { message: 'Triggered app event checkout' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['public-ish', futurePayload], flags: {}, @@ -256,13 +256,13 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a test('recordAction event log omits transformed messages for redacted positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-overlap-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['token', 'my-token-123'], flags: {}, result: { message: 'Ran my-token-123 after token' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['arg', 'my-arg-123'], flags: {}, @@ -284,7 +284,7 @@ test('recordAction event log omits transformed messages for redacted positionals test('recordAction event log does not leak short typed text or its length', async () => { const { store, session } = makeFixture('agent-device-session-events-short-text-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'type', positionals: ['e'], flags: {}, @@ -302,7 +302,7 @@ test('recordAction event log does not leak short typed text or its length', asyn test('recordAction event log omits value-bearing selector details', async () => { const { store, session } = makeFixture('agent-device-session-events-selector-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['value=123456'], flags: {}, @@ -329,7 +329,7 @@ test('recordAction event log omits value-bearing selector details', async () => test('recordAction event log rejects malformed provider scroll output', async () => { const { store, session } = makeFixture('agent-device-session-events-malformed-scroll-'); const privateValue = 'provider-private-scroll-value'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'scroll', positionals: [privateValue], flags: {}, @@ -416,7 +416,7 @@ test('writeSessionLog persists open --relaunch in script output', () => { test('writeSessionLog persists record --hide-touches flags in script output', () => { const fixture = makeFixture('agent-device-session-log-record-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'record', positionals: ['start', './capture.mp4'], flags: { @@ -435,7 +435,7 @@ test('writeSessionLog persists record --hide-touches flags in script output', () test('writeSessionLog persists screenshot flags in script output', () => { const fixture = makeFixture('agent-device-session-log-screenshot-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: ['./page.png'], flags: { platform: 'ios', screenshotFullscreen: true, screenshotScale: 0.3 }, @@ -471,7 +471,7 @@ test('writeSessionLog persists inline open runtime hints in script output', () = test('writeSessionLog persists runtime set hints in script output', () => { const fixture = makeFixture('agent-device-session-log-runtime-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'runtime', positionals: ['set'], flags: { @@ -494,7 +494,7 @@ test('writeSessionLog persists runtime set hints in script output', () => { test('writeSessionLog preserves interaction series flags for click/press/swipe', () => { const fixture = makeFixture('agent-device-session-log-series-flags-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['id="continue_button"'], flags: { @@ -507,7 +507,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'press', positionals: ['201', '545'], flags: { @@ -517,7 +517,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'swipe', positionals: ['10', '20', '30', '40'], flags: { @@ -528,7 +528,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', 'search'], flags: { @@ -537,7 +537,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'gesture', positionals: ['pan', '195', '443', '48', '0', '500'], flags: { @@ -561,19 +561,19 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', test('writeSessionLog optimizes selector chains and scopes fallback snapshots', () => { const fixture = makeFixture('agent-device-session-log-selectors-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'snapshot', positionals: [], flags: { platform: 'ios', snapshotInteractiveOnly: true }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e1'], flags: { platform: 'ios', count: 2 }, result: { selectorChain: ['text="Continue"', 'role=button'], refLabel: 'Continue' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'longpress', positionals: ['@e3', '800'], flags: { platform: 'ios' }, @@ -583,13 +583,13 @@ test('writeSessionLog optimizes selector chains and scopes fallback snapshots', }, }); // #1783: hover @ref publishes as a portable selector line like click/longpress. - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'hover', positionals: ['@e4~s12'], flags: { platform: 'web', settle: true }, result: { selectorChain: ['text="Second message"', 'role=link'] }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e2', 'hello world'], flags: { platform: 'ios', delayMs: 5 }, @@ -631,19 +631,19 @@ test('writeSessionLog preserves significant whitespace and empty string argument launchUrl: 'myapp://dev ', }, ); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'type', positionals: [' leading\ttrailing '], flags: { platform: 'ios' }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', ''], flags: { platform: 'ios' }, result: { refLabel: 'Search field' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: [' ./screens/final.png '], flags: { platform: 'ios' }, @@ -678,7 +678,7 @@ const SAVE_TARGET_EVIDENCE: TargetAnnotationV1 = { test('writeSessionLog emits the target-v1 annotation immediately before its action line', () => { const fixture = makeFixture('agent-device-session-log-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, @@ -700,7 +700,7 @@ test('writeSessionLog emits the target-v1 annotation immediately before its acti test('writeSessionLog never fabricates a target-v1 annotation for actions recorded without evidence', () => { const fixture = makeFixture('agent-device-session-log-no-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index 2a17a1d6ea..e44cf95932 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -86,7 +86,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { }); } -for (const change of ['rebuild', 'replace'] as const) { +for (const change of ['unchanged', 'rebuild', 'replace'] as const) { test(`snapshot completion respects a scoped lifetime after ${change}`, async () => { const sessionStore = makeSessionStore(); const address = 'cwd:snapshot-completion:default'; @@ -117,13 +117,18 @@ for (const change of ['rebuild', 'replace'] as const) { ); try { await entered.promise; - if (change === 'rebuild') { - const trace = { outPath: 'intervening-trace', startedAt: 1 }; - sessionStore.update(ref, { trace }); + if (change !== 'replace') { + const trace = + change === 'rebuild' ? { outPath: 'intervening-trace', startedAt: 1 } : ref.session.trace; + if (change === 'rebuild') sessionStore.update(ref, { trace }); + const priorRecord = sessionStore.requireCurrent(ref); release.resolve(); expect(await result).toMatchObject({ response: { ok: true } }); const current = sessionStore.requireCurrent(ref); expect(current.trace).toBe(trace); + expect(current).not.toBe(priorRecord); + expect(priorRecord.snapshot).toBeUndefined(); + expect(ref.session.snapshot).toBeUndefined(); expect(current.snapshot?.nodes[0]?.label).toBe('Captured'); } else { sessionStore.retire(ref); @@ -142,3 +147,32 @@ for (const change of ['rebuild', 'replace'] as const) { } }); } + +test('a composed snapshot refuses its supplied retired lifetime before facts or capture', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:composed-snapshot:default'; + const ref = sessionStore.publish(address, makeAndroidSession('default')); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeAndroidSession('default')); + const fixture = snapshotRuntimeFixture(); + const facts = fixture.inspectFacts; + let inspections = 0; + captureMock.mockResolvedValue({ nodes: [], truncated: false, backend: 'uiautomator' }); + await expect( + dispatchSnapshotViaRuntime({ + req: { command: 'snapshot', positionals: [], token: 't', session: 'default' }, + sessionName: address, + sessionRef: ref, + logPath: '/dev/null', + sessionStore, + ...fixture, + inspectFacts: async (device) => { + inspections++; + return await facts(device); + }, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(inspections).toBe(0); + expect(captureMock).not.toHaveBeenCalled(); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 4916c05ee9..98f776547d 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -148,6 +148,53 @@ function waitRequest(positionals: string[], flags: Record = {}) } as unknown as DaemonRequest; } +test.each(['rebuild', 'retire'] as const)( + 'sleep-only wait records in its admitted lifetime across %s', + async (transition) => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness(); + const sessionStore = makeSessionStore(); + const address = 'cwd:sleep-wait:default'; + const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); + const running = handleSnapshotCommands({ + req: waitRequest(['100']), + sessionName: address, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + await vi.advanceTimersByTimeAsync(0); + let current = ref.session; + if (transition === 'rebuild') { + current = sessionStore.update(ref, { actions: [], appName: 'Updated during sleep' }); + } else { + sessionStore.retire(ref); + current = sessionStore.publish( + address, + makeSession('default', { device: harness.device }), + ).session; + } + await vi.advanceTimersByTimeAsync(100); + const response = await running; + expect(harness.captureSnapshot).not.toHaveBeenCalled(); + if (transition === 'rebuild') { + expect(response?.ok).toBe(true); + expect(current.actions.map((action) => action.command)).toEqual(['wait']); + expect(ref.session.actions).toEqual([]); + expect(current.appName).toBe('Updated during sleep'); + } else { + expect(response).toMatchObject({ ok: true, data: { waitedMs: 100 } }); + expect(ref.session.actions).toEqual([]); + expect(current.actions).toEqual([]); + } + } finally { + vi.useRealTimers(); + } + }, +); + async function runWait( positionals: string[], harness: ReturnType, diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index 70e1ead5d6..9bd5d09a63 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -110,6 +110,8 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); + if (context.sessionRef && !context.sessionStore.resolveCurrent(context.sessionRef)) + return undefined; const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index 8b99e82bb4..e7ea6eba93 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -216,3 +216,45 @@ test('clipboard write with no text argument reports how to clear instead', async 'clipboard write requires text (use "" to clear clipboard)', ); }); + +for (const action of ['read', 'write'] as const) { + test(`clipboard ${action} refuses retirement during device resolution before binding`, async () => { + const spies = harness({ read: available, write: available }); + const input = request(action === 'read' ? ['read'] : ['write', 'text']); + const ref = input.sessionStore.lookup(input.sessionName)!; + mockResolveTargetDevice.mockImplementationOnce(async () => { + input.sessionStore.retire(ref); + input.sessionStore.publish(input.sessionName, makeSession(input.sessionName, androidDevice)); + return androidDevice; + }); + await expect( + handleSessionClipboardCommand({ + ...input, + req: { ...input.req, flags: { platform: 'android', serial: androidDevice.id } }, + ...spies, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(spies.inspectFacts).not.toHaveBeenCalled(); + expect(spies.bindDevice).not.toHaveBeenCalled(); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(spies.readClipboard).not.toHaveBeenCalled(); + expect(spies.writeClipboard).not.toHaveBeenCalled(); + }); +} + +test('clipboard read succeeds without journaling after its admitted lifetime ends', async () => { + const spies = harness({ read: available, write: available }); + const input = request(['read']); + const retired = input.sessionStore.lookup(input.sessionName)!; + const successor = makeSession(input.sessionName, androidDevice); + spies.readClipboard.mockImplementationOnce(async () => { + input.sessionStore.retire(retired); + input.sessionStore.publish(input.sessionName, successor); + return 'copied text'; + }); + const response = await handleSessionClipboardCommand({ ...input, ...spies }); + expect(response?.ok).toBe(true); + expect(spies.readClipboard).toHaveBeenCalledOnce(); + expect(retired.session.actions).toEqual([]); + expect(successor.actions).toEqual([]); +}); diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index a48adcfc7e..56a7dbca85 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -278,3 +278,73 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r appName: 'com.example.application', }); }); + +test.each(['rebuild', 'retire'] as const)( + 'held HarmonyOS deployment patches only its admitted lifetime across %s', + async (transition) => { + const store = makeStore(); + const address = 'cwd:held-harmony-deploy:default'; + const session = makeSession('default', { + platform: 'harmonyos', + id: '127.0.0.1:5555', + name: 'Emulator', + kind: 'emulator', + booted: true, + }); + const ref = store.publish(address, session); + const appPath = path.join(mkdtempForTestSync('held-deploy-'), 'Sample.hap'); + fs.writeFileSync(appPath, 'placeholder'); + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + mockDeployAppRuntime.mockImplementationOnce(async () => { + start(); + await released; + return { packageName: 'com.example.updated' }; + }); + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.updated', appPath], + flags: {}, + }, + sessionName: address, + sessionStore: store, + logPath: '/tmp/daemon.log', + invoke, + }); + await started; + let current = session; + if (transition === 'rebuild') + current = store.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + store.retire(ref); + current = store.publish(address, makeSession('default', session.device)).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(store.requireCurrent(ref).trace).toEqual(current.trace); + expect(store.requireCurrent(ref).appBundleId).toBe('com.example.updated'); + expect(current.actions.map((action) => action.command)).toEqual(['reinstall']); + expect(session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(store.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + }, +); diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 83690a8b3a..32f465ac08 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -290,3 +290,101 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc }), ); }); + +for (const phase of ['admission', 'effect'] as const) { + test(`runtime clear does not clear a successor's hints after retirement during ${phase}`, async () => { + const sessionStore = makeSessionStore(); + const address = `cwd:runtime-clear-${phase}:default`; + const device = { + platform: 'android' as const, + id: `runtime-clear-${phase}`, + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.old', + }); + sessionStore.setRuntimeHints(address, { platform: 'android', metroHost: 'old' }); + const successorHints = { platform: 'android' as const, metroHost: 'successor' }; + const replace = () => { + sessionStore.retire(ref); + sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.new', + }); + sessionStore.setRuntimeHints(address, successorHints); + }; + if (phase === 'admission') { + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + replace(); + return lifecycleRuntimeFacts(target); + }); + } else { + mockClearRuntimeHints.mockImplementationOnce(async () => { + replace(); + }); + } + await expect( + handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.getRuntimeHints(address)).toBe(successorHints); + expect(refFrameState(sessionStore.get(address)!)).toBe('active'); + expect(mockClearRuntimeHints).toHaveBeenCalledTimes(phase === 'admission' ? 0 : 1); + }); +} + +test('runtime clear expires and uses the latest matching record after admission', async () => { + const sessionStore = makeSessionStore(); + const device = { + platform: 'android' as const, + id: 'runtime-clear-rebuild', + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish('clear-rebuild', { + ...makeSession('clear-rebuild', device), + appBundleId: 'old.app', + }); + sessionStore.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'old' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + sessionStore.update(ref, { appBundleId: 'rebuilt.app' }); + return lifecycleRuntimeFacts(target); + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: ref.address, + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: ref.address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }); + expect(response).toMatchObject({ ok: true, data: { cleared: true } }); + expect(mockClearRuntimeHints).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'rebuilt.app' }), + ); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(refFrameState(ref.session)).toBe('active'); +}); diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 8f9d32fa61..cfdf9d8fd2 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -28,6 +28,8 @@ import type { SessionState } from '../../session-state.ts'; import { handleSessionCommands } from './session-command-harness.ts'; import { refFrameState } from '../../ref-frame.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { createUnavailableRuntimeFactsForTest } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; const available = Object.freeze({ available: true } as const); const keyboardFamilyDenial = Object.freeze({ @@ -35,6 +37,90 @@ const keyboardFamilyDenial = Object.freeze({ reason: 'owner-capability-missing' as const, }); +test.each(['rebuild', 'retire'] as const)( + 'app-event metadata and action stay in the admitted lifetime across %s', + async (transition) => { + vi.stubEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'https://example.test/{event}'); + try { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-app-event:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const device = ref.session.device; + mockResolveTargetDevice.mockResolvedValue(device); + const owner = localRuntimeOwner(device.platform); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const facts = { + ...base, + operations: { ...base.operations, triggerAppEvent: available, ensureReady: available }, + }; + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + ensureReady: async () => device, + triggerAppEvent: async () => { + start(); + await released; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'trigger-app-event', + positionals: ['ready'], + flags: {}, + }, + sessionName: address, + sessionStore, + logPath: '/tmp/daemon.log', + invoke: noopInvoke, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await started; + let current = ref.session; + if (transition === 'rebuild') + current = sessionStore.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + sessionStore.retire(ref); + current = sessionStore.publish(address, makeIosSession('default')).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(sessionStore.requireCurrent(ref).trace).toEqual(current.trace); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('com.apple.mobilesafari'); + expect(current.actions.map((action) => action.command)).toEqual(['trigger-app-event']); + expect(ref.session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + } finally { + vi.unstubAllEnvs(); + } + }, +); + /** Admits every keyboard operation so the ADR 0014 seam runs on real admission, not a rejection. * `keyboardDismiss` is overridable so a test can force the invocation itself to reject, proving * the frame expires before the mutating call runs rather than only after it resolves. */ diff --git a/src/daemon/handlers/__tests__/snapshot-alert.test.ts b/src/daemon/handlers/__tests__/snapshot-alert.test.ts index f759a84beb..c472de676b 100644 --- a/src/daemon/handlers/__tests__/snapshot-alert.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-alert.test.ts @@ -118,3 +118,23 @@ test('alert dismiss retries a typed absence whatever the message says', async () expect(response?.ok).toBe(true); expect(calls).toBe(3); }); + +for (const action of ['accept', 'dismiss']) { + test(`alert ${action} succeeds without journaling after its admitted lifetime ends`, async () => { + const store = makeSessionStore(); + const name = 'ios-alert-retirement'; + const retired = store.publish(name, makeSession(name)); + let successor: SessionState | undefined; + mockRunnerCommand.mockImplementationOnce(async () => { + store.retire(retired); + successor = makeSession(name); + store.publish(name, successor); + return { [action === 'accept' ? 'accepted' : 'dismissed']: true }; + }); + const response = await handleSnapshotCommands(name, store, [action]); + expect(response?.ok).toBe(true); + expect(mockRunnerCommand).toHaveBeenCalledOnce(); + expect(retired.session.actions).toEqual([]); + expect(successor?.actions).toEqual([]); + }); +} diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 028fec887b..5711d69785 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -30,12 +30,12 @@ export async function handleReactNativeCommands( params: InteractionRouteInput, ): Promise { params = bindInteractionSession(params); - const { req, sessionName, sessionStore } = params; + const { req, sessionStore } = params; if (req.command !== PUBLIC_COMMANDS.reactNative) return null; const parsed = parseReactNativeArgs(req.positionals ?? []); if (!parsed.ok) return parsed.response; - const session = sessionStore.get(sessionName); + const session = params.sessionRef ? sessionStore.requireCurrent(params.sessionRef) : undefined; if (!session) return noActiveSessionError(); // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. // It runs before the observing capture, exactly where the retired capability gate ran, so an @@ -182,7 +182,7 @@ async function executeReactNativeOverlayDismiss( ...successText(formatDismissMessage(verification)), }); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 9771b334d1..6fb7e4fa59 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -174,7 +174,7 @@ async function startRecording( const snapshot = adopted.handle.inspect(); recordSessionAction( params.sessionStore, - params.sessionStore.requireCurrent(adoptedRef), + adoptedRef, params.req, params.req.command, buildRecordingStartedAction(snapshot), @@ -260,20 +260,14 @@ async function stopRecording( const completion = stopped.completion; const response = buildRecordingStopResponse(completion); if (stopped.recordsSessionAction) { - recordSessionAction( - params.sessionStore, - ref ? params.sessionStore.resolveCurrent(ref) : undefined, - params.req, - params.req.command, - { - action: 'stop', - outPath: completion.outPath, - ...(completion.clientOutPath - ? { requestedFileName: path.basename(completion.clientOutPath) } - : {}), - showTouches: completion.showTouches, - }, - ); + recordSessionAction(params.sessionStore, ref, params.req, params.req.command, { + action: 'stop', + outPath: completion.outPath, + ...(completion.clientOutPath + ? { requestedFileName: path.basename(completion.clientOutPath) } + : {}), + showTouches: completion.showTouches, + }); } if (session.recordOnlySession && ref) params.sessionStore.retire(ref); return response; diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index 79768d7158..51e8d79efc 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -12,7 +12,7 @@ import { expireRefFrame } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { resolvePayloadInput } from '../payload-input.ts'; import { resolveDeployResultTarget } from '../../core/deploy-result-target.ts'; import { withSuccessText } from '@agent-device/kernel/success-text'; @@ -52,7 +52,8 @@ export async function handleAppDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, command, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -76,7 +77,7 @@ export async function handleAppDeploymentCommand(params: { const runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); // ADR 0014: deployment can replace the visible surface. Do this immediately before the // bound operation so an admission failure never discards a still-valid reference frame. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployApp({ app: target.app, appPath, @@ -87,11 +88,8 @@ export async function handleAppDeploymentCommand(params: { result, `Installed: ${result.appName ?? resolveDeployResultTarget(result)}`, ); - const sessionForRecord = updateHarmonyDeploymentSession(session, result); - if (sessionForRecord && sessionForRecord !== session) { - sessionStore.set(sessionName, sessionForRecord); - } - recordSessionAction(sessionStore, sessionForRecord, req, command, data); + updateHarmonyDeploymentSession(sessionStore, ref, result); + recordSessionAction(sessionStore, ref, req, command, data); return { ok: true, data }; } finally { if (uploadedArtifactId) cleanupUploadedArtifact(uploadedArtifactId); @@ -102,7 +100,8 @@ export async function handlePushNotificationCommand( params: RuntimeCommandHandlerParams, ): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector('push', session, flags); if (guard) return guard; @@ -131,7 +130,7 @@ export async function handlePushNotificationCommand( // ADR 0014: a notification dispatch may change the visible surface. Keep an admission failure // non-destructive, but expire immediately before dispatch so an attempted provider operation // that fails after crossing the side-effect seam cannot leave stale refs authorized. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const result = await runtime.operations.sendPushNotification({ appId, payload }); const data = isIosFamily(device) ? withSuccessText({ platform: 'ios', bundleId: appId }, `Pushed notification to ${appId}`) @@ -144,7 +143,7 @@ export async function handlePushNotificationCommand( }, `Pushed notification to ${appId}`, ); - recordSessionAction(sessionStore, session, req, 'push', data, { + recordSessionAction(sessionStore, ref, req, 'push', data, { positionals: [appId, payloadArg], }); return { ok: true, data }; @@ -198,15 +197,15 @@ function buildNonIosDeployResult( } function updateHarmonyDeploymentSession( - session: SessionState | undefined, + sessionStore: SessionStore, + ref: SessionRef | undefined, result: DeployResult, -): SessionState | undefined { - if (!session || result.platform !== 'harmonyos' || !result.appId) return session; - return { - ...session, +): void { + if (!ref || result.platform !== 'harmonyos' || !result.appId) return; + sessionStore.update(ref, { appBundleId: result.appId, appName: result.appName ?? result.app, - }; + }); } function resolveDeployTarget( diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index c1ee5a432c..013c0bf27a 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -51,7 +51,8 @@ export async function handleInstallFromSourceDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; let resolvedSource: ReturnType | undefined; let materialized: MaterializedAppSource | undefined; let retained: RetainedMaterializedPaths | undefined; @@ -88,14 +89,14 @@ export async function handleInstallFromSourceDeploymentCommand(params: { // ADR 0014 side-effect seam: materialization is request-local, but deployment can // replace the visible app surface. Expire immediately before its sole bound dispatch, // so admission or materialization failures preserve refs while a dispatch rejection does not. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployMaterializedApp({ artifact: materialized }); const result = buildInstallFromSourceResult(device, materialized, deployment, retained); const data = withSuccessText( result, `Installed: ${resolveInstallFromSourceResultTarget(result)}`, ); - recordSessionAction(sessionStore, session, req, 'install_source', data, { positionals: [] }); + recordSessionAction(sessionStore, ref, req, 'install_source', data, { positionals: [] }); return { ok: true, data }; } catch (error) { if (retained) { diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index 869e618d62..b4522ca903 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -143,7 +143,8 @@ export async function handleSessionClipboardCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); if (guard) return guard; @@ -155,6 +156,7 @@ export async function handleSessionClipboardCommand(params: { } const device = await resolveCommandDevice({ session, flags }); + if (ref) sessionStore.requireCurrent(ref); const bound = await resolveBoundClipboardRuntime({ device, action, @@ -163,10 +165,10 @@ export async function handleSessionClipboardCommand(params: { bindDevice, }); if (!bound.ok) return bound.response; - + const current = ref ? sessionStore.requireCurrent(ref) : undefined; const result = await bound.execute( - contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + contextFromFlags(logPath, req.flags, current?.appBundleId, current?.trace?.outPath), ); - recordSessionAction(sessionStore, session, req, req.command, result); + recordSessionAction(sessionStore, ref, req, req.command, result); return { ok: true, data: { platform: publicPlatformString(device), ...result } }; } diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index ce15cc7685..29b3321094 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -112,7 +112,6 @@ export function createReplaySession( const session = store.get(name); if (!session) return false; mutate(session); - store.set(name, session); return true; }; // One read set for both views: the narrowed one the port binds over, and the full-record one the @@ -147,23 +146,24 @@ export function createReplaySession( device, platform, }), - bindAuthority: (signal) => - bindInternalObservationAuthority({ - sessionStore: { get: () => store.get(name), update: updateSession }, - sessionName: name, - ...(signal ? { signal } : {}), - }), - capture: async ({ flags, logPath: captureLogPath }) => { - const session = store.get(name); - if (!session) { - throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); - } - return await captureSnapshot({ - device: session.device, - session, - flags, - logPath: captureLogPath, - }); + bindAuthority: (signal) => { + const ref = store.lookup(name); + return { + ...bindInternalObservationAuthority({ sessionStore: store, ref, signal }), + capture: async ({ flags, logPath: captureLogPath }) => { + const session = ref ? store.requireCurrent(ref) : undefined; + if (!session) { + throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); + } + return await captureSnapshot({ + device: session.device, + session, + flags, + logPath: captureLogPath, + signal, + }); + }, + }; }, }); } diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 8bdef171f8..0bea2f5106 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -2,6 +2,7 @@ import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { clearRuntimeHintsRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import { SessionStore } from '../session-store.ts'; +import type { SessionRef } from '../session-state.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { admitRuntimeUse } from '../runtime-admission.ts'; import { @@ -64,13 +65,14 @@ export async function handleRuntimeCommand(params: { 'runtime requires set, show, clear, port-reverse, or gesture-viewport', ); } - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const current = sessionStore.getRuntimeHints(sessionName); if (action === 'clear') { return await clearRuntimeCommand({ sessionName, sessionStore, - session, + ref, current, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, @@ -118,12 +120,13 @@ function isRuntimeAction(action: string): action is RuntimeAction { async function clearRuntimeCommand(params: { sessionName: string; sessionStore: SessionStore; - session: ReturnType; + ref: SessionRef | undefined; current: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; }): Promise { - const { sessionName, sessionStore, session, current, inspectFacts, bindDevice } = params; + const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice } = params; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; if (hasRuntimeTransportHints(current) && session?.appBundleId) { const admission = await admitClearRuntime({ device: session.device, @@ -133,13 +136,15 @@ async function clearRuntimeCommand(params: { if (admission.type === 'response') return admission.response; // Native hint removal can change the app's reachable surface. Expire the existing frame at // the mutation boundary, after admission and immediately before the bound package effect. - expireRefFrame(session); + const currentSession = sessionStore.requireCurrent(ref!); + expireRefFrame(currentSession); await admission.runtime.operations.clearRuntimeHints({ - appId: session.appBundleId, + appId: currentSession.appBundleId, values: runtimeHintValues(current), }); } - const cleared = sessionStore.clearRuntimeHints(sessionName); + const cleared = ref ? sessionStore.clearRuntimeHints(ref) : Boolean(current); + if (!ref) sessionStore.setRuntimeHints(sessionName, undefined); return { ok: true, data: { session: sessionName, cleared } }; } diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c41e8cbf73..078a26e61b 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,7 +1,7 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { @@ -32,9 +32,9 @@ type SessionCommandPrepareOutcome = /** * The one orchestration every session/selector-route leaf shares: guard, resolve the device, * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates - * (immediately before the prepared invocation runs, never after), derive and record the next - * session. `prepare` is where each leaf's own admission and binding lives; everything around it - * is identical, so it lives here once instead of once per command. Every leaf on this route now + * (immediately before the prepared invocation runs, never after), apply the optional session + * patch and record the action. `prepare` owns each leaf's admission and binding; the shared + * orchestration lives here once. Every leaf on this route now * supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand` * one with `trigger-app-event`. */ @@ -46,14 +46,10 @@ async function runSessionOrSelectorDispatch(params: { command: string; positionals: string[]; recordPositionals?: string[]; - deriveNextSession?: ( - session: SessionState, - result: Record | void, - device: DeviceInfo, - ) => Promise | SessionState; + updateSession?: (ref: SessionRef, result: Record | void) => Promise | void; prepare: ( device: DeviceInfo, - session: SessionState | undefined, + ref: SessionRef | undefined, ) => Promise; }): Promise { const { @@ -63,10 +59,11 @@ async function runSessionOrSelectorDispatch(params: { command, positionals, recordPositionals, - deriveNextSession, + updateSession, prepare, } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -75,29 +72,26 @@ async function runSessionOrSelectorDispatch(params: { session, flags, }); - const prepared = await prepare(device, session); + if (ref) sessionStore.requireCurrent(ref); + const prepared = await prepare(device, ref); if (!prepared.ok) return prepared.response; // ADR 0014 side-effect seam for session/selector-route leaves (keyboard // dismiss/enter/return, push, trigger-app-event). Expire the frame immediately before the // mutating invocation runs — not after it resolves — 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 current = ref ? sessionStore.requireCurrent(ref) : undefined; + if (current && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(current); } const result = await prepared.execute(); - if (session) { - const nextSession = deriveNextSession - ? await deriveNextSession(session, result, device) - : session; - recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { + if (ref) { + if (updateSession) await updateSession(ref, result); + recordSessionAction(sessionStore, ref, req, command, result ?? {}, { positionals: recordPositionals ?? positionals, }); - if (nextSession !== session) { - sessionStore.set(sessionName, nextSession); - } } return { ok: true, data: result ?? {} }; } @@ -150,7 +144,7 @@ type SessionRouteRuntimeResolver = ( /** * The whole shape a migrated session-route leaf needs: admit and bind through the caller's own * resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after - * expiring the frame. Only the resolver, the command name and the optional session derivation + * expiring the frame. Only the resolver, the command name and the optional post-execution session patch * differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from * drifting into two copies of the same wiring. */ @@ -164,7 +158,7 @@ async function runBoundSessionRoute( * lexically. A bare reference would dedupe the wiring and delete the proof with it. */ resolveRuntime: SessionRouteRuntimeResolver; - deriveNextSession?: Parameters[0]['deriveNextSession']; + updateSession?: Parameters[0]['updateSession']; }>, ): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; @@ -175,8 +169,8 @@ async function runBoundSessionRoute( sessionStore, command: params.command, positionals, - ...(params.deriveNextSession ? { deriveNextSession: params.deriveNextSession } : {}), - prepare: async (device, session) => { + ...(params.updateSession ? { updateSession: params.updateSession } : {}), + prepare: async (device, ref) => { const bound = await params.resolveRuntime({ device, positionals, @@ -185,6 +179,7 @@ async function runBoundSessionRoute( bindDevice, }); if (!bound.ok) return { ok: false, response: bound.response }; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const dispatchContext = { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), surface: session?.surface, @@ -217,19 +212,16 @@ export async function handleAppEventCommand( ...params, command: PUBLIC_COMMANDS.triggerAppEvent, resolveRuntime: (runtimeParams) => resolveBoundAppEventRuntime(runtimeParams), - deriveNextSession: async (session, result) => { + updateSession: async (ref, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; - const nextAppBundleId = eventUrl - ? ((await resolveSessionAppBundleIdForTarget( - session.device, - eventUrl, - session.appBundleId, - )) ?? session.appBundleId) - : session.appBundleId; - return { - ...session, - appBundleId: nextAppBundleId, - }; + if (!eventUrl) return; + const session = params.sessionStore.requireCurrent(ref); + const appBundleId = await resolveSessionAppBundleIdForTarget( + session.device, + eventUrl, + session.appBundleId, + ); + if (appBundleId !== undefined) params.sessionStore.update(ref, { appBundleId }); }, }); } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 14c10d4507..892db5decd 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -13,9 +13,9 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { SessionStore } from '../session-store.ts'; -import { recordIfSession } from '../snapshot-session.ts'; +import { recordSessionAction } from '../session-action-recorder.ts'; import { parseTimeout } from '@agent-device/command-registry/parse-timeout'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -28,7 +28,7 @@ type HandleAlertCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; @@ -118,7 +118,8 @@ async function executeDismissAlert( export async function handleAlertCommand( params: HandleAlertCommandParams, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const action = normalizeAlertAction(req.positionals?.[0]); const bound = await resolveBoundAlertRuntime({ device, @@ -128,6 +129,7 @@ export async function handleAlertCommand( bindDevice, }); if (!bound.ok) return bound.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; // 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 // the accept/dismiss mutations on every owner without touching the read paths. @@ -145,7 +147,7 @@ export async function handleAlertCommand( ...alertTarget(session), execution: runtimeExecutionFromContext(context), }); - recordIfSession(sessionStore, session, req, data); + recordSessionAction(sessionStore, ref, req, req.command, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index a74a8b6319..273a5aa6a2 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -26,8 +26,8 @@ import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtim import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; -import { recordIfSession } from '../snapshot-session.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; +import { recordSessionAction } from '../session-action-recorder.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readLocationCoordinate } from '@agent-device/kernel/location-coordinates'; @@ -62,7 +62,7 @@ type HandleSettingsCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; parsed: ParsedSettingsRequest; inspectFacts?: InspectDeviceRuntimeFacts; @@ -217,7 +217,8 @@ async function executeSettingsRead( params: HandleSettingsCommandParams, setting: ReadableSetting, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; const admission = await admitRuntimeUse({ @@ -230,6 +231,8 @@ async function executeSettingsRead( }); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + emitDiagnostic({ level: 'debug', phase: 'settings_read', @@ -249,7 +252,7 @@ async function executeSettingsRead( ...payload, ...successText(describeSettingRead(payload)), }; - recordIfSession(sessionStore, session, req, data); + recordSessionAction(sessionStore, ref, req, req.command, data); return { ok: true, data }; } @@ -257,7 +260,8 @@ async function executeSettingsWrite( params: HandleSettingsCommandParams, parsed: ParsedSettingsArgs, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const { setting, state } = parsed; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; @@ -269,8 +273,9 @@ async function executeSettingsWrite( bindDevice, readiness: !session, }); - const appBundleId = settingsWriteAppId(req, parsed, session); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + const appBundleId = settingsWriteAppId(req, parsed, session); const writeRefusal = settingsWriteRefusal(parsed, appBundleId); if (writeRefusal !== undefined) return writeRefusal; // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before @@ -292,7 +297,7 @@ async function executeSettingsWrite( ), describeSettingWrite(setting, state, appBundleId), ); - recordIfSession(sessionStore, session, req, data); + recordSessionAction(sessionStore, ref, req, req.command, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 67d5340d73..62b63cb1f9 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -90,7 +90,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { bindDevice, platformResourceCleanup, }) => { - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -99,7 +103,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, inspectFacts, bindDevice, @@ -119,7 +123,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -128,7 +136,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, parsed: parsedSettings.parsed, inspectFacts, diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts index 64f804f0b5..01d6486c61 100644 --- a/src/daemon/handlers/trace-runtime.ts +++ b/src/daemon/handlers/trace-runtime.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import type { TraceCommandResult } from '@agent-device/contracts/recording'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef } from '../session-state.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; @@ -17,24 +17,25 @@ export function handleTraceCommand(params: { if (action !== 'start' && action !== 'stop') { return errorResponse('INVALID_ARGS', 'trace requires start|stop'); } - const session = params.sessionStore.get(params.sessionName); - if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session'); + const ref = params.sessionStore.lookup(params.sessionName); + if (!ref) return errorResponse('SESSION_NOT_FOUND', 'No active session'); return action === 'start' - ? startTrace(params.req, params.sessionStore, session) - : stopTrace(params.req, params.sessionStore, session); + ? startTrace(params.req, params.sessionStore, ref) + : stopTrace(params.req, params.sessionStore, ref); } function startTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; - recordSessionAction(sessionStore, session, req, req.command, { action: 'start', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'start', outPath }); return { ok: true, data: { trace: 'started', outPath } satisfies TraceCommandResult, @@ -44,12 +45,13 @@ function startTrace( function stopTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (!session.trace) return errorResponse('INVALID_ARGS', 'no active trace'); const outPath = relocateTraceOutput(session.trace.outPath, req.positionals?.[1]); session.trace = undefined; - recordSessionAction(sessionStore, session, req, req.command, { action: 'stop', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'stop', outPath }); const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; return { ok: true, diff --git a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts index 446a27be37..9ee05da84a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts @@ -78,7 +78,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', secret], @@ -266,7 +266,7 @@ test.each([ }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', literal], diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts index d0163f192d..30ed2bf4b5 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts @@ -745,3 +745,32 @@ test('corroborated runtime taps retain target evidence through save and replay', expect(pressCount).toBe(2); expect(snapshotCount).toBeGreaterThanOrEqual(4); }); + +test('a tap corroborates against its admitted snapshot after a same-lifetime record rebuild', async () => { + const store = makeSessionStore(); + const name = 'ios-admitted-tap-baseline'; + const ref = store.publish( + name, + makeIosSession(name, { + appBundleId: 'com.example.app', + snapshot: snapshot(imageViewerNodes), + }), + ); + store.update(ref, { snapshot: snapshot(profileNodes) }); + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'press') throw new AppError('XCTEST_RECORDED_FAILURE', 'tap failed'); + if (command === 'snapshot') return snapshotPayload(imageViewerNodes); + return {}; + }); + const response = await handleInteractionCommands({ + req: { token: 'test', session: name, command: 'click', positionals: ['104', '222'], flags: {} }, + sessionName: name, + sessionRef: ref, + sessionStore: store, + contextFromFlags, + ...getRuntimeBindings(), + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('landed tap should be corroborated'); + expect(response.data?.warning).toMatch(/same-scope post-action accessibility capture changed/); +}); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts new file mode 100644 index 0000000000..8ec76cc90e --- /dev/null +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from 'vitest'; +import { makeSnapshotState } from '@agent-device/capture-kit/snapshot-state-fixtures'; +import { + makeAndroidSession, + makeIosAppSession, +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { makeTestScreenRecordingResource } from '../../../../__tests__/test-utils/screen-recording-live-handle.ts'; +import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; +import { clearAndroidObservationFixture } from '../../../__tests__/android-observation-fixture.ts'; +import { contextFromFlags } from './interaction-touch-fixtures.ts'; +import { resolveDirectTouchReferenceFrameSafely } from '../interaction-touch-reference-frame.ts'; + +function holdProbe(result: T) { + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + return { + started, + release, + read: vi.fn(async () => { + start(); + await held; + return result; + }), + }; +} + +test.each( + (['android', 'ios'] as const).flatMap((platform) => + (['none', 'rebuild', 'retire-and-republish', 'replace-recording'] as const).map((change) => ({ + platform, + change, + })), + ), +)( + '$platform frame probe after $change writes only to its current recording resource', + async ({ platform, change }) => { + const store = makeSessionStore(); + const session = { android: makeAndroidSession, ios: makeIosAppSession }[platform]('default'); + const resource = makeTestScreenRecordingResource(session); + session.screenRecording = resource; + const ref = store.publish(`cwd:frame-${platform}-${change}:default`, session); + const snapshot = makeSnapshotState([ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + ]); + const capture = holdProbe(snapshot); + const screenSize = holdProbe({ width: 390, height: 844 }); + const probe = { android: screenSize, ios: capture }[platform]; + const running = resolveDirectTouchReferenceFrameSafely({ + ref, + flags: undefined, + sessionStore: store, + contextFromFlags, + captureSnapshotForSession: capture.read, + observation: { ...clearAndroidObservationFixture, readScreenSize: screenSize.read }, + }); + await probe.started; + let replacement; + if (change === 'rebuild') { + store.update(ref, { appName: 'updated during probe' }); + } else if (change === 'retire-and-republish') { + store.retire(ref); + store.publish(ref.address, session); + } else if (change === 'replace-recording') { + replacement = makeTestScreenRecordingResource(session); + store.update(ref, { screenRecording: replacement }); + } + probe.release(); + const frame = await running; + const expected = + change === 'none' || change === 'rebuild' + ? { referenceWidth: 390, referenceHeight: 844 } + : undefined; + expect(frame, change).toEqual(expected); + expect(resource.handle.inspect().touchReferenceFrame, change).toEqual(expected); + expect(replacement?.handle.inspect().touchReferenceFrame, change).toBeUndefined(); + expect(probe.read).toHaveBeenCalledOnce(); + expect(capture.read.mock.calls.length + screenSize.read.mock.calls.length).toBe(1); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).appName).toBe('updated during probe'); + } + }, +); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index a471a83106..246ca33562 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -1,9 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + withDiagnosticsScope, + flushDiagnosticsToSessionFile, +} from '@agent-device/host-kit/diagnostics'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../../index.ts'; import { buildInteractionResponseData, + buildTargetedTouchResponsePayloads, transformTouchResponseData, } from '../interaction-touch-response.ts'; import type { PressCommandResult } from '@agent-device/contracts/interaction'; @@ -100,6 +108,155 @@ function selectorResult(readiness?: { polls: number; waitedMs: number }): PressC const WAITED_SELECTOR_RESULT = selectorResult({ polls: 3, waitedMs: 400 }); +test.each([false, true])('selector-touch observation after retirement=%s', async (retired) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + const ref = sessionStore.publish(address, session); + const result: PressCommandResult = { + ...selectorResult(), + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'press', positionals: ['@e1'], session: 'default' }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: vi.fn(), + }, + result, + staleRefsWarning: undefined, + extra: {}, + }); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } +}); + +test.each([ + [false, false], + [false, true], + [true, false], + [true, true], +])( + 'point-touch publication after a held frame probe, retired=%s, fails=%s', + async (retired, fails) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + installTestScreenRecording(session); + const ref = sessionStore.publish(address, session); + let startProbe!: () => void; + let releaseProbe!: () => void; + const probing = new Promise((resolve) => { + startProbe = resolve; + }); + const released = new Promise((resolve) => { + releaseProbe = resolve; + }); + const result: PressCommandResult = { + kind: 'point', + point: { x: 10, y: 20 }, + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + const logPath = path.join(mkdtempForTestSync('held-point-'), 'request.log'); + const running = withDiagnosticsScope( + { command: 'press', session: address, logPath, debug: true }, + async () => { + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { + token: 't', + command: 'press', + positionals: ['10', '20'], + session: 'default', + flags: {}, + }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: async () => { + startProbe(); + await released; + if (fails) throw new Error('frame capture failed'); + return sessionStore.requireCurrent(ref).snapshot!; + }, + }, + result, + staleRefsWarning: undefined, + extra: {}, + }); + flushDiagnosticsToSessionFile({ force: true }); + return payloads; + }, + ); + await probing; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + releaseProbe(); + const payloads = await running; + const diagnostics = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + expect(diagnostics.includes('touch_reference_frame_resolve_failed')).toBe(!retired && fails); + expect(payloads.responseData.x).toBe(10); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } + }, +); + test('the response builder reports the resolved wait and keeps the prior warning', () => { const { responseData, result } = buildInteractionResponseData({ source: { kind: 'runtime', result: WAITED_SELECTOR_RESULT }, @@ -478,3 +635,35 @@ test('fill @ref preserves fallback coordinates for recording when platform resul expect(event?.x).toBe(60); expect(event?.y).toBe(40); }); + +test('an already retired coordinate touch skips its frame probe without a warning', async () => { + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish('retired-point', makeSession('retired-point')); + sessionStore.retire(ref); + const capture = vi.fn(); + const logPath = path.join(mkdtempForTestSync('retired-point-'), 'request.log'); + await withDiagnosticsScope( + { command: 'click', session: ref.address, logPath, debug: true }, + async () => { + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'click', positionals: ['1', '2'], session: ref.address }, + sessionName: ref.address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: capture, + }, + result: { kind: 'point', point: { x: 1, y: 2 } }, + staleRefsWarning: undefined, + extra: {}, + }); + expect(payloads.responseData).toMatchObject({ x: 1, y: 2 }); + flushDiagnosticsToSessionFile({ force: true }); + }, + ); + expect(capture).not.toHaveBeenCalled(); + expect(fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '').not.toContain( + 'touch_reference_frame_resolve_failed', + ); +}); diff --git a/src/daemon/interaction/internal/find-target-capture.ts b/src/daemon/interaction/internal/find-target-capture.ts index 9160a93e85..3109598692 100644 --- a/src/daemon/interaction/internal/find-target-capture.ts +++ b/src/daemon/interaction/internal/find-target-capture.ts @@ -21,7 +21,6 @@ export function createFindTargetCapture( params: Readonly<{ ref: SessionRef; device: SessionState['device']; - session: SessionState; req: DaemonRequest; logPath: string; locator: FindLocator; @@ -36,11 +35,10 @@ export function createFindTargetCapture( captureProof: RequestCaptureProof; }>, ): () => Promise { - const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; + const { device, req, logPath, locator, query, sessionStore, sessionName } = params; const captureRuntime = createSelectorCaptureRuntime({ ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index b127ad8e70..1057e97804 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -45,7 +45,6 @@ type FindContext = { sessionStore: SessionStore; invoke: DaemonInvokeFn; sessionRef: SessionRef; - session: SessionState; device: SessionState['device']; command: string; locator: FindLocator; @@ -135,7 +134,6 @@ export async function handleFindCommands(params: FindRouteInput): Promise { - const { req, sessionName, sessionStore, session, invoke, command, locator, query, publicFlags } = - ctx; + const { + req, + sessionName, + sessionStore, + sessionRef, + invoke, + command, + locator, + query, + publicFlags, + } = ctx; const response = await invoke({ token: req.token, session: sessionName, @@ -301,7 +307,7 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< Object.assign(matchData, successText(clickMessage)); recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'click', locator, query }, @@ -315,7 +321,7 @@ async function handleFindFill( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, sessionName, sessionStore, session, invoke, command, publicFlags } = ctx; + const { req, sessionName, sessionStore, sessionRef, invoke, command, publicFlags } = ctx; // `''` is the clear request (#2063); only a MISSING value is an error. if (value === undefined) { return errorResponse('INVALID_ARGS', 'find fill requires text (use "" to clear the field)'); @@ -331,7 +337,7 @@ async function handleFindFill( if (!response.ok) return response; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'fill' }, @@ -352,12 +358,13 @@ async function handleFindType( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; if (!value) { return errorResponse('INVALID_ARGS', 'find type requires text'); } const focusResponse = await dispatchFocusForFindMatch(ctx, match); if (!focusResponse.ok) return focusResponse; + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); // 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. expireRefFrame(session); @@ -380,7 +387,7 @@ async function dispatchFocusForFindMatch( ctx: FindContext, match: ResolvedMatch, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; const coveredResponse = rejectCoveredFindMatch(match, 'be focused'); if (coveredResponse) return coveredResponse; const coords = match.resolvedNode.rect ? centerOfRect(match.resolvedNode.rect) : null; @@ -390,6 +397,7 @@ async function dispatchFocusForFindMatch( // 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. + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); expireRefFrame(session); // R40/R35: the operation came from the handler's ONE action-selected bind; the shared // executor is the single lexical owner of the `focusPoint` call. @@ -420,10 +428,10 @@ function rejectCoveredFindMatch(match: ResolvedMatch, interaction: string): Daem } function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string): void { - const { req, sessionStore, session, command, publicFlags } = ctx; + const { req, sessionStore, sessionRef, command, publicFlags } = ctx; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action }, diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index e07d9028f9..59eb620d8a 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -239,7 +239,7 @@ async function dispatchGestureInteraction( responseData.warning = `${existingWarning}${readiness.warning}`; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore: params.sessionStore, command, actionCommand: command, diff --git a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts index 977114c8d6..5fd0566d49 100644 --- a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts +++ b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts @@ -11,7 +11,7 @@ import { getRequestSignal } from '@agent-device/host-kit/request'; import { isLocalIosRunnerSession } from '../../direct-ios-selector.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; -import type { SessionRef } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; const XCTEST_RECORDED_FAILURE = 'XCTEST_RECORDED_FAILURE'; @@ -54,8 +54,9 @@ export type IosTapCorroboration = { export async function corroborateIosTapFailure( params: IosTapCorroborationParams, ): Promise { - if (!canCorroborateIosTapFailure(params)) return undefined; - const baseline = readCorroborationBaseline(params.ref.session.snapshot); + const session = params.sessionStore.resolveCurrent(params.ref); + if (!session || !canCorroborateIosTapFailure(params, session)) return undefined; + const baseline = readCorroborationBaseline(session.snapshot); if (!baseline) return undefined; const after = await captureCorroborationSnapshot( @@ -69,11 +70,14 @@ export async function corroborateIosTapFailure( return compareCorroborationEvidence(baseline.snapshot, after, params.command); } -function canCorroborateIosTapFailure(params: IosTapCorroborationParams): boolean { +function canCorroborateIosTapFailure( + params: IosTapCorroborationParams, + session: SessionState, +): boolean { return ( isTapCommand(params.command) && asAppError(params.error).code === XCTEST_RECORDED_FAILURE && - isLocalIosRunnerSession(params.ref.session, { skipPendingPostGestureStabilization: false }) + isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false }) ); } diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index cc146829b1..74b9ad5151 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -29,7 +29,7 @@ import { isSessionRecording } from '../../session-script-publication-capability. import { recordTouchVisualizationEvent } from '../../recording-gestures.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import type { BoundGestureExecutor } from '../../gesture-runtime.ts'; import { NO_ACTIVE_SESSION_MESSAGE } from '@agent-device/kernel/contracts'; @@ -99,16 +99,17 @@ type FinalizeTouchInteractionInput = Omit< Parameters[0], 'operations' > & { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; }; export function finalizeTouchInteraction(params: FinalizeTouchInteractionInput): DaemonResponse { - const { session, sessionStore, ...finalization } = params; + const { ref, sessionStore, ...finalization } = params; + const session = sessionStore.requireCurrent(ref); return finalizeInteraction({ ...finalization, operations: { - recordAction: sessionStore.recordAction.bind(sessionStore, session), + recordAction: sessionStore.recordAction.bind(sessionStore, ref), markDeferredOutcome: (mark) => markDeferredInteractionOutcome({ session, ...mark }), isSessionRecording: isSessionRecording.bind(null, session), recordGestureVisualization: recordTouchVisualizationEvent.bind(null, session), diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 46835feae8..96564df057 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -72,7 +72,7 @@ export async function dispatchDirectIosSelectorTap( }, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? [], @@ -86,7 +86,6 @@ export async function dispatchDirectIosSelectorTap( const corroboratedResponse = await buildDirectIosCorroboratedResponse({ error, handlerParams, - session, extra, positionals: [], actionStartedAt, @@ -118,12 +117,11 @@ async function buildDirectIosCorroboratedResponse(params: { handlerParams: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession; }; - session: SessionState; extra: Record; positionals: string[]; actionStartedAt: number; }): Promise { - const { error, handlerParams, session, extra, positionals, actionStartedAt } = params; + const { error, handlerParams, extra, positionals, actionStartedAt } = params; const corroboration = await corroborateIosTapFailure({ error, command: handlerParams.req.command, @@ -143,7 +141,7 @@ async function buildDirectIosCorroboratedResponse(params: { extra, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? positionals, diff --git a/src/daemon/interaction/internal/interaction-touch-press.ts b/src/daemon/interaction/internal/interaction-touch-press.ts index ac0b0e396b..33779fdba0 100644 --- a/src/daemon/interaction/internal/interaction-touch-press.ts +++ b/src/daemon/interaction/internal/interaction-touch-press.ts @@ -131,7 +131,6 @@ function buildTargetedRuntimeOptions( const resultDurationMs = readLongPressResultDuration(result); return await buildTargetedTouchResponsePayloads({ params, - session, result, staleRefsWarning, publicData: transformTouchResponseData({ diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index ecf4a54b22..e8c4c39389 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -18,11 +18,18 @@ async function resolveDirectTouchReferenceFrame(params: { }): Promise { const { ref, flags, sessionStore, contextFromFlags, captureSnapshotForSession, observation } = params; - const session = sessionStore.requireCurrent(ref); - const recording = session.screenRecording?.handle; - if (!recording) { + const session = sessionStore.resolveCurrent(ref); + if (!session) return undefined; + const resource = session.screenRecording; + if (!resource) { return undefined; } + const recording = resource.handle; + const rememberFrame = (frame: GestureReferenceFrame | undefined) => { + if (!frame || sessionStore.resolveCurrent(ref)?.screenRecording !== resource) return undefined; + recording.setTouchReferenceFrame(frame); + return frame; + }; const currentFrame = recording.inspect().touchReferenceFrame; if (currentFrame) { return currentFrame; @@ -35,26 +42,21 @@ async function resolveDirectTouchReferenceFrame(params: { ) { if (!observation) throw new Error('Android observation was not injected into the request'); const size = await observation.readScreenSize(session.device); - const referenceFrame = { + return rememberFrame({ referenceWidth: size.width, referenceHeight: size.height, - }; - recording.setTouchReferenceFrame(referenceFrame); - return referenceFrame; + }); } const snapshotFrame = getSnapshotReferenceFrame(session.snapshot); if (snapshotFrame) { - recording.setTouchReferenceFrame(snapshotFrame); - return snapshotFrame; + return rememberFrame(snapshotFrame); } const snapshot = await captureSnapshotForSession(ref, flags, sessionStore, contextFromFlags, { interactiveOnly: true, }); - const referenceFrame = getSnapshotReferenceFrame(snapshot); - if (referenceFrame) recording.setTouchReferenceFrame(referenceFrame); - return referenceFrame; + return rememberFrame(getSnapshotReferenceFrame(snapshot)); } export async function resolveDirectTouchReferenceFrameSafely(params: { @@ -68,11 +70,13 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { try { return await resolveDirectTouchReferenceFrame(params); } catch (error) { + const current = params.sessionStore.resolveCurrent(params.ref); + if (!current) return undefined; emitDiagnostic({ level: 'warn', phase: 'touch_reference_frame_resolve_failed', data: { - platform: params.ref.session.device.platform, + platform: current.device.platform, error: error instanceof Error ? error.message : String(error), }, }); diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index e7235e216e..74fed83a21 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -17,7 +17,6 @@ import { } from '../../../core/interaction-response-data-transform.ts'; import { issueSettleRefs } from '../../session-snapshot.ts'; import type { RecordedTargetCapture } from '@agent-device/selectors/target-evidence'; -import type { SessionState } from '../../session-state.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { resolveDirectTouchReferenceFrameSafely } from './interaction-touch-reference-frame.ts'; import { readSnapshotNodesReferenceFrame } from '@agent-device/capture-kit/touch-reference-frame'; @@ -288,14 +287,13 @@ export async function buildTargetedTouchResponsePayloads(params: { params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession; }; - session: SessionState; result: TargetedTouchResult; staleRefsWarning: string | undefined; publicData?: Record; extra: Record; }): Promise { const { params: handlerParams, result, publicData, extra } = params; - const referenceFrame = + const probedFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ ref: handlerParams.sessionRef!, @@ -305,19 +303,23 @@ export async function buildTargetedTouchResponsePayloads(params: { captureSnapshotForSession: handlerParams.captureSnapshotForSession, observation: handlerParams.androidObservation, }) - : readSnapshotNodesReferenceFrame( - handlerParams.sessionStore.requireCurrent(handlerParams.sessionRef!).snapshot?.nodes ?? - [], - ); + : undefined; + const current = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!); + const referenceFrame = current + ? result.kind === 'point' + ? probedFrame + : readSnapshotNodesReferenceFrame(current.snapshot?.nodes ?? []) + : undefined; + const currentResult = current ? result : { ...result, settle: undefined }; return buildInteractionResponseData({ - source: { kind: 'runtime', result, publicData }, + source: { kind: 'runtime', result: currentResult, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, settleRefsGeneration: issueSettleRefs( handlerParams.sessionRef, handlerParams.sessionStore, - result.settle, + currentResult.settle, ), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index aa7f4c400a..27ed55f7b1 100644 --- a/src/daemon/interaction/internal/interaction-touch-runtime.ts +++ b/src/daemon/interaction/internal/interaction-touch-runtime.ts @@ -108,7 +108,7 @@ export async function dispatchRuntimeInteraction< responseData.warning = warning; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef, sessionStore: params.sessionStore, command: params.req.command, positionals: params.req.positionals ?? [], @@ -176,7 +176,7 @@ async function buildRuntimeIosCorroboratedResponse(params: { extra: params.extra, }); return finalizeTouchInteraction({ - session: params.session, + ref: params.handlerParams.sessionRef!, sessionStore: params.handlerParams.sessionStore, command: params.handlerParams.req.command, positionals: params.handlerParams.req.positionals ?? [], diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 9906d71224..f39fa054f5 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -168,7 +168,7 @@ async function runTypeTextViaRuntime( const responseData: Record = { ...result }; appendTypeReadinessWarnings(responseData, recordingRecoveryWarning, readiness); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index c616d7a61b..ad1e4bff5b 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -9,12 +9,11 @@ import type { import { readSessionRuntimeRevision, refFrame } from './ref-frame.ts'; import type { RefFrame } from './ref-frame-slot.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; type InternalObservationLineage = Readonly<{ - sessionName: string; - session: SessionState; - sessionCreatedAt: number; + ref: SessionRef; snapshot: SnapshotState; snapshotGeneration: number; runtimeRevision: number; @@ -24,16 +23,11 @@ type InternalObservationLineage = Readonly<{ const evidenceLineage = new WeakMap(); type BoundInternalObservationSession = Readonly<{ - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; signal?: AbortSignal; }>; -type InternalObservationSessionStore = Readonly<{ - get: () => SessionState | undefined; - update: (mutate: (session: SessionState) => void) => boolean; -}>; - /** * Bind observation authority to one already admitted, locked session. Callers * cannot address another session through the resulting capability, and engines @@ -58,21 +52,15 @@ export function bindInternalObservationAuthority( * without activating, replacing, or expiring client ref authority. */ function storeInternalObservation( - params: Pick, + params: Pick, snapshot: SnapshotState, ): ReplayObservationCapture { - const { sessionStore, sessionName } = params; - let storedSession: SessionState | undefined; - if ( - !sessionStore.update((session) => { - setSessionSnapshot(session, snapshot); - storedSession = session; - }) || - !storedSession - ) { + const { sessionStore, ref } = params; + if (!ref) { throw new Error('Internal observation session is no longer available.'); } - const session = storedSession; + const session = sessionStore.requireCurrent(ref); + setSessionSnapshot(session, snapshot); const snapshotGeneration = session.snapshotGeneration; if (snapshotGeneration === undefined) { throw new Error('Internal observation did not establish a snapshot generation.'); @@ -80,9 +68,7 @@ function storeInternalObservation( const evidence = {} as ReplayObservationEvidence; evidenceLineage.set(evidence, { - sessionName, - session, - sessionCreatedAt: session.createdAt, + ref, snapshot, snapshotGeneration, runtimeRevision: readSessionRuntimeRevision(session), @@ -100,8 +86,8 @@ function storeInternalObservation( * before the response returns to the client. */ function finalizeClientRefPublication(params: { - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; evidence: ReplayObservationEvidence; projection: ReplayRefPublicationProjection; signal?: AbortSignal; @@ -115,7 +101,8 @@ function finalizeClientRefPublication(params: { if (refs.size === 0) return { published: false, reason: 'empty' }; if (params.signal?.aborted === true) return { published: false, reason: 'cancelled' }; - if (!lineage || !isCurrentLineage(params, lineage)) { + const session = lineage && resolveCurrentLineage(params, lineage); + if (!lineage || !session) { return { published: false, reason: 'stale-capture' }; } if ( @@ -125,7 +112,7 @@ function finalizeClientRefPublication(params: { return { published: false, reason: 'invalid-projection' }; } - markSessionPartialRefsIssued(lineage.session, refs); + markSessionPartialRefsIssued(session, refs); return { published: true, refsGeneration: lineage.snapshotGeneration, @@ -133,20 +120,19 @@ function finalizeClientRefPublication(params: { }; } -function isCurrentLineage( - params: Pick, +function resolveCurrentLineage( + params: Pick, lineage: InternalObservationLineage, -): boolean { - const current = params.sessionStore.get(); - return ( - params.sessionName === lineage.sessionName && - current === lineage.session && - current.createdAt === lineage.sessionCreatedAt && +): SessionState | undefined { + if (!params.ref || params.ref.lifetime !== lineage.ref.lifetime) return undefined; + const current = params.sessionStore.resolveCurrent(params.ref); + return current && current.snapshot === lineage.snapshot && current.snapshotGeneration === lineage.snapshotGeneration && readSessionRuntimeRevision(current) === lineage.runtimeRevision && refFrame(current) === lineage.refFrame - ); + ? current + : undefined; } function normalizeRefBodies(refs: readonly string[]): Set { diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index a3eaccfafe..37bd6db269 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -61,7 +61,7 @@ export type ResolvedGenericExecution = export async function dispatchGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionName: string; logPath: string; sessionStore: SessionStore; @@ -74,8 +74,8 @@ export async function dispatchGenericCommand(params: { recordedRequest?: RecordedGenericRequest; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, logPath, sessionStore, contextFromFlags } = params; - const sessionRef = sessionStore.lookup(params.sessionName); + const { req, ref: sessionRef, logPath, sessionStore, contextFromFlags } = params; + const session = sessionStore.requireCurrent(sessionRef); const platformCommand = req.command; const commandReadiness = await ensureGenericCommandReady( @@ -112,9 +112,10 @@ export async function dispatchGenericCommand(params: { }; const actionStartedAt = Date.now(); + const current = sessionStore.requireCurrent(sessionRef); const dispatchContext = { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - surface: session.surface, + ...contextFromFlags(req.flags, current.appBundleId, current.trace?.outPath), + surface: current.surface, }; // ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/ // tv-remote/app-switcher/viewport/focus). Effect classification @@ -122,10 +123,10 @@ export async function dispatchGenericCommand(params: { // 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); + expireRefFrame(current); } const data = await params.executePlatformCommand({ - session, + session: current, sessionName: params.sessionName, logPath, command: platformCommand, @@ -136,7 +137,7 @@ export async function dispatchGenericCommand(params: { }); return await finalizeGenericCommand({ req, - session, + ref: sessionRef, sessionStore, command: platformCommand, resolvedPositionals, @@ -159,7 +160,7 @@ export async function dispatchGenericCommand(params: { */ async function finalizeGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -170,9 +171,9 @@ async function finalizeGenericCommand(params: { observeSettle?: () => Promise; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, sessionStore, command } = params; + const { req, ref, sessionStore, command } = params; const postflightReadiness = await ensureNoAndroidBlockingDialogReady( - session, + sessionStore.requireCurrent(ref), command, params.androidObservation, 'after-command', @@ -181,7 +182,7 @@ async function finalizeGenericCommand(params: { let data = withReadinessWarnings(params.data, params.readinessWarnings); recordVisualizationAndAction({ - session, + ref, sessionStore, command, resolvedPositionals: params.resolvedPositionals, @@ -194,7 +195,7 @@ async function finalizeGenericCommand(params: { }); markDeferredInteractionOutcome({ - session, + session: sessionStore.requireCurrent(ref), command, positionals: params.resolvedPositionals, flags: req.flags, @@ -332,7 +333,7 @@ async function ensureGenericCommandReady( } function recordVisualizationAndAction(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -344,7 +345,7 @@ function recordVisualizationAndAction(params: { clientArtifactPaths: Record | undefined; }): void { const { - session, + ref, sessionStore, command, resolvedPositionals, @@ -355,6 +356,7 @@ function recordVisualizationAndAction(params: { flags, clientArtifactPaths, } = params; + const session = sessionStore.requireCurrent(ref); const visualizationData = augmentScrollVisualizationResult( session, command, @@ -370,7 +372,7 @@ function recordVisualizationAndAction(params: { actionStartedAt, actionFinishedAt, ); - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command, positionals: recorded.positionals, flags: recorded.flags, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index bf871c66e5..2d748f3be9 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -493,10 +493,11 @@ async function dispatchGenericForLockedScope(params: { androidObservation: AndroidObservationAdapter; }): Promise { const { lockedScope, logPath, sessionStore, androidObservation } = params; - const session = sessionStore.get(lockedScope.sessionName); - if (!session) { + const ref = sessionStore.lookup(lockedScope.sessionName); + if (!ref) { return noActiveSessionError(); } + const session = sessionStore.requireCurrent(ref); const runtimeExecution = await resolveGenericRuntimeExecution({ req: lockedScope.req, @@ -516,7 +517,7 @@ async function dispatchGenericForLockedScope(params: { const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ req: lockedScope.req, - session, + ref, sessionName: lockedScope.sessionName, logPath, sessionStore, diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 2cfd92c54d..6bd2a92089 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -26,7 +26,6 @@ const SELECTOR_CAPTURE_CACHE_TTL_MS = 750; export type SelectorCaptureRuntimeParams = { ref: SessionRef | undefined; device: SessionState['device']; - session: SessionState | undefined; sessionStore: SessionStore; sessionName: string; req: DaemonRequest; @@ -197,6 +196,7 @@ async function runCapture( snapshotScope: string | undefined, interactiveOnly = request.flags?.snapshotInteractiveOnly, ): Promise { + const session = params.ref ? params.sessionStore.requireCurrent(params.ref) : undefined; const flags = { ...request.flags, snapshotInteractiveOnly: interactiveOnly, @@ -204,7 +204,7 @@ async function runCapture( const boundCapture = params.capture; const capture = await captureSnapshot({ device: params.device, - session: params.session, + session, flags, outPath: request.outPath ?? params.req.flags?.out, logPath: params.logPath ?? '', @@ -217,7 +217,7 @@ async function runCapture( flags, logPath: params.logPath ?? '', meta: params.req.meta, - session: params.session, + session, snapshotScope, includeRects: request.includeRects, // The POLL's remaining budget, not the request's. A binding's signal is fixed at diff --git a/src/daemon/selector-recording.ts b/src/daemon/selector-recording.ts index b03679e242..38e044b079 100644 --- a/src/daemon/selector-recording.ts +++ b/src/daemon/selector-recording.ts @@ -3,6 +3,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { FindReadResult } from '@agent-device/contracts/interaction'; import { stripAndroidSystemChromeProvenanceFromNode } from '@agent-device/contracts/android-system-chrome'; import { SessionStore } from './session-store.ts'; +import type { SessionRef } from './session-state.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; import { isSessionRecording } from './session-script-publication-capability.ts'; import { @@ -127,7 +128,7 @@ export function stripSelectorChain>(result: T) export function recordIfSession( sessionStore: SessionStore, - sessionName: string, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, /** ADR 0012 decision 3: record-time input for the `target-v1` annotation. */ @@ -135,13 +136,14 @@ export function recordIfSession( /** #1349: `landmark` for wait's existence-semantics evidence; defaults to `action`. */ evidenceMode?: TargetEvidenceMode, ): void { - const session = sessionStore.get(sessionName); + if (!ref) return; + const session = sessionStore.resolveCurrent(ref); if (!session) return; const targetEvidence = isSessionRecording(session) && recordedTarget ? computeTargetEvidence(recordedTarget, { mode: evidenceMode }) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 1e87c6c47f..24ad3eeb8d 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -181,7 +181,6 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice : createSelectorCaptureRuntime({ ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 4ad7685b04..11ff2fdc99 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -66,7 +66,7 @@ export async function dispatchFindReadOnlyViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildFindRecordResult(result, action), ); @@ -168,7 +168,7 @@ export async function dispatchGetViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildGetRecordResult(result, sub), { @@ -236,7 +236,7 @@ export async function dispatchIsViaRuntime( }); const recordedTarget = readRecordedResolutionTarget(result); const strippedResult = stripResolutionPayload(result); - recordIfSession(params.sessionStore, params.sessionName, req, strippedResult, recordedTarget); + recordIfSession(params.sessionStore, resolvedRuntime.ref, req, strippedResult, recordedTarget); return stripSelectorChain(strippedResult); }); return withCaptureDisclosures({ diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index d0539a8fb2..fe721377b3 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -99,7 +99,9 @@ import { } from '../../__tests__/test-utils/device-claim-store.ts'; import { sendRequest } from '../../daemon-client/daemon-client-transport.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntime, teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; afterEach(() => { lifecycleEvents.length = 0; @@ -315,3 +317,47 @@ test('shutdown joins dispatch completion after force-closing the client before t await runtime?.shutdown(); } }); +test.each([false, true])( + 'shutdown forwards only the addressed lifetime’s runtime hints, retired=%s', + async (retired) => { + const sessionStore = makeSessionStore('shutdown-scoped-hints-'); + const address = 'cwd:shutdown-hints:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const publicRef = sessionStore.publish('default', makeIosSession('default')); + sessionStore.setRuntimeHints(address, { metroHost: 'scoped.example', metroPort: 8082 }); + sessionStore.setRuntimeHints('default', { metroHost: 'public.example', metroPort: 8083 }); + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeIosSession('default')); + sessionStore.setRuntimeHints(address, { metroHost: 'successor.example', metroPort: 8084 }); + } + const finalizeApplicationLifecycle = vi.fn(async () => {}); + + await teardownDaemonSessionForShutdown({ + ref, + sessionStore, + stderr: { write: () => {} }, + finalizeApplicationLifecycle, + }); + + expect(finalizeApplicationLifecycle).toHaveBeenCalledWith( + ref.session, + retired ? {} : { metroHost: 'scoped.example', metroPort: '8082' }, + ); + expect(sessionStore.resolveCurrent(publicRef)).toBe(publicRef.session); + expect(sessionStore.getRuntimeHints('default')).toEqual({ + metroHost: 'public.example', + metroPort: 8083, + }); + if (successor) { + expect(sessionStore.resolveCurrent(successor)).toBe(successor.session); + expect(sessionStore.getRuntimeHints(address)).toEqual({ + metroHost: 'successor.example', + metroPort: 8084, + }); + } else { + expect(sessionStore.lookup(address)).toBeUndefined(); + } + }, +); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index a55bc8d05e..2836d89a88 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -36,6 +36,7 @@ import { runtimeHintValues } from '../session-runtime.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn } from '../daemon-request.ts'; import type { SessionRef, SessionState } from '../session-state.ts'; +import type { RuntimeHintValues } from '@agent-device/contracts/application-lifecycle-runtime'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; @@ -137,7 +138,10 @@ export async function teardownDaemonSessionForShutdown(params: { sessionStore: SessionStore; stateDir?: string; stderr: WritableOutput; - finalizeApplicationLifecycle?: (session: SessionState) => Promise; + finalizeApplicationLifecycle?: ( + session: SessionState, + runtimeHints: RuntimeHintValues, + ) => Promise; beforeDelete?: (session: SessionState) => Promise; afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { @@ -150,7 +154,11 @@ export async function teardownDaemonSessionForShutdown(params: { beforeDelete, afterSuccessfulTeardown, } = params; - const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const current = sessionStore.resolveCurrent(ref); + const session = current ?? ref.session; + const runtimeHints = runtimeHintValues( + current ? sessionStore.getRuntimeHints(ref.address) : undefined, + ); const timeoutMs = resolveDaemonSessionTeardownTimeoutMs(session); // The ownership-fenced app-log side effect must settle while this process // still owns the daemon lock. It is intentionally outside the generic @@ -182,7 +190,8 @@ export async function teardownDaemonSessionForShutdown(params: { session, stderr, resource: 'lifecycle', - teardown: async () => await finalizeApplicationLifecycle(sessionAfterAppLog), + teardown: async () => + await finalizeApplicationLifecycle(sessionAfterAppLog, runtimeHints), }) : true; return genericTeardownSucceeded && lifecycleTeardownSucceeded; @@ -477,13 +486,13 @@ export async function startDaemonRuntime( ref, sessionStore, stderr, - finalizeApplicationLifecycle: async (sessionToFinalize) => + finalizeApplicationLifecycle: async (sessionToFinalize, runtimeHints) => await finalizeDaemonSessionApplicationLifecycle({ gateway: deviceRuntimeGateway, scope: createDaemonRecoveryPlatformScope(), session: sessionToFinalize, stateDir: baseDir, - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionToFinalize.name)), + runtimeHints, }), beforeDelete: async (sessionToFinalize) => { await finalizeDaemonSessionLease({ diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 3335c3bc2a..547dc8ef83 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -3,7 +3,7 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { recordedFlagKeys } from '@agent-device/command-registry/flag-registry'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionRuntimeHints, SessionState } from './session-state.ts'; +import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { applyRecordedSaveScriptFlags } from './session-script-publication-capability.ts'; import { repairSessionBoundary } from './session-replay-transaction.ts'; import type { MultiTargetAnnotationV1, TargetAnnotationV1 } from '@agent-device/contracts/replay'; @@ -98,24 +98,27 @@ export function recordActionEntry( return action; } -type SessionActionStore = { recordAction(session: SessionState, entry: RecordActionEntry): void }; +type SessionActionStore = { + resolveCurrent(ref: SessionRef): SessionState | undefined; + recordAction(ref: SessionRef, entry: RecordActionEntry): void; +}; /** - * Record a session action if a session is active. No-op when session is undefined. + * Record an action in its admitted lifetime. No-op when no session was admitted. * * By default the recorded positionals/flags mirror the request; pass `overrides` to * record a different set (e.g. resolved positionals or stripped public flags). */ export function recordSessionAction( sessionStore: SessionActionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, command: string, result: Record | undefined, overrides?: { positionals?: string[]; flags?: CommandFlags }, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref || !sessionStore.resolveCurrent(ref)) return; + sessionStore.recordAction(ref, { command, positionals: overrides?.positionals ?? req.positionals ?? [], flags: overrides?.flags ?? ((req.flags ?? {}) as CommandFlags), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 095d95cf90..f5d2f37a62 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -1,11 +1,17 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; +import fs from 'node:fs'; import type { ApplicationLifecycleRuntimeOperations, OpenApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { DaemonRequest } from '../../../daemon-request.ts'; +import { + registerRequestAbort, + markRequestCanceled, + clearRequestAbortRegistration, +} from '@agent-device/host-kit/request'; import { AppError } from '@agent-device/kernel/errors'; const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); @@ -436,3 +442,211 @@ test('open reports the launch confirmation its platform answered', async () => { expect(response?.ok).toBe(true); if (response?.ok) expect(response.data?.launchConfirmation).toBe('accepted'); }); + +function holdNativeOpen() { + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const bindDefault = mockBindDeviceRuntime.getMockImplementation(); + if (!bindDefault) throw new Error('the harness binds a default runtime'); + mockBindDeviceRuntime.mockImplementationOnce(async (device, use) => { + const binding = await bindDefault(device, use); + const operations = binding.operations as ApplicationLifecycleRuntimeOperations; + return { + ...binding, + operations: { + ...binding.operations, + openApplication: async (input: OpenApplicationInput) => { + entered(); + await held; + return await operations.openApplication(input); + }, + }, + }; + }); + return { reached, release }; +} + +function invokeHeldOpen( + sessionStore: ReturnType, + req?: Partial, +) { + return handleSessionCommands({ + req: { + token: 't', + command: 'open', + session: 'default', + positionals: ['com.example.demo'], + flags: { platform: 'android' }, + ...req, + }, + sessionName: 'cwd:held-open:default', + sessionStore, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + invoke: noopInvoke, + }); +} + +test('reopen publishes into the latest record of the same lifetime after native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store); + await native.reached; + store.update(ref, { recordOnlySession: true }); + native.release(); + expect((await pending)?.ok).toBe(true); + expect(store.lookup(ref.address)?.lifetime).toBe(ref.lifetime); + expect(store.requireCurrent(ref).recordOnlySession).toBe(true); + expect(store.requireCurrent(ref).createdAt).toBe(session.createdAt); + expect(store.requireCurrent(ref).name).toBe('default'); +}); + +test('a retired reopen cannot write hints or actions to the same record republished as a successor', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + await native.reached; + store.retire(ref); + const successor = store.publish(ref.address, session); + store.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'successor-host' }); + native.release(); + await refusal; + expect(store.requireCurrent(successor)).toBe(session); + expect(session.actions).toEqual([]); + expect(store.getRuntimeHints(ref.address)?.metroHost).toBe('successor-host'); +}); + +test('a provisional open refuses a successor before native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + await expect( + invokeHeldOpen(store, { + internal: { + openLifecycle: { + beforeDispatch: async () => { + const provisional = store.requireCurrent(ref); + store.retire(ref); + store.publish(ref.address, provisional); + }, + }, + }, + }), + ).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(store.get(ref.address)?.actions).toEqual([]); +}); + +test('fresh open completing after shutdown cannot publish or replace runtime hints', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-shutdown-open')); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'daemon_shutting_down' }), + }), + ); + await native.reached; + store.closeAdmission(); + native.release(); + await refusal; + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +test('cancelled fresh open does not publish after native launch returns', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-cancelled-open')); + const native = holdNativeOpen(); + const requestId = 'cancelled-open-lifetime'; + const registration = registerRequestAbort(requestId); + try { + const pending = invokeHeldOpen(store, { meta: { requestId } }); + await native.reached; + markRequestCanceled(requestId); + native.release(); + expect(await pending).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + code: 'COMMAND_FAILED', + details: expect.objectContaining({ reason: 'request_canceled' }), + }), + }), + ); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + } finally { + clearRequestAbortRegistration(registration); + } +}); + +test('fresh open does not publish when its artifact directory cannot be created', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-directory-failed-open')); + fs.writeFileSync(path.dirname(store.resolveSessionDir('cwd:held-open:default')), 'blocked'); + await expect( + invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }), + ).rejects.toMatchObject({ code: 'ENOTDIR' }); + expect(mockDispatch).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +for (const transition of ['rebuild', 'retire'] as const) { + test(`foreground composition retains the published lifetime across ${transition}`, async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue( + makeAndroidEmulator(`emulator-foreground-${transition}`), + ); + const record = store.recordAction.bind(store); + vi.spyOn(store, 'recordAction').mockImplementationOnce((recordedRef, entry) => { + record(recordedRef, entry); + const ref = store.lookup('cwd:held-open:default')!; + queueMicrotask(() => { + if (transition === 'rebuild') { + store.update(ref, { recordOnlySession: true }); + } else { + store.retire(ref); + store.publish(ref.address, recordedRef.session); + } + mockInspectDeviceRuntimeFacts.mockClear(); + }); + }); + const response = await invokeHeldOpen(store, { + flags: { platform: 'android', foreground: true }, + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('open must remain successful'); + if (transition === 'retire') { + expect(response.data?.initialSnapshotError).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockInspectDeviceRuntimeFacts).not.toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.snapshot).toBeUndefined(); + } else { + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.recordOnlySession).toBe(true); + } + }); +} diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index a2039a45f6..7794912797 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -4,6 +4,11 @@ const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); vi.mock('../../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime })); +import { + makeSessionStore, + makeSession, + makeAndroidEmulator, +} from './session-open-runtime.fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest, DaemonResponse } from '../../../daemon-request.ts'; import { @@ -13,6 +18,8 @@ import { const inspectFacts = vi.fn(); const bindDevice = vi.fn(); +let sessionStore: ReturnType; +let ref: ReturnType; function baseRequest(overrides: Partial = {}): DaemonRequest { return { @@ -26,6 +33,8 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { } beforeEach(() => { + sessionStore = makeSessionStore(); + ref = sessionStore.publish('default', makeSession('default', makeAndroidEmulator())); dispatchSnapshotViaRuntime.mockReset(); inspectFacts.mockReset(); bindDevice.mockReset(); @@ -142,9 +151,9 @@ const failedOpenResponse: DaemonResponse = { test('passes a failed open response through untouched', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: failedOpenResponse, inspectFacts, bindDevice, @@ -157,9 +166,9 @@ test('passes a failed open response through untouched', async () => { test('leaves a successful open response untouched when --foreground was not requested', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest(), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -175,9 +184,9 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] }); const result = await composeOpenWithInitialSnapshot({ req, - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -193,8 +202,9 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn flags: { ...req.flags, snapshotInteractiveOnly: true }, }, sessionName: 'default', + sessionRef: ref, logPath: '/tmp/daemon.log', - sessionStore: {}, + sessionStore, inspectFacts, bindDevice, }); @@ -224,9 +234,9 @@ test('a snapshot-capture failure never masks the successful open', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default', warnings: ['pre-existing warning'] } }, inspectFacts, bindDevice, @@ -266,9 +276,9 @@ test('a THROWN snapshot-capture failure never masks the successful open either', const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default' } }, inspectFacts, bindDevice, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts new file mode 100644 index 0000000000..cd48fa95c5 --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { publishOpenSession } from '../session-open-state.ts'; +import { makeSessionStore } from './session-open-runtime.fixtures.ts'; +import { + authoringPublication, + makeIosSession, +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; + +// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- +// +// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), +// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is +// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the +// publication state through untouched — it has no arming decision to get wrong. + +function reopen(existingSession: ReturnType) { + const store = makeSessionStore(); + const ref = store.publish(existingSession.name, existingSession); + return store.requireCurrent( + publishOpenSession({ + req: { token: 't', command: 'open', positionals: [], session: existingSession.name }, + sessionStore: store, + sessionName: existingSession.name, + existingRef: ref, + device: IOS_SIMULATOR, + surface: 'app', + appBundleId: 'com.example.other', + }), + ); +} + +test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { + const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); + + const next = reopen(aborted); + + assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); + assert.equal(isSessionRecording(next), false); +}); + +test('a re-open carries an armed authoring lifecycle through unchanged', () => { + const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); + + const next = reopen(armed); + + assert.deepEqual(next.scriptPublication, authoringPublication('armed')); + assert.equal(isSessionRecording(next), true); +}); + +test('a re-open of a session that never armed records nothing', () => { + const next = reopen(makeIosSession('s')); + + assert.equal(next.scriptPublication, undefined); + assert.equal(isSessionRecording(next), false); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts index 63d81782cb..12cb6b9f15 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts @@ -1,14 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { buildNextOpenSession, buildOpenResult } from '../session-open-surface.ts'; +import { buildOpenResult } from '../session-open-surface.ts'; import { resolveRequestedOpenSurface } from '../../../../platform-runtime-open-target.ts'; -import { - authoringPublication, - makeIosSession, -} from '../../../../__tests__/test-utils/session-factories.ts'; import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; -import { isSessionRecording } from '../../../session-script-publication-capability.ts'; test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { assert.throws( @@ -103,46 +98,3 @@ test('buildOpenResult reports an answered launch confirmation and omits one that ); assert.equal('launchConfirmation' in buildOpenResult(base), false); }); - -// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- -// -// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), -// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is -// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the -// publication state through untouched — it has no arming decision to get wrong. - -function reopen(existingSession: ReturnType) { - return buildNextOpenSession({ - existingSession, - sessionName: existingSession.name, - sessionScope: existingSession.sessionScope ?? { kind: 'named-local' }, - device: IOS_SIMULATOR, - surface: 'app', - appBundleId: 'com.example.other', - }); -} - -test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { - const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); - - const next = reopen(aborted); - - assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); - assert.equal(isSessionRecording(next), false); -}); - -test('a re-open carries an armed authoring lifecycle through unchanged', () => { - const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); - - const next = reopen(armed); - - assert.deepEqual(next.scriptPublication, authoringPublication('armed')); - assert.equal(isSessionRecording(next), true); -}); - -test('a re-open of a session that never armed records nothing', () => { - const next = reopen(makeIosSession('s')); - - assert.equal(next.scriptPublication, undefined); - assert.equal(isSessionRecording(next), false); -}); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index ec64319b86..ea8c3a69a6 100644 --- a/src/daemon/session-lifecycle/internal/session-close-script.ts +++ b/src/daemon/session-lifecycle/internal/session-close-script.ts @@ -18,12 +18,9 @@ export type RepairCloseCommit = | { kind: 'aborted' } | { kind: 'failed'; error: AppError }; -function recordCloseAction( - sessionStore: SessionStore, - session: SessionState, - req: DaemonRequest, -): void { - sessionStore.recordAction(session, { +function recordCloseAction(sessionStore: SessionStore, ref: SessionRef, req: DaemonRequest): void { + const session = sessionStore.requireCurrent(ref); + sessionStore.recordAction(ref, { command: 'close', positionals: req.positionals ?? [], flags: (req.flags ?? {}) as CommandFlags, @@ -40,7 +37,7 @@ export function commitRepairScriptBeforeClose( if (!isRepairArmedSession(session)) return { kind: 'not-armed' }; const actionsBeforeClose = session.actions.length; - recordCloseAction(sessionStore, session, req); + recordCloseAction(sessionStore, ref, req); const alreadyPublished = isSessionScriptPublished(session); const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), @@ -85,7 +82,7 @@ export function finalizeOrdinaryCloseScript(params: { const { req, ref, sessionStore, platformCloseError } = params; const session = sessionStore.requireCurrent(ref); if (!platformCloseError) { - recordCloseAction(sessionStore, session, req); + recordCloseAction(sessionStore, ref, req); } // The recorded close action already armed target/force through the recorder's flag ingress. // On a platform-close failure that action was never recorded, so the log still publishes to the diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 82d04c6364..ef8a65fbc0 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -8,10 +8,10 @@ import { type DeviceSelectionResult, } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { abortAuthoringOnSecondOpen, armAuthoringOnOpen, @@ -30,7 +30,8 @@ import { setSessionRuntimeHintsForOpen, } from '../../session-runtime.ts'; import { STARTUP_SAMPLE_METHOD, type StartupPerfSample } from './session-startup-metrics.ts'; -import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts'; +import { buildOpenResult } from './session-open-surface.ts'; +import { publishOpenSession, requireOpenSessionAdmission } from './session-open-state.ts'; import { markDeferredInteractionOutcome } from '../../deferred-interaction-outcome.ts'; import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; import { @@ -42,12 +43,7 @@ import { buildForeignWorkspaceSessionConflict, } from '../../session-recovery-hints.ts'; import { describeOpenWaitForRefusal } from '../../open-device-contention-wait.ts'; -import { - isImplicitSessionScopeConflict, - resolveSessionScope, - resolvePublicSessionName, -} from '../../session-routing.ts'; -import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { isImplicitSessionScopeConflict, resolvePublicSessionName } from '../../session-routing.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { abandonDeviceClaim, @@ -69,6 +65,10 @@ import { requireAllocatorHeldDeviceClaim } from '../../device/device-claim-alloc import { deviceClaimRuleForOwner } from '../../device/device-claim-rule.ts'; import { errorResponse, type DaemonFailureResponse } from '@agent-device/kernel/contracts'; +export type SessionOpenResult = + | Readonly<{ type: 'opened'; response: DaemonResponse; ref: SessionRef }> + | Readonly<{ type: 'response'; response: DaemonResponse }>; + type OpenTiming = { totalDurationMs?: number; relaunchCloseDurationMs?: number; @@ -92,10 +92,6 @@ export type RuntimeHintClearOperation = BoundDeviceRuntime< typeof openApplicationWithRuntimeHintClearUse >['operations']['clearRuntimeHints']; -function resolveOpenSessionScope(req: DaemonRequest): SessionScope { - return req.internal?.resolvedSessionScope ?? resolveSessionScope(req); -} - function applyOrdinaryScriptRecordingOpenOutcome(params: { session: SessionState; existingSession: SessionState | undefined; @@ -188,12 +184,12 @@ export async function completeOpenCommand(params: { runtimeHints: ReturnType; lifecycle: OpenApplicationRuntime; applyRuntimeHints?: RuntimeHintApplyOperation; - existingSession?: SessionState; + existingRef?: SessionRef; deviceClaim?: DeviceClaimSessionOwnership; /** The stale claim this open released before taking the device, when there was one. */ tookOverDeviceClaim?: TakenOverDeviceClaim; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -208,11 +204,14 @@ export async function completeOpenCommand(params: { runtimeHints, lifecycle, applyRuntimeHints, - existingSession, + existingRef, deviceClaim, tookOverDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + const existingSession = existingRef ? sessionStore.requireCurrent(existingRef) : undefined; + const freshnessBaseline = existingSession?.snapshot; const shouldRelaunch = req.flags?.relaunch === true; let sessionAppBundleId = appBundleId; const openCommandStartedAtMs = Date.now(); @@ -225,10 +224,14 @@ export async function completeOpenCommand(params: { surface, sessionAppBundleId, appName, - existingSession, + existingRef, }); - if (provisionalSession.type === 'response') return provisionalSession.response; - const openDispatchSession = provisionalSession.session ?? existingSession; + if (provisionalSession.type === 'response') return provisionalSession; + const openDispatchRef = provisionalSession.ref; + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + const openDispatchSession = openDispatchRef + ? sessionStore.requireCurrent(openDispatchRef) + : undefined; const openStartedAtMs = Date.now(); const outcome = await lifecycle.operations.openApplication({ target: openTarget, @@ -247,7 +250,7 @@ export async function completeOpenCommand(params: { execution: applicationLifecycleExecutionFromRequest( req, logPath, - existingSession?.trace?.outPath, + openDispatchSession?.trace?.outPath, ), }); sessionAppBundleId = outcome.appBundleId ?? sessionAppBundleId; @@ -258,35 +261,22 @@ export async function completeOpenCommand(params: { : undefined; if (isRequestCanceled(req.meta?.requestId)) { const canceled = createRequestCanceledError(); - return errorResponse(canceled.code, canceled.message, canceled.details); + return { + type: 'response', + response: errorResponse(canceled.code, canceled.message, canceled.details), + }; } - if (existingSession) { - // Mark before buildNextOpenSession clears the stored snapshot. `open` is one of the few - // nav-sensitive commands that would otherwise lose its pre-action freshness baseline. + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + if (existingRef) { markDeferredInteractionOutcome({ - session: existingSession, + session: sessionStore.requireCurrent(existingRef), command: 'open', positionals: [], flags: undefined, + androidFreshnessBaseline: freshnessBaseline, }); } - const nextSession = buildNextOpenSession({ - existingSession: openDispatchSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), - device, - surface, - appBundleId: sessionAppBundleId, - appName, - }); - nextSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - if (deviceClaim) nextSession.deviceClaim = deviceClaim; - if (req.runtime !== undefined) - setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); const sessionStateDir = sessionStore.ensureSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( sessionStateDir, @@ -300,7 +290,7 @@ export async function completeOpenCommand(params: { data: timing, }); const openResult = buildOpenResult({ - sessionName: nextSession.name, + sessionName: openDispatchSession?.name ?? resolvePublicSessionName(req), sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), requestLogPath, @@ -320,21 +310,34 @@ export async function completeOpenCommand(params: { if (tookOverDeviceClaim) { appendResponseWarning(openResult, deviceClaimTakeoverWarning(tookOverDeviceClaim)); } + const nextRef = publishOpenSession({ + req, + sessionStore, + sessionName, + existingRef: openDispatchRef, + device, + surface, + appBundleId: sessionAppBundleId, + appName, + deviceClaim, + }); + const nextSession = sessionStore.requireCurrent(nextRef); + if (req.runtime !== undefined) + setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); applyOrdinaryScriptRecordingOpenOutcome({ session: nextSession, - existingSession, + existingSession: existingRef ? nextSession : undefined, saveScriptRequested: Boolean(req.flags?.saveScript), responseData: openResult, }); - sessionStore.set(sessionName, nextSession); - sessionStore.recordAction(nextSession, { + sessionStore.recordAction(nextRef, { command: 'open', positionals: openPositionals, flags: req.flags ?? {}, runtime: req.runtime !== undefined ? runtimeHints : undefined, result: openResult, }); - return { ok: true, data: openResult }; + return { type: 'opened', ref: nextRef, response: { ok: true, data: openResult } }; } async function prepareOpenDispatchSession(params: { @@ -345,45 +348,28 @@ async function prepareOpenDispatchSession(params: { surface: SessionSurface; sessionAppBundleId: string | undefined; appName: string | undefined; - existingSession: SessionState | undefined; + existingRef: SessionRef | undefined; }): Promise< - { type: 'session'; session?: SessionState } | { type: 'response'; response: DaemonResponse } + { type: 'session'; ref?: SessionRef } | { type: 'response'; response: DaemonResponse } > { - const { req, sessionName, sessionStore, existingSession } = params; + const { req, sessionName, sessionStore, existingRef } = params; const beforeDispatch = req.internal?.openLifecycle?.beforeDispatch; - if (!beforeDispatch) return { type: 'session', session: existingSession }; - const provisionalSession = createProvisionalOpenDispatchSession(params); - sessionStore.set(sessionName, provisionalSession); + if (!beforeDispatch) return { type: 'session', ref: existingRef }; + const ref = publishOpenSession({ + req, + sessionName, + sessionStore, + existingRef, + device: params.device, + surface: params.surface, + appBundleId: params.sessionAppBundleId, + appName: params.appName, + }); const lifecycleResponse = await beforeDispatch(); if (lifecycleResponse && !lifecycleResponse.ok) return { type: 'response', response: lifecycleResponse }; - return { type: 'session', session: sessionStore.get(sessionName) ?? provisionalSession }; -} - -function createProvisionalOpenDispatchSession(params: { - req: DaemonRequest; - sessionName: string; - device: DeviceInfo; - surface: SessionSurface; - sessionAppBundleId: string | undefined; - appName: string | undefined; - existingSession: SessionState | undefined; -}): SessionState { - const { req, device, surface, sessionAppBundleId, appName, existingSession } = params; - const provisionalSession = buildNextOpenSession({ - existingSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), - device, - surface, - appBundleId: sessionAppBundleId, - appName, - }); - provisionalSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - return provisionalSession; + requireOpenSessionAdmission(sessionStore, sessionName, ref); + return { type: 'session', ref: sessionStore.refresh(ref) }; } /** @@ -466,7 +452,7 @@ export async function openNewSessionWithDeviceClaim(params: { clearRuntimeHints?: RuntimeHintClearOperation; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -482,8 +468,9 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); - if (conflict) return conflict; + if (conflict) return { type: 'response', response: conflict }; const ownerClaim = await acquireDeviceClaimForOwner({ req, @@ -494,8 +481,11 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, }); if (ownerClaim.status === 'conflict') - return buildDeviceClaimConflictError(device, ownerClaim.conflict); - if (ownerClaim.status === 'refused') return ownerClaim.response; + return { + type: 'response', + response: buildDeviceClaimConflictError(device, ownerClaim.conflict), + }; + if (ownerClaim.status === 'refused') return { type: 'response', response: ownerClaim.response }; const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; const tookOverDeviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.tookOver : undefined; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; @@ -507,6 +497,7 @@ export async function openNewSessionWithDeviceClaim(params: { sessionStore, }); try { + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const details = await prepareOpenCommandDetails({ req, logPath, @@ -519,14 +510,14 @@ export async function openNewSessionWithDeviceClaim(params: { }); if (details.type === 'response') { await rollbackClaim(); - return details.response; + return { type: 'response', response: details.response }; } // Preparation can boot the device or warm caches, but it cannot establish session ownership. // Stamping here is what covers a boot preparation caused for this very open; from // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the // app, so a failure from that point cannot prove ownership was never established. const reclaimed = await renewOpenSessionClaim(device, deviceClaim); - if (reclaimed) return reclaimed; + if (reclaimed) return { type: 'response', response: reclaimed }; effects.mayHaveStarted = true; const requestedPositionals = req.positionals ?? []; // `open ` carries both positionals; only `--foreground`, which has none, gets its @@ -556,7 +547,7 @@ export async function openNewSessionWithDeviceClaim(params: { tookOverDeviceClaim, selection, }); - if (!response.ok) await rollbackClaim(); + if (!response.response.ok) await rollbackClaim(); return response; } catch (error) { await rollbackClaim(); diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index c222ce2bed..201b68c740 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -1,5 +1,6 @@ import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { dispatchSnapshotViaRuntime } from '../../snapshot-runtime.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { @@ -102,14 +103,14 @@ export async function resolveForegroundOpenRequest(params: { */ export async function composeOpenWithInitialSnapshot(params: { req: DaemonRequest; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; openResponse: DaemonResponse; inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, sessionStore, openResponse } = params; + const { req, ref, logPath, sessionStore, openResponse } = params; if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; try { @@ -123,7 +124,8 @@ export async function composeOpenWithInitialSnapshot(params: { // key the snapshot runtime reads as `interactiveOnly`. flags: { ...req.flags, snapshotInteractiveOnly: true }, }, - sessionName, + sessionName: ref.address, + sessionRef: ref, logPath, sessionStore, inspectFacts: params.inspectFacts, diff --git a/src/daemon/session-lifecycle/internal/session-open-state.ts b/src/daemon/session-lifecycle/internal/session-open-state.ts new file mode 100644 index 0000000000..828c6c564d --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-state.ts @@ -0,0 +1,71 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import type { DaemonRequest } from '../../daemon-request.ts'; +import type { SessionRef } from '../../session-state.ts'; +import type { SessionStore } from '../../session-store.ts'; +import type { DeviceClaimSessionOwnership } from '../../device/device-claims.ts'; +import { clearSessionSnapshot } from '../../session-snapshot.ts'; +import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { resolvePublicSessionName, resolveSessionScope } from '../../session-routing.ts'; + +export function requireOpenSessionAdmission( + store: SessionStore, + address: string, + ref: SessionRef | undefined, +): void { + if (ref) { + store.assertAdmissionOpen(ref.address); + store.requireCurrent(ref); + } else { + store.assertPublishable(address); + } +} + +export function publishOpenSession(params: { + req: DaemonRequest; + sessionStore: SessionStore; + sessionName: string; + existingRef?: SessionRef; + device: DeviceInfo; + surface: SessionSurface; + appBundleId?: string; + appName?: string; + deviceClaim?: DeviceClaimSessionOwnership; +}): SessionRef { + const { + req, + sessionStore: store, + sessionName, + existingRef, + device, + surface, + appBundleId, + appName, + deviceClaim, + } = params; + requireOpenSessionAdmission(store, sessionName, existingRef); + if (existingRef) { + const session = store.update(existingRef, { + device, + surface, + appBundleId, + appName, + }); + session.lease = resolveSessionLeaseForRequest({ req, existingLease: session.lease }); + if (deviceClaim) session.deviceClaim = deviceClaim; + clearSessionSnapshot(session); + return store.refresh(existingRef); + } + return store.publish(sessionName, { + name: resolvePublicSessionName(req), + sessionScope: req.internal?.resolvedSessionScope ?? resolveSessionScope(req), + device, + surface, + appBundleId, + appName, + createdAt: Date.now(), + actions: [], + lease: resolveSessionLeaseForRequest({ req }), + deviceClaim, + }); +} diff --git a/src/daemon/session-lifecycle/internal/session-open-surface.ts b/src/daemon/session-lifecycle/internal/session-open-surface.ts index 82befde932..daa220a92e 100644 --- a/src/daemon/session-lifecycle/internal/session-open-surface.ts +++ b/src/daemon/session-lifecycle/internal/session-open-surface.ts @@ -1,12 +1,12 @@ import type { LaunchConfirmation } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import { isIosFamily, isSerialAddressablePlatform, publicPlatformString, type DeviceInfo, } from '@agent-device/kernel/device'; -import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; +import type { SessionRuntimeHints } from '../../session-state.ts'; import { successText } from '@agent-device/kernel/success-text'; import type { StartupPerfSample } from './session-startup-metrics.ts'; import type { DeviceSelectionResult } from '@agent-device/device-selection/device-selection-resolver'; @@ -90,36 +90,3 @@ function selectionResponseData( const { device: _device, ...metadata } = selection; return { selection: metadata }; } - -export function buildNextOpenSession(params: { - existingSession?: SessionState; - sessionName: string; - sessionScope: SessionScope; - device: DeviceInfo; - surface: SessionSurface; - appBundleId?: string; - appName?: string; -}): SessionState { - const { existingSession, sessionName, sessionScope, device, surface, appBundleId, appName } = - params; - if (existingSession) { - return { - ...existingSession, - device, - surface, - appBundleId, - appName, - snapshot: undefined, - }; - } - return { - name: sessionName, - sessionScope, - device, - createdAt: Date.now(), - surface, - appBundleId, - appName, - actions: [], - }; -} diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 098cb7f46d..8cc765fa3c 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -40,9 +40,11 @@ import { openNewSessionWithDeviceClaim, renewOpenSessionClaim, type OpenApplicationRuntime, + type SessionOpenResult, type RuntimeHintApplyOperation, type RuntimeHintClearOperation, } from './session-open-execution.ts'; +import { requireOpenSessionAdmission } from './session-open-state.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; export type SessionOpenCommandInput = Readonly<{ @@ -149,23 +151,29 @@ async function resolveOpenRuntimePlanAdmission(params: { } // fallow-ignore-next-line complexity -async function handleOpenCommand(params: SessionOpenCommandInput): Promise { +async function handleOpenCommand(params: SessionOpenCommandInput): Promise { const { sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); + const existingRef = sessionStore.lookup(sessionName); const foregroundResolution = await resolveForegroundOpenRequest({ req: params.req, - hasExistingSession: Boolean(session), + hasExistingSession: Boolean(existingRef), }); - if (foregroundResolution.type === 'response') return foregroundResolution.response; + if (foregroundResolution.type === 'response') + return { type: 'response', response: foregroundResolution.response }; const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req; - if (session) { + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + if (existingRef) { + const session = sessionStore.requireCurrent(existingRef); if (req.flags?.saveScript) { - return errorResponse( - 'INVALID_ARGS', - 'open --save-script can only arm a fresh session. Use the current session without --save-script, or close it and start a fresh session.', - ); + return { + type: 'response', + response: errorResponse( + 'INVALID_ARGS', + 'open --save-script can only arm a fresh session. Use the current session without --save-script, or close it and start a fresh session.', + ), + }; } const shouldRelaunch = req.flags?.relaunch === true; const requestedOpenTarget = req.positionals?.[0]; @@ -176,11 +184,14 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { - const openResponse = await handleOpenCommand(params); - if (!openResponse.ok || params.req.flags?.foreground !== true) return openResponse; + const result = await handleOpenCommand(params); + const openResponse = result.response; + if (result.type !== 'opened' || params.req.flags?.foreground !== true) return openResponse; return await composeOpenWithInitialSnapshot({ ...params, inspectFacts: requireRuntimeFacts(params.inspectFacts), bindDevice: requireRuntimeBinding(params.bindDevice), openResponse, + ref: result.ref, }); } diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 0dc3edb51a..328352681b 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -104,10 +104,9 @@ function recordSuccessfulPerfResponse( response: DaemonResponse, ): DaemonResponse { if (!response.ok) return response; - const session = params.sessionStore.resolveCurrent(params.ref); recordSessionAction( params.sessionStore, - session, + params.ref, params.req, 'perf', isDataRecord(response.data) ? { ...response.data } : {}, diff --git a/src/daemon/session-runtime.ts b/src/daemon/session-runtime.ts index 2cc69922f2..e218c35e27 100644 --- a/src/daemon/session-runtime.ts +++ b/src/daemon/session-runtime.ts @@ -199,7 +199,7 @@ export function setSessionRuntimeHintsForOpen( ): SessionRuntimeHints | undefined { if (!runtime) return undefined; if (countConfiguredRuntimeHints(runtime) === 0) { - sessionStore.clearRuntimeHints(sessionName); + sessionStore.setRuntimeHints(sessionName, undefined); return undefined; } sessionStore.setRuntimeHints(sessionName, runtime); diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 6e70a1b8dd..96efd49f5b 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -38,6 +38,10 @@ export function setSessionSnapshot(session: SessionState, snapshot: SnapshotStat } } +export function clearSessionSnapshot(session: SessionState): void { + session.snapshot = undefined; +} + /** Replaces a snapshot/diff observation and its scoped lineage without issuing client refs. */ export function setCommandSnapshot( session: SessionState, @@ -126,7 +130,8 @@ export function issueSettleRefs( settle: SettleObservation | undefined, ): number | undefined { if (!ref || !settle?.diff) return undefined; - const session = sessionStore.requireCurrent(ref); + const session = sessionStore.resolveCurrent(ref); + if (!session) return undefined; markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(settle)); return session.snapshotGeneration; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index e6818d4071..96affb7e06 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -199,19 +199,20 @@ export class SessionStore { return this.runtimeHints.get(name); } - setRuntimeHints(name: string, hints: SessionRuntimeHints): void { - this.runtimeHints.set(name, hints); + setRuntimeHints(address: string, hints: SessionRuntimeHints | undefined): void { + if (hints) this.runtimeHints.set(address, hints); + else this.runtimeHints.delete(address); } - clearRuntimeHints(name: string): boolean { - return this.runtimeHints.delete(name); + clearRuntimeHints(ref: SessionRef): boolean { + this.requireCurrent(ref); + return this.runtimeHints.delete(ref.address); } - recordAction(session: SessionState, entry: RecordActionEntry): void { - const action = recordActionEntry(session, entry); + recordAction(ref: SessionRef, entry: RecordActionEntry): void { + const action = recordActionEntry(this.requireCurrent(ref), entry); if (action) { - const sessionName = this.resolveStoredSessionName(session); - appendActionEvent(this.resolveEventLogPath(sessionName), sessionName, action); + appendActionEvent(this.resolveEventLogPath(ref.address), ref.address, action); } } @@ -275,7 +276,7 @@ export class SessionStore { finalizeRepairTeardown(ref: SessionRef): void { const session = this.resolveCurrent(ref); if (!session) return; - this.recordRepairFinalizeCloseIfCommitting(session); + this.recordRepairFinalizeCloseIfCommitting(ref); // #1258: no live request here (idle-reap/daemon-shutdown teardown), so // the only source of `force` is whatever was persisted on the session at // arm time. @@ -302,10 +303,11 @@ export class SessionStore { * (incomplete) transaction's write is a no-op regardless, so there is * nothing to make self-contained. */ - private recordRepairFinalizeCloseIfCommitting(session: SessionState): void { + private recordRepairFinalizeCloseIfCommitting(ref: SessionRef): void { + const session = this.requireCurrent(ref); const state = session.scriptPublication ?? NO_SCRIPT_PUBLICATION; if (!isRepairCommittable(state)) return; - this.recordAction(session, { + this.recordAction(ref, { command: 'close', positionals: [], flags: {}, @@ -487,15 +489,4 @@ export class SessionStore { resolveEventLogPath(sessionName: string): string { return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); } - - /** - * Resolve the map key for a live session object. SessionState.name is the - * public session name, while the map key may include cwd/tenant isolation. - */ - resolveStoredSessionName(session: SessionState): string { - for (const [name, entry] of this.sessions) { - if (entry.current === session) return name; - } - return session.name; - } } diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 9cc847c758..48d460825d 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -63,7 +63,8 @@ export async function dispatchSnapshotRuntimeCommand( session, device, async () => { - const { req, sessionName, logPath, sessionStore } = params; + const { req, logPath, sessionStore } = params; + const sessionName = ref?.address ?? params.sessionName; const capturedQuality: CapturedSnapshotQuality = {}; const { runtime, sessions } = createSnapshotRuntime({ req, @@ -102,7 +103,7 @@ export async function dispatchSnapshotRuntimeCommand( req, sessionName, sessionStore, - session: current, + ref: sessions.getRef(), result: result.record, }); const data = applyRecoveredWarningLatch({ @@ -146,15 +147,15 @@ function createSnapshotRuntime(params: { isRefScopedSnapshot(req), ); const snapshot = keepCurrentSnapshot ? current.snapshot : snapshotRecord.snapshot; - const nextSession: SessionState = - current ?? - createSnapshotSession({ - sessionName, - sessionScope: resolveSessionScope(req), - device, - snapshot, - appBundleId: record.appBundleId, - }); + const nextSession: SessionState = ref + ? sessionStore.update(ref, {}) + : createSnapshotSession({ + sessionName, + sessionScope: resolveSessionScope(req), + device, + snapshot, + appBundleId: record.appBundleId, + }); nextSession.appName = record.appName ?? current?.appName; setCommandSnapshot(nextSession, { snapshot, @@ -266,12 +267,11 @@ function recordSnapshotRuntimeAction(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; result: SnapshotRuntimeRecord; }): void { - const session = params.session; - if (!session) return; - params.sessionStore.recordAction(session, { + if (!params.ref) return; + params.sessionStore.recordAction(params.ref, { command: params.req.command, positionals: params.req.positionals ?? [], flags: params.req.flags ?? {}, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index f4df411463..961dd40fd8 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -41,6 +41,7 @@ import { errorResponse } from '@agent-device/kernel/contracts'; export type SnapshotRuntimeRouteParams = { req: DaemonRequest; sessionName: string; + sessionRef?: SessionRef; logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; @@ -135,7 +136,12 @@ export async function resolveBoundSnapshotCaptureRuntime( command: 'snapshot' | 'diff', ): Promise { const { req, sessionName, sessionStore } = params; - const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + params.sessionRef, + ); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index ea80c1bf7d..b5dba3370a 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,7 +1,7 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import type { SessionScope } from '@agent-device/contracts/session'; import { isActiveProviderDevice } from './provider-device-admission.ts'; import { SessionStore } from './session-store.ts'; @@ -10,9 +10,10 @@ export async function resolveSessionDevice( sessionStore: SessionStore, sessionName: string, flags: DaemonRequest['flags'], + boundRef?: SessionRef, ) { - const ref = sessionStore.lookup(sessionName); - const session = ref?.session; + const ref = boundRef ?? sessionStore.lookup(sessionName); + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); return { ref, session, device }; } @@ -38,21 +39,6 @@ export async function withSessionlessRunnerCleanup( } } -export function recordIfSession( - sessionStore: SessionStore, - session: SessionState | undefined, - req: DaemonRequest, - result: Record, -): void { - if (!session) return; - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result, - }); -} - export function createSnapshotSession(params: { sessionName: string; sessionScope: SessionScope; diff --git a/src/daemon/wait-runtime.ts b/src/daemon/wait-runtime.ts index 035b80aa42..9cd1281cd7 100644 --- a/src/daemon/wait-runtime.ts +++ b/src/daemon/wait-runtime.ts @@ -171,7 +171,7 @@ async function executeWaitRequest( const recordedTarget = readRecordedResolutionTarget(result); recordIfSession( sessionStore, - sessionName, + params.ref, req, stripResolutionPayload(result), recordedTarget, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f015a0e26c..9ba5fc234e 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -80,9 +80,10 @@ agent-device fold open - On an iOS Simulator, handing the URL to the device is one bounded `simctl openurl`: when CoreSimulator never answers it, the open fails within 20 seconds with `details.reason: ios-simulator-openurl-timeout` and kills the child, instead of the request being cancelled around a still-running `openurl`. The Simulator may still have received the URL, so retry the open or answer the prompt with `alert accept`. - `open ` opens a deep link on iOS. - On an iOS Simulator, iOS can ask `Open in ""?` before your app receives the URL from `open --launch-url `. If the URL's scheme belongs to your session app, `open` taps **Open** for you and its JSON data includes `launchConfirmation: "accepted"`. If no prompt appears, the response has no `launchConfirmation` field. -- `open` recognizes the prompt by its English title. On a Simulator set to another language, it leaves the prompt on screen and returns as if no prompt appeared; answer it with `alert accept` or `alert dismiss`. Run with `--debug` to see the title `open` did not recognize. +- `open` recognizes the prompt by its English title and the **Cancel** and **Open** buttons. On a Simulator set to another language, it leaves the prompt on screen and returns as if no prompt appeared; answer it with `alert accept` or `alert dismiss`. Run with `--debug` to see the title `open` did not recognize. - `open` reads the prompt whenever it cannot see your app after the launch, including on a slow host where the app's target discovery fails on its own deadline. A prompt that appears after `open` has already seen your app stays on screen; answer it with `alert accept` or `alert dismiss`. - When `open` taps **Open** and your app still provably never starts, it hands the same URL to the Simulator once more and reads the prompt again. If the app is still not running after that, `open` fails with `details.reason: launch_confirmation_unanswered` instead of reporting success for a launch that never happened; later commands that require a running app would have failed with `app is not running`. +- If the recognized prompt remains visible after an attempt to tap **Open** fails, `open` also fails with `details.reason: launch_confirmation_unanswered`, even if the app is running behind the prompt. Answer it with `alert accept` or `alert dismiss` before continuing. - If the URL's scheme belongs to a different installed app, `open` does not answer the prompt. The command fails with `details.reason: launch_confirmation_foreign_app`; `details.foreignAppBundleId` names that app. The prompt stays on screen: close it with `alert dismiss`, then pass a launch URL whose scheme your app handles. - If `open` cannot identify a single installed app that owns the scheme, it does not know which app would receive the URL, so it leaves the prompt on screen and returns as if no prompt appeared; answer it with `alert accept` or `alert dismiss`. This happens when no installed app declares the scheme, when more than one does (for example a debug and a release build), and when the app list or an app's `Info.plist` could not be read. - `open --launch-console ` captures launch-time stdout/stderr for direct iOS simulator app launches. It is not valid for URL opens or