Skip to content

Commit 5b6feaf

Browse files
thymikeeclaude
andauthored
Extract snapshot policy from daemon to host-side facet (#1983) (#2014)
* 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>
1 parent a830ac8 commit 5b6feaf

47 files changed

Lines changed: 1671 additions & 582 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ alone.
196196
**Presentation options**:
197197
The policy input controlling how one snapshot acquisition becomes a public projection.
198198

199-
**Snapshot presentation facet**:
200-
The host-side owner of the neutral acquisition-to-presentation contract and TypeScript policies.
199+
**Snapshot policy facet**:
200+
The host-side owner of neutral snapshot policy: presentation, freshness, timeout and overlay.
201201
Platform acquisition supplies raw facts and a fold policy; runner-side Swift presentation remains
202202
separate across the process boundary.
203203

docs/adr/0004-ios-snapshot-backend-strategy.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,61 @@ capture-plan tier before the process boundary. The contract fixture under
7878
`contracts/fixtures/snapshot-presentation-conformance.json` is the shared proof between those
7979
runtimes; it does not imply that Swift and TypeScript share an implementation.
8080

81-
This is the first ownership slice of the Wave 4 debt tracked by #1983. Freshness recovery,
82-
timeout evidence, and screenshot-overlay policy retain their existing daemon adapters until their
83-
neutral host seams are extracted; new consumers must use the facet rather than add another daemon
84-
presentation path.
81+
The same split now holds for the three remaining Wave 4 policies tracked by #1983, so
82+
`src/snapshot/` is the host-side owner of snapshot policy generally rather than of presentation
83+
alone:
84+
85+
- **Freshness recovery.** The freshness window, the Android staleness classification and its
86+
thresholds, and the retry loop live in `src/snapshot/snapshot-freshness/`. The loop is
87+
parameterized by a classifier and a retry schedule, so "how long may a backend lag behind a real
88+
transition" is a policy input rather than a constant the loop owns. The schedule is stated as a
89+
duration budget; the loop derives the deadline from the window's `markedAt` itself, so the
90+
budget is always spent from the action and a caller has no absolute instant it could get wrong.
91+
`src/daemon/session-snapshot-freshness.ts` keeps only what needs a session: reading and retiring
92+
the window on store-owned `SessionState`, and choosing the comparison baseline from snapshot
93+
lineage. It remains the declared R7 owner of `androidSnapshotFreshness`.
94+
- **Timeout evidence.** Whether a capture failed because the hierarchy never arrived is decided
95+
once, at the deepest boundary that has the evidence, from machine-defined values only:
96+
`snapshot-capture-failure-reason.ts` maps the helper's structured `errorType` field
97+
(`java.util.concurrent.TimeoutException`, by exact equality) and the SIGKILL exit code 137 to
98+
the typed reason `accessibility-timeout` (`ANDROID_CAPTURE_FAILURE_REASONS` in
99+
`@agent-device/contracts/android-snapshot-quality`). The helper-result, session-protocol, and
100+
killed-instrumentation error constructors attach it; every layer above rewraps it rather than
101+
reclassifying. No message shape is consulted anywhere on that path, so rewording helper or
102+
wrapper prose cannot move the reason, and prose that merely reads like a timeout does not become
103+
one — both directions are asserted end to end against the real producer.
104+
`src/snapshot/snapshot-timeout-policy.ts` reads the reason; the human-facing hint is derived
105+
from it rather than decided alongside it.
106+
107+
The published `details.androidSnapshotTimeoutScreenshot` payload is vocabulary in
108+
`@agent-device/contracts/snapshot-timeout-evidence`, a union whose arms encode which claims can
109+
coexist. The annotated arm carries a non-empty ref tuple, so "annotated with zero refs" is not a
110+
state a caller can build, and no arm stores a ref count: a count beside the refs is a second
111+
source of truth the type system cannot hold in step, so it is derived from the refs instead.
112+
The daemon keeps the ordering that genuinely needs it: resolving a bound screenshot runtime,
113+
writing the artifact, annotating it from the stored observation, and emitting the diagnostics.
114+
- **Screenshot-overlay policy.** Which Android nodes earn an overlay ref, and what rectangle an
115+
overlay for one of them covers, live in `src/snapshot/screenshot-overlay/`. The daemon keeps
116+
approved artifact and ref assembly only: ranking, projection to screenshot pixels, drawing, and
117+
PNG IO.
118+
119+
`scripts/layering/snapshot-presentation-boundary.test.ts` enforces the direction for the whole
120+
facet: nothing under `src/snapshot/` may import `src/daemon/`. It carries a positive control,
121+
because a filter that stopped matching would look identical to a boundary being obeyed.
122+
123+
The residual call sites #1983 also named are audited and deliberately left in place.
124+
`src/daemon/direct-ios-selector.ts` carries no presentation policy: `isLocalIosRunnerSession` and
125+
`readSimpleIosSelectorTarget` are session routing (device family, provider ownership, the
126+
stabilization window), while `deriveDirectIosNodeSelector` and `isDirectIosSelectorFallbackError`
127+
are selector derivation and ADR 0011 delegation-on-error. The latter two are pure and
128+
daemon-independent, but their owner would be the selector pipeline governed by R19, not this
129+
facet; moving them under ADR 0004 would widen it to a boundary it does not decide. The
130+
observation and interaction consumers — `selector-capture-runtime.ts`,
131+
`deferred-interaction-outcome.ts`, `snapshot-capture.ts` and
132+
`interaction-touch-android-freshness.ts` — now reach freshness only through the facet or its
133+
session binding.
134+
135+
New consumers must use the facet rather than add another daemon presentation path.
85136

86137
## Regression Notes
87138

fallow-baselines/health.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,6 @@
112112
"count": 1
113113
}
114114
},
115-
"src/daemon/android-snapshot-freshness.ts": {
116-
"crap_moderate": {
117-
"count": 1
118-
}
119-
},
120115
"src/daemon/client/daemon-client-lifecycle.ts": {
121116
"complexity_high": {
122117
"count": 1
@@ -575,7 +570,6 @@
575570
"src/daemon/handlers/session.ts:complexity",
576571
"src/replay/script-utils.ts:high impact",
577572
"packages/selectors/src/internal/predicates.ts:high impact",
578-
"src/daemon/android-snapshot-freshness.ts:high impact",
579573
"src/platforms/boot-diagnostics.ts:complexity",
580574
"src/compat/maestro/support.ts:high impact",
581575
"src/daemon/session-routing.ts:high impact",

packages/contracts/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@
307307
"types": "./src/snapshot-runtime.ts",
308308
"default": "./src/snapshot-runtime.ts"
309309
},
310+
"./snapshot-timeout-evidence": {
311+
"types": "./src/snapshot-timeout-evidence.ts",
312+
"default": "./src/snapshot-timeout-evidence.ts"
313+
},
310314
"./startup-recovery-fence": {
311315
"types": "./src/startup-recovery-fence.ts",
312316
"default": "./src/startup-recovery-fence.ts"

packages/contracts/src/android-snapshot-quality.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,36 @@ export function isUnreadableCaptureContentError(error: unknown): boolean {
3333
const reason = details?.androidSnapshotHelperFailureReason;
3434
return isAndroidContentRecoveryReason(reason);
3535
}
36+
37+
/**
38+
* Why an Android capture failed at the mechanism level, as a typed reason rather than a message
39+
* shape. `accessibility-timeout` means the hierarchy never arrived — the helper reported a
40+
* structured timeout, or its instrumentation was killed before it could answer.
41+
*
42+
* The producer decides this once, at the platform boundary, and publishes the decision. Readers
43+
* consume the reason instead of re-deriving it from hint text: the hint is human-facing prose
44+
* that may be reworded, and two readers sniffing it will drift apart (#1983).
45+
*/
46+
export const ANDROID_CAPTURE_FAILURE_REASONS = ['accessibility-timeout'] as const;
47+
48+
export type AndroidCaptureFailureReason = (typeof ANDROID_CAPTURE_FAILURE_REASONS)[number];
49+
50+
const ANDROID_CAPTURE_FAILURE_REASON_SET: ReadonlySet<string> = new Set(
51+
ANDROID_CAPTURE_FAILURE_REASONS,
52+
);
53+
54+
export function isAndroidCaptureFailureReason(
55+
value: unknown,
56+
): value is AndroidCaptureFailureReason {
57+
return typeof value === 'string' && ANDROID_CAPTURE_FAILURE_REASON_SET.has(value);
58+
}
59+
60+
/** The typed reason a thrown Android capture failure carries, when its producer named one. */
61+
export function readAndroidCaptureFailureReason(
62+
error: unknown,
63+
): AndroidCaptureFailureReason | undefined {
64+
if (!error || typeof error !== 'object') return undefined;
65+
const details = (error as { details?: Record<string, unknown> }).details;
66+
const reason = details?.androidCaptureFailureReason;
67+
return isAndroidCaptureFailureReason(reason) ? reason : undefined;
68+
}

packages/contracts/src/facades/platform.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ export type {
1414
AndroidInputOwnershipSource,
1515
} from '../android-input-ownership.ts';
1616
export {
17+
ANDROID_CAPTURE_FAILURE_REASONS,
1718
ANDROID_CONTENT_RECOVERY_REASONS,
1819
isAndroidContentRecoveryReason,
1920
isUnreadableCaptureContentError,
21+
isAndroidCaptureFailureReason,
22+
readAndroidCaptureFailureReason,
23+
} from '../android-snapshot-quality.ts';
24+
export type {
25+
AndroidCaptureFailureReason,
26+
AndroidContentRecoveryReason,
2027
} from '../android-snapshot-quality.ts';
21-
export type { AndroidContentRecoveryReason } from '../android-snapshot-quality.ts';
2228
export {
2329
ANDROID_SYSTEM_CHROME_PACKAGE,
2430
hasAndroidSystemChromeProvenance,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { ScreenshotOverlayRef } from '@agent-device/kernel/snapshot';
4+
import {
5+
snapshotTimeoutCaptureFailed,
6+
snapshotTimeoutEvidenceOverlayCounts,
7+
snapshotTimeoutEvidenceOverlayFailed,
8+
snapshotTimeoutEvidenceWithOverlayRefs,
9+
snapshotTimeoutEvidenceWithoutOverlaySource,
10+
type SnapshotTimeoutEvidence,
11+
} from './snapshot-timeout-evidence.ts';
12+
13+
function overlayRef(ref: string): ScreenshotOverlayRef {
14+
return {
15+
ref,
16+
label: ref,
17+
rect: { x: 0, y: 0, width: 10, height: 10 },
18+
overlayRect: { x: 0, y: 0, width: 10, height: 10 },
19+
center: { x: 5, y: 5 },
20+
};
21+
}
22+
23+
test('a failed capture publishes no path and no overlay claim', () => {
24+
const evidence = snapshotTimeoutCaptureFailed('adb screencap exited 1');
25+
assert.deepEqual(evidence, { captureFailed: true, error: 'adb screencap exited 1' });
26+
assert.deepEqual(snapshotTimeoutEvidenceOverlayCounts(evidence), {
27+
overlayRefCount: undefined,
28+
overlayRefsAnnotated: undefined,
29+
});
30+
});
31+
32+
test('a capture with no stored observation still discloses that refs were requested', () => {
33+
const evidence = snapshotTimeoutEvidenceWithoutOverlaySource('/tmp/shot.png');
34+
assert.deepEqual(evidence, {
35+
path: '/tmp/shot.png',
36+
overlayRefsRequested: true,
37+
overlayRefsAnnotated: false,
38+
overlayRefSource: 'unavailable',
39+
});
40+
});
41+
42+
test('the union cannot express an annotated capture with no refs', () => {
43+
// The annotated arm carries a non-empty tuple, so this literal is not assignable.
44+
// `@ts-expect-error` fails the build if it ever becomes valid again.
45+
// @ts-expect-error annotated:true requires at least one overlay ref
46+
const contradiction: SnapshotTimeoutEvidence = {
47+
path: '/tmp/shot.png',
48+
overlayRefsRequested: true,
49+
overlayRefSource: 'session-snapshot',
50+
overlayRefsAnnotated: true,
51+
overlayRefs: [],
52+
};
53+
assert.ok(contradiction);
54+
});
55+
56+
test('the union has no stored count that could disagree with the refs', () => {
57+
// The count is derived, so there is no field to set — a mismatched one is not merely wrong,
58+
// it is unwritable. This guards against reintroducing the second source of truth.
59+
const withStoredCount: SnapshotTimeoutEvidence = {
60+
path: '/tmp/shot.png',
61+
overlayRefsRequested: true,
62+
overlayRefSource: 'session-snapshot',
63+
overlayRefsAnnotated: true,
64+
overlayRefs: [overlayRef('e1')],
65+
// @ts-expect-error overlayRefCount is derived, not stored
66+
overlayRefCount: 0,
67+
};
68+
assert.ok(withStoredCount);
69+
});
70+
71+
test('an empty ref list is a capture that was not annotated, not an annotated one', () => {
72+
const none = snapshotTimeoutEvidenceWithOverlayRefs('/tmp/shot.png', []);
73+
const some = snapshotTimeoutEvidenceWithOverlayRefs('/tmp/shot.png', [overlayRef('e1')]);
74+
assert.deepEqual(snapshotTimeoutEvidenceOverlayCounts(none), {
75+
overlayRefCount: 0,
76+
overlayRefsAnnotated: false,
77+
});
78+
assert.deepEqual(snapshotTimeoutEvidenceOverlayCounts(some), {
79+
overlayRefCount: 1,
80+
overlayRefsAnnotated: true,
81+
});
82+
});
83+
84+
test('a failed annotation keeps the screenshot and names why the refs are missing', () => {
85+
const evidence = snapshotTimeoutEvidenceOverlayFailed('/tmp/shot.png', 'png decode failed');
86+
assert.deepEqual(evidence, {
87+
path: '/tmp/shot.png',
88+
overlayRefsRequested: true,
89+
overlayRefsAnnotated: false,
90+
overlayRefSource: 'session-snapshot',
91+
overlayAnnotationError: 'png decode failed',
92+
});
93+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { ScreenshotOverlayRef } from '@agent-device/kernel/snapshot';
2+
3+
/**
4+
* The typed `details.androidSnapshotTimeoutScreenshot` payload published when an accessibility
5+
* snapshot times out and the daemon falls back to a screenshot (#1983).
6+
*
7+
* The shape is a discriminated union rather than a bag of optional fields because each arm is a
8+
* different claim about what evidence exists: no capture at all, a capture with no snapshot to
9+
* annotate from, a capture annotated with overlay refs, or a capture whose annotation failed.
10+
* Callers construct it through the builders below so an assembly site cannot publish a fifth,
11+
* undeclared arm.
12+
*/
13+
type CapturedSnapshotTimeoutEvidenceBase = {
14+
path: string;
15+
overlayRefsRequested: true;
16+
};
17+
18+
/** At least one ref. Annotation is a claim that something was drawn, so it cannot be empty. */
19+
export type NonEmptyScreenshotOverlayRefs = readonly [
20+
ScreenshotOverlayRef,
21+
...ScreenshotOverlayRef[],
22+
];
23+
24+
/**
25+
* No arm stores a ref count. A count beside the refs is a second source of truth that the type
26+
* system cannot keep in step — `{annotated: true, count: 0, refs: [ref]}` would stay assignable —
27+
* so the count is derived from `overlayRefs` by `snapshotTimeoutEvidenceOverlayCounts` instead.
28+
* The arms that carry no refs have nothing to count, which `overlayRefsAnnotated: false` already
29+
* states.
30+
*/
31+
export type SnapshotTimeoutEvidence =
32+
| {
33+
captureFailed: true;
34+
error: string;
35+
}
36+
| (CapturedSnapshotTimeoutEvidenceBase & {
37+
overlayRefSource: 'unavailable';
38+
overlayRefsAnnotated: false;
39+
})
40+
| (CapturedSnapshotTimeoutEvidenceBase & {
41+
overlayRefSource: 'session-snapshot';
42+
overlayRefsAnnotated: true;
43+
overlayRefs: NonEmptyScreenshotOverlayRefs;
44+
})
45+
| (CapturedSnapshotTimeoutEvidenceBase & {
46+
overlayRefSource: 'session-snapshot';
47+
overlayRefsAnnotated: false;
48+
overlayRefs: readonly [];
49+
})
50+
| (CapturedSnapshotTimeoutEvidenceBase & {
51+
overlayRefSource: 'session-snapshot';
52+
overlayRefsAnnotated: false;
53+
overlayAnnotationError: string;
54+
});
55+
56+
/** No screenshot was taken: the evidence path itself failed. */
57+
export function snapshotTimeoutCaptureFailed(error: string): SnapshotTimeoutEvidence {
58+
return { captureFailed: true, error };
59+
}
60+
61+
/** A screenshot exists, but no stored observation was available to derive overlay refs from. */
62+
export function snapshotTimeoutEvidenceWithoutOverlaySource(path: string): SnapshotTimeoutEvidence {
63+
return {
64+
path,
65+
overlayRefsRequested: true,
66+
overlayRefsAnnotated: false,
67+
overlayRefSource: 'unavailable',
68+
};
69+
}
70+
71+
/**
72+
* A screenshot annotated from the stored observation. An empty ref list is not an annotation, and
73+
* the union says so: the annotated arm carries a non-empty tuple, so `annotated: true` with zero
74+
* refs is not a state a caller can build or a reader has to defend against.
75+
*/
76+
export function snapshotTimeoutEvidenceWithOverlayRefs(
77+
path: string,
78+
overlayRefs: readonly ScreenshotOverlayRef[],
79+
): SnapshotTimeoutEvidence {
80+
const base = { path, overlayRefsRequested: true, overlayRefSource: 'session-snapshot' } as const;
81+
return isNonEmptyOverlayRefs(overlayRefs)
82+
? { ...base, overlayRefsAnnotated: true, overlayRefs }
83+
: { ...base, overlayRefsAnnotated: false, overlayRefs: [] };
84+
}
85+
86+
function isNonEmptyOverlayRefs(
87+
refs: readonly ScreenshotOverlayRef[],
88+
): refs is NonEmptyScreenshotOverlayRefs {
89+
return refs.length > 0;
90+
}
91+
92+
/** A screenshot exists and a stored observation existed, but annotating it threw. */
93+
export function snapshotTimeoutEvidenceOverlayFailed(
94+
path: string,
95+
overlayAnnotationError: string,
96+
): SnapshotTimeoutEvidence {
97+
return {
98+
path,
99+
overlayRefsRequested: true,
100+
overlayRefsAnnotated: false,
101+
overlayRefSource: 'session-snapshot',
102+
overlayAnnotationError,
103+
};
104+
}
105+
106+
/**
107+
* The overlay counts a diagnostic may report, derived from the refs the evidence actually holds.
108+
* This is the only place a count exists, so it cannot disagree with the arm it came from.
109+
*/
110+
export function snapshotTimeoutEvidenceOverlayCounts(evidence: SnapshotTimeoutEvidence): {
111+
overlayRefCount: number | undefined;
112+
overlayRefsAnnotated: boolean | undefined;
113+
} {
114+
if ('captureFailed' in evidence) {
115+
return { overlayRefCount: undefined, overlayRefsAnnotated: undefined };
116+
}
117+
return {
118+
overlayRefCount: 'overlayRefs' in evidence ? evidence.overlayRefs.length : 0,
119+
overlayRefsAnnotated: evidence.overlayRefsAnnotated,
120+
};
121+
}

scripts/__tests__/test-file-size-ratchet.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
4040
'src/platforms/apple/core/__tests__/runner-session.test.ts': 2001,
4141
'src/utils/__tests__/daemon-client.test.ts': 1910,
4242
'src/utils/__tests__/output.test.ts': 1861,
43-
'src/platforms/android/__tests__/snapshot.test.ts': 1495,
43+
'src/platforms/android/__tests__/snapshot.test.ts': 1445,
4444
'src/platforms/apple/core/__tests__/runner-client.test.ts': 1615,
4545
'src/__tests__/client.test.ts': 1598,
4646
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1559,

0 commit comments

Comments
 (0)