spike(daemon): give the ADR-0014 ref frame private ownership (#2296)

* refactor(daemon): make the ADR 0014 ref frame one owned value

The four `refFrame*` fields on `SessionState` were policed only by the R7
ownership table: any daemon module could write them, and only a full-graph AST
scan could say whose write it was. They are now one `RefFrame` value whose brand
key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot
construct one and cannot edit the one a session holds; the transitions replace it
whole. Every transition, rejection reason and epoch rule is unchanged.

Readers moved to the accessors ref-frame.ts exports (`refFrameState`,
`refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`).
`internal-observation.ts` drops its four-field lineage copy and its field-by-field
comparison: frame identity is now one `===`.

Seen red: with the empty-result early return removed from
`markSessionPartialRefsIssued`, the new frame-identity assertion in
session-snapshot.test.ts fails; restored, it passes. A planted foreign writer
module was rejected by tsc (TS2741 missing brand, TS2540 read-only property)
before deletion.

* docs(depgraph): note the ref frame outgrew its R7 row

* refactor(daemon): make the ref frame nominal, not symbol-branded

A symbol brand on a plain object type stops construction from nothing, but not
`{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key,
so any daemon module could mint an incoherent frame (active state, stale tree)
out of a coherent one and it type-checked. Proven before the fix with a throwaway
module doing exactly that write: tsc reported nothing.

The frame is now a class with `#`-private fields behind getters. That makes the
type nominal, so no object literal is assignable to it — the same probe now fails
with TS2739 (`missing #fields, scope, generation, expired`). Construction stays
inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field
doc, and the two in the R7 owner table) now say what the type does and does not
judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays.

Expiry is idempotent by identity again. `expired()` returns THIS frame when the
frame is already expired, rather than an equal copy, which is what the lineage
check in internal-observation.ts compares with `===`. Seen red: with that early
return removed, the tightened ref-frame test fails with "Values have same
structure but are not reference-equal"; green with it.

Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame
activation again, instead of leaning on the pre-frame snapshotGeneration
fallback, and a find test drops a `?? []` that can no longer be reached.

Behavior is unchanged: same frame contents, same transitions, same admission.

* chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value

R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and
`refFrameGeneration` as four fields that had to be written together by one
module; the code now carries them as one nominal value, so the table carries one
row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19.

The row itself stays. The type stops construction, editing and spread-derivation
of a frame outside ref-frame.ts, but it cannot judge a whole frame moved
unchanged — clearing the field, or assigning another session's frame — and the
table can. The comments say that rather than claiming full enforcement.

Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails
R7 with "owned by src/daemon/ref-frame.ts"; green once reverted.

* style: apply oxfmt

* refactor(daemon): keep ref-frame expiry module-private

`RefFrame` exposed a public `expired()` method, so any module holding a
frame could derive a new valid one and install it through a reconstructed
session record, past the R7 field scan. Expiry is now a static on the
unexported class, reachable only inside ref-frame.ts; the frame's surface is
four getters. A type-level regression pins that no outside module can
construct, spread, edit, or derive a frame (tsc covers src tests, so a
directive that stops erroring fails typecheck).

* test(daemon): hold the three accessor migrations within the size ratchet

Each file grew by exactly its new ref-frame import; one blank line between
mock blocks goes so the files stay at their merge-base length.
This commit is contained in:
Michał Pierzchała
2026-09-05 22:32:57 +02:00
committed by GitHub
parent 96727a0b42
commit ba6c818d81
37 changed files with 362 additions and 271 deletions
+6 -2
View File
@@ -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.
+3 -1
View File
@@ -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
@@ -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',
+27 -35
View File
@@ -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'],
);
});
+11 -8
View File
@@ -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<Record<string, readonly string[]>> = {
// 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'],
@@ -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<Parameters<typeof recoverOwnedAndroidBlockingSystemDialog>[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');
});
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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'],
+3 -3
View File
@@ -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 () => {
+2 -2
View File
@@ -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);
@@ -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']));
});
@@ -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);
});
+40 -6
View File
@@ -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<string>): 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);
});
+26 -16
View File
@@ -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
@@ -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);
});
@@ -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'],
@@ -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 } }),
);
@@ -9,6 +9,7 @@ import {
mockInspectDeviceRuntimeFacts,
mockMaterializeAppSourceRuntime,
} from './session-command-harness.ts';
import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts';
const invoke = async (): Promise<never> => {
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: {
@@ -8,6 +8,7 @@ import {
mockInspectDeviceRuntimeFacts,
mockPushNotificationRuntime,
} from './session-command-harness.ts';
import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts';
const invoke = async (): Promise<never> => {
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: {
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {
@@ -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<typeof import('@agent-device/platform-apple/runner/operations')>();
@@ -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' }],
@@ -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 () => {
@@ -57,5 +57,5 @@ test('non-ambiguity errors do not change ref authority', () => {
}),
original,
);
assert.equal(state.refFrameScope, undefined);
assert.equal(state.refFrame, undefined);
});
@@ -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 () => {
@@ -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(),
@@ -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<typeof import('@agent-device/platform-android/mechanics')>();
@@ -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');
});
@@ -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) {
+4 -29
View File
@@ -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<object, InternalObservationLineage>();
@@ -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
);
}
+99 -22
View File
@@ -1,3 +1,4 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { SessionState } from './types.ts';
const runtimeRevisions = new WeakMap<SessionState, number>();
@@ -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;
}>;
/**
* 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.
* 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.
*/
export function refFrameEpoch(session: SessionState): number | undefined {
return session.refFrameGeneration ?? session.snapshotGeneration;
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 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;
}
/**
* 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,
* 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 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). 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<string> | 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 = {
@@ -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);
});
@@ -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<typeof import('../../../../core/dispatch-resolve.ts')>();
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
@@ -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),
);
});
+4 -2
View File
@@ -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 }
: {}),
}
: {}),
@@ -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({
+4 -37
View File
@@ -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;