mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
main
391 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ab20d5c2af | refactor: retire platforms source seam (#2119) | ||
|
|
d9677301f4 |
feat: add human takeover controls (#2078)
* feat: add human takeover controls * fix: harden human takeover controls * fix: align host XCTest selection count * fix: address takeover readiness feedback * fix: handle macos runner permission prompt in smoke tests * fix: detect background macos permission dialog * fix: dismiss inaccessible macos privacy sheet visually * fix: map macos privacy prompt without accessibility * refactor: own human-control holds in lease registry * fix: cancel pending human takeover on disconnect |
||
|
|
9abcd7fe03 |
refactor: move Apple platform family into package (#2118)
* refactor: move Apple platform family into package * fix: preserve Apple facade sync contracts * fix: complete Apple W4 rebase review fixes |
||
|
|
c7f42ccedc |
refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports * fix: address Android W5 review feedback * fix: update relocated routing fixture assertion |
||
|
|
838ed223b5 |
refactor: move W6 platform families behind package facades (#2116)
* refactor: move W6 platform families behind package facades * fix: address W6 loading and composition review |
||
|
|
af6f12e391 |
chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config * fix: preserve project lint boundaries * fix: remove redundant oxlint config |
||
|
|
437465f37b |
ci(1874): declare the diagnose lane and read its iterations honestly (#2059)
The loop that #1874 is investigated with could not tell the truth about itself. It classified every non-`passed` iteration as a stall, which after #2035 gave the looped test an XCTSkipIf meant an environment flip would report a 100% stall rate; it captured cadence only for failures, though an absorbed episode now passes; and it read its logs with shell pipelines whose exit status means "did this match", so an iteration that legitimately matched nothing killed the job before it could be summarized. scripts/diagnose-1874-iteration.ts reads one iteration: xcodebuild's own verdict, the `type-all` duration, and the cadence worth keeping. A nonzero exit outranks a green measured test — in `pair` mode the neighbour or the runner can fail while the measured test passes — and a run that produced no verdict is named as ours rather than counted as a stall. The workflow gains the #1781 lane declaration it never had. Its kill criterion names #2080, which the loop can now serve rather than merely claim to: the looped test is a dispatch input, so the fill route that #2080 traces loops the same way. One test pins the contract the script cannot check about itself — that the workflow hands it the status xcodebuild returned rather than a literal. Closes #1874. Both filed symptoms are resolved. `smoke:form-input` was root-caused and fixed in #2035: the fixture's placeholder was identical to the value every suite filled, so `fill` could never be verified on the penalized route — deterministic, not a flake, and only visible under load because that route is gated on a penalized XCTest channel. The targeted XCTest is mitigated by the progress-aware commit budget, with 200 consecutive green loop iterations across two dispatches. The issue's remaining question — why the input pipeline throttles — is answered by the second dispatch, and the premise was wrong: it does not. Posting 17 characters took 484 ms and the commit was observed on the first poll, inside an iteration whose `type-all` measured 14334 ms. The ~12.6 s went to accessibility round-trips before any character was posted, which is #1105's path, not the input pipeline's. |
||
|
|
ddb415a2c7 |
refactor: sink package-closed src modules into existing packages (#2106)
* refactor: sink package-closed src modules into existing packages Move closed modules into contracts, kernel, capture-kit, and ad-script, and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining provider-webdriver type cycle. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: keep contracts and capture-kit off generic sinks Move interaction-outcome, snapshot warning rendering, and inventory ALS behind focused owners, and plant R18/R70 domain-shape gates so they cannot return as package export-map growth. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop moved implementation comments from owner modules Names, types, and tests already carry those invariants; the relocated files should not keep review-history or control-flow narration. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop the empty snapshot-quality layering zone W1 moved the verdict into capture-kit and this PR moved warning rendering into snapshot-presentation, so the ranked zone no longer has production files. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
0ecfef1c17 |
refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2100)
* refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2082 W3) The shared files left directly under src/platforms move onto a declared owner: provisioning mechanics (install-source family, toolchain probing, boot-failure classification, app-resolution caching) form @agent-device/provision-kit above capture-kit; host mechanics resolve to host-kit's seams; kernel takes the pure numeric helpers; contracts keeps vocabulary only. Settings parsing, command-attempt rendering, and the unsupported-interactor factory stay with their families rather than pooling in a substrate package: android and apple settings each own their parsing, and the unsupported-interactor factory lives in root core with a vega-local copy. A platforms-root-shape rule rejects any new shared file or directory appearing directly under src/platforms, and the provision-kit direction gates (no platform imports in, no capture-kit importer) are planted red. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * test: cover the family-owned parsers and the unsupported-interactor factories The settings parsers and the unsupported-interactor factory arrived without owning tests, so their branches rode on callers. Each now has one: the appearance/state parsers over every accepted spelling and their rejections, the attempt summarizer over its arg join and stderr budget, and both interactor factories over the whole operation surface and the per-instance label. Also drops the duplicate ./snapshot-desktop-projection export key that a rebase left in capture-kit's manifest, where JSON silently keeps the last, and the root-shape docblock the violation message already states. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
7a6284bb36 |
refactor(platforms): break the four upward edges out of src/platforms (#2090)
* refactor(platforms): break the four upward edges out of src/platforms (#2082 W2) src/platforms carried four imports that point up into root src, each of which would become an R11 violation the moment its family moves into a platform package: - android/app-helpers.ts reached the composition root for the foreground parser. The parser is pure dumpsys vocabulary, so it moves to @agent-device/contracts/android-observation; the platform-android app-state module and app-helpers both consume it from there, and the composition wrapper plus the package facade's lazy re-export retire (R13 allows only the composition root to import platform packages, so vocabulary relocation is the inversion that stays legal). - web/provider.ts and web/agent-browser-network.ts type-imported the backend diagnostics/network-dump vocabulary from src/backend.ts. Those six types move to @agent-device/contracts/backend-diagnostics; backend.ts re-exports them for its SDK consumers. - snapshot/snapshot-desktop-surface.ts split three ways: the pure projection (scope/interactive/depth) moves to @agent-device/contracts/snapshot-desktop-projection, the per-family captures move to platforms/linux/surface-snapshot.ts and platforms/apple/os/macos/surface-snapshot.ts beside the code they dispatch to, and the root file keeps only the device-dispatching runtime host behind R3-tolerated dynamic imports. apple/interactor now reaches macOS surface capture family-internally instead of through root. src/platforms -> root src is now zero edges. Contracts grows two entries (pinned, budgeted); the android foreground-parser tests move beside the parser. Left for a later pass: app-parsers.ts shares the marker-walk loop shape with the contracts parser but parameterizes it for blocking-dialog parsing - generalizing that is a design change, not a move. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(platforms): keep platform parsing with its family; give snapshot shaping its capture owner ADR-0019's amendment forbids satisfying R13 by moving implementation into contracts, so this wave's two relocations invert instead of sink: - The Android dumpsys foreground parser returns to @agent-device/platform-android with its owning test, and contracts/android-observation goes back to observation vocabulary only. src/platforms/android/app-helpers exposes createAndroidAppStateReader(parseForegroundApp) and never imports upward; the composition seam in src/sdk/android-adb.ts injects the root-composed parser, keeping the published getAndroidAppStateWithAdb(adb) signature intact. - The desktop snapshot projection moves to @agent-device/capture-kit/snapshot-desktop-projection beside the rest of the capture-side snapshot behavior; contracts exports the snapshot-scope vocabulary it consumes. The #1832 history narration in the projection test is gone; the test name and golden fixture carry the invariant. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(platforms): the adb app-state read lives whole behind the platform-android seam The injected-parser loop retires: platform-android's app-state module owns the complete adb-executor read/parse (readAndroidAppStateWithExecutor, beside its host-based twin), the façade exposes it lazily, the composition root wraps it, and src/sdk/android-adb.ts reaches it through that root in one hop. app-helpers keeps only the app-list helpers, and the SDK-route tests live in SDK topology as src/sdk/android-adb.test.ts. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: drop the seam wrapper explainer comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: drop the projection docblocks the test names already carry Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
e832325e87 |
refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4454aef139 |
refactor(layering): remove retired migration scaffolding (#2092)
* refactor(layering): remove retired migration scaffolding * test: remove retired focus test wording |
||
|
|
ea1d6b8c55 | refactor: remove retired capability matrix (#2089) | ||
|
|
7b48531d3b | refactor: retire ADR-0019 cutover scaffolding (#2081) | ||
|
|
057b2da233 |
ci: run coverage in one job again (#2079)
The Coverage lane was split into two matrix shards plus a Coverage Report job that downloaded both blob reports and merged them. That claimed three runner slots per PR and put a barrier in front of the merge: the report job could not start until the slower shard finished, and the blobs it waited on are tens of MB to upload and download. One job asks for one slot and reports its own thresholds where it runs, so the lane finishes when the suite finishes. Everything the split needed goes with it: the shard/merge switches in vitest.config.ts, the blob reporter swap, the zeroed per-shard thresholds, and the env blanking that `test:fuzz-worker` carried only to keep the second leg from inheriting them. |
||
|
|
c794c11d7e |
refactor: close the daemon platform boundary (#2072)
* refactor: move runtime resource mechanics out of daemon * refactor: move Apple resource access out of daemon * chore: enforce the terminal daemon platform boundary |
||
|
|
3378d9c05a | refactor: move Android system observation out of daemon (#2071) | ||
|
|
03f0f408c2 | refactor: move platform provider composition out of daemon (#2070) | ||
|
|
ed8d5efb00 |
fix(daemon): address cwd-scoped sessions by their store key, not their public name (#2068)
* fix(daemon): address cwd-scoped sessions by their store key, not their public name An implicitly cwd-scoped session is NAMED `default` and STORED under `cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so each pointed at something that does not exist: - `DEVICE_IN_USE` reported `session "default"` and its recovery hint said `agent-device close --session default`. `--session` marks the session explicit, which disables cwd scoping, so following the hint addressed a different, absent session: `SESSION_NOT_FOUND`, and the device stayed held — the residual half of #2031 that #2057's superseded-daemon reconciliation does not cover. - `session list` reported `sessionStateDir` and `runnerLogPath` under `<state>/sessions/default`, a directory that is never created; the session's real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what `open` already answers with. `SessionStore.entries()` exposes the key alongside the record, and `buildSessionRecoveryHint` takes the session's address explicitly. Call sites that only hold the session the current request named pass its name — the address there — so their text is unchanged, as are explicitly named sessions everywhere. Live before/after on an iOS simulator: `open` (cwd-scoped default), then `open --session qa` on the same device. Before, the hint's own `close --session default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and the retry opens. Refs #2031, #1394 * fix(daemon): thread the resolved session address through every recovery producer The store key a request resolves — `cwd:<hash>:default` for an implicit session — was known upstream but dropped before the selector-conflict and lock-conflict producers, which then named `SessionState.name`. Both emitted `close --session default`, the unreachable recovery this PR fixes for DEVICE_IN_USE: `--session` marks the session explicit, so that command addresses a different session. `SessionRef` ({ address, session }) now carries the pair, so a recovery producer cannot be handed a record whose address was never resolved. `buildSessionRecoveryHint`, `assertSessionSelectorMatches` and `applyRequestLockPolicy` take it; `prepareLockedRequestBinding` and the Maestro replay route build it from the store key they already hold. Replaces the broad `SessionStore.entries()` with the store's own narrow lookups — `lookup`, `findByDevice`, `listRefs` — so callers receive the session with its address instead of enumerating raw key/record tuples. `session list` reports `address` alongside `name`, through the client contract and serializer, so its discovery output names a value `--session` accepts. Regressions run the production routes, not the helpers: router-level device-in-use, selector-conflict, lock-policy-conflict and `session list` cases open an implicit session and read the address back off the store, plus a typed-Maestro selector-conflict case. Each was observed red against the pre-fix behavior it pins. Refs #2031, #1394. * fix(daemon): doctor names same-device sessions by address; green the fallow gate - sessionChecks enumerated same-device sessions by SessionState.name: the printed close --session default was the exact unreachable recovery this PR removes elsewhere, the name-based dedupe hid two cwd-scoped default sessions from each other, and the evidence sessionStateDir pointed at <state>/sessions/default, which never exists. Candidates now come from listRefs() and report addresses. - findByDevice joins values/delete in .fallowrc.json usedClassMembers (same resolution false positive on this class; the call site is session-open-execution.ts). - the scoped-paths test narrows its response once instead of nine optional-chaining hops, which tripped the CRAP gate. * style: format .fallowrc.json --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> |
||
|
|
71214e11da |
refactor(runtime): close residue execution units (#2054)
* refactor(runtime): close residue execution units * refactor(runtime): address residue ownership review * fix(runtime): preserve viewport diagnostic log path |
||
|
|
9f16fc885c | refactor: migrate perf to device runtime (#2061) | ||
|
|
a904ef0d5d |
fix(fuzz): run parser cases in a worker process, not the runner's thread (#2053) (#2055)
The unit-lane corpus replay executed adversarial parser cases on worker threads of the Vitest worker running the test file. A fault in a worker thread ends its whole process, so a case that faulted killed the test runner: `[vitest-pool]: Worker forks emitted error / Worker exited unexpectedly`, with no test, file, or case named. Six of six Coverage deaths before #1994's split were this one file out of ~1100, and the uninstrumented second leg it created then lost the same file six more times in three days. Cases now run in a worker *process*. The two faults a case cannot report about itself are both classified from outside it: a case that never returns is a `hang` (unchanged), and one that ends the process it runs in is a new `crash` failure carrying the exit code or signal and the tail of the worker's stderr — the death certificate the lane used to lose. A sixth self-check target seeds that kind, so a regression in reporting it fails the harness self-check like every other kind. |
||
|
|
33a816966c |
perf(apple): uninstall stale runner bundles concurrently (#2058)
* perf(apple): start stale simulator runner bundle uninstalls concurrently cleanupStaleSimulatorRunnerBundles awaited each simctl uninstall sequentially while discarding the results (best-effort cleanup). Run the per-bundle uninstalls under Promise.allSettled like the sibling disposal paths, and pin the concurrent start with a deferred-promise test. * test(apple): split stale-bundle cleanup coverage out of the pinned runner-session suite runner-session.test.ts is over the test-size tripwire and its pin may only shrink. Move the three stale-bundle cleanup tests (boot availability, best-effort stall, concurrent start) into a sibling file named for the domain question, carrying the same seam scaffolding. * test(apple): lower runner-session suite pin to bank the stale-bundle extraction * test(apple): format stale bundle coverage |
||
|
|
8af2660332 |
refactor(android): extract the adb executor and IME cluster into packages/platform-android (#2041) (#2044)
* refactor(android): extract adb executor and IME cluster into packages/platform-android (#2041) Implementation moves behind an injected adb host port (R13-clean); shared vocabulary moves to contracts (android-touch-plan, android-helper-artifacts) and kernel (keyed-lock); root keeps thin re-export shims plus the composition wiring that binds the port. session.ts/session-observability.ts no longer import platform transport types (opaque unknown, R62 pattern). * refactor(android): trim shims to consumed surface, break helper-install cycle, add gate coverage Fallow-clean: shim re-exports carry only names root still consumes; helper-package-install imports the package subpath directly (no cycle) and reuses the contracts decision types; transitional R13 table gets planted-red tests; ADR-0019 records the transitional exception; eager-closure rows added for the new entry surfaces. * review: restore maxBuffer on spawn options, scope the transitional test allowance Adversarial review round 1: AndroidAdbSpawnOptions keeps maxBuffer for spawn- signature width parity with the pre-move ExecBackgroundOptions; the R13 test allowance narrows from any test file to the cluster's own src/platforms/android __tests__ directory, with planted-red coverage for a foreign test file; ADR text records the scoped allowance. * review: keep an unbound adb host port loud in listAndroidAdbSerialsQuick * style: format policy test * refactor(android): split the extracted adb and IME modules into focused owners Review: the moved adb-executor.ts (652 lines) and ime-lifecycle.ts (487 lines) carried three concerns each across the package boundary. The entry subpaths are now thin surfaces over focused modules — adb: transport vocabulary, failure classification+enrichment, provider normalization, provider scope/routing, port-reverse ownership, pull/install transfers; IME: ownership state, on-device settings record, activation transaction, restore+orphan recovery — every module under 250 lines, each with colocated tests over an in-memory host-port stub and a shared fake IME device. R31's fence invariant repoints to ime-activation.ts; eager-closure pins move to the split shape. * rebase: reconcile the transitional android table with the #2050 mechanics-facet model The R13 declaration check now composes both enumerated subpath sets on the exact-list model #2050 introduced; the policy test fixture declares android's transitional subpaths the same way it declares apple's runner facet. |
||
|
|
72cae2bc72 |
refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) (#2050)
* refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) Moves src/platforms/apple/core/runner/ (34 modules + apple-runner-platform.ts and the 30 runner test suites) into packages/platform-apple/src/runner/ — Apple mechanics live in the Apple package. Host capabilities (exec, diagnostics, retry, process probes, locks, Apple tooling, physical-device control) enter through the package-owned AppleRunnerHost port; the root composition module src/platforms/apple/core/runner-client.ts constructs the client exactly once and re-exposes the bound operations under their historical names. R13 admits the transitional state deliberately: the family exports its root façade plus exactly the enumerated ./runner, ./runner/client, and ./runner/test-host subpaths; the ./runner façade subpath is the recorded #1983 seam for unmigrated root consumers; ./runner/client has one composition root and ./runner/test-host one vitest installer; the runner subtree may own its cache files and sockets while raw process primitives stay banned. When #1983 completes, the subpaths and every subtree exemption are deleted and the family returns to a single implementation-lazy façade export. * docs(adr): model the runner subtree as a durable platform-owned facet Review correction on #2050: the sunset story attributed the runner-consumer migration to #1983, which owns snapshot/presentation vocabulary — not the runner's daemon/root consumers — so that event cannot delete the ./runner subpaths or the subtree exemptions. Reword ADR-0019, R13, and the gate comments: the facet is the intended ownership model, its seam is enumerated and pinned (exact export list, one client composition root, one test-host installer, raw-process ban, eager-closure pins), and the seam narrows only if a real runner-consumer migration retires the direct consumers. The declaration mechanism stays apple-specific until another family needs a mechanics facet. No behavior change; identifiers and comments only. |
||
|
|
74a70f1764 | refactor: remove next-major compatibility surfaces (#2046) | ||
|
|
80a3fdc79c | perf: raise local vitest worker cap to four (#2049) | ||
|
|
aeb2ff8402 |
refactor(contracts): retire the platform/interaction compatibility façades (#2048)
#1959 granularized the contracts entry surface but left the wide platform/interaction façades in place as a compatibility surface for ~490 type-only importers. This mechanically moves every importer (~350 files) onto the granular subpath each symbol actually lives on, deletes the two façade files, and removes the fallow ignoreExports entries and eager-closure-budget rows that existed only to cover them. Eight previously-unexported source files needed new package.json subpaths (clipboard, keyboard, network-traffic, platform-plugin, platform-providers, runner-lease-context, screen-recording-runtime-host), and ./interaction now points directly at src/interaction.ts instead of the deleted barrel. .oxlintrc.json's no-restricted-imports rules for the two façades are removed since there's no wide facade left to warn against value-importing. |
||
|
|
494f1c5ad0 |
Migrate audio probe to platform runtime with durable resource lifecycle (#2038)
* refactor(daemon): migrate audio onto the request-bound device runtime (R60) Wave 6 closure unit 1 of 2 for #1739. The audio probe leaves legacy execution for exact-owner runtime facts with the logs/record durable treatment: - contracts: audio-probe-runtime operations (audioProbeStart/Reattach/Cleanup for the durable host capture, audioProbeQuery for the stateless web page probe), audio-runtime-plan action-selected uses + shared positional grammar, audio-probe-runtime-host seams; two new required unavailable cells. - capture-kit: one shared host-capture implementation (descriptor codec v1 with cleanup-only recovery, start pipeline waiting on the sampler's first status publication, live handle, exact-identity recovery operations) used by both darwin-hosted owners. - owners: apple and android state their exact capture cells (macOS host only; the legacy bucket's physical-iOS over-claim becomes a stated refusal), web owns the page probe, all other families and both providers state refusals. - daemon: audio-probe.resource.json envelope via the DurableCaptureResource coordinator, fence-minting admission ledger, session slot becomes the neutral handle+envelope pair (store-owned under R7), teardown/close finish through the coordinator, startup recovery registered in the device-claim reconciler; the handler admits by facts inspection and binds once per plan. - deleted: the capability bucket, the WEB_QUERY_COMMANDS graft and its matrix pass, both supportsByDefault closures, src/daemon/audio-probe.ts, src/platforms/audio-probe-backend.ts, and the macOS backend shim. - gates: cutover row R60 (durable tier, lifecycle proof on the session slot), R7/R11 baselines moved for the slot reclassification and new subpaths. Known parity delta, itemized: status/stop with no active probe now answers without backend-specific notes (the daemon no longer knows a backend before a capture starts); the wire shape is otherwise unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(daemon): doctor becomes host-scoped execution behind the host-diagnostics surface (R62) Wave 6 closure unit 2 of 2 for #1739, carrying the ADR-level discriminator decision the pre-unit record names: CommandPlatformExecution gains { kind: 'host' } — host-scoped diagnostics contributed by platform families through a neutral surface, binding no device runtime of its own. 'none' remains barred as a migration target; doctor's device legs already ride the migrated inventory gateway, facts inspection, and the apps unit's use. - contracts: host-diagnostics facet (toolchain/device/ambient/warmup methods over the existing DoctorCheck vocabulary, a per-call context carrying the neutral inputs, and an opaque provider transport override the one owning family narrows back); the discriminator assert, entry gate (host is held to the same no-bucket rule as none), and error text extend to the new kind. - probes move to their owning families: apple (xcodebuild/xcode-select + runner-cache warmup), android (adb/SDK/license toolchain, Metro reverse, orphaned test-IME), harmonyos (hdc), vega (tool provider + VVD inventory read via the context), web (managed browser census); one shared first-line probe helper. Wire shapes and check ids are byte-identical. - composition: createHostDiagnostics with per-family lazy loading, injected from daemon-runtime through router/chain/session params — the daemon never imports the composition root, so no transitive platform edge returns. - daemon: session-doctor.ts keeps orchestration only; its six platform imports and the three probe-owning modules (session-doctor-{toolchain,android,web}.ts) are deleted. - gates: HostCutover row variant with a gateway-identity proof (R17's shape); R62 row; the descriptor-row completeness test now mandates rows for host descriptors; ADR 0019 rules-at-a-glance amended. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(audio): fold the per-family probe factories into one capture-kit operation set Post-migration fallow pass over R60/R62: apple and android carried byte-identical audio-probe operation factories, so the shared createHostAudioProbeCaptureOperations now lives in capture-kit and both bind arms call it directly; the family files keep only their stated capture facts. Also un-exports the plan/recovery symbols nothing consumes anymore, splits the durable-descriptor field validation out of the decoder, names the Linux audio refusal by its file's convention, and drops a stale session-doctor-web mock from the relocated doctor test. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN * fix(coverage): repoint platform audio/doctor coverage claims at the migrated evidence The R60/R62 rewrite renamed the daemon audio contract tests and moved the web doctor suite, which broke the macos-coverage smoke gate on CI and five sibling coverage claims that only fail on non-darwin hosts. Repoints the macOS, web, and iOS-simulator manifests at the surviving tests (adding an iOS-simulator start contract the manifest already named), converts the Linux audio row from capability-denial to a fact-owned contract backed by new stated-refusal assertions in the Linux runtime denominator test, and retires the darwin-dependent capability special-cases: audio admission is owned by the exact-owner runtime fact now, not the catalog. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN * fix(daemon): forward hostDiagnostics through the session command route Adversarial review of the R62 cutover found the wiring gap that broke every doctor request served through the daemon: handleSessionCommands re-composed its handler params without the hostDiagnostics gateway, so requireHostDiagnostics always threw. Forwards it, composes createHostDiagnostics() in the provider-scenario harness the same way the daemon runtime does (all nine doctor integration tests pass again), merges a duplicate test import that failed lint, applies oxfmt to the files the branch left unformatted, and trims blank-line residue from a deleted capabilities test. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN * fix(audio): refuse to publish a capture without an exact process identity Review P1 on the R60 unit: start permitted resolveManagedProcessIdentity to yield no marker, recovery then mapped the markerless descriptor to missing, read a possibly in-progress status file as completed, and terminalized the durable resource without proving or terminating the child. The marker is now required end to end: start terminates the helper and fails when the process exposes no identity, the descriptor codec rejects markerless bodies so a foreign or corrupted record routes to manual recovery instead of a guessed outcome, and a planted-red regression pins that a markerless record with a live status file is never read as completed or missing. Also splits the capture-kit audio-probe module along its concerns (descriptor codec / status reads / cleanup-only recovery / live-process owner) per the same review, moving the eager-closure budget rows the three new files cost. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN * fix(audio): let timed probes complete and never adopt a stale status file Live exact-head evidence on macOS surfaced two lifecycle defects: - The helper's runAudioProbe parked the CLI's main thread in a semaphore while an unstructured Task ran the capture loop; with no run loop ever spinning in the one-shot process, the loop stalled at its first Task.sleep suspension, so every timed probe froze after its first bucket and never completed on its own. The probe is now fully synchronous: ScreenCaptureKit's completion-handler APIs bridged through semaphores (the pattern the helper's screenshot path already uses in production) and a plain Thread.sleep cadence loop. - startHostAudioProbe accepted a pre-existing audio-probe.json, so restarting in a session that had already run a probe returned the previous run's snapshot as the new probe's first status. The status path is cleared before the spawn — the previous handle's finish() has already terminated and awaited its process, so any file observed after the spawn was written by the new helper. A planted-red regression pins that a stale file is neither adopted nor left behind. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN * test(audio): complete runtime host fixtures * fix(audio): surface helper death after a running checkpoint instead of completing it Review P1 on the live-evidence pass: the live handle discarded the helper's terminal result, so a sampler that died after publishing a running checkpoint read as running forever and stop fabricated a normal stopped completion; marker-missing recovery compounded it by finalizing any persisted status — a running checkpoint included — as completed. The handle now observes process.wait: a non-terminal status file plus an observed exit fails status and stop with the helper's exit detail, so the durable coordinator records the failed terminal transition and the record resolves through descriptor cleanup rather than a fabricated result. Recovery treats only a terminal stopped publication as a completion; a running checkpoint with the exact PID gone reports missing, which terminalizes the envelope as already-missing with no completion metadata. Planted-red regressions cover both: the live handle with a running checkpoint plus child exit, and a daemon-restart recovery over an orphaned running checkpoint. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd7fCKcxuDU3KrXF4MF9rN --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
c77bc40d48 |
refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63) (#2021)
* refactor(daemon): migrate clipboard onto request-bound runtimes (R55) Wave 6 unit 1 of the ADR 0019 platform-free daemon migration (#1739). `clipboard` leaves the legacy dispatch projection: admission is now the action-selected `readClipboard`/`writeClipboard` fact the parsed subcommand names, and the only execution is that one bound operation. - new `@agent-device/contracts/clipboard-runtime` facet, riding the existing `Interactor` seam through `interactor-operation-binding.ts`; read and write are separate cells because a provider can genuinely expose one half only. - every owner states its own cells: Apple gains a `system/` facts module (simulator or the macOS host, matching the retired `supportsHostOrSimulatorSurface` closure), Android admits every real kind, Linux the desktop device, and HarmonyOS/Vega/web refuse -- none ever carried a bucket. Limrun reuses the local Android interactor and refuses on iOS; WebDriver rides interactor reachability like `back`/`home`. - retires the `core/dispatch.ts` clipboard arm and handler, the descriptor's capability bucket and `dispatch` leaf, and the Apple plugin's clipboard admission closure. `handlers/session.ts` loses its inline handler (and its last `dispatchCommand`/`requireCommandSupported` imports) to the new `handlers/session-clipboard.ts`. - `bindLocalInteractorOperationSet` collapses the byte-identical local interaction bind list Android and Linux each held a copy of. Cutover row R55 with its retirement, admission-member and single-bind claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(daemon): migrate app-switcher onto request-bound runtimes (R56) Wave 6 unit 2 of the ADR 0019 platform-free daemon migration (#1739). `app-switcher` leaves the legacy dispatch projection: admission is the owner's `appSwitcher` fact and the only execution is that one bound operation, resolved by the generic route alongside back/home/orientation/tv-remote. - new `@agent-device/contracts/app-switcher-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. - Apple states one springboard reading for `home` and `app-switcher` (parity: the retired `supportsAppAndDeviceLifecycle` closure gated both off the same per-AppleOS row, so macOS and watchOS refuse); Android admits every real kind; HarmonyOS admits both kinds, restating the retired overlay membership; Linux/Vega/web refuse. Limrun reuses the local Android interactor and refuses on iOS; WebDriver rides interactor reachability. - retires the `core/dispatch.ts` arm, the capability bucket, the `dispatch` leaf, `HARMONYOS_SUPPORTED_COMMANDS` membership, the Apple plugin closure, and the now-readerless `appAndDeviceLifecycle` row in the per-AppleOS table. - router tests that used `app-switcher` as their legacy-dispatch stand-in move onto bound operations; the typed-error `supportedOn` test moves to `perf`, the one command that keeps a capability-matrix row after this wave. Cutover row R56 with its retirement, admission-member and single-bind claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(daemon): migrate trigger-app-event onto request-bound runtimes (R57) Wave 6 unit 3 of the ADR 0019 platform-free daemon migration (#1739). `trigger-app-event` leaves the legacy dispatch projection: admission is the owner's `triggerAppEvent` fact and the only execution is that one bound operation. The split follows ADR 0019 §2 — a facet input names no command, request, or CLI flag. The event name pattern, the payload size limit, and the per-platform `AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy and stay in `core/app-events.ts`; what reaches the owner is a resolved URL to open. They also stay downstream of admission, where the retired `dispatchCommand` ran them, so an unsupported device still reports its unsupported cell rather than an argument error. - new `@agent-device/contracts/app-event-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. - Apple admits every leaf with a constructible interactor (no closure ever gated this command beyond its bucket), Android every real kind, and Linux/HarmonyOS/Vega/web refuse. It is the one system leaf both Limrun legs serve, since each implements `open`; WebDriver rides interactor reachability. - retires the `core/dispatch.ts` arm and handler, the capability bucket, the `dispatch` leaf, and the session route's last capability-gate-then-`dispatchCommand` thunk: every leaf on that route now supplies a bind-and-execute thunk. - the end-to-end delivery tests keep their shell-level assertions and move onto the migrated composition. Cutover row R57 with its retirement and single-bind claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(daemon): migrate settings onto request-bound runtimes and retire the legacy dispatcher (R58) Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings` was the last `DISPATCH_HANDLERS` arm, so this change closes the command and retires the legacy command dispatcher whole. - new `@agent-device/contracts/settings-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. What reaches the owner is its own settings vocabulary (setting, state, resolved app id, typed coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state app-id check and the coordinate typing are daemon policy and stay daemon-side, downstream of admission where the retired leaf ran them. - Apple shares clipboard's exact host-or-simulator reading (the retired admission intersected the `settings` bucket with the same `supportsHostOrSimulatorSurface` closure); Android admits every real kind; HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse. Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver refuses unconditionally, since its interactor declares settings unsupported. - retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`, `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps only `dispatchGestureViewport`, whose last consumers are replay/test. Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press` onto a bound runtime: react-native overlay dismissal and the opt-in interaction no-change retry both called `dispatchCommand(device, 'press', …)`, which has thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run the same bound `tapPoint` every other touch leaf uses. The retry declares its own callback seam rather than importing runtime admission, so the policy stays readable without the binding stack — and that inversion, plus the dispatcher's retirement, drops the largest type-level import cycle from 25 files to 21. Cutover row R58 with its retirement and single-bind claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(daemon): migrate alert, react-native and capabilities onto facts (R59/R61/R63) Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739), plus the residue reclassification the tracker asks for as an analysis task. R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`) on the shared `Interactor` seam. The daemon route admits and binds exactly the leg the parsed subcommand names, and the poll and retry windows move to the owners with it: how long a transient sheet takes to appear, and how many times to re-ask a runner that says it is not there yet, are family mechanics, not request policy. `src/platforms/apple/alert.ts` now holds the Apple windows verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read the same presented tree `snapshot` publishes, which is why their occlusion reading still holds. Apple's cell is the retired `supportsAlertSurface` closure restated as facts — the host-or-simulator reading widened by physical iOS — and that closure was the per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts` goes with it. R61 `react-native` — the command's device work moved onto a bound `tapPoint` with R48; this retires the capability gate that still stood in front of it and moves admission ahead of the observing capture, so an owner that cannot dismiss an overlay refuses without first spending a snapshot on it. That exposed a real defect: the request handler chain never forwarded the request's runtime bindings to this route, so the dismissal leg had been reaching a missing gateway ever since R48 — only the no-overlay-detected path returned early enough to hide it. Fixed, with a chain-level regression test. R63 `capabilities` — the projection now reads each command's own declared `platformExecution` uses instead of a hand-written map plus a "no capability bucket means supported everywhere" fallback. That fallback is what let a stopped Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD advertise every migrated command; both collapse to the fact-derived set here. The command itself executes nothing on a device, so it declares `none`. Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no device and delegates nothing that does. `replay`/`test` keep their gesture viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in their own CLI modules, and `react-devtools` still injects device-runtime `runtime`, so all five stay `legacy`. Cutover rows R59 and R61 with their retirement and single-bind claims. Descriptors: 32 legacy at the wave checkpoint, 9 now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(daemon): restore two settings/alert sequences the migration had shifted Self-review of the Wave 6 diff against `origin/main` found two places where the migrated routes were faithful in what they did but not in when: - `settings` typed its location coordinates before expiring the ref frame, so a request that failed on a bad coordinate no longer expired it. The retired route expired the frame first, then emitted its diagnostic, then typed the coordinates inside the leaf. Same order again. - `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which also stripped the bundle from the XCTest runner leg. That narrowing was only ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`; the runner leg gets `session.appBundleId` unconditionally again, pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(daemon): address adversarial review of the Wave 6 cutovers Three independent reviews (behavior parity, correctness, ADR 0019 conformance) ran against the branch. What they found, and what changed: Correctness - The R48 retry seam was unreachable. `captureSnapshot` builds it from the request's runtime bindings, but no caller forwarded them, so every retry resolved to a skip. The `snapshot` route now threads `inspectFacts`/ `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend down to the capture. - A retry tap that rejected escaped the capture it was decorating and turned a plain `snapshot` into an error. It is caught and reported as a skip, matching what the seam's own contract already claimed. - The attempt is spent before the device work again, as the retired route did, so an owner that fails mid-flight cannot be re-attempted from a full budget. - `react-native dismiss-overlay` reached its required `tapPoint` through `?.` and answered `dismissed: true` when the operation was absent. It refuses. - `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an empty `required` as proof (`[].every` is vacuously true). Both fail closed. ADR 0019 conformance - §6 forbids a `none` descriptor from binding a device, and `capabilities` bound three times to answer `logs`/`network`/`record`. Every owner composes a binding's facts with the same function `inspectFacts` calls, so those probes read back values the single inspection already carries — at the cost of a device claim on a read-only query. They are gone, and with them the last three empty-`required` admission uses. - §9 is one admission per handler; the retry tap re-admitted on every retry round. It memoizes per device. - `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to retire: the general projection returns the same verdict for all four install-family commands. Deleted. Leftovers the cutovers created - `requireCommandSupported` lost its last production caller when R56 migrated `app-switcher`: every generic-route command is admitted from owner facts before the dispatcher runs. The dead arm, the function, and `commandUsesDeviceRuntimeExecution` are removed. - `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=` field described a dispatcher R58 deleted. - `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock whose module export no longer exists, so three assertions were vacuous. - `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it did. It pins the routing table as total over the generic route. - Comments and test names describing the retired dispatcher, the deleted AppleOS capability table, and a react-native regression that never shipped. Also records two deliberate provider cell changes the migration made (physical Apple `clipboard` admitted, provider `alert` refused) and the react-native widening to Linux, web and HarmonyOS, and drops a scratch probe file that was committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(apple): type the alert-absence retry instead of matching error prose Review blocker 1 on #2021. The Apple alert legs decided retry and hint eligibility by substring-matching error messages for "alert not found" / "no alert", and `alert wait` swallowed *every* read failure. A dead runner, an unreachable macOS helper or a canceled request was therefore spent as poll budget and finally reported as `alert wait timed out`, hiding the real cause. Both backends now state absence as typed evidence: - The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used. - The macOS helper adds `reason: "alert-not-found"` to its JSON error details, which the helper client already forwards verbatim. `isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws anything that is not a typed absence instead of polling through it, and the scoped-snapshot fallback hint attaches to typed absence alone. The three tests the review asked for, plus coverage the daemon-altitude copies could not express: a non-absence failure propagates immediately from `wait`; an action does not retry a failure whose message merely reads like an absence; the macOS helper's typed reason is retried like the runner's. The daemon-level non-absence test moved to the family suite that owns this policy since R59, lowering that file's size pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(runtime): admit clipboard and provider operations from what execution checks Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator: `capabilities` advertised `clipboard`, then `clipboard read` failed with `UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this device.` Admission and execution were consulting different authorities, which ADR 0019 §2 forbids — a bound operation must already be admitted. Android. `cmd clipboard` has no shell implementation on every build, and the retired bucket admitted both halves on every real Android kind, leaving the leaf to discover the refusal after the fact. Support is now a fact: the owner probes once per device (cached for its lifetime — a build's shell command set cannot change while the device is up) and states `owner-capability-missing` when adb names the condition. The probe is definitive in one direction only: adb saying so means unsupported, a probe that cannot run means unknown, and reporting unknown as unsupported would hide a working clipboard behind a transport hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support` so admission and the leaf's own defense-in-depth check cannot drift apart. Cost, stated plainly: the first facts inspection per device now spends one adb round trip, including for requests that never touch the clipboard. WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`, while fact generation admitted from interactor reachability alone — so a provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }` was admitted and then thrown out of. The declared capability map is now an input to fact generation, and the refusal carries the map author's own note. Applied to every operation with an unambiguous capability key, not just the two named in review: the mechanism is identical and a half-applied fix would leave the same defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is unchanged by default — every one of those is `supported` or `partial` in the base map — so only an explicit override bites. `focus`, the gesture tiers and `trigger-app-event` keep reachability: no capability key maps to them 1:1. Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in the cutover table into one parameterized factory (second review point). Parity tests: an Android build reporting either unsupported-shell phrasing, the probe cache, an adb failure staying admitted, and a WebDriver override refused at admission for each keyed operation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(layering): split the Wave 6 cutover rows into a sibling module Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325 lines, past the point where one read covers it. Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are spread back in, leaving the table at 1,095 lines. The split is by wave because that is how these rows are retired: a wave's rows are deleted together once the ADR declares its commands' migrations closed, and deleting a whole file is a cleaner end than excising a run of literals from the middle of a larger one. `retiredDispatchProjectionProof` moves to the shared extensions module, since both tables now use it — the main table for `snapshot`/`diff`, the sibling for its own eight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(android): never fabricate clipboard availability from a failed probe Review blocker on #2021. The probe I added had a `catch { return true }`, then cached that result by device id for the runtime owner's lifetime. A transient adb offline or timeout therefore made `capabilities` advertise the clipboard on a build with no clipboard shell — recreating the exact lie the fix was for, and pinning it for the rest of the session. A test locked the behavior in. Support is now a typed verdict with three states, because "we could not ask" is not "it works": `supported | unsupported | probe-failed`. Only a definitive answer is cached; `probe-failed` refuses conservatively with a hint saying support could not be determined, and is deliberately not remembered, so the next inspection asks again. The same change repairs the ownership boundary. Turning raw adb stdout/stderr into a verdict is Android tool knowledge, so it belongs to the Android owner, not to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now carries the typed union alone. The parser returns to `src/platforms/android/adb.ts` and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport` that hands owners the verdict. That also settles which Android home owns it: R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`, so a parser shared between the package and the root leaf cannot live in the package either. Tests now cover the failure path the previous ones locked the wrong way: a failed probe refuses instead of admitting, its refusal says it could not determine support rather than claiming the build lacks it, and it is not cached — a second inspection re-probes and admits once the device answers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * refactor(contracts): declare each interactor operation once Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same operation set three times — a name tuple, a complete local binder map, and a complete provider binder map — and each facet carried a mirrored `bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was which interactor source to use and which label a refusal names. There is now one row per operation, carrying its facts key, its provider refusal label, and the facet's own executor. The local/provider split lives in the two adapters, which differ by exactly the thing that differs: the interactor source. Adding an operation is adding one row. Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases that existed only to be re-exported. Kept: every facet's typed executor, now exported as its binding surface. Net −563 production lines in `packages/contracts`. Two consumers moved onto the catalog's public entry point rather than keeping a private path to a single operation: the app-event delivery test and the provider scenario fixture, whose two hand-bound keyboard legs are now whichever legs its facts admit. Each facet's tests spell out the composition the retired wrappers performed, so every assertion still exercises one executor reached through one source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(android): let only a clean adb exit prove clipboard support Third review P1 on #2021. The typed verdict landed one layer too high. The adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a non-zero exit comes back as an ordinary result rather than a throw — and the only thing standing between that result and `supported` was the missing-shell prose check. A device that had gone offline, was unauthorized, timed out, or failed for any other reason produced none of that prose, so it fell through to `supported` and was then cached by device id for the runtime owner's lifetime. The `catch` I added guarded the one path adb almost never takes. Each adb outcome now proves only what it can: - `exitCode === 0` is the sole evidence of support, because it is the only result that shows the command ran. - The recognized missing-shell prose is the sole evidence of absence, and is read before the exit code — adb reports that condition non-zero, so checking the code first would turn every honest `unsupported` into a refusal. - Everything else — non-zero without that prose, and the transport throw — is `probe-failed`, which admission refuses and the cache does not remember. The package tests mocked the typed verdict, so they sat downstream of the bug and could not see it. The regression is therefore at the adapter, over the raw adb result: four planted reds (offline, unauthorized, device-not-found, generic failure) that all returned `supported` before this change, plus the two definitive verdicts and the ordering case that keeps `unsupported` reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(android): never read adb's refusal prose out of the clipboard's contents Fourth review P1 on #2021, and a second instance of the same bug it names. The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's contents — arbitrary user text. Anyone who had copied "unknown command" or "no shell command implementation" (from a terminal, a bug report, this repo) had their own working clipboard classified `unsupported`, and the runtime owner cached that for its lifetime. Ordering prose ahead of the exit code to keep `unsupported` reachable traded one wrong admission for another. The exit code is decisive on its own when it is zero, so it goes first. Only a call that failed can carry prose about the call itself, which makes the missing- shell phrases meaningful on non-zero exits alone: if (result.exitCode === 0) return 'supported'; return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed'; `isClipboardShellUnsupported` now states that precondition, because reading it on a successful call is exactly the mistake to prevent. The same defect was already shipped in the helper's other caller. `runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts` has checked the prose before the exit code since #1950, so `clipboard read` on a clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user their device does not support a clipboard it had just read correctly. It is not this wave's code and not reachable from the migration, but it is the same helper misused the same way, and documenting a precondition while leaving a caller that violates it invites the next regression. Repaired here, with the failure ordering otherwise unchanged: a non-zero exit still reports missing-shell as `UNSUPPORTED_OPERATION` and anything else as the adb result error. Both repairs are pinned by regressions that fail against the code they replace: four exit-0 cases at the adapter (verified red against the ordering this commit removes), and three at `readAndroidClipboardWithAdb` (verified red against `origin/main`) covering contents that look like a refusal, a genuine missing command, and an unrelated non-zero failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * fix(cli): bring the workflow help card back under its size budget `Coverage (2)` has been red on `main` and on every PR branched from it since #2020, which replaced three short Bootstrap lines with one longer line carrying the new selection semantics. It updated the content matcher for that line but not the size assertion beside it, so the card went to 9003 bytes against the `< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce. Nothing #2020 added is removed here — all of it is pinned by the matcher it shipped, and it is the sentence agents most need. The bytes come back from a clumsy repetition elsewhere in the card, where "settle" named itself twice in one clause: ... only when you did not settle, settle reported not settled, or ... ... only when you did not settle, it reported not settled, or ... which reads better short and puts the card at 8999. That is one byte inside the budget, which is the real finding: the card has no slack left, and the next sentence anyone adds re-opens this. The durable fix is a base-owner call between raising the budget and moving a block down into its sub-topic — the mechanism the card already uses, and which its own test documents. Flagged on #2021 rather than decided here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX * test(cli): raise the workflow help-card budget to 9100 The card is a curated agent-facing reference, and #2020 grew it for a good reason: the selection semantics it added are what an agent needs to predict which device a bare `open` picks. Holding that content to a limit set before it existed just moves the cost onto whoever writes the next sentence. 9100 is headroom, not a target. The previous commit left the card at 8999 of 9000 -- one byte -- which is not a state anyone should have to work in, and I had already established there is no slack left to reclaim: no trailing whitespace, and the only repeated runs are the deliberate column alignment in the Escalate footer. Trimming further would have meant deleting content the tests pin as load-bearing. This is explicitly interim. The card is ~9KB of dense prose in one string, and the real answer is to move a block down into its owning sub-topic -- the mechanism the card already uses and its own test documents ("Deep content moved out of the compact card, not deleted"). Raising the ceiling buys room to do that deliberately instead of under a red CI. Both enforcement sites move together, since they measure the same card through different surfaces: `cli-help.test.ts` reads it through the CLI, and `cli-help-topics.test.ts` through `usageForCommand`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
52ac5da091 |
fix(daemon): stop branch-named daemon before replacement (#2015)
* fix(daemon): recognize branch-named daemon entries * fix(daemon): require process identity for takeover * fix(daemon): preserve shutdown cleanup identity |
||
|
|
608bf7aa47 |
Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version
Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).
- release:publish now runs release:mark-dev after npm publish, moving
package.json and the synchronized server.json to the next patch with a
-dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
0.20.10 while the tool surface kept changing, which is the live finding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* fix(mcp): remove credential inputs from the model-writable tool surface
Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:
- the keys are omitted from every advertised tool schema (MCP and AI SDK,
which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
/ AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts
Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.
Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* feat(ci): inventory every dynamic value reaching a device shell
adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.
scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* fix(shell-argv): satisfy the fallow audit without suppressions
The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* fix(test): close the port-reuse race in the unreachable-takeover test
Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* fix(mcp): enforce the advertised tool schema at a real admission boundary
P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.
Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.
Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
* revert(ci): remove the check:shell-argv inventory gate
The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.
Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
5b6feafe92 |
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
|
||
|
|
a830ac8df2 |
feat: add Linux command evidence lane (#2017)
* feat: add Linux command evidence lane * fix: assert Linux find result shape * fix: read Linux find result envelope * fix: reset Linux calculator before diff * fix: release Linux session before reset * fix: guard Linux evidence session reset * fix: forward Linux evidence timeout * fix: tighten Linux evidence assertions * fix: preserve Linux replay session identity * fix: close Linux replay session before reset * fix: share Linux evidence daemon state * fix: keep Linux swipe evidence in bounds * fix: keep Linux artifact gap honest |
||
|
|
d97a628e38 |
fix(ci): make the two rg-based static checks actually run (#2006)
* fix(ci): make the two rg-based static checks actually run
ripgrep is never installed on ubuntu-latest, so both `rg` assertions in
the Lint & Format job failed with "command not found" (exit 127) on
every run. `if rg ...; then ... fi` cannot distinguish that from "no
matches" (exit 1) — both read as false, so each step silently passed
without its assertion ever executing. The DI-seams check had 7 live
violations it never reported.
Rewrite both against `grep`, which every runner ships, with match/
no-match/error exit codes handled explicitly so a broken scan fails
the lane instead of reading as a pass, plus a zero-tracked-files guard
so a renamed directory can't quietly go uncovered.
The DI-seam pattern also gets narrower to drop two classes of false
positive surfaced by actually running it: `typeof fetch` (fetchImpl?/
fetch? seams inject the one global with no module boundary vi.mock can
intercept; auth-session.ts/cloud-profile.ts/daemon-proxy.ts exercise
the seam directly in their unit tests, while CLI-level tests use
vi.stubGlobal('fetch', ...) where the seam isn't reachable — a
deliberate, exercised seam) and `typeof SOME_CONSTANT` in
SCREAMING_SNAKE_CASE (derives a literal union type from a constant,
e.g. interaction-touch-response.ts's dispatchPath field — not an
injectable seam at all).
Fixes #1976
* fix(ci): replace the DI-seam name-based allowlist with an explicit per-site one
Review on PR #2006 (#1976): the previous revision fixed the exit-code
handling but decided which `?: typeof X` matches to ban with a regex
that exempted matches by the *spelling* of the typeof target
(`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always
passed). That's a name-based semantic allowlist, not ownership: a new,
genuinely test-only `typeof fetch` seam anywhere in the tree would
have silently passed, while an equally legitimate seam under any
other name would still fail.
Add scripts/di-seams: a small, tested TypeScript checker that judges
each match against an explicit, typed, per-site allowlist
(scripts/di-seams/approved.ts) keyed by (file, field name, typeof
target) rather than by name. A triple is exempt only because it was
individually reviewed and named — never because of how it's spelled —
and the gate fails just as hard on a stale approval (one whose triple
no longer matches anything, e.g. after a rename) as on an unapproved
seam, so the list can't silently drift out of sync with the code it
describes.
Moves the DI-seams step in ci.yml to run after Setup toolchain (it's
no longer a toolchain-free text scan); the Swift trailing-comma check
stays where it was.
* fix(ci): register di-seams as a real gate and route it through the tmpdir wrapper
CI caught two things the local (dependency-free) run couldn't:
- oxfmt formatting on the two new files.
- scripts/node-test-tmpdir.test.ts's repo-wide audit: every package.json
script that invokes `node --test` directly must route through
scripts/node-test-tmpdir.ts, or a crash/timeout mid-run leaks its
scratch TMPDIR. check:di-seams now does.
- check:gate-manifest: a package.json script that runs `node --test`
must be covered by a registered CHECK_CATALOG gate, or the audit
reports the test suite as run by no lane. Registered 'di-seams' in
scripts/check-affected/{model,checks}.ts and wired the CI step
through run-gate like every other structural guard in this job,
instead of invoking pnpm directly.
Verified locally with node_modules installed: check:di-seams,
check:gate-manifest, check:gate-manifest:test, check:affected:test,
check:layering, check:fallow (scoped to the changed files), format,
lint, and typecheck all pass.
* fix(ci): close the multiline and duplicate-site gaps in the DI-seam scanner
Review round 2 on PR #2006 (#1976):
- findSeamMatches scanned line by line, so a declaration split across
lines (`field?:` on one line, `typeof X` on the next) was invisible.
Matching now runs against each file's whole source in one pass —
`\s` matches a real newline in JavaScript regexes with no extra flag
needed — with the line number derived from the match's character
offset.
- checkSeams keyed approval by (file, field, target) alone, so once
one occurrence of a triple was approved, any further occurrence of
that same triple anywhere in the file passed too. The key now
includes the line the match starts on, so an approval names one
specific declaration, not a recurring pattern. approved.ts expands
from 5 collapsed entries to the 7 exact sites this closes down to.
Added regression tests planting both gaps directly (a cross-line
declaration, and a second unreviewed fetchImpl?: typeof fetch at a
different line in an already-approved file) and verified both against
the real tree with injected violations, restored cleanly afterward.
Re-ran the full local gate suite (di-seams, gate-manifest, layering,
fallow, format, lint, typecheck) — all green.
* fix(ci): resync approved DI-seam line after merging main
Merging main (#2002) removed an unused import above the approved
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH declaration in
interaction-touch-response.ts, shifting it from line 61 to line 60 —
exactly the location-specific-approval staleness the gate is designed
to catch, just triggered by an unrelated upstream edit rather than a
change in this PR. Updated the approved line to match.
* fix(ci): replace the DI-seam positional table with a code-local approval marker
Review round 3 on PR #2006 (#1976): CI proved the round-2 fix's core
assumption wrong within one push. Keying approval by (file, line,
field, target) made a line number the identity — an unrelated edit
anywhere earlier in a file shifts every approval below it, and that's
exactly what happened: merging main removed an unused import above
the approved dispatchPath declaration, and the gate rejected an
unchanged, already-reviewed line.
Detection is now AST-based (oxc-parser, the same tool
scripts/layering/*.ts already uses) instead of a source-text regex:
any `{ optional: true, typeAnnotation: TSTypeQuery }` node — a
property signature or a bare parameter — is a candidate, which finds
a multiline `field?:\n typeof X` declaration for free instead of
needing a special case for it.
Approval is a `// di-seam-approved: <reason>` comment immediately
above the declaration, matching this repo's own `//
fallow-ignore-next-line complexity` convention: the marker precedes
what it exempts. approved.ts (the external table) is deleted — there
is nothing left to keep in sync, since the approval travels with the
code it approves. A second, unmarked seam under the same field/target
elsewhere still fails; reordering unrelated code around an approved
declaration no longer touches it.
Added the marker to the 7 real approved sites (fetch-global
injection seams in auth-session.ts/cloud-profile.ts/daemon-proxy.ts;
the literal-type-derivation false positive in
interaction-touch-response.ts) and regression tests proving: a
cross-line declaration is still found, a second unmarked occurrence
of an approved field/target pair still fails, and an unrelated
insertion above an approved declaration no longer breaks it. Verified
against the real tree with an injected multi-line unrelated insertion
before an approved site — still green. Re-ran the full local gate
suite (di-seams, gate-manifest, layering, fallow, format, lint,
typecheck, auth-session unit tests) — all green.
* fix(ci): reject a di-seam-approved marker with no reason text
Review round 4 on PR #2006 (#1976): approvalReason() returned '' (not
null) for a bare `// di-seam-approved:` comment with nothing after
it, and checkSeams() only filtered out null, so an empty marker
silently approved a seam with zero justification — exactly the kind
of unreviewed bypass this gate exists to prevent.
approvalReason() now returns null when the joined reason text is
empty after trimming, so a bare or whitespace-only marker is treated
the same as no marker at all. Added tests for both the model-level
behavior and the end-to-end checkSeams() result, plus verified
against the real tree by injecting a bare-marker declaration and
confirming it's flagged, then restored cleanly.
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
775eddd749 |
feat: session-scoped echo protection for parameterized recorded inputs (#2013)
* feat: session-scoped echo protection for parameterized recorded inputs Extends ADR 0017's fill-step-scoped guarantee to the whole recording session (#1398). After #1349, a later read-only action (`wait`, `is`, `get`) can independently observe and record an app-rendered echo of an already-parameterized `fill --record-as` value in its own result or target-v1 identity evidence, re-leaking the literal even though the originating fill was protected. - SessionState gains a small, ephemeral, never-serialized literal->placeholder registry populated only from explicit `--record-as` pairs, owned by session-action-recorder.ts. - Result/event payload fields get content-aware substring redaction (reusing the fill boundary's recursive scrub) for every literal registered so far in the session, longest-literal-first. - target-v1/targets-v1 identity evidence is never silently text-substituted while still claiming a trustworthy identity (replay compares against the live tree, which re-renders the real value). A landmark-mode (wait) echo is dropped to no annotation, exactly like #1349's existing identity-empty case, so an echoing landmark can no longer serve as an ADR 0016 destination guard. Action-mode evidence (get/is/mutating actions) redacts the label and downgrades verification to "unverifiable" instead, since ADR 0012/0016 forbid dropping required identity evidence. - Amends ADR 0017 (new mechanism), ADR 0012 (#1349/writer-invariant cross-references), and ADR 0016 (destination guard cross-reference). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui * fix: placeholder-safe single-pass multi-literal redaction Addresses review feedback on #2013: sequential single-literal replacement (register somethinglong -> ${ABC}, then ABC -> ${OTHER}) could rewrite a placeholder produced by an earlier pass, corrupting it to ${${OTHER}}. Replaces the per-pair sequential loop with one placeholder-safe left-to-right multi-literal pass (parameterizeAgainstLiteralMap): it never re-scans text it has already emitted, so no literal can be matched inside another pair's placeholder token in either direction. A registered literal is matched before checking for an existing placeholder token, so a value that itself happens to look like ${SOMETHING} is still redacted correctly. The scan uses a sticky regex instead of slicing per character, and literal pairs are sorted once per payload/evidence walk instead of once per string leaf. parameterizeRecordedFillPayload/parameterizeBackendOutput are generalized to take injected leaf-transform/carries callbacks so the single-pair fill-boundary path (with its existing whitespace-collapse behavior) and the new multi-pair session-wide path share one structural traversal. Adds regression coverage for both result payloads and action-mode target evidence, plus the placeholder-shaped-literal edge case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui * fix: unexport parameterizeAgainstLiteralMap (CI: fallow dead-code gate) Only used internally within this file (by parameterizeRecordedResultEcho and parameterizeTargetEvidenceEcho); the export had no consumer outside the module, which the fallow audit correctly flags as dead code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
50f460cce4 |
refactor(snapshot): establish presentation ownership boundary (#2005)
* refactor(snapshot): establish presentation ownership boundary * docs: keep context glossary within budget * fix(snapshot): address presentation boundary review * test(snapshot): ratchet eager closure budgets |
||
|
|
021fe2aa1d |
feat(maestro): support assertTrue phase 1 - literal/${VAR} truthiness (#1295) (#2010)
* feat(maestro): support assertTrue phase 1 - literal/${VAR} truthiness (#1295)
Adds the assertTrue command, scoped to literal values and bare ${VAR}
lookups per the #1292 lookup-only decision; JS expressions keep
failing loud at parse time with a runScript hint. Truthiness on a
looked-up value is evaluated against a pinned falsy-string table
("", "false", "0", "null", "undefined") since flow config/env/
runScript-output values are always stored as strings, rather than
native JS truthiness (which would treat "false" as truthy).
Wires assertTrue through the parser, interpreter, optional/warning
composition, and the layer-1 conformance oracle, narrowing the
067_assertTrue_pass divergence to the JS-expression case and removing
the now-satisfied 076_optional_assertion entry. Also materializes
scrollUntilVisible's default direction in the conformance canonical
projection, a latent gap only exposed once 076 could fully compare.
* fix(maestro): correct assertTrue truthiness claim in CLI help/docs
The support-matrix text said assertTrue is "evaluated with JS
truthiness", but the engine actually uses a pinned falsy-string table
("", "false", "0", "null", "undefined") since looked-up values always
arrive as strings — native JS truthiness would treat "false" as
truthy. Spell out the actual rule instead of the misleading claim.
* fix(maestro): fix oxfmt quote-style violation in expected-divergence.ts
CI's format gate failed on a single-quoted string containing an
apostrophe; oxfmt prefers double quotes there.
* fix(maestro): bump eager-closure-budget pin for the new truthiness module
engine-truthiness.ts is a genuinely new module on the core interpreter
path (assertTrue is dispatched unconditionally by
replay-plan-step-execution.ts), so packages/maestro/src/index.ts now
eagerly evaluates 105 modules instead of 104 — a deliberate growth,
not a laziness regression.
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
dbc4f2f955 |
chore(test): start the subprocess-stub kill-criterion experiment (#1823) (#2007)
Deletes the serialized `subprocess-stub` Vitest project and drops SUBPROCESS_STUB_TESTS from unit-core's exclude, so its two real spawners (client-metro.test.ts, harness.test.ts — corpus-replay.test.ts already left for fuzz-worker in #1994) run un-serialized in the default forks pool per #1823's own kill criterion. Revert if a timeout-shaped failure shows up before 20 consecutive CI runs pass clean. The files stay excluded from the mutation lane (SERIALIZED_TESTS): that exclusion is about mutant-rerun cost, independent of Vitest project structure. Updated the comments/docs/scripts that described the old project by name so none of them assert a project that no longer exists. Claude-Session: https://claude.ai/code/session_015YPgKE1xmjdqh7T1q987DA Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
893ce4b866 |
fix(ci): repair nightly XCTest and conformance lanes (#1989)
* fix(ci): repair nightly XCTest and conformance lanes * fix(ci): harden nightly failure classification * fix(ci): stabilize macOS replay cleanup * fix(ci): close nightly review gaps * fix(ci): classify device claims as infrastructure |
||
|
|
957a6727f8 |
fix(android): publish covered state from exact order evidence (#1981)
* fix(android): unify snapshot occlusion across API levels * fix(android): preserve exact occlusion evidence * fix(android): restore collective occlusion coverage * fix(android): preserve snapshot evidence across consumers |
||
|
|
104fe75248 |
fix(ci): run the fuzz corpus replay outside the coverage lane (#1994)
The Coverage job intermittently ends with no failing test and one file's
results missing:
Test Files 1070 passed (1071)
Errors 1 error
Error: [vitest-pool]: Worker forks emitted error.
Caused by: Error: Worker exited unexpectedly
This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.
The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies, and neither a missed
per-case budget nor STARTUP_BUDGET_MS is implicated. Partial test counts
(3/11 and 9/11 reported) place the death mid-file, inside runCases.
So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.
Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.
The second leg goes through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. ci.yml sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them and the shard would die: Vitest refuses
`--shard=1/2` over this one-file project, and the blob reporter
overwrites the instrumented shard's report on its way out. Verified on
the merged tree — shard 1/2 (549 files), shard 2/2 (548), and the merge
job (1097 files, 90.38% lines) all pass, and the leg still fails without
the blanking.
Refs #1824
|
||
|
|
02d548dfc9 |
ci: consolidate CI workflow from 15 jobs to 8 (#1996)
* ci: consolidate CI workflow from 15 jobs to 8 Merge single-gate ubuntu jobs into grouped jobs sharing one checkout and install: Lint & Format (plus the static text assertions), Repo Guards (layering/selector/wiring/maestro/mcp-metadata), Compatibility & Provenance (shared fetch-depth: 0 checkout), Typecheck & Package, and Integration Tests (absorbs the web smoke with step-scoped env). Every gate remains an independently named run-gate step; the gate manifest derives lane ownership structurally. Drop the Bun setup from FreeRange: @chenglou/freerange's bin is a plain Node script. It stays GitHub-owned; only the runtime requirement is retired. * ci: fold FreeRange into Repo Guards and skip no-op fixture release jobs FreeRange runs on plain Node now, so its gate joins Repo Guards as the last step instead of occupying its own worker for the slowest guard. The fixture release matrix filters to entries that will actually build, so a cached-fingerprint PR starts zero release runners. * ci: fold host XCTests into the macOS smoke lane and shard Coverage The macOS lane now builds one unit-test-flagged runner bundle that both the host XCTest run and the replay smoke consume, so the host lane no longer occupies its own macos-26 runner behind a separate queue. The host lane's file moves with it, and check:xctest-selection follows. Coverage shards across two runners via blob reports and merges them on a report job that evaluates thresholds once over the full suite and produces every coverage artifact. The tmpdir leak check runs per shard, since a leak lands on whichever runner executed the file. * ci: drop local shard-smoke artifacts from tracking * ci: enforce coverage thresholds only on the merged run A shard evaluates its own half-suite coverage, so the global gate fired per shard. Shards now report without gating; Coverage Report keeps the real thresholds over the full merged suite. * ci: include hidden files when uploading coverage blobs |
||
|
|
bcca714a07 | refactor: move gesture family to platform runtime (#1952) | ||
|
|
759f175332 |
refactor: move Wave 5 touch commands to platform runtime (#1987)
* refactor: move touch commands to platform runtime * fix: require direct selector touch binding * fix: address touch runtime review * fix: classify maestro direct click guarantee * fix: distinguish Maestro direct selector dispatch |
||
|
|
cc521decb8 |
test(structure): per-package eager-closure budgets — the ADR-0019 loading-shape probe (#1739) (#1965)
* test(structure): per-package eager-closure budgets (#1960) ADR-0019 requires platform-package façades to stay implementation-lazy and is explicit that a startup threshold alone is not a substitute for preserving the loading shape. #1950 built the AST-level walker (eager-import-closure.fixtures.ts) and proved the planted-red procedure on one file (session-teardown.ts's android-helper denylist); this generalizes it into a data-driven budget table so any workspace-package façade -- or a designated hub module -- can get an eager-closure ceiling without a bespoke test. Seeds a budget for every packages/*/src/facades/*.ts file (discovered the same way package-boundaries.test.ts discovers façades, not hand-listed) from its measured current closure size, plus a platform-implementation denylist for façades whose contract is implementation-neutral vocabulary. Also demonstrates the mechanism on two designated hub modules (cli.ts, session-teardown.ts) alongside their existing, more specific ad hoc pins. Closes #1960 * test(structure): derive facade roots from manifests, add edge chains, reseed tight Review findings on #1965: 1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every package that publishes its entry surface straight from the manifest — including all six `packages/platform-*/src/index.ts` façades, the exact subject of ADR-0019's implementation-laziness rule. Discovery now derives from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/` files, reusing the R11 helper rather than reimplementing it so the two gates cannot disagree about what an entry surface is. The table grows from 14 façades + 2 hubs to 95 entry surfaces + 8 hubs. 2. Budgets carried a few files of slack each. They are now exact ratchets with no headroom, matching how the repo pins R9/R10 and test-file size: growth is allowed, it just has to be a visible number change in the diff of the PR that causes it. Every budget is reseeded from post-#1969 measurement. 3. Violations printed a flat sorted set, which named the offender but not the route. `eagerClosureGraphOf` records each file's discoverer, so failures now print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps its contract and is expressed in terms of the new walk; per-file edges are memoized, which also cuts the existing pins' runtime (cli closure test 2336ms -> ~550ms). The platform-package façades evaluate exactly one module each — themselves — so their budget of 1 is the tightest statement of "metadata-eager, implementation-lazy" the walker can make. * test(structure): make the eager-closure pins exact, bounded, and single-owner Second review pass on #1965 found four holes, two of which were places the PR text claimed a property the code did not have. 1. Rows were documented as exact ratchets but asserted with `<=`, so a shrink silently became headroom a later regression could grow back into. The comparison is now equality, in a pure `classifyBudget` with a separate message for each direction ("lower its pin to N in this PR so the ratchet keeps the gain"), matching test-file-size-ratchet.ts and the R9/R10 pins. 2. An over-pin failure printed a chain per evaluated module — 361 of them for src/cli.ts. It now prints a bounded attribution: the entry's heaviest direct edges (capped at 4) with a couple of representative deep routes each, ranked so a newly added import sorts first. The comment states plainly that this attributes by shortest import route and does NOT diff against a recorded baseline; naming a true delta would mean checking in ~1,500 module paths and rewriting them on every contracts refactor. 3. Discovery reimplemented a one-level `src/facades` scan while canonical R11 discovery is recursive, so a nested façade file could be covered by R11 and silently missing here. `facadeEntryFiles` is now a single exported owner in package-boundaries.ts that both R11's façade gate and this table consume. 4. Rows were converted to Sets before any uniqueness check, so a duplicate was unobservable. The table is now two `Record<string, number>` literals keyed by path, making an in-record duplicate a TypeScript error (ts1117); the only remaining case — one path in both records — is asserted on the array. Each of the four holes gets a test that fails when the rule is broken, since a tree that happens to satisfy its pins cannot distinguish a correct rule from a vacuous one. Writing those found a real bug in the duplicate check itself (`Set.add` returns the Set, so the filter never matched). Pins reseeded on |
||
|
|
8d280a2081 |
refactor(request): move the device-inventory context out of core (#1993)
`device-inventory-context.ts` is an AsyncLocalStorage holder: it imports only `node:async_hooks`, kernel errors, and contracts types, and nothing from `core`. `daemon-modularity.ts` already describes `src/request/` as "request-global daemon plumbing (progress sinks, cancellation, AsyncLocalStorage)", and `request/progress.ts` is the same shape — a `withX`/accessor pair over one store. Living in `core` (rank 2) made the platform-runtime composition root reach up to rank 2 for `listLocalDeviceInventory`. Injecting the lookup would not have fixed that: the only caller, `platform-runtime-operation-host.ts`, is in the same zone, so the edge would move rather than disappear. Relocating the module does remove it, and every other consumer — daemon (rank 4) and `core/dispatch-resolve.ts` (rank 2) — now reaches down instead of sideways or up. Pure rename; the module body is unchanged and 14 import specifiers are retargeted. R17's `DEVICES_INVENTORY_IMPORT_SOURCES` pin follows the module: the rule still asserts the devices handler imports the neutral gateway, does not shadow it, and calls it. This clears the last composition-root upward value edge that was not deliberate. Confirmed with `pnpm depgraph`: those edges drop from 3 to 2, and the two that remain are the documented ones — `provider-limrun-runtime.ts` (an in-file comment marks it a deliberate static seam) and `runtime.ts -> commands/index.ts` (`bindCommands` is the aggregate binder). Cycle, back-edge and R6 counts unchanged. Claude-Session: https://claude.ai/code/session_01WWLBCBDBpdR8z1aCmDerXS Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
296447707e |
refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime (#1955)
* refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime Continues the ADR 0019 platform-runtime migration (Wave 5 generic leaves): five generic-route commands move off dispatchKnownCommand/Interactor legacy dispatch onto fact-owned admission, one bind per handler. keyboard uses the R35 action-selected single-bind pattern (status/dismiss/enter each admit and bind independently). All 8 owner runtime packages gained fact-cell tests for the new operations; six smoke-coverage integration oracles and nine daemon/capability unit test files were updated for the retired capability- catalog admission these commands no longer carry. * refactor: extract shared interactor-resolution prelude in keyboard-runtime bindKeyboardStatus/Dismiss/Enter repeated the same signal-check + resolveInteractor call; factor it into resolveKeyboardInteractor so each binder is a two-line call instead of a six-line copy. No behavior change — the three contract-module mutants planted earlier in review still kill on this shape. * fix: refuse watchOS admission for back/home/orientation/keyboard; pin tv-remote non-TV parity P1: watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009), matching the existing captureScreenshot/captureSnapshot/readTextAtPoint/findSelector pattern in this same file. appleBackFact/appleHomeFact/appleMobileInputEligible admitted every Apple OS but tvOS/macOS, wrongly including watchOS. Facts now refuse watchOS explicitly for back, home, orientation, and keyboard dismiss/enter, with a fact-cell test asserting no binding for every one of them. P2: verified the daemon's generic-route capability gate already reproduced the retired per-platform tv-remote hint text (message stays the generic "<command> is not supported on this device", hint carries the owner-specific text) for every device that could reach dispatch in the old system -- the retired handleTvRemoteCommand's own "supported only on TV targets" check was unreachable there and only exercised by a test calling dispatchCommand directly. Added a daemon-level test pinning the exact iOS and Android-mobile hint strings to make that parity explicit instead of implicit. Also fixes a fallow complexity finding the P1 test edit introduced by splitting the fact-cell assertions into five small named helpers instead of one large function. * fix: stop orientation-runtime.test.ts's router-join test from hitting real adb Root-caused the CI-only Coverage failure (unreproducible locally in isolation, reproducible 2/2 in the full CI run): every generic-route leaf this migration touches carries `androidBlockingDialogGuard: true`, and `dispatchGenericCommand` calls `ensureNoAndroidBlockingDialogReady` unconditionally for any `platform: 'android'` session reaching the real request router -- regardless of whether admission is fact-based or capability-based. That check calls `getAndroidBlockingDialogFocus`, which shells out to the real `adb` binary. orientation-runtime.test.ts's "request router joins..." test used a synthetic `platform: 'android'` device through `createRequestHandler` (the real router), without stubbing the platform ADB layer -- only the runtime gateway was mocked. On a host with a real `adb` binary (my machine) the subprocess fails fast and `allowFailure` tolerates it, costing ~800ms-1.1s but still succeeding. On a host with no `adb` binary at all (CI's Coverage job, a plain unit-test lane with no Android SDK) the spawn itself throws, which isn't something `allowFailure` catches, producing exactly the observed `ok: false` unsupported-operation response. back/home/tv-remote's equivalent router-join tests already use Apple/Vega devices, so they never reached this path. Switched orientation's fixture to match -- Apple, since the fixture's facts/execution are fully synthetic and platform-agnostic regardless. Also: renamed the widely-shared 'emulator-5554'/'ios-simulator' device-id literals in back/orientation/tv-remote/keyboard-runtime.test.ts to file-scoped ids. Device claims for a `local-family` owner binding hit the real on-disk `require-owner` claim file (keyed only by canonical device id), and 27+ pre-existing test files already share 'emulator-5554'; this migration added three more consumers of it under a `require-owner` policy that reaches real admission, which was worth eliminating as a source of doubt even though it wasn't the actual root cause here. * refactor: extract navigation/keyboard concepts into sibling modules; test the real Android dialog-guard path packages/platform-apple/src/runtime.ts and packages/provider-limrun/src/app-log-runtime.ts grew past the repo's 500-line extraction threshold. Move the new back/home/orientation/ tv-remote/keyboard facts and bindings into packages/platform-apple/src/navigation/runtime.ts (new sibling module, matching deployment/runtime.ts's existing pattern), and the new keyboard facts/bindings for limrun into the existing packages/provider-limrun/src/interaction-operations.ts (which already held the sibling navigation logic). Also fix orientation-runtime.test.ts's router-join test: it previously swapped its device fixture from Android to Apple to dodge the real adb-backed blocking-dialog guard, which masked the Android route that was actually failing in CI. Keep the Android fixture and stub getAndroidBlockingDialogFocus instead, the same seam request-router-android-modal.test.ts already uses. * refactor: adopt granular contracts subpaths for back/home/orientation/tv-remote/keyboard Following main's #1969 (facade granularization), give each of this branch's five new contract modules their own package.json entry subpath and move every value-importer (owner runtime packages, the daemon binders, and their tests) off the wide @agent-device/contracts/platform facade onto the specific module that owns the symbol — the same convention #1969 established for the rest of the vocabulary. Keeps this migration's files out of the contracts-entry-closure gate and out of the eager-evaluation cost #1969 measured for the daemon's permanent hubs (registry.ts, dispatch.ts). * refactor: shared navigation/keyboard binder table; dedupe keyboard admission; drop restated types Addresses the review's finding 1 (seven per-owner copies of the same "fact-keyed table of interactor binders" pattern) by extracting bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations into packages/contracts/src/interactor-operation-catalog.ts. Each owner now requests the subset of back/home/setOrientation/tvRemote/keyboard{Status, Dismiss,Enter} it admits, instead of hand-writing `facts.operations.<key>.available ? bind…(resolver) : {}` per operation. Applied across all seven call sites (apple, android, harmonyos, vega, linux, webdriver, limrun) and collapsed limrun's two separate bind functions (navigation, keyboard) into one shared call. Finding 3 (resolveBoundKeyboardRuntime copy-pastes admit-then-wrap three times): extracted a local admitKeyboardAction<...> helper mirroring resolveBoundGenericRuntime's admit-then-defer shape, so the three action branches (status/dismiss/enter) share one admission path. Finding 6 (execute* helpers hand-restate a contract that can drift): back/ home/orientation/tv-remote/keyboard's execute functions are now typed off `BoundDeviceRuntime<typeof xRuntimeUse>` (derived from the actual bind-use value) instead of a hand-written `Readonly<{ operations: Readonly<{...}> }>` shape. Also fixed provider-limrun's `RuntimeOperationUnavailability | { available: true }` restating RuntimeOperationFact by hand — folded away entirely once the bind functions it typed were removed. Finding 7 (naming/placement): platform-apple/runtime.ts's misleadingly-named `captureOperations` bucket (held deployment/network/recording/find, not just capture) collapsed into one flat `operations` object now that the navigation bucket is a single function call instead of six ternaries. Exported RuntimeAdmissionRequest from runtime-admission.ts (needed by the new keyboard admission helper). Added packages/contracts/src/ interactor-operation-catalog.test.ts for the new shared binder table. pnpm typecheck, check:fallow, check:layering, and the full unit-core suite (1010 files / 7513 tests, one known contention-flake excluded) are green. * refactor: split generic-mutating command traits from the legacy dispatch pair Addresses the review's finding 5: GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS bundled two orthogonal things (daemon/recording traits, and the legacy capability+dispatch pair migration strips), forcing every migrated descriptor to hand-expand the constant minus two fields plus an explanatory comment. Split into GENERIC_MUTATING_COMMAND_TRAITS (the shared daemon/recording traits) and LEGACY_LINUX_DEVICE_EXECUTION (the dispatch/capability pair). back/home/orientation/tv-remote (this migration) and focus (an earlier one, same pattern, previously a stale reference to the retired constant name) now spread the trait constant directly instead of hand-expanding it; the still-legacy `scroll` descriptor spreads both pieces, equivalent to the retired constant. pnpm typecheck, check:fallow, check:layering, and the registry/daemon test suites are green. * refactor: table-ify packages/contracts/src/keyboard-runtime.ts's three-way duplication Finding 3's second half: the three bindKeyboardX functions and six bindLocal/ProviderKeyboardXInteractor entry points differed only by method name and label string. Replaced with one generic bindKeyboardAction<Key> dispatching off the operation key (interactor[key], resolved from a small label table) plus two shared local/provider dispatch helpers the six named exports each call with their own key — collapsing three copies of the bind logic into one and six near-duplicate entry-point bodies into one line each, while keeping every exported name and type signature unchanged. pnpm typecheck, check:fallow, check:layering, and pnpm check:affected --run are green. * refactor: parameterize runSessionOrSelectorDispatch with an execute strategy Addresses the review's finding 2: handleKeyboardCommand re-implemented runSessionOrSelectorDispatch's orchestration step for step (session/selector guard, device resolve, ref-frame expiry, record) instead of reusing it, because the shared function had no seam for keyboard's bind-and-execute admission — only the legacy requireCommandSupported + dispatchCommand path. That left the shared orchestrator with one caller instead of two, and set a precedent that would fork a new copy for each of the 28 remaining session-route migrations. Gave runSessionOrSelectorDispatch an `execute` parameter: the orchestration (guard, resolve device, admit-then-execute, expire ref frame if mutating, derive and record next session) stays in one place, and callers supply their own admission/execution strategy. Extracted `legacySessionDispatchExecute` for the still-legacy capability-gate-then-dispatchCommand shape `handleTriggerAppEventCommand` (the remaining legacy caller) now passes explicitly, and `keyboardSessionExecute` for keyboard's bind-and-execute shape. Deleted the now-fully-redundant `executeBoundKeyboardCommand` — its result recording duplicated what the shared orchestrator's tail already does. pnpm typecheck, check:fallow, check:layering, the full daemon test suite (321 files / 2271 tests), and pnpm check:affected --run are green. * refactor: extract limrun facts-runtime.ts; discriminate KeyboardDismissResult by owner app-log-runtime.ts was still 589 lines after the shared-abstraction fixes; moves fact assembly (limrunAppLogFacts/limrunAppLogRecoveryFacts/limrunLifecycleFacts/deploymentOptions) to a new facts-runtime.ts and the shared device-identity predicate to device.ts, the leaf both files already depend on. app-log-runtime.ts is now 336 lines. KeyboardDismissResult was an 11-field optional bag with executeKeyboardDismiss separately re-deriving platform from the device and projecting subsets by hand. Each owner (android, apple, harmonyos) now tags its own result with a `kind` discriminant, so an owner can only ever produce its own shape, and the daemon derives the wire `platform` label from `kind` instead of guessing from the device a second time. Wire output is unchanged. * fix: expire ref frame before the mutating call, not after; extract session/selector dispatch; derive catalog operations from facts runSessionOrSelectorDispatch awaited execute(device, session) — which bundled admission and the mutating invocation together — before expiring the ref frame, so a rejecting or timed-out invocation left a stale frame active (ADR 0014 requires expiry immediately before the mutating call, with no success-only rollback). Split the execute thunk into `prepare` (admission only) + a deferred `execute` invocation, so the orchestrator can expire between them regardless of how the invocation resolves. Added a regression test proving the frame still expires when the invocation rejects. Extracted runSessionOrSelectorDispatch and its keyboard/trigger-app-event callers into a new session-selector-dispatch.ts, matching this file's own convention of one file per command-group (session.ts shrinks from 571 to well under its 500-line budget). bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations accepted both a facts object and a separately hand-maintained `operations` array naming the same keys — a second source of truth that could drift from what the facts actually admit. Removed the array; the binder now walks the fixed set of navigation operations and lets each owner's own facts decide what binds, exactly as before but with one source of truth. * style: reformat legacySessionDispatchExecute call in session-selector-dispatch.ts * refactor: derive catalog operation list from one canonical tuple; move keyboard orchestration tests NAVIGATION_INTERACTOR_OPERATIONS was declared as a plain readonly array independently of the NavigationInteractorOperation union it walked, so a future union member could compile without ever being added to the walk list, silently preventing an admitted fact from binding. Made the tuple the single canonical value: the union type is now derived from it via `(typeof TUPLE)[number]`, so LOCAL_BINDERS/PROVIDER_BINDERS' Record<NavigationInteractorOperation, ...> completeness is checked against the same tuple, not a separately hand-kept list. Added a regression test binding all seven operations at once to pin the runtime walk, independent of the type-level guarantee. Moved the four keyboard-orchestration tests (the two ADR 0014 ref-frame seam tests plus the two session/selector-guard tests) out of the mixed appstate/perf test file into a new session-selector-dispatch.test.ts, colocated with the file they exercise. Strengthened the rejection regression test to assert the frame is already expired from inside the rejecting keyboardDismiss callback itself, pinning the exact pre-invocation seam rather than only checking the end state after the dispatch settles. * fix: restore back/home/orientation/tv-remote/keyboard-runtime exports lost in rebase Rebasing onto origin/main dropped these five package.json export entries during conflict resolution (the granular-subpath commit's package.json changes silently lost during merge). Restored, confirmed by pnpm typecheck across all 17 workspace packages and the full unit-core suite (1023 files / 7581 tests). * test: pin the exact point the live iOS email field value goes missing Two prior CI runs on this PR saw the seeded email field ("ada@example") end up containing only a typed suffix (".test") by the time the flow reads it back at the end — after fill, keyboard dismiss, coordinate refocus, and type. Since this PR touches executeKeyboardDismiss's response shaping, the reviewer asked to disprove keyboard dismiss as the cause rather than assume the pre-existing dropped-keystroke flake pattern applies. Added two read-back checkpoints: right after seeding (before dismiss runs at all) and right after dismiss (before the coordinate refocus + type steps that follow). If both hold "ada@example", the loss happens during refocus/type, not dismiss — matching the documented flake, not a regression in this PR's diff. * refactor: make keyboard status/enter owner-discriminated too; trim review-round prose KeyboardStatusResult and KeyboardEnterResult were bare objects; executeKeyboardStatus and executeKeyboardEnter derived the wire platform label from device.platform via keyboardPlatformLabel, the same re-derivation already fixed for dismiss. Each owner now tags its own result with a kind (android's status/enter as 'ime-probe' and 'android-acknowledged', harmonyos's enter as 'harmonyos-acknowledged', apple's enter as 'visibility-echo'), and the daemon derives platform from a kind-keyed lookup table for all three actions. keyboardPlatformLabel and its isIosFamily import are gone — nothing derives platform from the device anymore. Android and HarmonyOS's enter acknowledgments are structurally identical (empty besides kind), so the discriminant alone — not result shape — is what tells the daemon which owner actually ran. Added a harmonyos enter test alongside the existing ios/android ones so all three owners are covered for both dismiss and enter's kind-to-platform mapping. Also trimmed several comments that narrated which PR review round motivated them down to just the durable invariant or rationale — the type shape, test names, and assertions already carry the proof. |
||
|
|
c4b1a6131e |
dx(test): opt-in worker-count override for solo local vitest runs (#1964)
* dx(test): opt-in worker-count override for solo local vitest runs resolveVitestMaxWorkers() caps local runs at 2 workers so parallel worktrees and spawn-heavy tests keep headroom, but a solo run that owns the machine pays 6x on a 12-core host for no benefit. Add AGENT_DEVICE_VITEST_MAX_WORKERS to opt in to a higher cap. It is clamped to os.cpus().length so a runaway value can't oversubscribe the host, and it is a no-op in CI (CI already derives its own worker count). A missing, blank, non-numeric, non-integer, or non-positive value falls through to the existing default cap rather than throwing. Default (unset) behavior is unchanged. Closes #1962 * docs: tighten the worker-override note to fit the agent-guidance budget docs/agents/testing.md sits at a 10,000-byte per-file ceiling enforced by check:agent-guidance, and the first phrasing pushed it to 10,065. Restate the override in one tighter bullet that leads with the "solo run only" caveat, which is the constraint a reader most needs. * fix(test): clamp the worker override with os.availableParallelism() Node documents cpus().length as unfit for sizing application parallelism: it ignores CPU affinity and cgroup limits, so it can report a pool wider than the process may actually use. Clamping against it would inflate the very ceiling this override's safety clamp exists to enforce. availableParallelism() honors those constraints, so the clamp now means what it claims on constrained hosts. Test updated to match. * test: keep the resolver cases in the already-included setup test Review feedback: a new test file beside the resolver, plus its entry in vitest.config.ts's unit-core include list, is a change to test discovery that the mutation lane's `vitest related` graph reads. Fold the override cases into src/__tests__/hermetic-env-setup.test.ts, which is already in the unit suite and already imports the resolver, and drop the config edit entirely so this PR no longer touches test discovery at all. Same six assertions, no coverage lost. |
||
|
|
6dd7d406c0 |
perf(android): keep the automation helper warm across fill and scroll (#1974)
* perf(android): keep the automation helper warm across fill and scroll Android permits one UiAutomation owner, so a command-scoped helper call stops the instrumentation session when it finishes and the next call pays a fresh `am instrument` start plus the UiAutomation connect wait. `fill` reads the live hierarchy four times per attempt (the pre-action target read plus the 0/150/350 ms settling samples) and every one of those reads was command-scoped, so a single fill ran four instrumentation lifecycles — seven on retry — and left `scroll` without a session for its viewport read. Thread the existing `daemon-session` scope, which app-backed sessions already select for snapshots and whose release session teardown already owns, through fill's captures and the gesture viewport read. The viewport read may now warm the session so it and the gesture that follows share one instrumentation instead of starting two. Snapshot capture and the viewport read build their capture options through one path, because the session identity is derived from them: two builders would restart each other's session instead of sharing it. Session teardown also stops force-stopping the runtime once the helper both acknowledged `quit` and its process exit was observed — that pair is the release evidence. Forced, timed-out, and aborted teardowns still force-stop, so a daemon that dies without sending `quit` cannot leave the helper squatting UiAutomation. The settling samples still capture separately; only the session is shared. * fix(android): reset the helper runtime on content-failure retirement REVIEW.md F1. Gating the teardown force-stop on confirmed release also changed what `stopAndroidSnapshotHelperSession` returning `true` means to its callers. `retireAndroidSnapshotHelperAfterContentFailure` reads that return as "the session stop was the runtime reset" and skips `resetAndroidSnapshotHelperRuntime` when it is true, so a helper whose output failed content validation three times over a daemon session was retired with no `am force-stop` at all, while the one-shot arm of the same recovery still reset the runtime. The session stop now takes `resetRuntime`, and the retirement passes it. Content failure is a recovery path, not a clean release: the helper answered with output we could not trust, so the next capture must meet a runtime that was reset, and "it quit politely" is not a reason to leave a suspect process owning it. Genuine session close still skips the round trip, which is the optimization this branch exists for. The pre-existing test "content failure retirement does not layer a second reset over a persistent session stop" rested on a premise this branch had already made false — stopping a live session was no longer the runtime reset. It is renamed to "makes the session stop reset the runtime instead of layering a second one" and asserts the requirement it now depends on, so its title states what it enforces. The reviewer's probe returns as a regression test in `snapshot.test.ts`. REVIEW.md F2. `observeAndroidSnapshotHelperProcessExit` returns an observation that knows whether the end it saw is release evidence: the process must have been alive when the teardown started watching and must then exit with code 0 and no terminating signal. A signal, a non-zero code, and a host child that was already gone before `quit` was sent all mean the transport died — and in the pre-died case the acknowledgement is positive evidence that the device-side helper OUTLIVED its host through the open forward. None of them confirm release now. What is left is declared at the gate: host-side exit codes are only as strong as adb's exit forwarding, and a device without shell protocol v2 can report 0 for an instrumentation that did not finish. REVIEW.md F3. `&& graceful.exited` survived deletion against all 423 tests. Two named tests now fail without it, both cheap because the stricter evidence above makes acknowledged-but-not-released reachable without waiting out the 11 s graceful-exit timeout. Gates: `pnpm vitest run src/platforms/android` (44 files, 423 tests) and with `src/core/interactors` (426 tests), `pnpm typecheck`, `pnpm lint`, `pnpm format`, `pnpm check:fallow --base origin/main` (24 changed files, clean). No emulator is attached in this worktree, so the teardown-path force-stop counts were not re-observed live. * test: lower the snapshot.test.ts ratchet pin to 1,495 (PR #1974 CI) This branch extracted the helper-session, retirement, and touch-helper cases out of src/platforms/android/__tests__/snapshot.test.ts into sibling files, taking it from 1,658 to 1,495 lines. The test-file size ratchet fails an un-banked shrink ("lower its pin in this PR so the ratchet keeps the gain"), so bank it. The pin only moves down; nothing grew into it. * refactor(android): own helper-session lifecycle and text input in their own modules PR #1974 review, blocker 1. Both files the branch grew were already at the extraction threshold, so the behavior landed on top of a boundary that should have moved first. `snapshot-helper-session.ts` (563) carried two concepts: who owns the device's UiAutomation, and what runs over that ownership. Ownership — the live-session registry, the enable gate, session identity, start, reuse, and retirement — moves to `snapshot-helper-session-lifecycle.ts`. What is left is 170 lines of snapshot capture and touch commands that acquire through it and never reach the registry themselves; the touch path reads ownership through `getLiveAndroidSnapshotHelperSession` instead of the map. The stale re-export block for the retirement symbols is gone with its last importer. `input-actions.ts` (508, pre-branch 501) sheds every text-entry path to `text-input.ts`: provider injection, the test IME, the adb-shell writer, and the fill orchestration this branch changed. What is left is 172 lines of pointer, key, and gesture actions — below the pre-branch length. Tests follow their owners. `snapshot-helper-session.test.ts` splits around the same seam into a lifecycle file (start, reuse, identity, teardown, quarantine) and a capture file (budgets, cancellation, fallback), and its session fake becomes a named export of the sibling fixture module, which drops the duplicated process double. `input-actions-fill.test.ts` and `input-actions-test-ime.test.ts` are renamed to `text-input-*`, and the `typeAndroid`/`fillAndroid` cases in `input-actions.test.ts` move to a new `text-input.test.ts`. Blocker 2. The teardown skipped `am force-stop` on an acknowledged quit plus a clean host exit, and its own comment admitted the hole: adb without shell protocol v2 exits 0 whenever the connection closed cleanly, including for instrumentation that never finished, so that exit status was never proof the device released UiAutomation. A residual-risk paragraph is not an invariant. `adb-shell-protocol.ts` asks the transport instead. `adb features` lists only the features both ends negotiated, so `shell_v2` there is positive proof that a host `adb shell` child's exit status is the device command's. The skip now requires it; an unsupported transport, a failed probe, and an adb too old to know `features` all keep the device-side stop. The probe is host-side and cached per device, so it costs one query per device rather than one per teardown, and it clears with the session registry. Quarantine keys on the same proven-release signal, so an unproven quit whose stop also failed now reports unknown ownership instead of trusting the quit. Red first: a transport without `shell_v2` and an unanswerable probe both skipped the stop against the pre-fix gate, and the cache fence saw no probe at all. Rebased onto origin/main, which had moved the fill files. |