mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
5b6feafe92
* refactor(snapshot): give the Wave 4 policies neutral host seams (#1983)
#2005 established the presentation ownership boundary and moved the iOS
presentation policies out of `src/daemon/`. It left the three remaining Wave 4
policies behind their existing daemon adapters. This closes that gap, so
`src/snapshot/` owns host-side snapshot policy generally rather than
presentation alone.
Freshness recovery: the window vocabulary, the Android staleness classification
and its thresholds, and the retry loop move to `src/snapshot/snapshot-freshness/`.
The loop is parameterized by a classifier and a retry schedule, so how long a
backend may lag behind a real transition is a policy input rather than a
constant the loop owns. `src/daemon/session-snapshot-freshness.ts` keeps only
what needs a session — reading and retiring the window on store-owned
`SessionState`, and choosing the comparison baseline from snapshot lineage — and
remains the declared R7 owner of `androidSnapshotFreshness`. The two call sites
#1739 named as the Wave 5 blockers, `selector-capture-runtime.ts` and
`deferred-interaction-outcome.ts`, now reach freshness through the seam.
Timeout evidence: whether a failure is the accessibility-timeout shape becomes a
policy in `src/snapshot/snapshot-timeout-policy.ts`. The published
`details.androidSnapshotTimeoutScreenshot` payload becomes vocabulary in
`@agent-device/contracts/snapshot-timeout-evidence`, built through constructors
so an assembly site cannot publish a fifth, undeclared arm. It gets its own
subpath rather than riding the shared capture facade, which keeps it out of the
CLI cold-start closure. Typed details, diagnostics and screenshot evidence are
unchanged.
Screenshot-overlay policy: which Android nodes earn an overlay ref, and what
rectangle an overlay covers, move to `src/snapshot/screenshot-overlay/`. The
daemon keeps approved artifact and ref assembly only — ranking, projection to
screenshot pixels, drawing and PNG IO.
The boundary test generalizes from the presentation subtree to the whole facet:
nothing under `src/snapshot/` may import `src/daemon/`. It gains a positive
control, because a filter that stopped matching would look identical to a
boundary being obeyed.
The residual call sites #1983 also named are audited and deliberately left in
place. `direct-ios-selector.ts` carries no presentation policy; its two pure
exports are selector derivation and ADR 0011 delegation-on-error, whose owner
would be the selector pipeline governed by R19, not this facet. ADR 0004 records
the finding so it does not have to be re-derived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
* refactor(snapshot): address adversarial review of the Wave 4 seams
Three findings from an adversarial pass over bc95d7f, all in the new seams.
`SnapshotFreshnessRetrySchedule.deadlineMs` was an absolute epoch instant named
almost identically to the duration constant `ANDROID_FRESHNESS_RETRY_DEADLINE_MS`
that feeds it. A backend binding the loop with the duration instead of
`markedAt + duration` type-checked, drove `remainingMs` hugely negative, and
silently ran zero retries with no annotation. Renamed to `retryUntilMs` — the
pre-refactor local's name — and the doc now says which one it is. The recovery
loop also gains direct tests it never had: the trustworthy, recovered and
still-suspicious paths, plus an already-expired deadline that pins the budget to
the action rather than to whenever the first capture returned, which is the shape
the mis-binding would have taken.
Two stale doc references from earlier drafts of the same commit: the timeout
assembly claimed its evidence shape lives in `@agent-device/contracts/capture`,
which is where it deliberately does NOT live — following that comment would
re-home the type into the shared facade and reintroduce the cold-start closure
cost the dedicated subpath exists to avoid. And the freshness window doc cited
`SnapshotFreshnessPolicy`, a type removed before commit for being unused; the
real seam is the loop's `classify` callback.
No production behavior change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
* refactor(snapshot): key timeout evidence on a typed reason, budget retries by duration
Addresses the three review findings on the Wave 4 facet work.
1. The recovery loop accepted an absolute `retryUntilMs`, and its own comment
admitted that passing a duration type-checks and silently disables retries.
Documenting a footgun is not removing one. The schedule is now a duration
budget and the loop derives the deadline from the window's `markedAt`
itself, so there is no absolute instant a caller can get wrong. Two tests
pin the invariant: a budget already spent before the loop starts runs the
capture once, and the same budget retries or not depending only on how old
the window is — a loop measuring from its own start would return the same
count for both.
2. The timeout policy recognized failures from hint prose and helper message
text. That is a message shape standing in for a decision, and extracting it
into a named facet made it worse by promoting the sniffing to declared
policy. The Android platform boundary now decides once and publishes the
typed reason `accessibility-timeout`, joining the existing
`ANDROID_CONTENT_RECOVERY_REASONS` taxonomy in the contract that already
exists to stop producers and consumers growing separate ones. The facet
reads that reason. The hint is derived from it rather than decided
alongside it, so rewording prose can no longer change what a reader
concludes. Coverage now runs producer to consumer: the platform tests assert
that both timeout shapes publish the reason, that an ordinary helper failure
does not, and that the real policy recognizes exactly what the real producer
emits — the message-sniffing approvals are gone.
3. `SnapshotTimeoutEvidence` still permitted `annotated: true` with zero refs.
The annotated arm now carries a non-empty tuple, so the contradiction is
unconstructible rather than merely unconstructed, with a `@ts-expect-error`
guard that fails the build if it ever becomes valid again.
The timeout tests moved out of `snapshot.test.ts` into a cohesive
`snapshot-capture-failure-reason.test.ts` rather than growing a file already
over the size tripwire; its pin ratchets down 1495 -> 1445.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
* refactor(snapshot): decide the capture-failure reason from machine values only
Addresses the two remaining typed-policy blockers on #2014.
P1. The previous commit moved the message sniff rather than removing it:
`androidCaptureFailureReasonOf` still ran `/timed out/i` over helper and
wrapper prose, and a regex over the wrapper message for exit 137. A producer
sniffing prose is the same defect as a consumer sniffing prose, one layer down.
The decision now happens at the deepest boundary that holds the evidence, from
machine-defined values only. `snapshot-capture-failure-reason.ts` maps the
helper's structured `errorType` field by exact equality against
`java.util.concurrent.TimeoutException` — the same constant
`isUiAutomationConnectionTimeoutResponse` already compares — and the SIGKILL
exit code 137, which the fallback constructor knows structurally instead of
re-deriving from the message it just wrote. The helper-result,
session-protocol, and killed-instrumentation constructors attach the reason;
every layer above rewraps it. Both regexes are deleted, and the only
`TimeoutException` string left on the path is that constant.
This tightens behavior deliberately: a helper reporting ok=false with
timeout-looking prose but some other `errorType` is no longer classified as a
timeout. Both directions are proved end to end against the real producer —
four rewordings of the helper message (including empty) keep the typed value,
and three timeout-looking messages under non-timeout error types produce no
value and are not recognized by the real policy.
P2. The evidence union stored `overlayRefCount` beside the refs, so
`{annotated: true, count: 0, refs: [ref]}` and arbitrary mismatches stayed
assignable. No arm stores a count now — it is derived from `overlayRefs`, the
one source of truth — and the arms that carry no refs have nothing to count,
which `overlayRefsAnnotated: false` already states. Two type regressions guard
it: the empty-annotated contradiction, and the reintroduction of a stored
count, both as `@ts-expect-error` so the build fails if either becomes valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
* refactor(snapshot): retire the duplicate timeout classifier on the session path
`isUiAutomationConnectionTimeoutResponse` compared `helper.errorType` to
`java.util.concurrent.TimeoutException` on its own, so the session fallback
diagnostic decided "was this a UiAutomation timeout" a second time. I cited it
as precedent for the constant in the previous round without noticing that
leaving it standing is the drift it was cited against: one taxonomy, two
deciders. The session protocol already publishes the typed reason on exactly
these errors, so the diagnostic now reads it.
The regression is proved rather than assumed: with the protocol's
`androidCaptureFailureReason` attachment removed, the new session-path test
fails; with it restored, it passes. It rides the existing
`ui-automation-timeout` fixture, so it exercises the real socket response
shape rather than a hand-built error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R
---------
Co-authored-by: Claude <noreply@anthropic.com>
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
// 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 — its four fields moved together
|
|
// across two modules until `activateRefFrame` took the transition.
|
|
//
|
|
// 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 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'],
|
|
// 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'],
|
|
applePerf: ['src/daemon/handlers/session-perf-xctrace.ts', 'src/daemon/session-teardown.ts'],
|
|
nativePerf: ['src/daemon/session-teardown.ts'],
|
|
audioProbe: ['src/daemon/audio-probe.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/handlers/session-open-execution.ts'],
|
|
deviceClaim: ['src/daemon/handlers/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',
|
|
'createdAt',
|
|
'device',
|
|
'name',
|
|
'recordOnlySession',
|
|
'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));
|
|
}
|
|
|
|
/**
|
|
* 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 = /export type SessionState = \{([\s\S]*?)\n\};/.exec(typesSource);
|
|
if (!declaration) throw new Error('SessionState declaration not found in daemon/types.ts');
|
|
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,
|
|
);
|
|
}
|