diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 033cca5c5a..afddd3d5f5 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -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, @@ -549,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([]); }); 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.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index bfdc799b69..ab83d1ddfc 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), { 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-recording.test.ts b/src/daemon/__tests__/selector-recording.test.ts index ea319a2ff7..9cc5a4225a 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,12 @@ 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([]); +}); diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index bf16d25932..89f7ed3cb8 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,111 @@ 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: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + }, +); + async function withRunner(operation: () => Promise): Promise { return await withAppleRunnerProvider( mockRunAppleRunnerCommand, 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__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 4916c05ee9..ff76e6fa85 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -148,6 +148,55 @@ 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: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + } finally { + vi.useRealTimers(); + } + }, +); + async function runWait( positionals: string[], harness: ReturnType, 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-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/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 12cf18cad1..e7c698bb08 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -172,7 +172,7 @@ async function startRecording( const snapshot = adopted.handle.inspect(); recordSessionAction( params.sessionStore, - params.sessionStore.requireCurrent(adoptedRef), + adoptedRef, params.req, params.req.command, buildRecordingStartedAction(snapshot), @@ -258,7 +258,7 @@ async function stopRecording( const completion = stopped.completion; const response = buildRecordingStopResponse(completion); if (stopped.recordsSessionAction) { - recordSessionAction(params.sessionStore, session, params.req, params.req.command, { + recordSessionAction(params.sessionStore, ref, params.req, params.req.command, { action: 'stop', outPath: completion.outPath, ...(completion.clientOutPath 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..7806b6fe28 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; @@ -163,10 +164,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-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c41e8cbf73..275e7e2d29 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 { @@ -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 ?? {} }; } @@ -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..858bf36405 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -13,7 +13,7 @@ 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 { parseTimeout } from '@agent-device/command-registry/parse-timeout'; @@ -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); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index a74a8b6319..e2d9ba450a 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -26,7 +26,7 @@ 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 type { SessionRef, SessionState } from '../session-state.ts'; import { recordIfSession } from '../snapshot-session.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -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); + recordIfSession(sessionStore, ref, req, 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); + recordIfSession(sessionStore, ref, req, 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/find.ts b/src/daemon/interaction/internal/find.ts index b127ad8e70..92c8e04ae7 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; @@ -153,7 +152,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 +308,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 +322,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 +338,7 @@ async function handleFindFill( if (!response.ok) return response; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'fill' }, @@ -352,12 +359,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 +388,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 +398,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 +429,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-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-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/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 b057bbca74..52da4cdef3 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-recording.ts b/src/daemon/selector-recording.ts index b03679e242..e7e141313a 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,13 @@ export function recordIfSession( /** #1349: `landmark` for wait's existence-semantics evidence; defaults to `action`. */ evidenceMode?: TargetEvidenceMode, ): void { - const session = sessionStore.get(sessionName); - if (!session) return; + if (!ref) return; + const session = sessionStore.requireCurrent(ref); 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.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/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 3335c3bc2a..3300d2d7b0 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,24 @@ export function recordActionEntry( return action; } -type SessionActionStore = { recordAction(session: SessionState, entry: RecordActionEntry): void }; +type SessionActionStore = { 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) 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 b5f11e6f11..d7d41a58ef 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 @@ -618,15 +618,15 @@ for (const transition of ['rebuild', 'retire'] as const) { makeAndroidEmulator(`emulator-foreground-${transition}`), ); const record = store.recordAction.bind(store); - vi.spyOn(store, 'recordAction').mockImplementationOnce((session, entry) => { - record(session, entry); + 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, session); + store.publish(ref.address, recordedRef.session); } mockInspectDeviceRuntimeFacts.mockClear(); }); 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 980f3a54ca..ef8a65fbc0 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -330,7 +330,7 @@ export async function completeOpenCommand(params: { saveScriptRequested: Boolean(req.flags?.saveScript), responseData: openResult, }); - sessionStore.recordAction(nextSession, { + sessionStore.recordAction(nextRef, { command: 'open', positionals: openPositionals, flags: req.flags ?? {}, diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index f8761f6231..328352681b 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -75,7 +75,7 @@ export async function handlePerfRuntimeCommand( const plan = resolvePerfRuntimePlan(request); if (plan.kind === 'capture-stop') { return recordSuccessfulPerfResponse( - params, + bound, await stopPerfCapture(bound, session, plan.request), ); } @@ -91,7 +91,7 @@ export async function handlePerfRuntimeCommand( ); } return recordSuccessfulPerfResponse( - params, + bound, await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { @@ -100,14 +100,13 @@ export async function handlePerfRuntimeCommand( } function recordSuccessfulPerfResponse( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, response: DaemonResponse, ): DaemonResponse { if (!response.ok) return response; - const session = params.sessionStore.get(params.sessionName); recordSessionAction( params.sessionStore, - session, + params.ref, params.req, 'perf', isDataRecord(response.data) ? { ...response.data } : {}, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index e6818d4071..4cdc55e0de 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -207,11 +207,10 @@ export class SessionStore { return this.runtimeHints.delete(name); } - 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 +274,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 +301,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 +487,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 6ec78b4bfb..76199e7b96 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -102,7 +102,7 @@ export async function dispatchSnapshotRuntimeCommand( req, sessionName, sessionStore, - session: current, + ref: sessions.getRef(), result: result.record, }); const data = applyRecoveredWarningLatch({ @@ -266,12 +266,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-session.ts b/src/daemon/snapshot-session.ts index ea80c1bf7d..74f1a2e08a 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'; @@ -40,12 +40,12 @@ export async function withSessionlessRunnerCleanup( export function recordIfSession( sessionStore: SessionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref) return; + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, 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,