Skip to content

refactor(ios-runner): make the snapshot quality verdict state a closed enum - #2888

Merged
thymikee merged 5 commits into
mainfrom
refactor/2872-snapshot-quality-state-enum
Sep 24, 2026
Merged

thymikee merged 5 commits into
mainfrom
refactor/2872-snapshot-quality-state-enum

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

The runner wrote the snapshot quality state as a free Swift String while the host accepts only healthy | recovered | sparse, and the contracts annotation reader cast any string state into the verdict type. A one-sided rename therefore dropped the verdict and its disclosure.

  • SnapshotQualityState: String, Codable, CaseIterable owns the runner side: the verdict field, every producer, the sparse-truncation rule. Raw-value Codable keeps the wire JSON unchanged; reasonCode stays open.
  • The kernel states the vocabulary once as SNAPSHOT_QUALITY_STATES and SnapshotQualityVerdict['state'] derives from it. Each reader gates state and backend with a Record<_, true> keyed over the kernel union, so a name added on one side fails to compile on the other. The maps sit in the readers: the eager-closure gate holds the contracts capture façade at 9 modules and capture-kit's verdict reader at 2, with kernel/snapshot.ts the only module both already evaluate.
  • contracts/fixtures/ios-snapshot-quality-states.json is the shared table, compared as a set by Swift allCases, the kernel tuple, and both reader tests.

Out of scope per the issue: a new state still needs its warning case.

Validation

On 6ccf3aef3 (Swift lanes on 159b11c45):

  • pnpm check:affected --run: 6766 tests; eager-closure-budgets 688, no entry grew; xctest-selection 311 methods, 0 dark; packaged-runner-swift.
  • Host lane (AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1): 3 focused RunnerTests passed; build:xcuitest:ios compiles.
  • Red first: contracts accepted "heathy"; a fixture missing a state failed the Swift pin; case recovered = "rencovered" failed both Swift tests; a 4th tuple state broke both readers.

Closes #2872

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.81 MB 4.81 MB +1.2 kB
Package (unpacked) 4.80 MB 4.81 MB +1.2 kB
Package (download) 1.44 MB 1.44 MB +473 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 20.6 ms 20.9 ms +0.3 ms
CLI --help 58.0 ms 57.8 ms -0.1 ms

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear structural pass. The core move is right and feels inevitable in hindsight: the runner's free String becomes a closed enum, SnapshotQualityVerdict['state'] derives from one kernel tuple, both readers call one predicate, and a fixture pins the cross-language table. The three-way vocabulary is the correct (really only) mechanism and matches the house pattern (ios-system-surface-hosts.json); the path walks fail loudly (Data(contentsOf:) / readFileSync throw on a move); no file crossed 1k (kernel 642, Swift 729); and (TUPLE as readonly string[]).includes(value) is this file's existing idiom, not a new smell.

One change I would like before merge, plus two cheap on-brand items.

Also worth a look (did not inline):

  • packages/kernel/src/snapshot.ts:7 module doc now says capture-kit's snapshot-quality-verdict.ts "owns reading a verdict off the wire", but snapshot-capture-annotations.ts is a second wire reader this PR deepened. Name the real ownership (kernel owns the vocabulary; capture-kit parses an untrusted runner payload; contracts re-hydrates a response this repo published) so the sentence stops contradicting the code.
  • wireTestCapture() in RunnerTests+SnapshotQualityStateTests.swift is a ~28-line single-node builder that no assertion reads (the loop only decodes snapshotQuality.state) and is a near-4th copy of that builder in the target — build it from the existing helper or DataPayload(truncated: false) and drop the extra import.

