diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 23ff21443..6205c867c 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -17,8 +17,12 @@ ref-frame vocabulary is promoted into `CONTEXT.md`. - A session owns at most one **ref frame** — the authorization namespace for mutation refs (epoch exposed as `refsGeneration`, immutable source tree, `active`/`expired` state, `all` or bounded - issuance scope) — owned by `src/daemon/ref-frame.ts` and kept separate from the latest - operational observation (`session.snapshot`). + issuance scope) — kept separate from the latest operational observation (`session.snapshot`). + The frame is one value on `SessionState`, replaced whole by a transition and never edited in + place; its type is nominal (`#`-private fields), so no module outside + `src/daemon/ref-frame.ts` can construct a frame, edit one, or derive one from an existing + frame. What the type cannot judge is a whole frame moved unchanged — clearing the field, or + assigning another session's frame — which the field-owner gate still covers. - A complete snapshot activates an `all` frame; `find`, settled diffs, and replay divergence screens activate a bounded partial frame that supersedes the prior one; internal read-only captures never activate, reindex, or expire a frame. diff --git a/docs/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 0e8884890..5890e9888 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -338,7 +338,9 @@ of 27 fields already have exactly one writer**. The sharp case was ADR 0014's re `refFrameState`, `refFrameScope`, `refFrameTree`, `refFrameGeneration` must move together or the frame is incoherent, yet complete issuance wrote them in `ref-frame.ts` and partial issuance wrote the same four in `session-snapshot.ts`, even though `ref-frame.ts` claims in its header to -be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`. +be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`, +and the four fields have since been replaced by one `refFrame` value whose type only +`ref-frame.ts` can construct — so that ownership no longer rests on the R7 table alone. `recordSession` deliberately moves alone in two paths (recording without arming a publication), so the save-script cluster got no invented abstraction. It got ownership: **R7** records every diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 29ab5c865..74d6496c5 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -23,7 +23,7 @@ import { buildAmbiguousMatchError } from '../../src/daemon/selector-match-errors import { refMutationAdmissionResponse } from '../../src/daemon/interaction/index.ts'; import { buildDeviceInUseBySessionError } from '../../src/daemon/session-recovery-hints.ts'; import { buildDeviceClaimConflictError } from '../../src/daemon/device-claim-conflict.ts'; -import { readRefMutationFrame } from '../../src/daemon/ref-frame.ts'; +import { activateCompleteRefFrame, readRefMutationFrame } from '../../src/daemon/ref-frame.ts'; import { resolveRefStalenessWarning } from '../../src/daemon/session-snapshot.ts'; import type { SessionState } from '../../src/daemon/types.ts'; import { buildAppNotInstalledError } from '@agent-device/platform-apple/app-resolution'; @@ -423,7 +423,10 @@ export const SAMPLE_PRODUCERS: SampleProducer[] = [ producer: 'the real ADR 0014 admission rejection and staleness hint', sample: STALE_REF_SAMPLE, render: () => { - const session = { refFrameGeneration: 7 } as SessionState; + const session = { snapshotGeneration: 7 } as SessionState; + // Pin the epoch in the frame itself, as a real published namespace does, so the + // sample exercises the frame epoch rather than the pre-frame fallback. + activateCompleteRefFrame(session); const response = refMutationAdmissionResponse({ session, ref: '@e12', diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 888436e0e..c32594644 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -293,46 +293,40 @@ test('SessionState field names come from the declaration, not a hand-kept list', " kind: 'cwd';", ' id: string;', ' };', - ' refFrameState?: RefFrameState;', + ' refFrame?: RefFrame;', '};', '', 'export type Other = { notAField: string };', ].join('\n'), ); // Nested object members are not session fields, and neighbouring types are not scanned. - assert.deepEqual(fields, ['name', 'sessionScope', 'refFrameState']); + assert.deepEqual(fields, ['name', 'sessionScope', 'refFrame']); }); test('session-state writes are found by field, and non-daemon or undeclared names are not', () => { const writes = findSessionStateWrites( new Map([ - ['src/daemon/ref-frame.ts', "session.refFrameState = 'active';"], + ['src/daemon/ref-frame.ts', "session.refFrame = 'active';"], ['src/daemon/session-snapshot.ts', 'session.snapshotGeneration += 1;'], // the store owns the record and may write anything on it - ['src/daemon/session-store.ts', "session.refFrameState = 'expired';"], + ['src/daemon/session-store.ts', "session.refFrame = 'expired';"], // a runner session outside the daemon is a different type that happens to share a name - ['src/platforms/apple/runner-session.ts', 'session.refFrameState = 1;'], + ['src/platforms/apple/runner-session.ts', 'session.refFrame = 1;'], // a local that is not a declared SessionState field ['src/daemon/session-observability/internal/session-audio.ts', 'session.somethingElse = 1;'], // reads and comparisons are not writes - [ - 'src/daemon/interaction/internal/find.ts', - "if (session.refFrameState === 'active') return;", - ], + ['src/daemon/interaction/internal/find.ts', "if (session.refFrame === 'active') return;"], // a write into a sub-object is not a write to the field itself - ['src/daemon/handlers/session-probe.ts', 'session.refFrameState.inner = 1;'], + ['src/daemon/handlers/session-probe.ts', 'session.refFrame.inner = 1;'], // a different binding that happens to have a matching property - [ - 'src/daemon/session-lifecycle/internal/session-close.ts', - "other.refFrameState = 'expired';", - ], + ['src/daemon/session-lifecycle/internal/session-close.ts', "other.refFrame = 'expired';"], ]), - ['refFrameState', 'snapshotGeneration'], + ['refFrame', 'snapshotGeneration'], ); assert.deepEqual( writes.map(({ file, field }) => `${file}:${field}`), - ['src/daemon/ref-frame.ts:refFrameState', 'src/daemon/session-snapshot.ts:snapshotGeneration'], + ['src/daemon/ref-frame.ts:refFrame', 'src/daemon/session-snapshot.ts:snapshotGeneration'], ); }); @@ -340,23 +334,21 @@ test('every assignment form is a write, including the ones a regex forgets', () // A line-based matcher has to enumerate operators, and the ones it misses are the natural // ways to write these: `??=` for a default on an optional field, `||=`/`&&=` for a flag. const forms = [ - 'session.refFrameState = 1;', - 'session.refFrameState ??= 1;', - 'session.refFrameState ||= 1;', - 'session.refFrameState &&= 1;', - 'session.refFrameState += 1;', - 'session.refFrameState -= 1;', - 'session.refFrameState++;', - '--session.refFrameState;', - 'session\n .refFrameState = 1;', + 'session.refFrame = 1;', + 'session.refFrame ??= 1;', + 'session.refFrame ||= 1;', + 'session.refFrame &&= 1;', + 'session.refFrame += 1;', + 'session.refFrame -= 1;', + 'session.refFrame++;', + '--session.refFrame;', + 'session\n .refFrame = 1;', ]; for (const form of forms) { - const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), [ - 'refFrameState', - ]); + const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), ['refFrame']); assert.deepEqual( writes.map(({ field }) => field), - ['refFrameState'], + ['refFrame'], `expected ${JSON.stringify(form)} to count as a write`, ); } @@ -364,8 +356,8 @@ test('every assignment form is a write, including the ones a regex forgets', () test('a computed session write is reported rather than silently unattributed', () => { const writes = findSessionStateWrites( - new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrameState`] = 2;']]), - ['refFrameState'], + new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrame`] = 2;']]), + ['refFrame'], ); // `[computed]` has no entry in SESSION_STATE_FIELD_OWNERS, so R7 fails on it by // construction — a computed write can never pass as an owned one. @@ -401,19 +393,19 @@ test('a session write counts through an aliased binding, not only one named `ses 'src/daemon/probe.ts', [ 'nextSession.snapshotGeneration = 3;', - 'preEntrySession.refFrameState = "active";', + 'preEntrySession.refFrame = "active";', 'completedSession.saveScriptComplete = true;', // Not a session binding, and not a session write. 'result.snapshotGeneration = 9;', - 'flags.refFrameState = "x";', + 'flags.refFrame = "x";', ].join('\n'), ], ]), - ['snapshotGeneration', 'refFrameState', 'saveScriptComplete'], + ['snapshotGeneration', 'refFrame', 'saveScriptComplete'], ); assert.deepEqual( writes.map(({ field, line }) => `${line}:${field}`), - ['1:snapshotGeneration', '2:refFrameState', '3:saveScriptComplete'], + ['1:snapshotGeneration', '2:refFrame', '3:saveScriptComplete'], ); }); diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index a51199bbc..1c1f14528 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -25,8 +25,10 @@ // the set of writers that exist, so the gate's job is to stop the set from growing quietly. // Adding a field to `SessionState` forces a deliberate owner; writing an existing field from // a new module fails until that module is either declared an owner or, better, calls the -// owner instead. ADR 0014's ref frame is the worked example — its four fields moved together -// across two modules until `activateRefFrame` took the transition. +// owner instead. ADR 0014's ref frame is the worked example, and the one that has since been +// taken further than this table can go: its four fields moved together across two modules +// until `activateRefFrame` took the transition, and they are now a single value whose nominal +// type no other module can construct, edit, or derive from an existing frame. // // Detection is AST-based (`oxc-parser`, already a devDependency) rather than a line regex. A // regex has to enumerate assignment operators, and the ones it forgets are exactly the ones @@ -49,12 +51,13 @@ export type SessionStateWrite = { * owner list has one entry is a field only that module can get wrong. */ export const SESSION_STATE_FIELD_OWNERS: Readonly> = { - // ADR 0014 ref frame: the four frame fields move together or the frame is incoherent, so - // both issuance forms go through ref-frame.ts. - refFrameState: ['src/daemon/ref-frame.ts'], - refFrameScope: ['src/daemon/ref-frame.ts'], - refFrameTree: ['src/daemon/ref-frame.ts'], - refFrameGeneration: ['src/daemon/ref-frame.ts'], + // ADR 0014 ref frame. The four frame fields this row replaced moved together or the frame was + // incoherent, and only this table said so; `RefFrame` is now a nominal type (`#`-private + // fields) that no other module can construct, edit, or spread into a new frame, and the + // transitions replace it whole. The row stays because the type cannot judge a whole frame + // moved unchanged: assigning `undefined` (a reset to the pristine frame) and assigning a + // frame read off another session. + refFrame: ['src/daemon/ref-frame.ts'], // Scoped-snapshot lineage is cleared at two distinct events: crossing a device side-effect // seam (ref-frame.ts) and replacing the stored observation (session-snapshot.ts). snapshotScopeSource: ['src/daemon/ref-frame.ts', 'src/daemon/session-snapshot.ts'], diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts index b07b0ed74..c345c5f35 100644 --- a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -13,6 +13,7 @@ import { androidObservation } from '../../platform-runtime.ts'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; import { makeAndroidSnapshotCapture } from '../../__tests__/test-utils/android-snapshot-capture.ts'; +import { refFrameState } from '../ref-frame.ts'; const recoverAndroidBlockingSystemDialog = ( params: Omit[0], 'observation'>, @@ -45,12 +46,12 @@ test('android blocking-dialog recovery expires the ref frame before its recovery outPath: '/tmp/anr.mp4', startedAt: 0, }); - expect(session.refFrameState).toBeUndefined(); // active + expect(refFrameState(session)).toBe('active'); const result = await recoverAndroidBlockingSystemDialog({ session }); // The recovery tap was dispatched, and the frame is expired as a result. expect(vi.mocked(runAndroidAdb)).toHaveBeenCalled(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(result.status).not.toBe('absent'); }); diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts index cbd9b5902..e2946824c 100644 --- a/src/daemon/__tests__/back-runtime.test.ts +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundBackRuntime } from '../back-runtime.ts'; @@ -162,7 +162,7 @@ test('request router joins back admission to execution, recording, and ref inval ok: true, data: { action: 'back', mode: 'in-app', message: 'Back' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'back' }); expect(harness.inspectFacts).toHaveBeenCalledTimes(1); expect(harness.bind).toHaveBeenCalledTimes(1); diff --git a/src/daemon/__tests__/focus-runtime.test.ts b/src/daemon/__tests__/focus-runtime.test.ts index fa328af30..1d7f73696 100644 --- a/src/daemon/__tests__/focus-runtime.test.ts +++ b/src/daemon/__tests__/focus-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { readFocusPoint, resolveBoundFocusRuntime } from '../focus-runtime.ts'; @@ -213,7 +213,7 @@ test('request router joins focus admission to execution, recording, and ref inva ok: true, data: { x: 40, y: 90, message: 'Focused (40, 90)' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'focus', positionals: ['40', '90'], diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 0ce19cdf4..71f14008a 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -3,7 +3,7 @@ import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import type { CommandFlags } from '@agent-device/contracts/command'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -215,7 +215,7 @@ test('scroll --settle answers with the settled diff against the stored pre-actio // The settled tree became the stored snapshot, and its refs were published: // a partial frame is active at the generation the payload reports. const stored = sessionStore.get(sessionName) as SessionState; - expect(stored.refFrameState).toBe('active'); + expect(refFrameState(stored)).toBe('active'); expect(settle.refsGeneration).toBe(stored.snapshotGeneration); expect(stored.snapshot?.nodes.some((node) => node.label === 'Load more')).toBe(true); }); @@ -288,7 +288,7 @@ test('scroll without --settle takes no observation captures and issues no refs', expect(captureObservations).toEqual([]); // ADR 0014: the leaf side-effect seam expired the frame and nothing // re-published it. - expect((sessionStore.get(sessionName) as SessionState).refFrameState).toBe('expired'); + 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 () => { diff --git a/src/daemon/__tests__/home-runtime.test.ts b/src/daemon/__tests__/home-runtime.test.ts index e3710cecd..a852e01de 100644 --- a/src/daemon/__tests__/home-runtime.test.ts +++ b/src/daemon/__tests__/home-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundHomeRuntime } from '../home-runtime.ts'; @@ -135,7 +135,7 @@ test('request router joins home admission to execution, recording, and ref inval }); expect(response).toMatchObject({ ok: true, data: { action: 'home', message: 'Home' } }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.inspectFacts).toHaveBeenCalledTimes(1); expect(harness.bind).toHaveBeenCalledTimes(1); expect(harness.home).toHaveBeenCalledTimes(1); diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 5dd626369..0066b7275 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -4,7 +4,13 @@ import { expect, test } from 'vitest'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { bindInternalObservationAuthority } from '../internal-observation.ts'; -import { expireRefFrame } from '../ref-frame.ts'; +import { + expireRefFrame, + refFrame, + refFrameScope, + refFrameState, + refFrameTree, +} from '../ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; import { SessionStore } from '../session-store.ts'; @@ -73,10 +79,10 @@ test('publishes exactly the validated outward refs from a current internal captu refsGeneration: input.refsGeneration, refCount: 1, }); - expect(input.session.refFrameState).toBe('active'); - expect(input.session.refFrameScope).toEqual(new Set(['e2'])); - expect(input.session.refFrameTree).toBe(input.captured); - expect(input.session.refFrameGeneration).toBe(input.refsGeneration); + expect(refFrameState(input.session)).toBe('active'); + expect(refFrameScope(input.session)).toEqual(new Set(['e2'])); + expect(refFrameTree(input.session)).toBe(input.captured); + expect(refFrame(input.session).generation).toBe(input.refsGeneration); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); }); @@ -89,8 +95,8 @@ test('empty publication never supersedes prior client authority', () => { }); expect(result).toEqual({ published: false, reason: 'empty' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('an empty finalization consumes its evidence and cannot later publish', () => { @@ -103,7 +109,7 @@ test('an empty finalization consumes its evidence and cannot later publish', () expect(first).toEqual({ published: false, reason: 'empty' }); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('cancelled publication leaves prior client authority intact', () => { @@ -116,8 +122,8 @@ test('cancelled publication leaves prior client authority intact', () => { reason: 'cancelled', }); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('a newer capture makes older capture evidence stale', () => { @@ -125,16 +131,16 @@ test('a newer capture makes older capture evidence stale', () => { setSessionSnapshot(input.session, snapshot('e3', 'Later capture')); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('a later ref publication prevents an older capture from superseding it', () => { const input = scenario(); markSessionPartialRefsIssued(input.session, ['e2']); - const laterTree = input.session.refFrameTree; + const laterTree = refFrameTree(input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameTree).toBe(laterTree); + expect(refFrameTree(input.session)).toBe(laterTree); }); test('a runtime side effect invalidates capture evidence without rolling authority back', () => { @@ -142,8 +148,8 @@ test('a runtime side effect invalidates capture evidence without rolling authori expireRefFrame(input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('runtime revision invalidates evidence even when the ref frame was already expired', () => { @@ -158,8 +164,8 @@ test('runtime revision invalidates evidence even when the ref frame was already }); expect(result).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('session close invalidates capture evidence', () => { @@ -171,7 +177,7 @@ test('session close invalidates capture evidence', () => { // a stale finalization attempt already consumed. input.sessionStore.set(input.sessionName, input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('same-name session replacement cannot inherit capture evidence', () => { @@ -180,7 +186,7 @@ test('same-name session replacement cannot inherit capture evidence', () => { input.sessionStore.set(input.sessionName, replacement); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(replacement.refFrameTree).toBeUndefined(); + expect(refFrameTree(replacement)).toBeUndefined(); }); test('generation and ref projection must match the exact captured tree', () => { @@ -202,6 +208,6 @@ test('generation and ref projection must match the exact captured tree', () => { }); expect(refResult).toEqual({ published: false, reason: 'invalid-projection' }); expect(publishCurrent(wrongRef)).toEqual({ published: false, reason: 'stale-capture' }); - expect(wrongGeneration.session.refFrameScope).toEqual(new Set(['e1'])); - expect(wrongRef.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(wrongGeneration.session)).toEqual(new Set(['e1'])); + expect(refFrameScope(wrongRef.session)).toEqual(new Set(['e1'])); }); diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts index 00fed2042..e4e16ff4b 100644 --- a/src/daemon/__tests__/orientation-runtime.test.ts +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -39,7 +39,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { @@ -221,7 +221,7 @@ test('request router joins orientation admission to execution and ref invalidati message: 'Rotated to landscape-left', }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.bind).toHaveBeenCalledTimes(1); expect(setOrientation).toHaveBeenCalledTimes(1); }); diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 50266e1c5..0fe154904 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -2,11 +2,14 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { activateCompleteRefFrame, + activatePartialRefFrame, admitRefMutation, expireRefFrame, + refFrame, refFrameEpoch, refFrameScope, refFrameState, + type RefFrame, type RefFrameAdmission, } from '../ref-frame.ts'; import type { SessionState } from '../types.ts'; @@ -24,8 +27,19 @@ function reason(admission: RefFrameAdmission): string | undefined { return admission.admitted ? undefined : admission.reason; } -test('defaults: no frame fields set reads as active / all / undefined epoch', () => { +/** + * A partial frame can only be reached through its transition: the frame value is + * constructible only inside `ref-frame.ts`, so a test cannot seed one by hand. + */ +function partialSession(generation: number, scope: ReadonlySet): SessionState { + const state = session({ snapshotGeneration: generation }); + activatePartialRefFrame(state, scope); + return state; +} + +test('defaults: a session with no frame reads as active / all / undefined epoch', () => { const s = session(); + assert.equal(s.refFrame, undefined); assert.equal(refFrameState(s), 'active'); assert.equal(refFrameScope(s), 'all'); assert.equal(refFrameEpoch(s), undefined); @@ -54,7 +68,8 @@ test('a pinned ref at another epoch is a generation mismatch', () => { }); test('an expired frame rejects every ref, and expiry wins over a matching pin', () => { - const s = session({ snapshotGeneration: 42, refFrameState: 'expired' }); + const s = session({ snapshotGeneration: 42 }); + expireRefFrame(s); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), 'ref_frame_expired', @@ -66,7 +81,7 @@ test('an expired frame rejects every ref, and expiry wins over a matching pin', }); test('a partial frame rejects a plain ref: it requires a complete frame', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), 'plain_ref_requires_complete_frame', @@ -74,7 +89,7 @@ test('a partial frame rejects a plain ref: it requires a complete frame', () => }); test('a partial frame admits only pinned refs it issued, at the current epoch', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { admitted: true, }); @@ -86,7 +101,7 @@ test('a partial frame admits only pinned refs it issued, at the current epoch', test('generation mismatch is evaluated before issuance scope', () => { // A pin at the wrong epoch is a mismatch even if the body was in scope. - const s = session({ snapshotGeneration: 43, refFrameScope: new Set(['e1']) }); + const s = partialSession(43, new Set(['e1'])); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), 'ref_generation_mismatch', @@ -97,8 +112,12 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { const s = session({ snapshotGeneration: 42 }); expireRefFrame(s); assert.equal(refFrameState(s), 'expired'); + const expired = refFrame(s); expireRefFrame(s); // idempotent assert.equal(refFrameState(s), 'expired'); + // Identity, not shape: lineage holders compare frames with `===`, so a repeat + // expiry must leave the same frame rather than an equal one. + assert.equal(refFrame(s), expired); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), 'ref_frame_expired', @@ -106,7 +125,7 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { }); test('activateCompleteRefFrame re-authorizes a complete frame after expiry', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); expireRefFrame(s); activateCompleteRefFrame(s); assert.equal(refFrameState(s), 'active'); @@ -128,3 +147,18 @@ test('expireRefFrame clears scoped-snapshot lineage at the seam (ADR 0014)', () // repeated scoped snapshot cannot borrow stale lineage across the side effect. assert.equal(s.snapshotScopeSource, undefined); }); + +test('a frame cannot be constructed, edited, or derived outside ref-frame.ts', () => { + const frame = refFrame(session()); + const rejectedByTsc = [ + // @ts-expect-error the class value is not exported, so no literal is a frame + (): RefFrame => ({ state: 'expired', scope: 'all', tree: undefined, generation: 1 }), + // @ts-expect-error a spread drops the #private field + (): RefFrame => ({ ...frame, state: 'expired' }), + // @ts-expect-error every field is a getter + () => void (frame.state = 'expired'), + // @ts-expect-error expiry is not on the frame's surface + () => frame.expired(), + ]; + assert.equal(rejectedByTsc.length, 4); +}); diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index 9115644bb..c3c2bd7b2 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -8,7 +8,15 @@ import { setSnapshotLineage, STALE_SNAPSHOT_REFS_WARNING, } from '../session-snapshot.ts'; -import { activateCompleteRefFrame, refFrameEpoch } from '../ref-frame.ts'; +import { + activateCompleteRefFrame, + activatePartialRefFrame, + expireRefFrame, + refFrame, + refFrameEpoch, + refFrameScope, + refFrameState, +} from '../ref-frame.ts'; function makeSession(): SessionState { return { @@ -35,7 +43,7 @@ test('setSessionSnapshot advances the generation on every tree replacement (#107 expect(seeded).toBeGreaterThanOrEqual(100_000); expect(seeded).toBeLessThan(1_000_000); // ADR 0014: replacing the observation does NOT touch the ref frame. - expect(session.refFrameState).toBeUndefined(); + expect(session.refFrame).toBeUndefined(); // Storing the SAME snapshot object again is not a replacement. setSessionSnapshot(session, first); @@ -71,11 +79,11 @@ test('a reopened session reseeds so pins from a previous lifetime do not silentl test('resolveRefStalenessWarning: frame expiry is checked before the epoch (ADR 0014 evidence #17)', () => { const session = makeSession(); session.snapshotGeneration = 15; - session.refFrameGeneration = 15; + activateCompleteRefFrame(session); // Expired frame: ANY read is stale, even a pin matching the epoch — a matching // pin proves identity within the retained frame, not that the UI is current. - session.refFrameState = 'expired'; + expireRefFrame(session); expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 })).toBe( STALE_SNAPSHOT_REFS_WARNING, ); @@ -85,7 +93,7 @@ test('resolveRefStalenessWarning: frame expiry is checked before the epoch (ADR // Active frame: a pin matching the epoch and a plain ref are both clean; a pin // from another epoch gets the precise generation warning. - session.refFrameState = 'active'; + activateCompleteRefFrame(session); expect( resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 }), ).toBeUndefined(); @@ -101,14 +109,13 @@ test('resolveRefStalenessWarning names the frozen frame epoch, not the bumped ob const session = makeSession(); // A frame was issued at generation 15. session.snapshotGeneration = 15; - session.refFrameGeneration = 15; - session.refFrameState = 'active'; + activateCompleteRefFrame(session); // A read-only capture replaces the observation and advances the observation // counter WITHOUT re-issuing the frame — the frame epoch stays frozen at 15. setSessionSnapshot(session, makeSnapshot()); expect(session.snapshotGeneration).toBe(16); - expect(session.refFrameGeneration).toBe(15); + expect(refFrame(session).generation).toBe(15); // A pin matching the FROZEN frame epoch is clean, even though the observation // generation has since advanced past it. @@ -134,21 +141,24 @@ test('resolveRefStalenessWarning treats a missing stored generation as s0', () = test('markSessionPartialRefsIssued: an empty result leaves all frame state untouched (ADR 0014)', () => { const session = makeSession(); // A useful prior frame exists. - session.refFrameState = 'active'; - session.refFrameScope = new Set(['e1']); - session.refFrameGeneration = 7; + session.snapshotGeneration = 7; + activatePartialRefFrame(session, new Set(['e1'])); + const priorFrame = refFrame(session); // An empty partial publication (no refs) must not supersede that authority. markSessionPartialRefsIssued(session, []); - expect(session.refFrameState).toBe('active'); - expect(session.refFrameScope).toEqual(new Set(['e1'])); - expect(session.refFrameGeneration).toBe(7); + // The frame is one value, so "untouched" is one identity check rather than a + // field-by-field comparison that can miss the field nobody thought to assert. + expect(refFrame(session)).toBe(priorFrame); + expect(refFrameState(session)).toBe('active'); + expect(refFrameScope(session)).toEqual(new Set(['e1'])); + expect(refFrame(session).generation).toBe(7); // A non-empty result supersedes with exactly its bodies. session.snapshotGeneration = 9; markSessionPartialRefsIssued(session, ['@e5~s7', 'e6']); - expect(session.refFrameScope).toEqual(new Set(['e5', 'e6'])); - expect(session.refFrameGeneration).toBe(9); + expect(refFrameScope(session)).toEqual(new Set(['e5', 'e6'])); + expect(refFrame(session).generation).toBe(9); }); // The observation counter and the authorization epoch are different clocks, and conflating them diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts index 727974449..17fdb7713 100644 --- a/src/daemon/__tests__/tv-remote-runtime.test.ts +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundTvRemoteRuntime } from '../tv-remote-runtime.ts'; @@ -238,7 +238,7 @@ test('request router joins tv-remote admission to execution and ref invalidation ok: true, data: { action: 'tv-remote', button: 'down', message: 'Pressed TV remote down' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.bind).toHaveBeenCalledTimes(1); expect(harness.tvRemote).toHaveBeenCalledTimes(1); }); diff --git a/src/daemon/__tests__/viewport-runtime.test.ts b/src/daemon/__tests__/viewport-runtime.test.ts index 985a742dc..08235b396 100644 --- a/src/daemon/__tests__/viewport-runtime.test.ts +++ b/src/daemon/__tests__/viewport-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundViewportRuntime } from '../viewport-runtime.ts'; @@ -189,7 +189,7 @@ test('request router joins viewport admission to execution, recording, and ref i ok: true, data: { width: 1280, height: 900, message: 'Viewport set: 1280x900' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'viewport', positionals: ['1280', '900'], diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 8cba0bfd5..077ad999a 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -10,6 +10,7 @@ import { mockTapPoint, resetGetRuntimeFixture, } from '../../__tests__/interaction-get-runtime-fixture.ts'; +import { refFrameState } from '../../ref-frame.ts'; vi.mock('../../snapshot-capture.ts', () => ({ captureSnapshot: vi.fn(), @@ -79,7 +80,7 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste expect(response?.ok).toBe(true); // ADR 0014 side-effect seam: overlay dismissal taps the device, so it expires // the ref frame. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); expect(mockDismissTap).toHaveBeenCalledWith( expect.objectContaining({ point: { x: 379, y: 820 } }), ); diff --git a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts index f5b242219..714190288 100644 --- a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -9,6 +9,7 @@ import { mockInspectDeviceRuntimeFacts, mockMaterializeAppSourceRuntime, } from './session-command-harness.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts'; const invoke = async (): Promise => { throw new Error('install_source ref-frame tests must stay on the runtime route'); @@ -37,7 +38,7 @@ test('install_source admission failure preserves an active ref frame', async () expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); expect(inspectFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -54,7 +55,7 @@ test('install_source materialization failure preserves an active ref frame', asy expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -63,7 +64,7 @@ test('install_source deploy attempt expires an active ref frame when the bound o const session = activeSession(); sessionStore.set('default', session); mockDeployMaterializedAppRuntime.mockImplementationOnce(async () => { - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); throw new Error('provider deployment failed'); }); @@ -75,13 +76,13 @@ test('install_source deploy attempt expires an active ref frame when the bound o expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); }); function activeSession(): SessionState { const snapshot = { createdAt: Date.now(), nodes: [] }; - return { + const session: SessionState = { name: 'default', createdAt: Date.now(), actions: [], @@ -94,8 +95,9 @@ function activeSession(): SessionState { }, snapshot, snapshotScopeSource: snapshot, - refFrameState: 'active', }; + activateCompleteRefFrame(session); + return session; } async function dispatchInstallSource(params: { diff --git a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts index beb71b59d..7e3d17b81 100644 --- a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts @@ -8,6 +8,7 @@ import { mockInspectDeviceRuntimeFacts, mockPushNotificationRuntime, } from './session-command-harness.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts'; const invoke = async (): Promise => { throw new Error('push ref-frame tests must stay on the runtime route'); @@ -36,7 +37,7 @@ test('push admission failure preserves an active ref frame', async () => { expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); expect(inspectFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -51,13 +52,13 @@ test('push dispatch attempt expires an active ref frame when the bound operation expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockPushNotificationRuntime).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); }); function activeSession(): SessionState { const snapshot = { createdAt: Date.now(), nodes: [] }; - return { + const session: SessionState = { name: 'default', createdAt: Date.now(), actions: [], @@ -70,8 +71,9 @@ function activeSession(): SessionState { }, snapshot, snapshotScopeSource: snapshot, - refFrameState: 'active', }; + activateCompleteRefFrame(session); + return session; } async function dispatchPush(params: { diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 3866045f3..2b8be533a 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -66,6 +66,7 @@ import { scheduleIosRunnerIdleStop, } from '@agent-device/platform-apple/runner/operations'; import { runMacOsAlertAction } from '@agent-device/platform-apple/macos'; +import { refFrameState } from '../../ref-frame.ts'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -195,7 +196,7 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails }), ); // A freshly issued frame is active before the relaunch. - expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); // The relaunch close dispatches and then fails/times out AFTER the app may // already have been torn down. @@ -212,7 +213,7 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails // session's frame was expired BEFORE the close dispatch and stays expired — a // post-dispatch close failure never restores it (there is no rollback). expect(response?.ok ?? false).toBe(false); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('open --relaunch does not let an ambient provider claim suppress a local pre-close', async () => { diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 0a76b8a0f..1c560d561 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -19,6 +19,7 @@ import { gestureInspectFacts, gestureRuntimeSpies, } from '../../__tests__/test-device-runtime-gateway.ts'; +import { refFrameState } from '../../ref-frame.ts'; test('runtime set/show/clear manages session-scoped runtime hints before open', async () => { const sessionStore = makeSessionStore(); @@ -151,7 +152,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }); mockClearRuntimeHints.mockImplementationOnce(async () => { // Planted route witness: deleting the handler's pre-mutation expiry leaves this frame active. - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); const response = await handleSessionCommands({ @@ -170,7 +171,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary expect(response?.ok).toBe(true); expect(mockClearRuntimeHints).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); test('runtime clear rejects a false runtime-hints fact before its one implementation bind', async () => { diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index c4b640bc6..df1e71091 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -27,6 +27,7 @@ import { } from './session-test-harness.ts'; import type { SessionState } from '../../types.ts'; import { handleSessionCommands } from './session-command-harness.ts'; +import { refFrameState } from '../../ref-frame.ts'; const available = Object.freeze({ available: true } as const); @@ -97,7 +98,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves inspectFacts, bindDevice, }); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // status is a read-only probe → frame preserved (undefined === active). sessionStore.set(sessionName, makeSession(sessionName, device)); @@ -116,7 +117,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves inspectFacts, bindDevice, }); - expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); }); // ADR 0014 requires the frame to expire immediately before the mutating call, with no @@ -137,7 +138,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i const logPath = path.join(os.tmpdir(), 'daemon.log'); const { inspectFacts, bindDevice } = keyboardCapableRuntime(device, { keyboardDismiss: () => { - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); return Promise.reject(new AppError('COMMAND_FAILED', 'runner timed out')); }, }); @@ -160,7 +161,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i bindDevice, }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('keyboard requires an active session or explicit device selector', async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 3257ece49..522da36e5 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -47,7 +47,6 @@ vi.mock('../../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); - vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); @@ -64,6 +63,7 @@ vi.mock('../../ios-app-session-hint.ts', () => ({ import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; +import { expireRefFrame, refFrame, refFrameState, refFrameTree } from '../../ref-frame.ts'; const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); @@ -445,7 +445,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy backend: 'android', }; // A prior device action expired the frame. - session.refFrameState = 'expired'; + expireRefFrame(session); sessionStore.set(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], @@ -463,7 +463,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // The snapshot response hands every stored node's ref to the client: it // re-activates a complete frame, so refs are current again. expect(snapshotResponse?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const diffResponse = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), @@ -475,7 +475,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // diff replaces the observation but is a read (summary response): it preserves // the authorized frame rather than expiring it. expect(diffResponse?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); }); // #1076 versioned refs — shared harness for the refsGeneration tests below. @@ -517,8 +517,8 @@ function expectInternalObservationResult(params: { expect(params.response?.ok ? params.response.data?.refsGeneration : undefined).toBeUndefined(); expect(params.session?.snapshotGeneration).toBe((params.publishedGeneration as number) + 1); expect(params.session?.snapshot).not.toBe(params.publishedTree); - expect(params.session?.refFrameGeneration).toBe(params.publishedGeneration); - expect(params.session?.refFrameTree).toBe(params.publishedTree); + expect(refFrame(params.session!).generation).toBe(params.publishedGeneration); + expect(refFrameTree(params.session!)).toBe(params.publishedTree); } test('snapshot responses carry refsGeneration and advance it per capture (#1076 versioned refs)', async () => { @@ -560,8 +560,8 @@ test('daemon-private snapshot observation advances capture state without publish await runVersionedRefsCommand({ sessionStore, sessionName, command: 'snapshot' }); const published = sessionStore.get(sessionName); - const publishedGeneration = published?.refFrameGeneration; - const publishedTree = published?.refFrameTree; + const publishedGeneration = refFrame(published!).generation; + const publishedTree = refFrameTree(published!); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Internal' }], diff --git a/src/daemon/interaction/internal/__tests__/find.test.ts b/src/daemon/interaction/internal/__tests__/find.test.ts index c674256da..5dc241945 100644 --- a/src/daemon/interaction/internal/__tests__/find.test.ts +++ b/src/daemon/interaction/internal/__tests__/find.test.ts @@ -17,7 +17,6 @@ vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { resolveTargetDevice: actual.resolveTargetDevice, }; }); - vi.mock('../../../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; @@ -30,6 +29,7 @@ import { resetFindTouchRuntimeFixture, } from './find-touch-runtime-fixture.ts'; import { invokeFindHandler } from './find-handler-fixture.ts'; +import { refFrameScope, refFrameState } from '../../../ref-frame.ts'; beforeEach(() => { resetFindTouchRuntimeFixture(); @@ -92,7 +92,7 @@ test('mutating find focus crosses the ADR 0014 side-effect seam and expires the nodes: [node], }); expect(response.ok).toBe(true); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); test('handleFindCommands click returns deterministic metadata across locator variants', async () => { @@ -618,8 +618,8 @@ test('handleFindCommands list returns every match without acting', async () => { // next command (a plain `@eN` still requires a complete frame by design — // the MCP/CLI layers pin from `matches` + `refsGeneration`). expect(typeof response.data?.refsGeneration).toBe('number'); - expect(session.refFrameState).toBe('active'); - expect([...(session.refFrameScope ?? [])].sort()).toEqual(['e2', 'e3', 'e4']); + expect(refFrameState(session)).toBe('active'); + expect([...refFrameScope(session)].sort()).toEqual(['e2', 'e3', 'e4']); }); test('handleFindCommands list on a unique match still lists instead of tapping', async () => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts index 7085c428c..c10ca3ab2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts @@ -57,5 +57,5 @@ test('non-ambiguity errors do not change ref authority', () => { }), original, ); - assert.equal(state.refFrameScope, undefined); + assert.equal(state.refFrame, undefined); }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index cffbe001b..d9ad3853d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -7,7 +7,7 @@ import type { SessionState } from '../../../types.ts'; import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import { buildSnapshotState } from '../../../../core/snapshot-state.ts'; import { setSessionSnapshot } from '../../../session-snapshot.ts'; -import { activateCompleteRefFrame } from '../../../ref-frame.ts'; +import { activateCompleteRefFrame, expireRefFrame, refFrameState } from '../../../ref-frame.ts'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { @@ -229,7 +229,7 @@ test('press --settle responds with the settled diff, refsGeneration, and activat const session = sessionStore.get(sessionName) as SessionState; // The settle response handed the settled tree's refs to the client: it // activated a partial frame and the payload carries the stored generation. - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(settle.refsGeneration).toBe(session.snapshotGeneration); // The settled tree became the stored session snapshot. expect(session.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); @@ -308,7 +308,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation const sessionName = 'settle-stale-ref'; const session = seedSession(sessionName, sessionStore); // ADR 0014: a device action since the snapshot expired the ref frame. - session.refFrameState = 'expired'; + expireRefFrame(session); sessionStore.set(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), @@ -337,7 +337,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation expect(String(response.error.details?.hint)).toMatch(/refs were issued/); } expect(mockCaptureSnapshotForSession).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('a settle observation without a diff leaves ref staleness untouched', async () => { @@ -377,7 +377,7 @@ test('a settle observation without a diff leaves ref staleness untouched', async expect(settle.hint).toMatch(/Settle observation unavailable/); // The press mutated (expiring the frame) and no partial frame was published, // so the frame stays expired — refs are stale until a fresh snapshot. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('a stalled settle capture receives its deadline signal and leaves the interaction responsive', async () => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts index f4af1438c..7a1296b90 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts @@ -43,6 +43,7 @@ import { getAndroidScreenSize, } from '@agent-device/platform-android/mechanics'; import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; +import { activateCompleteRefFrame } from '../../../ref-frame.ts'; const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogObservation = vi.mocked(getAndroidBlockingDialogObservation); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); @@ -151,7 +152,7 @@ test('ADR 0014: Android freshness cannot retarget an admitted ref by positional }; session.snapshot = frameTree; // ADR 0014: the authorized frame tree names WHICH node @e1 authorizes. - session.refFrameTree = frameTree; + activateCompleteRefFrame(session); session.androidSnapshotFreshness = { action: 'press', markedAt: Date.now(), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index e9aec8195..c5d5d554b 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -13,6 +13,7 @@ import { makeStaleRefSession, runInteraction, } from './interaction-touch-fixtures.ts'; +import { refFrameState } from '../../../ref-frame.ts'; vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); @@ -71,5 +72,5 @@ test('Maestro selector click crosses the ADR 0014 fused seam and expires the ref expect(click?.ok).toBe(true); expect(mockTapElementSelector).toHaveBeenCalledOnce(); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts index 1b11e2395..ea0c48158 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts @@ -1,7 +1,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; -import { activateCompleteRefFrame } from '../../../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../../ref-frame.ts'; import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../../session-snapshot.ts'; import { handleInteractionCommands } from '../../index.ts'; import { @@ -179,7 +179,7 @@ test('press selector then press @ref rejects refs that outlived the stored snaps // ADR 0014: the selector press crossed the side-effect seam and expired the // frame, so the ref that outlived it is rejected before dispatch. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); const touchCallsBeforeStaleRef = mockTapPoint.mock.calls.length; const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(false); @@ -204,12 +204,12 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram }); // A freshly issued complete frame is active. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(press?.ok).toBe(true); // The transition is wired at the leaf seam. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) until a fresh observation re-authorizes', async () => { @@ -229,7 +229,7 @@ test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) unti // `snapshot -> press @e1`: admitted; crosses the seam and expires the frame. const first = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(first?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // `-> press @e2`: the unobserved second mutation rejects (bare). const bare = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); @@ -276,7 +276,7 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' ]); expect(selectorPress?.ok).toBe(true); // The selector press expired the frame (ADR 0014 seam). - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // Simulate the snapshot command re-issuing the complete ref namespace: it // re-activates a complete frame (through buildNextSnapshotSession; covered in @@ -342,7 +342,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r createdAt: Date.now(), backend: 'xctest', }); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); sessionStore.set(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -350,7 +350,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r expect(response?.ok).toBe(true); expect(mockTapPoint).toHaveBeenCalled(); // Crossing the seam expired the frame, so a SECOND plain ref is now rejected. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); const second = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(second?.ok).toBe(false); if (second && !second.ok) { diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index 8ac278d5a..c2965bc0c 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -1,5 +1,5 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import { readSessionRuntimeRevision } from './ref-frame.ts'; +import { readSessionRuntimeRevision, refFrame, type RefFrame } from './ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; import type { SessionState } from './types.ts'; @@ -13,13 +13,6 @@ export type InternalObservationEvidence = { readonly [INTERNAL_OBSERVATION_EVIDENCE]: true; }; -type RefFrameLineage = Readonly<{ - state: SessionState['refFrameState']; - scope: SessionState['refFrameScope']; - tree: SessionState['refFrameTree']; - generation: SessionState['refFrameGeneration']; -}>; - type InternalObservationLineage = Readonly<{ sessionName: string; session: SessionState; @@ -27,7 +20,7 @@ type InternalObservationLineage = Readonly<{ snapshot: SnapshotState; snapshotGeneration: number; runtimeRevision: number; - refFrame: RefFrameLineage; + refFrame: RefFrame; }>; const evidenceLineage = new WeakMap(); @@ -120,7 +113,7 @@ function storeInternalObservation( snapshot, snapshotGeneration, runtimeRevision: readSessionRuntimeRevision(session), - refFrame: readRefFrameLineage(session), + refFrame: refFrame(session), }); return { evidence, refsGeneration: snapshotGeneration }; } @@ -179,25 +172,7 @@ function isCurrentLineage( current.snapshot === lineage.snapshot && current.snapshotGeneration === lineage.snapshotGeneration && readSessionRuntimeRevision(current) === lineage.runtimeRevision && - sameRefFrameLineage(readRefFrameLineage(current), lineage.refFrame) - ); -} - -function readRefFrameLineage(session: SessionState): RefFrameLineage { - return { - state: session.refFrameState, - scope: session.refFrameScope, - tree: session.refFrameTree, - generation: session.refFrameGeneration, - }; -} - -function sameRefFrameLineage(left: RefFrameLineage, right: RefFrameLineage): boolean { - return ( - left.state === right.state && - left.scope === right.scope && - left.tree === right.tree && - left.generation === right.generation + refFrame(current) === lineage.refFrame ); } diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 5166c24d6..ddb018e45 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -1,3 +1,4 @@ +import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { SessionState } from './types.ts'; const runtimeRevisions = new WeakMap(); @@ -44,20 +45,95 @@ export type RefFrameAdmission = | { admitted: true } | { admitted: false; reason: RefFrameRejectReason }; +type RefFrameFields = Readonly<{ + state: RefFrameState; + scope: RefFrameScope; + tree: SnapshotState | undefined; + generation: number | undefined; +}>; + +/** + * One value, replaced whole by this module's transitions. The class is not exported, so nothing + * outside this file can construct, edit, or derive a frame. + */ +class SessionRefFrame { + readonly #fields: RefFrameFields; + + constructor(fields: RefFrameFields) { + this.#fields = fields; + } + + get state(): RefFrameState { + return this.#fields.state; + } + + get scope(): RefFrameScope { + return this.#fields.scope; + } + + get tree(): SnapshotState | undefined { + return this.#fields.tree; + } + + get generation(): number | undefined { + return this.#fields.generation; + } + + /** Idempotent by identity: an expired frame is returned as is, so repeated expiry compares `===`. */ + static expire(frame: SessionRefFrame): SessionRefFrame { + if (frame.#fields.state === 'expired') return frame; + return new SessionRefFrame({ ...frame.#fields, state: 'expired' }); + } +} + +export type RefFrame = SessionRefFrame; + /** - * The frame epoch exposed to clients as `refsGeneration`. Frozen at issuance - * (`refFrameGeneration`) so a later read-only capture that advances the - * observation counter (`snapshotGeneration`) does not shift the epoch a valid - * pin is compared against. Falls back to `snapshotGeneration` for pre-frame - * sessions. + * The frame a session has before anything issues refs: complete authority over + * an empty namespace, deferring tree and epoch to the operational observation. + * A shared constant, so lineage identity is stable for a session that never + * reached a transition. + */ +const PRISTINE_FRAME = new SessionRefFrame({ + state: 'active', + scope: 'all', + tree: undefined, + generation: undefined, +}); + +/** + * The session's current frame, as one comparable value. Identity changes on + * every transition and only on a transition, so a caller holding an earlier + * frame can tell whether authority moved with a single `===`. + */ +export function refFrame(session: SessionState): RefFrame { + return session.refFrame ?? PRISTINE_FRAME; +} + +/** + * The frame epoch exposed to clients as `refsGeneration`. Frozen at issuance so + * a later read-only capture that advances the observation counter + * (`snapshotGeneration`) does not shift the epoch a valid pin is compared + * against. Falls back to `snapshotGeneration` for pre-frame sessions. */ export function refFrameEpoch(session: SessionState): number | undefined { - return session.refFrameGeneration ?? session.snapshotGeneration; + return refFrame(session).generation ?? session.snapshotGeneration; +} + +/** + * The tree that minted the frame's refs, retained so a ref resolves to the node + * the caller was authorized against rather than to whatever now sits at that + * index in a newer observation. Undefined before any issuance. + */ +export function refFrameTree(session: SessionState): SnapshotState | undefined { + return refFrame(session).tree; } /** - * Expire the current frame at a device side-effect seam (ADR 0014). Idempotent: - * additional effects while already expired are a no-op. Call this SYNCHRONOUSLY, + * Expire the current frame at a device side-effect seam (ADR 0014). The frame + * transition is idempotent by identity: an effect crossed while already expired + * leaves the SAME frame in place (the runtime revision below still advances, one + * per effect, because that is what tracks effects). Call this SYNCHRONOUSLY, * immediately before awaiting the operation that may change device-visible * element identity, so that a post-dispatch failure (timeout, connection loss, * ambiguous error) still leaves the frame expired — there is no success-only @@ -70,7 +146,7 @@ export function refFrameEpoch(session: SessionState): number | undefined { */ export function expireRefFrame(session: SessionState): void { advanceSessionRuntimeRevision(session); - session.refFrameState = 'expired'; + session.refFrame = SessionRefFrame.expire(refFrame(session)); session.snapshotScopeSource = undefined; } @@ -102,7 +178,7 @@ export function readSessionRuntimeRevision(session: SessionState): number { * {@link activateRefFrame}. */ export function activateCompleteRefFrame(session: SessionState): void { - activateRefFrame(session, undefined); + activateRefFrame(session, 'all'); } /** @@ -119,29 +195,30 @@ export function activatePartialRefFrame(session: SessionState, scope: ReadonlySe } /** - * The frame's four fields move together or the frame is incoherent: an `active` state with a - * stale `refFrameTree` resolves refs against a namespace nobody authorized, and a frame - * pinned to the wrong `refFrameGeneration` invalidates correct pins. Both issuance forms - * therefore land here rather than each writing the four fields itself. + * Both issuance forms land here because they differ only in scope, and because the frame is + * one value: an `active` state paired with a stale tree resolves refs against a namespace + * nobody authorized, and a frame pinned to the wrong generation invalidates correct pins. * * Retains the just-published tree (`session.snapshot`) as the frame's immutable source by * SHARED reference — no deep copy (ADR 0014 performance). A later read-only capture advances * `session.snapshot` without disturbing this tree, so a ref keeps resolving against the * namespace that authorized it. */ -function activateRefFrame(session: SessionState, scope: ReadonlySet | undefined): void { - session.refFrameState = 'active'; - session.refFrameScope = scope; - session.refFrameTree = session.snapshot; - session.refFrameGeneration = session.snapshotGeneration; +function activateRefFrame(session: SessionState, scope: RefFrameScope): void { + session.refFrame = new SessionRefFrame({ + state: 'active', + scope, + tree: session.snapshot, + generation: session.snapshotGeneration, + }); } export function refFrameState(session: SessionState): RefFrameState { - return session.refFrameState ?? 'active'; + return refFrame(session).state; } export function refFrameScope(session: SessionState): RefFrameScope { - return session.refFrameScope ?? 'all'; + return refFrame(session).scope; } export type RefMutationFrame = { diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts index 625079581..c0ce839aa 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts @@ -12,7 +12,7 @@ import { makeIosSession } from '../../../../__tests__/test-utils/session-factori import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { ReplayDivergence } from '@agent-device/contracts/divergence'; import { bindInternalObservationAuthority } from '../../../internal-observation.ts'; -import { expireRefFrame } from '../../../ref-frame.ts'; +import { expireRefFrame, refFrameScope, refFrameState, refFrameTree } from '../../../ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../../session-snapshot.ts'; import { SessionStore } from '../../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; @@ -134,8 +134,8 @@ test('internal divergence capture updates observation without publishing client expect(observation.state).toBe('available'); expect(input.session.snapshot?.nodes[0]?.label).toBe('Captured again'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test.each([ @@ -158,8 +158,8 @@ test.each([ const refs = result.screen.state === 'available' ? result.screen.refs.map((entry) => entry.ref) : []; expect(refs).toHaveLength(expected); - expect(input.session.refFrameScope).toEqual(new Set(refs)); - expect(input.session.refFrameTree).toBe(input.snapshot); + expect(refFrameScope(input.session)).toEqual(new Set(refs)); + expect(refFrameTree(input.session)).toBe(input.snapshot); }); test('missing capture evidence fails closed instead of exposing unauthorized refs', () => { @@ -180,8 +180,8 @@ test('missing capture evidence fails closed instead of exposing unauthorized ref reason: 'ref-publication-missing-evidence', }), ); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('degenerate projected refs fail closed when publication normalizes to empty', () => { @@ -219,8 +219,8 @@ test('degenerate projected refs fail closed when publication normalizes to empty { selector: 'label="Empty ref"', basis: 'label' }, { selector: 'label="Empty scoped ref"', basis: 'label' }, ]); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('an empty outward projection consumes its one-shot capture evidence', () => { @@ -257,8 +257,8 @@ test('an empty outward projection consumes its one-shot capture evidence', () => reason: 'ref-publication-stale-capture', }), ); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('successful overflow publishes the refs exposed by the exact artifact projection', () => { @@ -280,8 +280,8 @@ test('successful overflow publishes the refs exposed by the exact artifact proje screen: { refs: Array<{ ref: string }> }; }; const artifactRefs = artifact.screen.refs.map((entry) => entry.ref); - expect(input.session.refFrameScope).toEqual(new Set(artifactRefs)); - expect(input.session.refFrameTree).toBe(input.snapshot); + expect(refFrameScope(input.session)).toEqual(new Set(artifactRefs)); + expect(refFrameTree(input.session)).toBe(input.snapshot); }); test('failed overflow artifact with no inline refs publishes nothing', () => { @@ -305,8 +305,8 @@ test('failed overflow artifact with no inline refs publishes nothing', () => { expect(result.artifactUnavailable).toBe(true); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('stale capture suppresses outward refs and preserves newer authority', () => { @@ -326,7 +326,7 @@ test('stale capture suppresses outward refs and preserves newer authority', () = }); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); }); test('cancellation after capture suppresses outward refs without reactivating an expired frame', () => { @@ -346,6 +346,6 @@ test('cancellation after capture suppresses outward refs without reactivating an }); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts index 74cfb2cdc..1626d1118 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; - vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() }; @@ -43,6 +42,7 @@ import { legacyDispatchCapture, resetLegacySnapshotCapture, } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { refFrameScope, refFrameState } from '../../../ref-frame.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -871,8 +871,8 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the // screen refs — no more (no over-pin surface), no less (every advertised ref // is usable), even though every one of them is a `covered` node here. const stored = sessionStore.get(sessionName); - expect(stored?.refFrameState).toBe('active'); - expect(stored?.refFrameScope).toEqual(screenRefBodies); + expect(refFrameState(stored!)).toBe('active'); + expect(refFrameScope(stored!)).toEqual(screenRefBodies); }); // #1264 (capture parity, point 1): the divergence capture must go through the diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts index 605b99133..fc6abbf83 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts @@ -34,6 +34,7 @@ import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { refFrameScope } from '../../../ref-frame.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -368,7 +369,7 @@ test('typed Maestro failure publishes exactly the refs exposed by its divergence expect(divergence.screen.state).toBe('available'); expect(exposedRefs).toEqual(['e2']); - expect(scenario.sessionStore.get(scenario.sessionName)?.refFrameScope).toEqual( + expect(refFrameScope(scenario.sessionStore.get(scenario.sessionName)!)).toEqual( new Set(exposedRefs), ); }); diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 80ab3fbcd..5d494faf0 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -1,5 +1,6 @@ import type { CommandSessionRecord, CommandSessionStore } from '../runtime-contract.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { refFrameTree } from './ref-frame.ts'; import type { SessionState } from './types.ts'; export type RuntimeSessionRecordOptions = { @@ -20,6 +21,7 @@ function toRuntimeSessionRecord( options: RuntimeSessionRecordOptions = {}, ): CommandSessionRecord | undefined { if (!session) return undefined; + const frameTree = refFrameTree(session); return { name, appBundleId: session.appBundleId, @@ -30,8 +32,8 @@ function toRuntimeSessionRecord( // ADR 0014: expose the authorized frame tree so ref resolution binds a // `@eN` to the node the caller was authorized against, not to whatever // now sits at that index in a newer observation. - ...(session.refFrameTree && options.omitRefFrameSnapshot !== true - ? { refFrameSnapshot: session.refFrameTree } + ...(frameTree && options.omitRefFrameSnapshot !== true + ? { refFrameSnapshot: frameTree } : {}), } : {}), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 77e5ea326..a63f9e948 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -3,7 +3,11 @@ import os from 'node:os'; import path from 'node:path'; import { SessionStore } from '../../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../../types.ts'; -import { readSessionRuntimeRevision } from '../../../ref-frame.ts'; +import { + activateCompleteRefFrame, + readSessionRuntimeRevision, + refFrameState, +} from '../../../ref-frame.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; const runtimeHintsModule = vi.hoisted(() => ({ @@ -83,7 +87,7 @@ test('an app-only close without a target fails before admission or ref-frame exp booted: true, }; const session = makeSession(sessionName, device); - session.refFrameState = 'active'; + activateCompleteRefFrame(session); sessionStore.set(sessionName, session); const revisionBeforeClose = readSessionRuntimeRevision(session); @@ -101,7 +105,7 @@ test('an app-only close without a target fails before admission or ref-frame exp expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); expect(mockDispatch).not.toHaveBeenCalled(); expect(readSessionRuntimeRevision(session)).toBe(revisionBeforeClose); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(sessionStore.get(sessionName)).toBe(session); }); @@ -329,7 +333,7 @@ test('close expires the ref frame immediately before its admitted platform mutat session.appBundleId = 'com.example.app'; sessionStore.set(sessionName, session); mockDispatch.mockImplementationOnce(async () => { - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); const response = await close({ diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 7e78f05e6..3a5cf134d 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -32,7 +32,7 @@ import type { ReplayTargetGuardDenotation, TargetAnnotationV1, } from '@agent-device/contracts/replay'; -import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; +import type { RefFrame } from './ref-frame.ts'; export type DaemonInstallSource = PublicDaemonInstallSource; export type SessionRuntimeHints = PublicSessionRuntimeHints; export type DaemonArtifact = PublicDaemonArtifact; @@ -302,43 +302,10 @@ export type SessionState = { /** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */ snapshotScopeSource?: SnapshotState; /** - * ADR 0014 ref-frame lifecycle state. Undefined is treated as `active`. A - * device side effect transitions the frame to `expired` (wired at the - * side-effect seam in a later migration step); an expired frame admits no ref - * mutation. Managed only through `src/daemon/ref-frame.ts`. + * ADR 0014 ref frame, owned by `ref-frame.ts` by construction: read it through that module's + * accessors. Undefined is the pristine frame. */ - refFrameState?: RefFrameState; - /** - * ADR 0014 issuance scope of the current ref frame. Undefined is treated as - * `all` (a complete namespace). A bounded set names the ref bodies a partial - * publication (`find`, settled diff, replay divergence) actually issued, which - * are the only bodies a pinned mutation may target. Managed only through - * `src/daemon/ref-frame.ts`. - */ - refFrameScope?: RefFrameScope; - /** - * ADR 0014 immutable source tree of the current ref frame: the tree that - * minted the frame's refs, retained so a ref resolves to the node the caller - * was authorized against — never to a different element by positional - * coincidence in a newer operational observation (`snapshot`). Shares the - * capture object with `snapshot` at activation (no deep copy); an Android - * freshness or other read-only capture advances `snapshot` WITHOUT touching - * this, so the two intentionally diverge. Managed only through - * `src/daemon/ref-frame.ts` and the partial-issuance writer. Undefined falls - * back to `snapshot` (pre-frame sessions). - */ - refFrameTree?: SnapshotState; - /** - * ADR 0014 ref-frame epoch, frozen at the generation the frame was issued at - * (the `refsGeneration` the client received). A later read-only capture - * advances `snapshotGeneration` (the observation counter) WITHOUT reissuing - * refs, so admission and pin comparisons use this frame-pinned epoch — a - * correct pin from the issuing frame is not falsely rejected because an - * intervening read bumped the observation counter. Undefined falls back to - * `snapshotGeneration`. Managed only through `src/daemon/ref-frame.ts` and the - * partial-issuance writer. - */ - refFrameGeneration?: number; + refFrame?: RefFrame; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: SnapshotFreshnessWindow;