mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
96727a0b42
commit
ba6c818d81
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
+18
-18
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
|
||||
+8
-4
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user