Comment thread packages/contracts/src/snapshot-capture-annotations.ts
Comment thread packages/kernel/src/snapshot.ts
Comment thread packages/capture-kit/src/snapshot-quality-verdict.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Both non-inlined items taken in 7b8b463:

  • kernel/snapshot.ts module doc — the ownership sentence no longer claims capture-kit owns the wire reading. It names the split: this module owns the vocabularies, capture-kit parses an untrusted runner payload into them, contracts re-hydrates a verdict this repo published.
  • wireTestCapture() — gone. The stamping test builds its capture as SnapshotBackendCapture(payload: DataPayload(nodes: [], truncated: false), effectiveDepth: nil), the 3-line form RunnerTests+SnapshotTimingTests.swift already uses, and import AgentDeviceSnapshotPresentation is dropped with it. The file is now 3 tests, no unread builder.

Evidence on 7b8b4636c: pnpm check:affected --run 6753 tests + xctest-selection (311 methods, 0 dark) + packaged-runner-swift; eager-closure-budgets 688 (no entry grew); macOS host lane with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 ran the 3 focused RunnerTests; build:xcuitest:ios compiles. Red proofs: the parity test fails when the contracts gate drops one field, and a 4th tuple state still breaks both readers at compile time.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 7b8b463. The closed Swift enum and the single kernel vocabulary look sound, and the wire JSON stays the same. All 16 checks were still queued at review time.

I could not find the eager-closure budget entries for facades/capture.ts and capture-kit/snapshot-quality-verdict.ts, so I can't confirm the claim that the gate refuses a kernel/snapshot.ts value import. Can you point to those entries, or file the refusal on #2872 if it's real?

I didn't compile TypeScript or Swift myself, so the compile-time exhaustiveness and the Swift test-target membership are your reported results, not something I verified. Same for the runner side: the wire format is unchanged by construction and the host unit lane passed by your report, but there's no live iOS device run backing this, and iOS smoke CI is still queued.

