Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/adr/0014-session-ref-frame-lifetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/dependency-graph-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions scripts/__tests__/help-conformance-sample-producers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
62 changes: 27 additions & 35 deletions scripts/layering/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,79 +293,71 @@ 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'],
);
});

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`,
);
}
});

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.
Expand Down Expand Up @@ -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'],
);
});

Expand Down
19 changes: 11 additions & 8 deletions scripts/layering/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'],
Expand Down
5 changes: 3 additions & 2 deletions src/daemon/__tests__/android-system-dialog-ref-frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>,
Expand Down Expand Up @@ -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');
});
4 changes: 2 additions & 2 deletions src/daemon/__tests__/back-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/__tests__/focus-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'],
Expand Down
6 changes: 3 additions & 3 deletions src/daemon/__tests__/generic-settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/__tests__/home-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading