mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
dcd8b65d4c
* refactor(daemon): split daemon/types.ts into request and session-state modules `src/daemon/types.ts` served two audiences from one file: the dispatch request shape and the daemon's live session record. It also sat in the only daemon type cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState` back — so neither file could be read in isolation. Three modules replace it, each importing only downward: - `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request with no `internal` key and no property path to `SessionState` or `DeviceLease`, so a consumer can read a request's command, flags and public metadata without depending on the session record. - `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which stays unexported) plus the response vocabulary. - `session-state.ts` owns `SessionState` and the shapes only it holds. The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and `session-state.ts`: it owns the frame VALUE (the class stays unexported, so the type remains nominal and unconstructible from outside), while `ref-frame.ts` keeps every lifetime transition and every `session.refFrame` write. No behavior change: every importer moves to the module owning the symbol it uses, with no re-export shim at the old path. `client-normalizers.ts` takes `SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * test(daemon): assert the wire request shape cannot reach session state A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an `internal` key or grows a property path back to `SessionState` or `DeviceLease`. Positive controls over `DaemonRequest` prove the walk finds both when they are there, so a walk that never matches anything cannot pass by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * test(daemon): keep the three over-budget test files at their base length Splitting `daemon/types.ts` turns one combined import into two in every file that used both halves. Three of those test files are already over the 1,000-line tripwire, where the size ratchet allows no growth, so each sheds one line that was carrying nothing: - `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength` assertion an adjacent `toEqual` on an explicit array literal already makes. - `session-replay-repair-transaction.test.ts` names the filtered close actions instead of wrapping the expression across three lines inside `expect`. No assertion is weakened and no test content is removed. Splitting these files along the modules they mirror is the standing remedy, but none of those modules split here, so it stays out of this change and is tracked in #2353. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * chore(gates): point the daemon modularity and wire-compat gates at the split modules R7 now locates the `SessionState` declaration by the declaration itself rather than by a recorded path: `sessionStateWritePressure` measures the merge-base tree too, and that tree still declares it in `daemon/types.ts` — a path constant would measure it as zero pressure and bank the headroom. R10's external-importer ratchet covers all three modules that replaced `daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to a new outside zone. The recorded membership is unchanged: `client-normalizers.ts` and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only. The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and `DaemonArtifact` by path, so those three keys follow the declarations to `daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the server-side narrowing of the kernel declaration that fixes the wire shape — rather than calling it a re-export alias. The `live-state-shape` and session-resource declaration sites move with `SessionState`; the depgraph lookalike fixture takes a new plausible path now that `daemon/session-state.ts` is the real root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y --------- Co-authored-by: Claude <noreply@anthropic.com>
325 lines
15 KiB
TypeScript
325 lines
15 KiB
TypeScript
// Catches: a daemon module writing a SessionState field it does not own — aliasing through
|
|
// SessionStore.get()/set() lets any module mutate store-owned state, and only a full-graph
|
|
// AST walk over every assignment site (not a review of one module) can tell whose write
|
|
// it was.
|
|
// Evidence: PR #1392 (e8b779cb32) fixed a close-time script-save failure leaking the session/
|
|
// device claim — a symptom of unowned SessionState writes; the field-owner table this file
|
|
// enforces is the durable fix.
|
|
// Cost: 262 LOC (no dedicated test file; exercised through daemon-modularity.test.ts and
|
|
// model.test.ts).
|
|
// Kill criterion: none enforced today; retire only by maintainer decision that per-field
|
|
// SessionState write ownership no longer matters. SessionStore hands out the live record
|
|
// through get()/set(), so a `session.<field> =` from any module type-checks; no owner exists
|
|
// at the type level.
|
|
//
|
|
// R7 session-state ownership.
|
|
//
|
|
// `SessionStore.get()` hands back the live `SessionState` out of a private Map, and `set()`
|
|
// re-puts the same reference — so a `session.<field> = …` anywhere in the daemon is a durable
|
|
// write to store-owned state, and whether it persists depends on aliasing rather than on an
|
|
// API call. That is workable while each field has an owner that keeps its invariants; it stops
|
|
// being workable the moment a field's rule is spread across modules, because nothing at the
|
|
// store boundary can check it.
|
|
//
|
|
// So the ownership is written down here and enforced. The table is not an aspiration: it is
|
|
// 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, 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
|
|
// that slip through: `??=` on an optional field is the natural way to write a default, and a
|
|
// computed `session[key] =` hides the field name entirely. The parser reports every
|
|
// assignment and update form for free, and a `session.a.b = …` sub-object write is reported
|
|
// as what it is rather than mistaken for a write to `a`.
|
|
|
|
import { parseSync } from 'oxc-parser';
|
|
import path from 'node:path';
|
|
|
|
export type SessionStateWrite = {
|
|
file: string;
|
|
line: number;
|
|
field: string;
|
|
};
|
|
|
|
/**
|
|
* Which modules may write each `SessionState` field, as paths under `src/`. A field whose
|
|
* 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 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'],
|
|
snapshot: ['src/daemon/session-snapshot.ts'],
|
|
snapshotGeneration: ['src/daemon/session-snapshot.ts'],
|
|
lastComparisonSafeSnapshot: ['src/daemon/session-snapshot.ts'],
|
|
androidSnapshotFreshness: ['src/daemon/session-snapshot-freshness.ts'],
|
|
// One-shot deferred-warning latch (#1587 follow-up): the transition function is the only
|
|
// writer, so the latch's window semantics live in a single module.
|
|
recoveredSnapshotWarningLatch: ['src/daemon/snapshot-quality-latch.ts'],
|
|
|
|
// #1478 P4a script publication. The tagged aggregate replaced the eight co-resident
|
|
// `saveScript*`/`scriptRecordingState`/`repair*` fields; its ONLY writers are the two
|
|
// daemon-private projections (`session-replay-transaction.ts`,
|
|
// `session-script-publication-capability.ts`) and the writer's commit transition.
|
|
// It also absorbed `recordSession`, whose separate ownership let handler surfaces arm
|
|
// recording without moving the lifecycle that authorized it (#1533). Recording is now derived
|
|
// (`isRecordingPublication`), so there is no second field to keep in step.
|
|
scriptPublication: [
|
|
'src/daemon/session-replay-transaction.ts',
|
|
'src/daemon/session-script-publication-capability.ts',
|
|
'src/daemon/session-script-writer.ts',
|
|
],
|
|
// #1478 P4b: moved from `session-replay-resume.ts` into the `ReplayCoordinator`
|
|
// (`session-replay-coordinator.ts`) — the one locked gateway a native replay request uses to
|
|
// reach both this watermark and the P4a `scriptPublication` transitions above.
|
|
pendingRecordAndHeal: ['src/daemon/session-replay-coordinator.ts'],
|
|
|
|
trace: ['src/daemon/handlers/trace-runtime.ts'],
|
|
pendingInteractionOutcome: ['src/daemon/interaction-outcome-policy.ts'],
|
|
postGestureStabilization: ['src/daemon/deferred-interaction-outcome.ts'],
|
|
|
|
// Snapshot lineage on a freshly BUILT record. snapshot-command-runtime.ts constructs a new
|
|
// SessionState rather than mutating the stored one, so it cannot call setSessionSnapshot —
|
|
// but the rule is the same, so the two-field transition lives in session-snapshot.ts.
|
|
appName: ['src/daemon/snapshot-command-runtime.ts'],
|
|
// Open execution owns the paired lease/claim transition after the handler has admitted one
|
|
// lifecycle binding. Keeping the records together prevents request-policy routing from gaining
|
|
// a second durable owner as the execution seam stays package-bound.
|
|
lease: ['src/daemon/session-lifecycle/internal/session-open-execution.ts'],
|
|
deviceClaim: ['src/daemon/session-lifecycle/internal/session-open-execution.ts'],
|
|
|
|
// #1398 (ADR 0017 session-scoped echo protection amendment): the ephemeral
|
|
// literal->placeholder registry is populated and consulted only at the
|
|
// recorder's single choke point.
|
|
recordedFillLiterals: ['src/daemon/session-action-recorder.ts'],
|
|
};
|
|
|
|
/**
|
|
* Fields no daemon module writes through a session binding: they are set when the record is
|
|
* constructed (an object literal, not a field assignment) or inside `session-store.ts`, which
|
|
* owns the record and is excluded from the scan.
|
|
*
|
|
* This list exists so the classification is EXHAUSTIVE. Without it, a new `SessionState` field
|
|
* that happened to have no direct write would satisfy the gate by being invisible to it, and R7
|
|
* would silently stop covering part of the type it claims to cover. Being here is a positive
|
|
* claim — "the store establishes this, nothing mutates it later" — so acquiring a direct write
|
|
* fails the gate until the field is moved into `SESSION_STATE_FIELD_OWNERS` with a real owner.
|
|
*/
|
|
export const STORE_OWNED_SESSION_STATE_FIELDS: ReadonlySet<string> = new Set([
|
|
'actions',
|
|
'appBundleId',
|
|
'appLog',
|
|
'appLogFailure',
|
|
'audioProbe',
|
|
'createdAt',
|
|
'device',
|
|
'lastPerfProfile',
|
|
'name',
|
|
'recordOnlySession',
|
|
'perfCapture',
|
|
'screenRecording',
|
|
'sessionScope',
|
|
'snapshotDiagnostics',
|
|
'surface',
|
|
]);
|
|
|
|
export function sessionStateFieldCount(): number {
|
|
return Object.keys(SESSION_STATE_FIELD_OWNERS).length + STORE_OWNED_SESSION_STATE_FIELDS.size;
|
|
}
|
|
|
|
export type FieldClassificationDrift = {
|
|
field: string;
|
|
problem: 'unclassified' | 'both' | 'not-a-field';
|
|
};
|
|
|
|
/**
|
|
* Where the two ownership tables disagree with `SessionState` itself. Empty means every declared
|
|
* field is classified exactly once and neither table names a field that no longer exists.
|
|
*/
|
|
export function fieldClassificationDrift(fields: readonly string[]): FieldClassificationDrift[] {
|
|
const declared = new Set(fields);
|
|
const owned = new Set(Object.keys(SESSION_STATE_FIELD_OWNERS));
|
|
const drift: FieldClassificationDrift[] = [];
|
|
|
|
for (const field of fields) {
|
|
const inOwners = owned.has(field);
|
|
const inStore = STORE_OWNED_SESSION_STATE_FIELDS.has(field);
|
|
if (inOwners && inStore) drift.push({ field, problem: 'both' });
|
|
else if (!inOwners && !inStore) drift.push({ field, problem: 'unclassified' });
|
|
}
|
|
for (const field of [...owned, ...STORE_OWNED_SESSION_STATE_FIELDS]) {
|
|
if (!declared.has(field)) drift.push({ field, problem: 'not-a-field' });
|
|
}
|
|
|
|
return drift.sort((left, right) => left.field.localeCompare(right.field));
|
|
}
|
|
|
|
const SESSION_STATE_DECLARATION = /export type SessionState = \{([\s\S]*?)\n\};/;
|
|
|
|
/**
|
|
* The daemon module that declares `SessionState`, found by the declaration rather than by a
|
|
* recorded path. `sessionStateWritePressure` below measures the merge-base tree with the same
|
|
* function, and that tree's declaration may still sit where this tree no longer has it — a
|
|
* path constant would silently measure such a tree as zero pressure and bank the headroom.
|
|
*/
|
|
export function sessionStateDeclarationFile(
|
|
sources: ReadonlyMap<string, string>,
|
|
): string | undefined {
|
|
for (const [file, source] of sources) {
|
|
if (file.startsWith('src/daemon/') && SESSION_STATE_DECLARATION.test(source)) return file;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Field names declared by `SessionState` itself, so the scan cannot be fooled by a daemon
|
|
* module with an unrelated local named `session` (a provider session, a runner session).
|
|
*/
|
|
export function sessionStateFields(typesSource: string): string[] {
|
|
const declaration = SESSION_STATE_DECLARATION.exec(typesSource);
|
|
if (!declaration) throw new Error('SessionState declaration not found');
|
|
return [...declaration[1]!.matchAll(/^ {2}([a-zA-Z][A-Za-z0-9]*)\??:/gm)].map(
|
|
(match) => match[1]!,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a binding holds a `SessionState`. The daemon names these records by role, not always
|
|
* `session`: `nextSession`, `provisionalSession`, `completedSession`, `preRunSession`,
|
|
* `preEntrySession`, `activeSession`. Matching only the literal name `session` is what let three
|
|
* genuine foreign writes sit unreported — `nextSession.snapshotGeneration` in snapshot-runtime.ts
|
|
* among them — while the gate claimed every write was inside its owner.
|
|
*
|
|
* There is no type information here, so this is a name test, and it is deliberately paired with
|
|
* the declared-field filter in `findSessionStateWrites`: a binding must look like a session AND
|
|
* the field must be one `SessionState` declares. A provider or runner session that happens to be
|
|
* named `…Session` only registers if it also writes a field name `SessionState` owns, and the
|
|
* remedy then is to declare the owner — the same remedy as for a real write.
|
|
*/
|
|
function isSessionBinding(name: string): boolean {
|
|
return /session/i.test(name);
|
|
}
|
|
|
|
/** A member expression being assigned to, or updated with `++`/`--`. */
|
|
type WriteTarget = {
|
|
object: string | undefined;
|
|
field: string | undefined;
|
|
computed: boolean;
|
|
offset: number;
|
|
};
|
|
|
|
function writeTarget(node: Record<string, unknown>): WriteTarget | null {
|
|
const type = node['type'];
|
|
const member =
|
|
type === 'AssignmentExpression'
|
|
? (node['left'] as Record<string, unknown> | undefined)
|
|
: type === 'UpdateExpression'
|
|
? (node['argument'] as Record<string, unknown> | undefined)
|
|
: undefined;
|
|
if (!member || member['type'] !== 'MemberExpression') return null;
|
|
const object = member['object'] as Record<string, unknown> | undefined;
|
|
const property = member['property'] as Record<string, unknown> | undefined;
|
|
return {
|
|
// Only a direct `<identifier>.field` write is a session write; `a.b.c = …` writes into a
|
|
// sub-object and its `object` is a MemberExpression, so it has no identifier name here.
|
|
object: object?.['type'] === 'Identifier' ? (object['name'] as string) : undefined,
|
|
field: property?.['type'] === 'Identifier' ? (property['name'] as string) : undefined,
|
|
computed: member['computed'] === true,
|
|
offset: typeof member['start'] === 'number' ? member['start'] : 0,
|
|
};
|
|
}
|
|
|
|
function lineOf(source: string, offset: number): number {
|
|
let line = 1;
|
|
for (let index = 0; index < offset && index < source.length; index++) {
|
|
if (source[index] === '\n') line++;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
/**
|
|
* Every write to a declared `SessionState` field through a binding named `session`, in any
|
|
* assignment or update form. `session-store.ts` is excluded: it owns the record and may write
|
|
* anything on it.
|
|
*
|
|
* A computed write (`session[key] = …`) cannot be attributed to a field, so it is reported
|
|
* against the sentinel field name `[computed]` — which has no owner and therefore fails,
|
|
* rather than passing unnoticed.
|
|
*/
|
|
export function findSessionStateWrites(
|
|
sources: ReadonlyMap<string, string>,
|
|
fields: readonly string[],
|
|
): SessionStateWrite[] {
|
|
const declared = new Set(fields);
|
|
const writes: SessionStateWrite[] = [];
|
|
|
|
for (const [file, source] of sources) {
|
|
if (!file.startsWith('src/daemon/')) continue;
|
|
if (path.posix.basename(file) === 'session-store.ts') continue;
|
|
|
|
const parsed = parseSync(file, source);
|
|
const visit = (node: unknown): void => {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child);
|
|
return;
|
|
}
|
|
const record = node as Record<string, unknown>;
|
|
const target = writeTarget(record);
|
|
if (target && target.object !== undefined && isSessionBinding(target.object)) {
|
|
if (target.computed) {
|
|
writes.push({ file, line: lineOf(source, target.offset), field: '[computed]' });
|
|
} else if (target.field !== undefined && declared.has(target.field)) {
|
|
writes.push({ file, line: lineOf(source, target.offset), field: target.field });
|
|
}
|
|
}
|
|
for (const key of Object.keys(record)) visit(record[key]);
|
|
};
|
|
visit(parsed.program);
|
|
}
|
|
|
|
return writes.sort(
|
|
(left, right) => left.file.localeCompare(right.file) || left.line - right.line,
|
|
);
|
|
}
|
|
|
|
export type SessionStateWritePressure = Readonly<{
|
|
/** Declared fields that some daemon module writes directly. */
|
|
writerOwnedFields: number;
|
|
/** Distinct (field, writing module) pairs — what `SESSION_STATE_FIELD_OWNERS` claims. */
|
|
ownerFileClaims: number;
|
|
}>;
|
|
|
|
/**
|
|
* R10's measurement of R7 pressure: how many declared fields have a direct writer, and how many
|
|
* module claims that takes. Read from the tree rather than from the ownership table, so the same
|
|
* function measures a merge-base tree whose table is not in scope. On a tree R7 accepts, both
|
|
* numbers equal the table's own size.
|
|
*/
|
|
export function sessionStateWritePressure(
|
|
sources: ReadonlyMap<string, string>,
|
|
): SessionStateWritePressure {
|
|
const declarationFile = sessionStateDeclarationFile(sources);
|
|
if (!declarationFile) return { writerOwnedFields: 0, ownerFileClaims: 0 };
|
|
const writes = findSessionStateWrites(
|
|
sources,
|
|
sessionStateFields(sources.get(declarationFile)!),
|
|
).filter((write) => write.field !== '[computed]');
|
|
return {
|
|
writerOwnedFields: new Set(writes.map((write) => write.field)).size,
|
|
ownerFileClaims: new Set(writes.map((write) => `${write.field}\0${write.file}`)).size,
|
|
};
|
|
}
|