The contracts reader change means version-skewed Node clients (https://github.com/callstack/agent-device/blob/7b8b463/src/agent-device-client.ts#L544) now drop unknown state/backend verdicts instead of passing them through. There's no CHANGELOG entry for that, though the issue does scope it as intended, so that may be fine as is.

Do the two readers actually agree on which payloads count as a verdict at all? The test at https://github.com/callstack/agent-device/blob/7b8b463/packages/capture-kit/src/snapshot-quality-verdict.test.ts#L352 says they must, but capture-kit gates on iOS-only backend capabilities while contracts accepts android-helper, and VERDICT_PAYLOADS just omits that payload so the test can't catch the mismatch. Can you scope the doc comment to the iOS capture backends and add an android-helper row that asserts the asymmetry on purpose?

Could isSnapshotQualityState (and an isSnapshotCaptureBackend) live in kernel/snapshot.ts next to the tuple, the same way isSnapshotBackend and isIosTargetActivationReason already do there? Both readers would call the one predicate, and that removes two DECLARED_STATES maps, DECLARED_BACKENDS, and both local isDeclared copies, roughly 30 lines, at https://github.com/callstack/agent-device/blob/7b8b463/packages/contracts/src/snapshot-capture-annotations.ts#L135. If the eager-closure budgets for facades/capture.ts and capture-kit/snapshot-quality-verdict.ts genuinely block that one extra kernel import, that's worth recording on #2872 as the accepted deviation from the "one predicate" plan; otherwise this seems like the more direct fix.

Not blocking: the paragraph-long comments justifying the local maps, the partial re-read, and the review history in both readers and the kernel test could shrink to a single-sentence constraint note each, and the inline Record<SnapshotQualityState, true> assertion in packages/kernel/src/snapshot-quality-states.test.ts around line 674 could drop everything but its @ts-expect-error line — take these or leave them.

No conflicts. The next step is your answer on the shared kernel predicate.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 6e9ab31, following up on the evidence-pending review at 7b8b463 (#2888 (comment)). The enum change itself looks sound, but nothing new closes the evidence gap from that review, so it stays open.

Not blocking: packages/contracts/src/snapshot-capture-annotations.ts#L120 (https://github.com/callstack/agent-device/blob/6e9ab31/packages/contracts/src/snapshot-capture-annotations.ts#L120) has a leftover word "Reading" at the end of a sentence before the next one starts; worth cleaning up but can wait.

The runner's Swift stamping path is device-facing, and the only live run so far failed before reaching it, so this still needs a green iOS simulator smoke run on 6e9ab31, or a rerun of run 36009426964, that gets smoke:automation-input past "wait text Automation lab" and shows a successful snapshot with snapshotQuality.state stamped "healthy" or "recovered". The queued macOS smoke run also needs to finish green before this can merge.

The iOS Smoke Tests run (36009426964) failed at "wait text Automation lab" in smoke:automation-input with COMMAND_FAILED invalid-viewport, because every backend timed out preparing the tree snapshot on the XCTest main thread (reasonCode capture-failed). The value stamped in the log is well formed (state "sparse", backend "private-ax"), so this looks like a main-thread capture timeout rather than an encoding bug from this change, though the route does pass through the stampedSnapshotPayload fallback this PR touches. I did not compile Swift or TypeScript or run any tests, so the enum's exhaustiveness and the new XCTest membership rest on your report. I also could not check whether this same capture timeout happens on main, or whether the iOS smoke passed at 7b8b463. No conflicts.

Could you also confirm where the eager-closure budget entries land and whether the kernel predicate should be shared, since the previous review raised both and the delta doesn't answer them? The next step is to rerun the iOS Smoke Tests until they pass, let the macOS smoke finish, and answer those two questions.

@thymikee

Copy link
Copy Markdown
Member Author

Answering the review at 7b8b463 (#2888 (comment)) — all four asks, on e905886f0.

1. Where the eager-closure budgets live (your main question). Nowhere as numbers, which is why you could not find them. scripts/__tests__/eager-closure-budgets.ts measures each entry surface's eager closure and, for any entry the merge-base already carries, the budget is the merge-base measurement — eager-closure-budgets.test.ts:42: "ratcheted against the committed merge-base rather than against a table of numbers" (tree at :518-520, per-entry assertion at :594). Both readers are entries because they are published package subpaths, discovered via discoverFacadeEntryFiles/facadeEntryFiles (eager-closure-budgets.ts:93), not listed in HUB_ENTRY_FILES (:103). So the numbers are 9 modules for contracts/src/facades/capture.ts and 2 for capture-kit/src/snapshot-quality-verdict.ts, and they are not reviewable by reading a file.

2. Shared kernel predicate: implemented, measured, reverted. I re-installed isSnapshotQualityState + isSnapshotCaptureBackend in kernel/snapshot.ts and pointed both readers at them on the current tree. Six carried entries fail: contracts/src/facades/capture.ts 10 vs 9, capture-kit/src/snapshot-quality-verdict.ts 3 vs 2, capture-kit/src/png-resize.ts 19 vs 18, capture-kit/src/recording/contact-sheet.ts 58 vs 57, capture-kit/src/snapshot/snapshot-freshness/index.ts 17 vs 16, replay-test/src/index.ts 21 vs 20. The functional tests all pass in that configuration — it is purely loading shape. The only module both frozen closures already evaluate is kernel/snapshot.ts, i.e. exactly the refused edge. Recorded as the accepted deviation on #2872: #2872 (comment)

3. The android-helper asymmetry does not exist. SNAPSHOT_QUALITY_BACKEND_CAPABILITIES is { ...SNAPSHOT_BACKEND_CAPABILITIES, ...ANDROID_SNAPSHOT_BACKEND_CAPABILITIES } (packages/capture-kit/src/snapshot-quality-backend-capabilities.ts:88), so its keys are tree | queries | private-ax | android-helper — identical to SnapshotCaptureBackend. I added the { state: 'recovered', backend: 'android-helper', reasonCode: 'requested-backend' } row anyway; it passes, so the table now proves the agreement instead of omitting the case. The doc comment is not scoped to iOS backends because that scope would be wrong.

4. CHANGELOG entry added under Unreleased for the client-side drop of an undeclared state/backend, since version-skewed readers of agent-device-client.ts are the affected callers.

Non-blocking items taken: the justification comments in both readers and the kernel doc are now one constraint sentence each (the review-history narration is gone), and the kernel test keeps only its @ts-expect-error line.

@thymikee

Copy link
Copy Markdown
Member Author

Answering the follow-up at 6e9ab31 (#2888 (comment)), on e905886f0.

Leftover word at snapshot-capture-annotations.ts:120 — gone, with the surrounding paragraph shrunk to one constraint sentence.

Smoke rerun: green. Run 36009426964 attempt 2 completed success on 6e9ab31, so smoke:automation-input got past wait text "Automation lab" — that step is a hard assertion (test/integration/ios-simulator-e2e/live-automation-scenario.ts:89 via assertWaitText), not a soft probe. All four iOS smoke lanes and the macOS lane finished green; the only non-green row left was the intentional matrix.name skip.

The healthy/recovered evidence you asked for cannot come from a smoke run, and I would rather say so than imply otherwise. That assertion exists — assertSnapshotBackendConformance requires quality?.state === 'healthy' || quality?.state === 'recovered' (test/integration/ios-simulator-e2e/snapshot-backend-conformance.ts:81-85) — but it belongs to the full tier (context.tier === 'full', live-runner.ts:100-102) while the Smoke Tests job pins AGENT_DEVICE_IOS_E2E_TIER=smoke (.github/workflows/ios.yml:300). I found no dispatchable full-tier lane.

What the green run does show device-facing: the runner stamped AGENT_DEVICE_RUNNER_SNAPSHOT_RECOVERED backend=private-ax reason=the XCTest tree capture exceeded its 8s time slice (and two deferred-tier variants) from inside the simulator at 14:48:51 and 14:54:12/14:54:25 in that attempt's log — the enum-valued state arriving through the production stamping path. Your own read of the failed attempt corroborates the same thing from the other side: state "sparse", backend "private-ax" off the wire.

I also drove a simulator here (booted iPhone Duo): open com.apple.Preferences then snapshot -i --json on this head carries no snapshotQuality at all — and the identical command with origin/main's copies of the three changed TS files behaves the same, so that absence is a property of that capture path, not of this PR. If you want that healthy/recovered assertion on device as merge evidence, the honest route is a full-tier run against a locally built fixture app; say the word and I will run it.

@thymikee
thymikee force-pushed the refactor/2872-snapshot-quality-state-enum branch from e905886 to dedd8e8 Compare September 24, 2026 15:39
@thymikee

Copy link
Copy Markdown
Member Author

Rebased on main (the only conflict was CHANGELOG.md, where both sides added an ## Unreleased entry — kept both, no other file conflicted), so the tested heads moved. Mapping for the two reviews that cite SHAs:

reviewed / evidence now
7b8b4636c 3618c3a4a
6e9ab3140 3fc635cea
e905886f0 dedd8e895

Validation re-run on dedd8e895: pnpm check:affected --run 6760 tests with xctest-selection at 316 declared methods and 0 dark, eager-closure-budgets 688 with no entry grown, typecheck, lint, format clean, and fallow reports no clone groups.

Worth noting for the failed lane: upstream landed #2902 ("wait out the launch an accepted deep-link confirmation releases") in this range, which is the wait text "Automation lab" spot that timed out in run 36009426964 attempt 1 — so the flake this PR hit is being fixed under it.

@thymikee
thymikee force-pushed the refactor/2872-snapshot-quality-state-enum branch from dedd8e8 to 3a1d6ef Compare September 24, 2026 16:33
…lary

The runner wrote the verdict `state` as a free String while the host accepts only
healthy | recovered | sparse, and the contracts annotation reader cast any string state into
the verdict type. A typo or a one-sided rename therefore dropped the verdict and its
disclosure.

`SnapshotQualityState` now owns the runner side, raw-value Codable keeps the wire JSON
unchanged, and `reasonCode` stays open. The kernel states the vocabulary once as
`SNAPSHOT_QUALITY_STATES` with `isSnapshotQualityState`; capture-kit and contracts both read
through it, so neither keeps a second accepted-state set and the annotation reader rejects an
unknown state instead of casting it. `contracts/fixtures/ios-snapshot-quality-states.json` is
the table the Swift `allCases` order and the kernel tuple are each pinned to.
…reader

The readers reached the vocabulary through `isSnapshotQualityState` in `kernel/snapshot.ts`, which
made that module eager in six entry closures the eager-closure gate holds at its merge-base size —
the contracts capture façade at 9 modules, capture-kit's verdict reader at 2. The only module those
two closures already evaluate is the one whose edge the gate rejects, so no single runtime home for
the set exists.

Each reader now keys a `Record<SnapshotQualityState, true>` over the kernel union, the home the
gate prescribes for code that has to live where it is already evaluated. Membership goes through
`Object.hasOwn`, so an inherited key is never a state, and a state added to the tuple without a key
in a reader is a compile error there: the guarantee the shared import was bought for, without the
eager edge. Both reader tests walk the tuple and the kernel test still pins it to the fixture.
…y order

The annotation reader was one predicate plus a blanket cast. `backend` names the recovery strategy
in the warning line, so it goes through a declared map keyed against the kernel union now, and
capture-kit lost both of its `as` casts on the way: the strategy is gated on the capability table it
already imports, the reason code on an exhaustive map over its union.

A full projection in contracts was tried and the repo's own gates refuse it — fallow reports a
4-group, 104-line clone family against capture-kit's normalizer, on top of the eager-closure gate
that already forbids a shared reader. The re-read stays in the shape `readTargetActivation` in the
same file uses: check the two names that decide presentation, forward what this module published, and
pin the pair payload-by-payload from capture-kit's test.

Order pinning drops out. The fixture is compared as a set on both sides, the stamping test asserts
each case's own raw value, and the unread 28-line capture builder and its import are gone.
…eader

Two same-named readers with different trust levels is how the half-migration read as validating more
than it does. This module's version checks the two load-bearing names and forwards what this repo
published, in the shape `readTargetActivation` uses, so it is now `readPublishedSnapshotQualityVerdict`
and the strict per-field reading keeps the plain name for capture-kit's untrusted-payload reader.
…vidence

The eager-closure refusal the review asked to be pointed at or filed is not a table of budgets: the
gate ratchets every entry's closure against the committed merge-base tree, so the numbers to read are
9 for `facades/capture.ts` and 2 for `snapshot-quality-verdict.ts`. Re-installing one shared kernel
predicate for both readers on this head fails six entries and names both of those, which is why the
vocabulary stays a map per reader; recorded on #2872 as the accepted deviation.

The `android-helper` asymmetry is not real: `SNAPSHOT_QUALITY_BACKEND_CAPABILITIES` merges the
Android declaration, so both readers accept that strategy and the parity table now carries the row
instead of omitting it. The client-side drop of an unknown state or backend gets its CHANGELOG entry,
the justification comments shrink to one constraint sentence each, the kernel test keeps only its
`@ts-expect-error`, and the word left dangling by the last rename goes.
@thymikee

Copy link
Copy Markdown
Member Author

The enum refactor at dedd8e8 looks clean. The delta since the earlier review touches only docs, the changelog, and one parity test row, so no runtime or Swift logic changed, and all 18 checks were green on that head. I did not run Swift compilation or the SnapshotQualityStateTests, so enum exhaustiveness and wire JSON parity rest on that green CI.

The branch has since moved to 3a1d6ef, which this review does not cover. GitHub still shows a conflict with main: #2922 removed CHANGELOG.md, so the changelog entry needs to go too. Please rebase once more and get CI green on the new head.

@thymikee
thymikee force-pushed the refactor/2872-snapshot-quality-state-enum branch from 3a1d6ef to 6ccf3ae Compare September 24, 2026 16:43
@thymikee

Copy link
Copy Markdown
Member Author

Heads moved twice more while this was in review, and the branch is now on current main with no conflict.

Latest heads: 10c382c02 (runner enum) · 1a3d455f4 (reader maps) · 159b11c45 (backend gate, order) · 0394c17b6 (verdict re-read name) · 6ccf3aef3 (review round). The Swift evidence applies from 159b11c45 onward; nothing after it touches apple/.

One thing to note: CHANGELOG.md was deleted upstream in #2922 in favour of generated release notes, so the entry this review asked for is gone rather than merged. The behaviour change still has a home in versioned help and the user docs; if you want release-note copy for it, say where it should live now.

Re-run on 6ccf3aef3: check:affected --run 6766 tests, eager-closure-budgets with no entry grown, typecheck, lint, format clean.

@thymikee

Copy link
Copy Markdown
Member Author

The closed-enum refactor at 6ccf3ae looks correct, and it fixes the gap flagged in the earlier review (6e9ab31). Three of the four Smoke Tests jobs and every other check passed on this commit; one Smoke Tests job (run 36029357255) is still queued, not failed, and the changed lines here (comments, one parity-test row, one kernel test) don't touch the Swift stamping path that smoke exercises, which is unchanged from 6e9ab31, where iOS smoke passed on attempt 2. I did not download the logs for run 36009426964 attempt 2, so the claim that the runner stamped a RECOVERED verdict there is the author's, and the six-entry gate failure for the shared kernel predicate is also the author's report; I compiled no Swift or TypeScript and ran no tests myself, and there's no full-tier device run that asserts snapshotQuality.state healthy/recovered, since the smoke tier doesn't reach that assertion. Not blocking: the commit message for 6ccf3ae says an unknown verdict state or backend drop "gets its CHANGELOG entry," but CHANGELOG.md was deleted in #2922 and the PR now documents this behavior change nowhere, so a release-note sentence in the PR body (the generated notes read from it) plus a fix to that stale claim on squash would help, though it can be taken or left. Once the queued Smoke Tests job finishes green on 6ccf3ae, there's nothing left in the code to stop this from merging.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
@thymikee
thymikee merged commit d7a1f2c into main Sep 24, 2026
18 of 19 checks passed
@thymikee
thymikee deleted the refactor/2872-snapshot-quality-state-enum branch September 24, 2026 17:42
@thymikee

Copy link
Copy Markdown
Member Author

The red Smoke Tests on 6ccf3aef3 (run 36029357255) is not this change. It failed in the step Verify clean-installed Simulator snapshot bridge preparation and the fold-helper -Werror gate, at suite level in packages/platform-apple/src/foldable/fold-helper-cache.test.ts:

Error: xcrun timed out after 30000ms
    createTimeoutError packages/host-kit/src/internal/exec.ts:643

All 51 tests in that file passed and one was skipped; the timeout came from the -Werror compile check on the production fold-helper argv, i.e. xcrun did not answer inside its 30 s budget on a loaded runner. Nothing under snapshot, contracts, capture-kit, or the Apple snapshot quality path is involved, and eager-closure-budgets, typecheck, lint, and check:affected were green on the same head. A rerun is queued; the queue for that macOS lane has been ~50 min so far.

No issue covers this yet, so the owning files are #2858's -Werror gate and packages/host-kit/src/internal/exec.ts's 30 s xcrun budget. Say the word and I'll open it with this log attached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apple-runner: make the snapshot quality verdict state a closed Swift enum

1 participant