mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
v0.21.0
1829 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80997b6bf1 |
fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal Two CI flake families on main and PRs since 2026-09-03. iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the runner's stampedSnapshotPayload set `truncated: true` on every non-healthy capture, so a complete private-AX tree taken while the XCTest channel was penalized as slow (the normal state on a loaded CI host) was reported as truncated. Nothing consumed that until the strict absence assertion (#2245) refused truncated captures. `truncated` now tracks completeness only: payload truncation, a depth-limited capture, or a sparse terminal payload. The E2E conformance helper asserted the old conflation and now asserts `truncated === false`; a runner unit test pins the new contract and joins the targeted list in ios.yml. Android Smoke, `get text id="automation-alert-result"` selector miss (5 of 5 failures): #2260 replaced a polling wait with a one-shot read right after `alert dismiss`, and Android's `alert accept|dismiss` returned as soon as the button was pressed, while the dialog window was still the only thing in the accessibility tree. They now poll until the same dialog is gone (a different alert taking its place counts as dismissed), bounded by the existing action budget, else fail with "did not dismiss the visible alert" like the iOS runner already does. * test(provider): model Android dialogs that leave the tree after the alert action The scripted Android alert scenarios served the same dialog to every capture, which encoded the old return-after-press behavior; alert accept/dismiss now confirm the dialog is gone, so a dialog that never leaves is the failure it should be (covered by a new scenario). The fixtures now hide the dialog once its button is tapped or Back is sent, the way the ANR recovery scenario already did. * test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step The Android smoke still missed `id="automation-alert-result"` on CI right after a confirmed dismissal: the daemon opened a fresh helper session for that read and its 2s capture had no such node, while the same one-shot read passes locally in 150ms. The fixture's re-render after the button callback is app timing, so the scenario waits for the outcome text (the polling landmark #2260 removed) and then pins it to the canary element. The harness kept only a screenshot, and only for wait timeouts, so the tree that produced a selector miss was never in the artifacts. Every unexpected step failure now writes failed-step-N.png and failed-step-N-snapshot.json next to failed-step.txt. * test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle The test-file size ratchet rejects growth in android-lifecycle.test.ts (1,597 lines at the merge-base), and the dialog re-check work added a scenario there. The alert scenarios now live in android-alert.test.ts and the scripted dialog surfaces they share with the ANR scenarios in android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines. |
||
|
|
4b7c561d1e |
refactor(commands): author each executable command once in its facet (#2313)
* refactor(commands): let the facet derive its executable definition * refactor(commands): author interaction commands once * refactor(commands): author system commands once * refactor(commands): author capture commands once * refactor(commands): author management commands once * refactor(commands): author observability commands once * refactor(commands): author recording commands once * refactor(commands): author replay commands once * refactor(commands): author debugging commands once * refactor(commands): author react-native commands once * refactor(commands): author metro commands once * refactor(commands): author batch commands once * refactor(commands): author perf commands once * refactor(commands): state the facet type docs in one line each * chore(gates): scan facets, not executables, for provider coverage The integration-progress scanner mapped client calls to command names by finding defineExecutableCommand blocks. Those are gone; a facet's run callback now holds the call, and its name may be a module constant. |
||
|
|
cf83afb9c9 |
feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge * fix(ios): preserve snapshot fallback lineage * fix(ios): keep regular depth in presentation * perf(ios): reuse process-verified snapshot targets * fix(ios): refuse snapshots beneath another foreground owner * test(ios): bound native setup and isolate runner reset * test(ios): synchronize helper crashes with request dispatch * test(ios): exercise foreground guards through native capture * chore(gates): run native snapshot ownership regression on iOS CI |
||
|
|
f1d4efe059 |
refactor(daemon): inline the touch sub-switch into the interaction dispatcher (#2302)
* refactor(daemon): inline the touch sub-switch into the interaction dispatcher interaction-touch.ts was a 31-line pass-through re-switching on press/click/longpress/hover/fill only to call the same five handlers handleInteractionCommands already imports transitively. Inline the five cases into the existing switch and delete the file, dropping one hop from the press/click/longpress/hover/fill dispatch path. Point the response-construction-guard sentinel at interaction-touch-press.ts (a file that still exists) so it keeps watching for a hand-rolled responseData branch across the touch handler set. * fix(daemon): close ADR-0011 guard gap and refresh the stale hop trace PR review findings on #2302: - The ADR-0011 responseData guard scanned files by an interaction-touch*.ts name prefix, which stopped covering the touch dispatch switch once it moved into interaction.ts. Added a targeted second check that extracts the press/click/longpress/hover/fill case bodies from interaction.ts and scans them for hand-rolled responseData, without widening the whole-file scan onto interaction.ts (which also hosts the unrelated `type` command's legitimate local responseData construction). Planted red: reverted one switch case to a hand-rolled responseData construction twice (press, then click) and reran `npx vitest run src/daemon/interaction/internal/__tests__/interaction-response-construction-guard.test.ts`; both times the new test failed with the expected "Hand-rolled interaction responseData found in the touch dispatch switch" message, then passed clean again after reverting. - docs/adr/0019-end-state-hop-trace.md pinned itself to a commit and claimed the press/Android route was untouched by this PR, but the PR's whole point is merging two of that table's traced files (interaction.ts, interaction-touch.ts). Merged the corresponding table rows, renumbered the remaining hops, updated the file/class summary counts and every downstream hop-range reference in the same doc, and did the same for the one line in docs/adr/0019-request-bound-platform-runtime.md that also cited the now-stale 24-hop figure. * test(daemon): hold the touch dispatch switch to structural delegation The ADR-0011 guard extracted each touch case body with a regex that stopped at the first nested case/default, so a hand-rolled responseData placed after a nested switch was invisible. Parse interaction.ts with oxc-parser instead and require every press/click/longpress/hover/fill case to be exactly one return await <handler>(...) where the handler is imported from an interaction-touch*.ts module. Inline planted-red cases cover the nested-switch shape, an inline literal, a foreign callee, and a missing command. * style(daemon): oxfmt the interaction response construction guard * test(daemon): fold the guard's delegation check under the complexity threshold |
||
|
|
0c8227e9b7 |
refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly
The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.
Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.
Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.
Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.
* chore(gates): drop the retired app-inventory/app-state host allowances
The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.
* refactor(runtime): construct the Android runtime module with its adb host binding
The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
|
||
|
|
ba6c818d81 |
spike(daemon): give the ADR-0014 ref frame private ownership (#2296)
* refactor(daemon): make the ADR 0014 ref frame one owned value
The four `refFrame*` fields on `SessionState` were policed only by the R7
ownership table: any daemon module could write them, and only a full-graph AST
scan could say whose write it was. They are now one `RefFrame` value whose brand
key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot
construct one and cannot edit the one a session holds; the transitions replace it
whole. Every transition, rejection reason and epoch rule is unchanged.
Readers moved to the accessors ref-frame.ts exports (`refFrameState`,
`refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`).
`internal-observation.ts` drops its four-field lineage copy and its field-by-field
comparison: frame identity is now one `===`.
Seen red: with the empty-result early return removed from
`markSessionPartialRefsIssued`, the new frame-identity assertion in
session-snapshot.test.ts fails; restored, it passes. A planted foreign writer
module was rejected by tsc (TS2741 missing brand, TS2540 read-only property)
before deletion.
* docs(depgraph): note the ref frame outgrew its R7 row
* refactor(daemon): make the ref frame nominal, not symbol-branded
A symbol brand on a plain object type stops construction from nothing, but not
`{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key,
so any daemon module could mint an incoherent frame (active state, stale tree)
out of a coherent one and it type-checked. Proven before the fix with a throwaway
module doing exactly that write: tsc reported nothing.
The frame is now a class with `#`-private fields behind getters. That makes the
type nominal, so no object literal is assignable to it — the same probe now fails
with TS2739 (`missing #fields, scope, generation, expired`). Construction stays
inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field
doc, and the two in the R7 owner table) now say what the type does and does not
judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays.
Expiry is idempotent by identity again. `expired()` returns THIS frame when the
frame is already expired, rather than an equal copy, which is what the lineage
check in internal-observation.ts compares with `===`. Seen red: with that early
return removed, the tightened ref-frame test fails with "Values have same
structure but are not reference-equal"; green with it.
Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame
activation again, instead of leaning on the pre-frame snapshotGeneration
fallback, and a find test drops a `?? []` that can no longer be reached.
Behavior is unchanged: same frame contents, same transitions, same admission.
* chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value
R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and
`refFrameGeneration` as four fields that had to be written together by one
module; the code now carries them as one nominal value, so the table carries one
row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19.
The row itself stays. The type stops construction, editing and spread-derivation
of a frame outside ref-frame.ts, but it cannot judge a whole frame moved
unchanged — clearing the field, or assigning another session's frame — and the
table can. The comments say that rather than claiming full enforcement.
Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails
R7 with "owned by src/daemon/ref-frame.ts"; green once reverted.
* style: apply oxfmt
* refactor(daemon): keep ref-frame expiry module-private
`RefFrame` exposed a public `expired()` method, so any module holding a
frame could derive a new valid one and install it through a reconstructed
session record, past the R7 field scan. Expiry is now a static on the
unexported class, reachable only inside ref-frame.ts; the frame's surface is
four getters. A type-level regression pins that no outside module can
construct, spread, edit, or derive a frame (tsc covers src tests, so a
directive that stops erroring fails typecheck).
* test(daemon): hold the three accessor migrations within the size ratchet
Each file grew by exactly its new ref-frame import; one blank line between
mock blocks goes so the files stay at their merge-base length.
|
||
|
|
96727a0b42 |
fix(apple-runner): never compare an unavailable toolchain probe; name the mismatching cache keys (#2306)
* fix(apple-runner): never compare an unavailable toolchain probe A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used to fall back to the literal `unknown`, which was memoized for the process and then persisted into the rebuilt cache's metadata, so every later daemon on a healthy host mismatched again and paid a full build-for-testing. Unavailability is now a distinct outcome with no comparable value: only successful probes are memoized, an unreadable toolchain fails the cache decision with a retriable typed error naming the probe that could not answer, and the CI metadata writer refuses to persist a probe it could not read. The cache_metadata_mismatch diagnostic now lists the differing keys with expected and actual values instead of only saying the metadata differed. The runner-source fingerprint moves to the module that owns the runner's source roots, keeping the cache-metadata module within its size budget without adding a module to the Apple facades' eager closure. * refactor(apple-runner): home the fingerprint tests and the rebuild-decision glue Tests mirror source topology: the runner-source fingerprint tests move with the function into runner-source.test.ts and call it directly instead of reaching it through resolveExpectedRunnerCacheMetadata. The rebuild diagnostic's mismatch details move next to the cache state that carries them, so runner-artifact.ts — already past the 500-line extract threshold — gains no behavior. * fix(apple-runner): memoize only a parsed toolchain fingerprint runToolchainProbe cached every nonempty zero-exit answer before parseXcodeVersionOutput could classify it, so a transient malformed xcodebuild answer stayed cached and every later cache decision in the process kept failing after the host recovered. The memo now holds the complete parsed fingerprint per SDK, written only after all three probes answered and parsed; a failed round keeps nothing, so the next request re-probes. Tests cover malformed-to-healthy recovery in one process without resetting the memo, and that a partial round is not kept. * style(apple-runner): oxfmt the cache-metadata module and its tests |
||
|
|
51bed41381 |
test(contracts): one typed conformance helper for the runtime-family suites (#2301)
* test(contracts): one typed conformance helper for the runtime-family suites Extracts the shared binding-rule mechanics that ten *-runtime.test.ts files repeated by copy into interactor-operation-conformance.fixtures.ts. Each module keeps its own expected table and calls the helper with it; the helper owns no expectations. * chore(gates): add cross-module completeness test for interactor operation conformance Asserts both directions against the production registry: every catalog operation has exactly one conformance table naming it, and every named operation is registered in the catalog. * docs(contracts): note the interactor conformance gate's coordinated-deletion blind spot Review on PR #2301 (P2): the two-direction completeness check in interactor-operation-conformance.test.ts can't catch a catalog row and its conformance rows being deleted together in one change — both sets shrink in lockstep and neither assertion trips. No behavior change; documents the limitation for a future maintainer who wants to close it with an independent count. * test(contracts): drive interactor-operation conformance from the catalog itself The completeness gate read raw test sources and regex-matched operation literals, so a commented-out row still counted as conformed while nothing executed it. The three binding rules now live in one test file that walks INTERACTOR_OPERATIONS and binds through each catalog row's own bind and label, with the per-operation expectations (interactor method, minimal input) in a table typed over every catalog operation. A missing, duplicated or unregistered row fails typecheck and the table-vs-catalog test; every row is executed by construction. The conformance fixture and the per-facet conformInteractorOperations blocks are gone; each facet test keeps only its dedicated tests. |
||
|
|
7a2d48d160 |
perf: bundle runtime dependencies and report full install size (#2310)
* perf: bundle runtime dependencies and report full install size * refactor: remove unused size report breakdowns |
||
|
|
9d9e93a321 | ci: avoid unrelated Apple runner cache invalidation (#2303) | ||
|
|
2dabe29b14 |
fix(web): preserve the backend ref so snapshot refs match actionable refs (#2283)
* fix(web): preserve the backend ref so snapshot refs match actionable refs
The web/agent-browser backend mints refs in tree order and skips
non-interactive nodes, so its `@eN` refs are not dense. agent-device was
dropping that ref in `normalizeAgentBrowserSnapshot` and then re-minting a
dense positional `e${index+1}` in `attachRefs`. The ref an agent reads off
the snapshot (dense, positional) therefore did not equal the ref the backend
resolves on the next action (tree-ordered). On the ShopDemo login screen the
username textbox displayed as one ref while the backend's ref for the same
position pointed at the passcode field, so `fill @e3` landed in the wrong
input.
Preserve the backend ref on each web node and make `attachRefs` keep a
node's existing `ref` when present, falling back to dense numbering for
backends that do not mint refs (iOS/Android/maestro are unaffected).
* test(web): cover non-dense refs through routed actions
|
||
|
|
c82c5840f5 |
test(daemon): session-open-url-prewarm through the request seam (#2304)
* test(daemon): session-open-url-prewarm through the request seam Rewrites session-open-url-prewarm.test.ts (16 open/prepare scenarios covering URL bundle-id inference and iOS runner prewarm timing) to drive through the production request router (createRequestHandler + lifecycleDeviceRuntimeGateway, the seam request-router-open.test.ts and the #2262 session-relaunch-close rewrite already use) instead of calling the daemon's session handler directly through the bypassed session-test-harness.ts. Every scenario title and observable assertion (response payload, session-store state, dispatch context, prewarm call order, prepare timing/response data) is unchanged; only the entry point and mock surface moved. The file no longer imports session-test-harness.ts or session-command-harness.ts (both still back their other consumers, untouched). It keeps only the module mocks these open/prepare scenarios actually reach: platform-apple runner operations, app-resolution, macos, device-ready, and the Android open-target shim. logPath assertions now match the real session-scoped runner.log path the router computes (rather than the harness's single hardcoded daemon.log stand-in), and the blocked-prewarm error assertion reads the top-level normalized error.hint field instead of a details.hint that this seam never carried. * test(daemon): restore dropped bind/inspectFacts assertions in prepare ios-runner tests PR #2304 review found the router-seam rewrite silently dropped two assertions the original suite had: that `prepare ios-runner` inspects facts and binds exactly once on an explicit iOS selector, and that it inspects facts once but never binds before rejecting a non-Apple device. The shared `lifecycleDeviceRuntimeGateway` fixture is frozen and reused across many suites, so it can't be spied on in place. Add `createLifecycleDeviceRuntimeGatewaySpies()` to test-device-runtime-gateway.ts: a fresh, unfrozen gateway whose inspectFacts/bind are vi.fn() spies backed by the same binding logic, scoped per test so no call counts leak across files. Use it in the two affected tests to reassert the original call-count expectations. Planted red: flipped each restored assertion's expected count/call and confirmed the test failed, then reverted. Command: npx vitest run --project unit-core -t "prepare ios-runner starts the XCTest runner on an explicit iOS selector" src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts Message: AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times Command: npx vitest run --project unit-core -t "prepare ios-runner rejects non-Apple runner devices" src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts Message: AssertionError: expected "vi.fn()" to be called at least once |
||
|
|
7aa0b8312d |
test(daemon): session-devices-batch-runtime through the request seam (#2305)
Rewrites session-devices-batch-runtime.test.ts to drive each of its 14 scenarios through the production handler its own subject actually owns instead of the bypassed handlers/__tests__/session-test-harness.ts: handleSessionInventoryCommands for the five `devices` scenarios, runBatchCommands for the seven `batch` scenarios, handleSessionCommands (the facts/facets harness, unchanged) for the materialized-close scenario, and handleReleaseMaterializedPathsCommand directly for release_materialized_paths. Every scenario title and observable assertion (response payload, error code/details, claimedBy projection) is unchanged. The file no longer imports session-test-harness.ts, so it drops the 10 module-level vi.mock calls that harness pulled in as a side effect (core/dispatch-resolve.ts, the Apple runner/app-resolution/macos mocks, Android mechanics, host-kit/command, platform-runtime-*) that none of these devices/batch/close scenarios ever reach. It keeps one narrow, locally-scoped vi.mock of materialized-path-registry.ts's cleanupRetainedMaterializedPathsForSession, the one production dependency the close teardown path imports directly rather than through inspectFacts/bindDevice. |
||
|
|
006f2d9f60 |
chore(gates): layering baselines ratchet against merge-base (#2299)
* refactor(layering): ratchet R6, R9 and R10 against the merge-base tree R6 type-spine inversions, R9's largest type cycle and R10's R7 ownership pressure now compare the working tree with the same measurement taken over the merge-base with origin/main, read through the shared committed-tree reader (one git ls-tree, one git cat-file --batch, no second checkout). Growth still fails with the same message shape, a shrink needs no edit, and no change can bank headroom by leaving a number above the tree. R9's per-zone check gains membership from the reference, so the overflow message names the file that joined instead of listing the whole zone. * chore(gates): delete the R6, R9 and R10 pins the merge-base now supplies TYPE_INVERSION_BASELINE, LARGEST_TYPE_CYCLE_ZONE_CEILINGS, TYPE_CYCLE_BASELINE and DAEMON_MODULARITY_BASELINE.sessionState were the hand-edited references these three ratchets compared against. The merge-base measurement replaces them, so there is no number left to leave above the tree and no entry to raise. externalDaemonTypesImporters stays: it names files, not a count. |
||
|
|
17e0c8688f |
test(daemon): one typed conformance helper for the daemon runtime suites (#2298)
Extracts the shared exact-owner-unavailable admission mechanics repeated across back/home/app-switcher/focus/viewport/orientation/tv-remote/type/diff runtime test suites into runtime-binding-conformance.ts. Each suite keeps its own expected table (device, unavailable fact, and any owner-specific refusal wording); a new cross-module completeness test checks the shared table against the command registry's runtime-use declarations in both directions, including the newly-registered swipe command. |
||
|
|
a04b587993 |
chore(layering): derive the contracts export inventory from package.json (#2297)
* chore(gates): derive the contracts export inventory from package.json R11's package-boundaries test pinned every packages/contracts export subpath by hand (~115 entries, compared with deepEqual), so every merge that touched packages/contracts/package.json also had to hand-edit the pin. Replace it with a structural check derived from the manifest itself: every exports target must resolve to an existing, tracked source file, and the number of resolved targets must equal Object.keys(manifest.exports).length. The must-not-resolve negative list (Node resolution enforces the exports map at runtime) is untouched. * chore(gates): restore exact-membership for the contracts export surface assertExportTargetsMatchManifest's two checks (targets resolve to tracked files, count equals manifest.exports keys) both read from the same manifest.exports object, so a subpath added or removed moves both sides together and the equality holds regardless -- the widen/shrink guarantee the deleted ~120-line CONTRACT_EXPORTS pin gave was silently gone (#2297 review). Add scripts/layering/contracts-exports.snapshot.json, an independently committed baseline regenerated by the new generate-contracts-exports-snapshot.ts, and deepEqual the live export specifiers against it. A subpath change now fails until the contributor reruns the generator and reviews the snapshot diff -- "run a script and commit its output" instead of hand-retyping an alphabetized array, so the original maintenance-burden goal holds too. Planted red: widened packages/contracts/package.json#exports with a throwaway "./planted-red-widen" subpath, then separately deleted the "./wait" subpath; `node --test --experimental-strip-types scripts/layering/package-boundaries.test.ts` failed both times on the new deepEqual with the regenerate-and-review message. Reverted before committing. |
||
|
|
a90d95aab7 |
perf: bundle tar-stream to reduce install footprint (#2286)
* perf: bundle tar-stream to reduce install footprint * fix: align bundled archive dependencies and simplify config |
||
|
|
0da105e3c3 |
docs: simplify agent context and resolve conflicting guidance (#2287)
* docs: clarify agent task scope and validation * docs: remove redundant and conflicting agent guidance |
||
|
|
35362fe517 |
refactor(cli): let help resolve command aliases itself and retire R12 (#2293)
* refactor(cli): let cli-help resolve the --help alias itself bin.ts's --help fast path composed buildCommandUsageText(normalizeCliCommandAlias(helpTarget)) inline, which let a future edit call buildCommandUsageText raw without anyone noticing until an alias's help silently dropped back to a full CLI bootstrap (the regression #1641 fixed). Move the composition into cli-schema/cli-help.ts as resolveHelpTargetUsageText, so bin.ts just calls one function that owns its own alias normalization; bin.ts no longer imports the alias registry at all. Retargets cli-help-alias-fast-path.test.ts at the new function (same three cases) and adds a process-level smoke test asserting `tap --help`/`launch --help` stdout is byte-identical to `press --help`/`open --help`. Seen red by temporarily removing the `tap` alias from CLI_COMMAND_ALIASES (both fast and slow paths lose the alias, producing an "Unknown command: tap" mismatch); green again after restoring it. Verified manually: `node --experimental-strip-types src/bin.ts tap --help` stays byte-identical to `press --help`, and `launch --help` to `open --help`; `rotate --help` still falls through to the retired-command error. * chore(gates): retire R12 now that cli-help owns its own alias resolution bin.ts can no longer compose buildCommandUsageText and normalizeCliCommandAlias incorrectly because it doesn't hold either import any more — resolveHelpTargetUsageText in cli-schema/cli-help.ts is the only call site, and cli-help-alias-fast-path.test.ts plus the new smoke-cli process test pin it. The static R12 checker existed only to prove that composition from source text; delete it along with its rule wiring in check.ts (rule function, import, LAYERING_RULE_IDS/LAYERING_RULES entries, header comment, summary string). Drops scripts/layering/bin-alias-fast-path.ts (352 lines) and its test (311 lines). Updates the two stale references left behind: record-runtime-mechanics-policy.ts's comparison to R12's "delegate to your single owner" shape, and check-wiring.test.ts's header, which named bin-alias-fast-path.test.ts as the seam it protects. rule-ids.ts discovers rule ids by scanning source text rather than a hand-maintained list, so no entry there needed updating. Verified: pnpm check:layering green (175/175), including check-wiring.test.ts and rule-ids.test.ts; pnpm check:quick (lint + typecheck) clean; scripts/__tests__/eager-closure-budgets.test.ts (418/418) unaffected, since neither bin.ts nor cli-help.ts sits in any HUB_ENTRY_FILES or facade closure — both files reach cli-help.ts only through a dynamic import. * test(cli): pin the alias help fast path with a coverage-based oracle The byte-identical stdout test cannot fail when the fast path is bypassed: src/cli.ts's slow path resolves the same alias and writes the identical string, so a reintroduced hand-written table in bin.ts (the exact shape of #1641) would still pass it. Add a second process-level test that runs `tap`/`launch --help` and `rotate --help` with NODE_V8_COVERAGE set and reads the subprocess's own coverage report for src/cli/process-entry.ts, the one module runCli's slow path loads and the fast path never does. Seen red: forcing the fast path to always fall through to runCli (simulating the reintroduced-table bug) failed this test (bootstrappedFullCli true where false was expected) while the byte-identical test stayed green; reverted and confirmed both green. * test(cli): restore an independent oracle for alias help parity The canonical side of "alias help output matches its canonical command" also called resolveHelpTargetUsageText, so the assertion became self-consistency: a degenerate normalizer that maps every input to one canonical command would make aliasHelp and canonicalHelp equal for every case. Compare resolveHelpTargetUsageText(alias) against buildCommandUsageText(canonical) (no alias normalization on the canonical side) instead, restoring the original two-source oracle. Seen red: pointing resolveHelpTargetUsageText at a degenerate `return buildCommandUsageText('press')` failed this test ("launch --help" no longer byte-identical to "open --help"); reverted and confirmed green. * refactor(mcp): route the help tool through resolveHelpTargetUsageText server-guide.ts's help tool composed buildCommandUsageText(normalizeCliCommandAlias(topic)) inline, the same composition bin.ts held before this PR moved it into cli-help.ts. That left a second hand-written call site the R12 gate's own kill criterion said had to be gone before retirement was moot. Call resolveHelpTargetUsageText(topic) instead; behavior is unchanged (manually confirmed tap/press and rotate topics still match) since it's the same composition, and no closure/layering change since server-guide.ts already imports cli-help.ts statically. * style: apply oxfmt * test(cli): prove the help fast path for every registered alias * refactor(cli): make the process entry importable and test it directly bin.ts ran its dispatch at import time, so the only way to prove that an alias --help never loads the full CLI was to spawn the process under NODE_V8_COVERAGE and grep the report for process-entry.ts. That oracle needed a paragraph to justify; the code was wrong, not the comment. The dispatch now lives in src/cli/entry.ts as runEntry(argv, modules, io), with the five lazy imports injected by bin.ts. entry.test.ts drives it with recording loaders and the real help module: every registry alias prints its canonical help with only the help module loaded, an unknown topic falls through to the CLI loader, --version, bare usage, mcp, and startup failures each have one case. The subprocess coverage machinery, the alias table pin, and the multi-line comments are gone; the smoke test keeps one registry-derived byte-identical alias --help check against the real bin.ts. Seen red: hand-routing long-press and relaunch to the CLI loader inside entry.ts failed "every registered alias prints its canonical help without loading the CLI"; restored. |
||
|
|
d1b9914d88 |
refactor(commands): retire the navigation-only type projection (#2294)
* refactor(commands): retire the navigation-only type projection `commands/system/navigation-projection.ts` built the five navigation client methods out of a phantom-typed registry: a `unique symbol` brand carrying Options/Result/required-ness, two conditional types to read them back, and a mapped type keyed on `clientMethod`. Nothing else ever used the concept, so the machinery existed to derive five signatures that fit in five lines. Those five now say what they mean. `BackCommandOptions`, `HomeCommandOptions`, `OrientationCommandOptions`, `AppSwitcherCommandOptions` and `TvRemoteCommandOptions` join their siblings in `packages/contracts/src/client-system.ts`, and `AgentDeviceCommandClient` declares all 14 methods in one object type. `back` keeps the `--settle` triple (#1638), and `orientation`/`tv-remote` keep their required options parameter. The five MCP output schemas move to `mcp/command-output-schemas.ts` beside the other handwritten ones, byte-identical. With the projection gone, `defineExecutableCommand`'s third overload, `ExecutableCommandProjection`, `AnyCommandDefinition.projection`, `ProjectedCommandOutputSchemas`/`projectCommandOutputSchemas` and the family's `clientCommandMethods` table have no users either. Removing the table also removes the `as unknown as` cast the client used to build eight system methods from it; the client now writes all eight out, typed. That closes the `commands/system` -> `client` inversion the client-types header called the one remaining one. Public API: the five method signatures are unchanged (structural comparison of the built `dist/src/index.d.ts` before and after: empty diff). `HomeCommandOptions` is a new published name for the shape `home` already took. Tests seen red before green: - `src/__tests__/client-system-commands.test.ts` (new): wired `home` to the `app-switcher` daemon command, saw it fail, restored. - `src/mcp/__tests__/command-tools.test.ts`: dropped `durationMs` from the inlined `tv-remote` schema, saw the dispatch-shape assertion fail, restored. - `src/commands/system/index.test.ts`: made `home`'s options parameter required, saw `expectTypeOf` fail under `pnpm typecheck`, restored. * test(mcp): pin the closed top-level shape of the navigation output schemas Retiring the projection replaced an identity assert (`schema === projection.outputSchema`) with a deep-equal over properties/required, which no longer rejected an extra top-level key such as a stray `description` or `additionalProperties`. The loop now also asserts the key set is exactly type/properties/required, so the closed shape is pinned by a test again rather than by object identity. Seen red once by giving the `app-switcher` schema a description argument, which adds a top-level `description` key: the new assert failed with `+ "description"`. Green after removing it. The `deriveSettleObservationSchemas` docstring cited that deleted identity assert as the reason for copying. The press/click shared-object half is the real reason and is all that remains. * chore(gates): drop the retired projection from the R6 inversion rationale The R6 baseline numbers are unchanged (5 inversions, commands -> client still 3): retiring the projection removed a client -> commands edge, which the ratchet does not count. What changed is the ARGUMENT next to those numbers. The commands/mcp -> client bullet justified itself with a zone-level cycle (client-types.ts imported ProjectedNavigationCommandClient back out of commands/system/); that cycle no longer exists, so the bullet now rests only on the port argument that was always the second half of it. docs/dependency-graph-findings.md §0/§0b/§1 carried the same claim and the same 'move the navigation-projection types out of commands/' follow-up, now recorded as answered by deletion. The blocked-shapes table in §1 now reads eight-at-the-time / three-still-blocked, matching the struck navigation row directly under it. * test(mcp): split the navigation schema tests out of command-tools.test.ts |
||
|
|
54df6f3f4b | feat(runtime): route managed leases through contained transports (#2285) | ||
|
|
fd221b15e1 |
refactor(contracts): build unavailable runtime facts once (#2291)
* refactor(contracts): build unavailable runtime facts from one policy table freezeUnavailableFacts() classified each of the 34 UnavailablePlatformRuntimeFacts cells with its own Object.freeze call and its own inline comment, and createUnavailablePlatformRuntimeFacts() destructured all 38 fields off the result before spreading them into the operations map. Replace the per-field freezes with a single UNAVAILABLE_CELL_POLICY table (satisfies Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>) and a loop, so a new cell added to UnavailablePlatformRuntimeFacts must get a policy entry or the file fails to type-check. The scattered per-field comments collapse into one block comment above the table citing #1873. Drop the 39-line destructure; createUnavailablePlatformRuntimeFacts now reads the frozen record's fields directly. Add createFullyUnavailablePlatformRuntimeFacts(), an exported constructor that points every cell (lifecycle included) at one unavailability reason, for missing-owner cases that have no per-family classification of their own. Verified the table forces every cell to be classified: flipping `apps` from 'inherits-network' to 'owner-stated' (an optional cell most local runtimes still leave unclassified) is not a type error, since the table only governs runtime fallback and the field stays optional either way — but it does turn into a real test failure: packages/platform-linux/src/runtime.test.ts's `listApps` assertion goes from `{ available: false }` to `{}`, because the loop now treats an unclassified `apps` cell as owner-stated and stops falling back to the network gap. Reverted before committing. * refactor(gateway): reuse the shared fully-unavailable facts constructor unavailableProviderBinding() and unavailableProviderFacts() each hand-spelled every UnavailablePlatformRuntimeFacts cell for a provider mode with no registered module. The two lists had already drifted: the binding map omitted `readiness`, the facts map included it — harmless only because both fall back to the same 'unsupported-provider-mode' reason today, but nothing would have caught a real divergence. Both become one-liners over createFullyUnavailablePlatformRuntimeFacts(), so there is exactly one place that enumerates "every cell is this one reason", and unavailableProviderLifecycleFacts() is no longer needed. Added a regression test asserting bind() and inspectFacts() produce byte-for-byte equal facts for the same unregistered-provider device: it passes today (confirmed against the pre-refactor gateway.ts, restored temporarily to check) since the drift was reason-compatible, but it now pins that equivalence so a future cell added to only one of the two paths fails loudly instead of silently drifting again. * refactor(contracts): derive unavailable-cell fallback from optionality UNAVAILABLE_CELL_POLICY hand-duplicated which cells inherit the network gap and which are owner-stated, restating exactly what UnavailablePlatformRuntimeFacts's optional vs required properties already say. Nothing checked the two against each other, so a misclassified entry passed `satisfies Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>` and the cast at the read site turned it into `Object.freeze({ ...undefined })` = `{}` at runtime - a fact object missing `available` entirely. Replaced the two-value table with a key-only UNAVAILABLE_CELLS list and one `mapUnavailableCells` helper shared by both call sites. freezeUnavailableFacts now reads `unavailable[cell] ?? unavailable.network`, which TypeScript resolves without a cast because the union already covers the optional case - a wrong classification is impossible to express, not just checked for. Seen red once: reverted the `?? unavailable.network` fallback (kept `unavailable[cell]` alone) and reran platform-runtime-unavailable.test.ts - the new `listApps` assertion failed with the exact `{}` malformed-fact shape the policy-table bug could produce. Restored the fallback and it passes. Also added the missing inherits-network assertion itself (platform-runtime-unavailable.test.ts): the existing test only exercised owner-stated cells, so the inheriting path had no direct coverage in this file. * test(gateway): fold bind/inspect parity into the existing fixture The parity test duplicated the whole gateway construction (single apple module, inline webdriver ProviderDeviceRuntime literal) from the test directly above it instead of reusing it. Appended the inspectFacts() call and the equality assertion to that test instead, and switched toEqual to toStrictEqual: toEqual ignores undefined-valued keys, and an omitted cell is exactly how the two facts maps could drift from each other. |
||
|
|
d23346c1a6 |
refactor(cli): derive the common flag readers from the common-field table (#2292)
* refactor(cli): derive the common flag readers from the common-field table
commonInputFromFlags and selectionOptionsFromFlags in
src/commands/cli-grammar/common.ts each hand-restated the common input
key list as object literals. A key added to the common-field table in
commands/common-input-fields.ts (the schema/read/clientKey/audience
source of truth) had no structural link to either flag reader, so
keeping a new key flowing through the CLI depended on remembering to
touch both literals by hand (the failure mode #1304/#1305 hit for
--no-record).
Add a flagKey/flagIn column to CommonInputFieldSpec: flagKey names the
CliFlags property when its spelling differs from the row (only
deviceTarget, which reads flags.target), and flagIn lists which of the
two flag-derived projections ('input', 'selection') the row joins.
commonFlagProjection(flags, projection) walks the table once and
reuses the existing clientKey renaming (deviceTarget -> target) for
the 'selection' projection, matching commonToClientOptions's existing
convention. commonInputFromFlags and selectionOptionsFromFlags become
thin wrappers over that projection; COMMON_COMMAND_SUPPORTED_FLAG_KEYS
in flag-groups.ts is untouched, per its own documented reason for
staying separate.
Added src/commands/cli-grammar/common.test.ts as a characterization
test before touching the implementation: it feeds a CliFlags fixture
with every common flag set, and one with none set, and asserts the
literal objects both functions return. It passed unmodified against
both the old hand-written implementation and the new table-derived
one, proving the projections are byte-identical.
Validation: pnpm vitest run --project unit-core src/commands (575
tests, all green); pnpm check:quick (lint + typecheck) clean;
pnpm check:layering clean (cli-grammar/common.ts importing
commands/common-input-fields.ts is an intra-commands-zone import, not
core/daemon reaching into commands, so R2 commands-floor does not
apply).
* fix(cli): require a typed flagKey wherever a common row joins flagIn
Reviewer findings on the common-flag-reader refactor (
|
||
|
|
09c1caeb7b |
feat(daemon): add managed allocation operation journal (#2284)
* feat(daemon): add managed allocation operation journal * fix: harden allocation journal recovery * refactor: share durable file publication seam * fix: preserve host-kit import locality * fix: keep directory sync helper private |
||
|
|
fa06c8c6e9 | fix(daemon): fence managed readiness behind runtime admission (#2280) | ||
|
|
5bb3ea3b2a |
feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge * fix: address Simulator AX bridge review comments * docs: refresh Simulator AX evidence * fix: address new Simulator AX bridge review comments * docs: record public snapshot source timings * fix: preserve size report helper on base checkout * fix: allow base packages without snapshot bridge * fix: close simulator snapshot source ownership gaps * docs: explain simulator bridge language choice |
||
|
|
17b6ca36f8 |
test(coverage): rename-only hunks owe no changed-line coverage (#2248)
* test(coverage): rename-only hunks owe no changed-line coverage Pass --find-renames=90% to the changed-line diff so rename detection no longer depends on the host diff.renames setting: a 100%-similarity move contributes no changed lines and an edited move contributes only the hunks that differ from its source. Threshold unchanged. * docs(agents): pure moves carry their tests unchanged Drops the stale src/daemon/handlers/session.ts over-budget bullet (242 lines on main) to stay under the AGENTS.md byte budget. * style: format coverage-changed run.ts * docs(agents): restore the session.ts over-budget rule |
||
|
|
172ee149cf |
feat(screenshot): add --crop-on to crop captures to a selector frame (#2276)
* feat(screenshot): add crop-on geometry core and cropTarget selector rows
* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan
* feat(screenshot): run the crop leaf after the platform write and before scale
* feat(screenshot): expose --crop-on in the CLI and surface crop warnings
* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption
* refactor(screenshot): split crop target/policy module and trim redundant coverage
Address review comments at
|
||
|
|
658f822c40 |
fix: encode the mcp subcommand in server.json package arguments (#2275)
* fix: encode the mcp subcommand in server.json package arguments A registry-format launcher (e.g. one consuming /.well-known/mcp.json or the MCP registry entry) starts the server from the package descriptor only; without the positional "mcp" argument it runs the bare CLI instead of the stdio MCP server (bin.ts only starts the MCP server for the mcp subcommand). Enforce the argument in scripts/sync-mcp-metadata.mjs so sync and the CI/prepack checks (check:mcp-metadata) keep server.json correct, and regenerate server.json. * test: own the registry launch-argument invariant; add changelog entry - scripts/__tests__/mcp-metadata.test.ts asserts the checked-in server.json's agent-device npm package entry declares the exact fixed positional mcp argument (and stays stdio-only), so a missing or wrong argument fails the unit lane in both directions. Wired into the unit-core project include list. - Changelog: user-visible release fix under Unreleased. |
||
|
|
33084c7748 |
perf(ios): decide Simulator AX bridge viability (GO, Node-direct guest reader) (#2237)
* test(ios): add guest simulator AX bridge evidence * test(ios): make alert cleanup selector unique * test(ios): admit recovered alert cleanup surface * test(ios): narrow AX spike to guest evidence path * docs(ios): record guest AX bridge decision * chore(ios): remove unused spike import * docs(ios): correct simulator bridge verdict * test(ios): drive the guest Simulator AX bridge directly from Node Replace the idb companion + Python reader in the #2192 spike with a Node client for idb v1.5.2's in-Simulator SimulatorFrameworkBridge: one private guest per session spawned through simctl, 4-byte length-prefixed JSON over a UNIX socket, single-fetch traversal with automation mode asserted per request, nested trees flattened to parent-linked raw nodes with XCTest type names, and typed crash/timeout/cancel/stale-generation failures. The targeted harness now observes app readiness with a throwaway probe instead of admitting on pid presence, relaunches the app per bootstrap sample, records host load per sample, and runs recovery probes through the adapter. Hard tiers follow the corrected #2192 contract (warm 300/500 ms, relaunch 500 ms); the former 75/150 ms and 250 ms values are reported as stretch findings. Preboot preference edits are optional and unused by the guest path. The prototype's targeted artifact is preserved under a -python-prototype name; its bootstrap and recovery samples measured the packaging, not the mechanism. * test(ios): narrow Simulator bridge decision evidence * docs: publish Simulator bridge evidence out of tree * fix: tighten iOS bridge evidence gates * docs: publish corrected bridge evidence * docs: point to post-rebase bridge evidence |
||
|
|
941ca0e7e0 | ci: enforce the image-size parser mitigation through a test-app gate (#2269) | ||
|
|
7bf8d8c4a3 |
fix(remote): materialize test suite artifacts against a remote daemon (#2272)
* fix(remote): materialize test suite artifacts against a remote daemon (#2246) `agent-device test` crashed with ENOENT against a remote daemon because the scheduler resolved `--artifacts-dir` against the caller's `cwd`, sent over the wire, on the daemon's own filesystem. Mirrors #1802's read-side fix for the same command: the CLI now redirects `--artifacts-dir` to a temp directory the daemon owns before the suite runs, and the daemon rewrites every artifact path in its response back to the caller-local root and registers the suite directory as one downloadable artifact through the existing screenshot/record transport, extended here to also support directories via the codebase's existing safe archive extractor (the archive comes from a remote daemon, a different trust domain, so a raw `tar` invocation was not enough). * fix(remote): publish test artifacts atomically * perf(cli): keep artifact downloads lazy |
||
|
|
309a5360f5 |
feat(daemon): add the allocator-held device claim kind (#2263)
* feat(daemon): add the allocator-held device claim kind
ADR 0021 foundations, unit 2. A device an allocator-managed pool owns is held for that identity's
whole pool lifetime, not for a session, so the claim store gains a second record kind whose
principal is an installation instead of a process. Nothing writes one in production yet.
- `AllocatorHeldDeviceClaim` is a SEPARATE record at `schemaVersion: 3` with `kind: 'allocator'`:
`stateDir` + `allocator.instanceId` + `allocator.identityIncarnationId`, and no
ownerPid/ownerStartTime/ownerToken/session/workspace/abandonedAtMs — a record carrying any of
them does not decode. `DeviceClaim` and its v2 files are untouched, so an older daemon never
meets a changed process-owned record and only ever sees a v3 file for a managed identity, which
it reads as an unreadable claim record and fails closed on.
- The managed owner is DERIVED from the recorded allocator instance (`allocatorHeldClaimOwner`),
never stored, so an owner that disagrees with the claim's principal cannot exist.
- `InspectedDeviceClaim` becomes a union: the allocator member carries `allocatorClaim` and
`claim: undefined`. Every clearing surface — ownership match, abandon, stale release, the
startup sweep, acquire-path reconciliation, session close, lease expiry, the shutdown ledger,
and `processOwnsActiveDeviceClaim` — reaches for `claim`, so none of them can be written against
an allocator-held claim, and `DeviceClaimReconciler` stays typed to the process-owned record.
- New classification `allocator-held`: not stale, not owner-releasable. `device status` shows it
in the normal view with its allocator, incarnation and installation; `device release --stale`
refuses it with `allocator-held-owner`.
- The verifier gains `covered` and `incarnation-stale`. A session or transient command whose fence
names the incarnation the claim holds executes under it, acquiring and clearing nothing; a fence
for a re-provisioned identity is refused, because an incarnation is stable for that identity's
pool lifetime.
- The ORDINARY arm of the admission gate now performs a read-only inspection under every policy
but `none` and refuses `DEVICE_IN_USE` / `DEVICE_CLAIM_ALLOCATOR_HELD`: `apps` and `app-state`
boot a device through `ensureReady` exactly as a mutation would. It still acquires nothing, so
the policy/claim-file table is unchanged. That reason is deliberately outside
`DeviceClaimConflictReason`, so replay never retries it as infrastructure. The inspection reads
the record and stops (`readAllocatorHeldClaimFile`): the owner-liveness probe `inspectDeviceClaimFile`
runs costs a host process observation per binding, and this kind has no owner process to observe.
- `acquireAllocatorHeldDeviceClaim` reattaches only on the full principal and never reconciles or
supersedes an ordinary claim (a conflicting ordinary claim prevents publication, ADR 0021 §4);
`releaseAllocatorHeldClaim` takes removal proof and is the only clearing path. Both are
by-design production-unused in this unit, with a comment naming the unit that will call them.
- The record/decoder and the lock/write leaves move out of device-claims.ts into
device-claim-record.ts and device-claim-store.ts, which dissolves the
device-claims <-> device-claim-inspection type cycle and lets the allocator module share the
writer without device-claims.ts ever importing it.
`devices` still reports no `claimedBy` for an allocator-held claim: the public field is
`{ session, workspace }` and this kind has neither.
* docs: condense CONTEXT.md entries to leave room for sibling units
Wording-only tightening of 41 existing definitions. No term is added, removed or redefined, and the
five entries unit 1 added and the three unit 2 touched are left alone. The glossary ends at 11,589
of the 12,000-byte guidance budget, which leaves room for the sibling unit's vocabulary to land
without a second trim.
* fix(daemon): register device-claim-store as the claim file's atomic-publish owner
device-claims.ts no longer writes its claim file directly: it delegates every write to
writeDeviceClaim in the new device-claim-store.ts, the single writer shared by the process-owned
and allocator-held claim kinds. atomic-publish-ownership.test.ts's SIMPLE_PUBLISHERS list still
named device-claims.ts, so its source-grep for `publishFileSync` no longer matched anything and
the ownership gate failed in CI. Register device-claim-store.ts as the owner instead.
Also rename DeviceClaimRecord -> StoredDeviceClaim (and decodeDeviceClaimRecord ->
decodeStoredDeviceClaim): the shutdown ledger's own DeviceClaimRecord in daemon-shutdown-report.ts
is a different type (one row of what teardown released), and the shared name invited confusing
that unrelated type for this module's claim-file record.
* fix(daemon): extend the allocator-held admission exhaustiveness table
The rebase onto adr0021/u1-owner-kind's decideAllocatorHeldAdmission fix carried forward
ADMITTED_BY_OUTCOME_STATUS and its outcomes array from before this branch added the covered and
incarnation-stale statuses, so the table no longer covered the whole AllocatorHeldClaimAdmission
union and TS2739 caught it. Add both: covered admits, incarnation-stale does not.
* fix(daemon): fail closed on a corrupted allocator-looking claim record
readAllocatorHeldClaimFile gated on entry.allocatorClaim, which is only set once a record
decodes all the way through. A v3-schema record corrupted into also carrying a process
principal field fails decodeAllocatorHeldClaim's carriesProcessPrincipal check and
decodeStoredDeviceClaim returns null -- exactly like a record that never existed. Ordinary
admission's inspectAllocatorHeldDeviceClaim then read that as "no allocator claim" and let
observe and every other non-transient-exclusive policy proceed, against a device an
allocator may actually hold.
A record that declares schemaVersion 3 but fails to decode is not provably a non-allocator
record, so it cannot be treated as absent. Add looksLikeAllocatorHeldClaim (device-claim-
record.ts) to distinguish "declares the allocator schema, doesn't decode" from every other
kind of corruption, give it its own classification (allocator-inconsistent), and have
readAllocatorHeldClaimFile return the entry -- not null -- for it. Ordinary admission then
refuses through the existing deviceClaimConflictError path (DEVICE_IN_USE /
DEVICE_CLAIM_OWNER_UNCERTAIN), same as any other claim it cannot verify.
deviceClaimRequiresStaleInspection, deviceClaimOwnerCannotRelease and conflictReason are
exhaustive switches over DeviceClaimClassification, so the new member forced a decision at
every site rather than one that could be missed: not stale (nothing dead to surface), not
owner-releasable (no process proof exists to make), and DEVICE_CLAIM_OWNER_UNCERTAIN like
the other undecodable-record classifications, not a permanent-condition reason.
Planted-red verified: reverting readAllocatorHeldClaimFile's gate to entry?.allocatorClaim
alone makes the new regression fail with error.code 'UNKNOWN' -- admission resolves with no
error at all, exactly the silent pass-through this fixes.
* refactor(daemon): fold staleReleaseRefusalReason into a lookup table
The switch exceeded fallow's health gate at 10 cyclomatic / 31.6 CRAP once the
allocator-inconsistent case joined it. A Record<DeviceClaimClassification, string> reads as
one branch to the complexity walker instead of one per case, while TypeScript still refuses
to compile a missing key -- the same exhaustiveness guarantee a switch gave, at a fraction
of the counted complexity.
* docs: trim CONTEXT.md back under the 12,000-byte guidance budget
Main gained bytes elsewhere since this stack's own docs-condense commit landed, pushing
CONTEXT.md to 12,028. Tighten four of this stack's own entries (managed local owner, request
generation, identity incarnation, managed device allocator port) rather than touch anyone
else's; 11,947 bytes leaves headroom against the next PR that lands first.
|
||
|
|
e882cf9723 |
feat(runtime): add managed-local ownership and the exact-only managed runtime (#2258)
* docs: trim the CONTEXT.md glossary within the guidance byte budget
CONTEXT.md sat at 11,992 of its 12,000-byte guidance budget, so no new domain term could be added
without first paying for it.
- Condense eighteen definitions that had grown past one line (platform leaf, command surface,
runtime use, runner command traits, interactor, coordinate-first resolved element activation,
parent-owned touch point, guarantee cell, delegation-on-error, ref frame, snapshot producer,
snapshot policy facet, capture hint, regular presented-depth frontier, clip fold,
AX-unavailable target invalidation, Maestro program, Maestro observation generation). The
definitions keep their meaning; only the elaboration is gone.
- Move the five test-harness terms of 'Providers and tests' (provider-backed integration
scenario, provider transcript, scenario transcript, in-process provider scenario harness, HTTP
contract test) to docs/agents/domain.md, which AGENTS.md already routes to for domain
vocabulary. None of them names a concept a command or a wire shape carries, and none appears in
a test name.
CONTEXT.md is 10,517 bytes after this pass.
* feat(runtime): add the managed-local owner kind, device-claim rule, and managed binding fence
ADR 0021 foundations, unit 1. Nothing registers a managed local owner yet, so every arm below is
reached from tests only; the point of the unit is that the arms exist and fail closed.
- `RuntimeOwnerRef` gains `{ kind: 'managed-local'; instance }` with `managedLocalRuntimeOwner`:
one owner per allocator instance, family-agnostic because the device carries its family. Every
owner-kind discrimination becomes an exhaustive switch, so a fourth kind is a type error at each
site: the owner key, the unavailable-facts provider mode, the durable envelope decode, and the
gateway's provider-mode acceptance and exact-owner selection.
- `deviceClaimRuleForOwner` ('ordinary' | 'allocator-held' | 'none') in the new leaf
src/daemon/device-claim-rule.ts replaces the boolean `isLocalDeviceClaimTarget`. Both claim
gates switch on it, and the admission gate now evaluates it under every device-claim policy: the
`transient-exclusive` condition moved inside the ordinary arm, so a managed owner is verified
where an ordinary owner would never have touched the store.
- `requireAllocatorHeldDeviceClaim` (src/daemon/device-claim-allocator.ts) is the one read-only
verifier both gates consult. It never acquires, never locks and never clears; in this unit it
can only answer `binding-invalid`, `missing`, or `conflict`, because no allocator-held claim
kind exists until unit 2. `allocatorHeldAdmissionError` answers each outcome with its own
refusal through an exhaustive switch, so an outcome the verifier learns to produce is a
compile error until it is answered.
- A missing allocator-held claim refuses with COMMAND_FAILED / `allocator-claim-missing`,
`retriable: false`. It is deliberately not a `DeviceClaimConflictReason`: replay retries every
conflict reason as infrastructure, and a managed identity no allocator activated is permanent.
- `managedBindingFence` / `decodeManagedBindingFence` encode `[requesterId, identityIncarnationId]`
as the fence token and the request generation as its generation, so two requesters on one
identity incarnation never share a fence. The ids are fenced verbatim, and the decoder accepts a
token only if it re-encodes to itself.
- Claim admission now receives the binding intent the gateway bound, so an exact-owner fence
reaches the gate unchanged. Session open still binds ordinarily and passes an ordinary intent:
a managed local owner is therefore refused there structurally, and the Host open route replaces
that intent when it lands.
- CONTEXT.md: managed local owner, device-claim rule, managed binding fence, request generation,
identity incarnation.
* fix(daemon): decide allocator-held admission totally instead of by an optional error
`allocatorHeldAdmissionError` returned `AppError | undefined`, so its switch without a default
was never exhaustiveness-checked: TS2366 fires only when the return type excludes `undefined`,
`noImplicitReturns` is off, and oxlint has no exhaustiveness rule. A verifier outcome nobody
answered would therefore fall out as `undefined`, which both gates read as an admission — claim
admission throws nothing and session open proceeds to open the session on a device it never
verified.
Replace it with `decideAllocatorHeldAdmission`, returning
`{ admitted: true } | { admitted: false; error }`. The return type excludes `undefined`, so
dropping an arm is now a compile error at the switch, and a gate asks whether the outcome was
admitted rather than whether an error happened to come back. `buildAllocatorHeldRefusal` and the
admission gate are projections of that one decision.
* docs: restore the meaning five CONTEXT.md definitions lost in the trim
The condensing pass shortened these five past the point where they still said what they meant:
- Capture hint said 'presented depth' where the term is 'regular presented depth', which is what
Regular presented-depth frontier is measured against; the short form read as a different axis.
- Clip fold lost both that the interpreter runs inside presentation for every backend and that a
platform difference may not enter as a backend exception. Those are the whole rule.
- Snapshot policy facet lost the process boundary that makes it host-side at all: runner-side
Swift presentation stays separate.
- Runner command traits lost 'independently of the public command surface', which is what
distinguishes them from the command surface.
- Delegation-on-error said 'settles', and Settled observation makes 'settle' a term of its own.
CONTEXT.md is 11,674 of its 12,000-byte budget.
* docs(daemon): correct the claim-gate and managed-owner comments
- The claim-gate docstring claimed there is no other way to obtain device operations. That is
true of command handlers, but two daemon-owned recovery paths bind outside the seam:
application-lifecycle-recovery.ts (ordinary intent, daemon shutdown) and
durable-capture-runtime-recovery.ts (exact-owner intent read back from a durable envelope,
which this unit makes able to carry a managed local owner). Name them instead of claiming
coverage the seam does not have.
- The open path's comment described a session executing under an allocator-held claim, a state
this route cannot produce. Say what the `{ kind: 'ordinary' }` literal actually is: the truth
of a route that binds ordinarily, which the Host open route replaces with the request's exact
intent when it lands.
- Name U3 as the unit that fills the exact-owner selection arm, rather than the whole ADR.
* fix(runtime): accept transport-composed facts for a managed owner
providerModeMatchesOwner's managed-local arm accepted mode === 'local' only, but
selectExactOwner's managed-local arm loads the device's local family owner through the same
loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim. A
managed binding over a transport-composed local device (e.g. a remote ADB or web-provider
transport) would fail bindingContractFailure's facts check and be rejected as an owner/facts
mismatch. Accept the same local-family modes the local-family arm already does; still
unreachable until U3 registers the exact-only owner, which is where the binding regression
test that pins this lives.
* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port (#2259)
* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port
ADR 0021 foundations, unit 3. Unit 1 added the `managed-local` owner kind and left the gateway's
exact-owner arm for it failing closed; this unit gives that arm a registry and the owner it selects.
Nothing in production registers a managed owner yet, so both are reached from tests only.
- `createComposedPlatformRuntimeGateway` gains a `managedOwners` list that only the `managed-local`
arm of `selectExactOwner` reads. `selectOrdinaryProvider`, `inspectFacts` and the ordinary `bind`
arm never see it, and `providerModules` pairs one provider-runtime owner with one
`ProviderDeviceRuntime`, so ordinary selection cannot reach a managed owner by construction
rather than by a check. A duplicate instance is refused at composition.
- The wrapper (src/platform-runtime-managed-owner.ts, root zone, no platform imports) binds only
under an exact-owner intent naming itself, loads the device's own family owner through the
gateway's loader, delegates with an ordinary intent — a family owner refuses a foreign exact
owner — and republishes the binding under the managed owner. It does not read the fence: what a
managed binding fence proves is the device-claim gate's business. `ownsDevice` returns false.
- Twenty cells are withheld as `owner-capability-missing`, enumerated by mechanics rather than by
catalog group: the four device-lifecycle cells, the four application cells that boot or shut the
device down (`prepareApplicationOpen`, `prepareAppleRunner`, `closeApplication`,
`finalizeApplicationClose`), and the twelve durable-capture cells, which a managed binding could
never reattach because the family runtime stamps envelopes with its own local owner. The
operations are then filtered by those facts, so an operation cannot outlive its own fact.
- `@agent-device/contracts/managed-device-allocation` is agent-device's own allocator port: lease
request, lookup, supersession, cancellation, renewal, release, activation confirmation, identity
status, removal acknowledgement, and the typed environment projection. Types only, named to match
the allocator's published contract so the two sides cannot drift, with no dependency on any
allocator package. Its only implementation is a scripted fake under `*.fixtures.ts`.
- Budgets: the new contracts entry surface is a one-module closure; the `src/platform-runtime.ts`
hub moves 47 -> 48 for the wrapper, whose own value imports were already in that closure.
* fix(runtime): withhold the deployment cells from a managed binding and trim the allocator port
Review findings on the managed local owner.
- `deployApp` and `deployMaterializedApp` join the lifecycle group. Both family deployment runtimes
ensure device readiness before installing, and `deployAppUse` requires `deployApp` alone — so
`install` on a managed binding would have booted the allocator's device with nothing to refuse
it. Twenty withheld cells become twenty-two, and the refused-uses test covers `deployAppUse`.
- The wrapper's doc comment no longer implies that withholding cells is a complete lifecycle
exclusion: several retained Apple cells (screenshot capture, settings, clipboard, application
launch) boot the simulator lazily inside the family runtime, where cell selection cannot reach.
That is the same class as the pre-binding readiness path, and closing it is a family-runtime
change.
- `readLeaseEnvironment` leaves the allocator port. It was beyond the vocabulary the contract
fixes, and it made the scripted fake carry a real parser whose only test passed with every
production line reverted. `ManagedLeaseEnvironment`, `ManagedLeaseEnvironmentKey` and
`LeaseEnvironmentError` stay as types; the reader that produces them lands with the unit that
first turns a grant into a device.
- CONTEXT.md drops an operation enumeration that was already incomplete.
* fix(runtime): withhold the lazily-booting Apple system and screenshot cells
Screenshot capture, settings, clipboard and application launch were retained on a managed
binding even though their Apple family-runtime implementations can boot the simulator lazily
below cell-selection granularity (screenshot's shutdown-failure retry boot; settings, clipboard
and application launch each resolve a local interactor the same way). That preserves rather than
blocks the exact bypass ADR-0021 section 3's hard boundary names: managed lifecycle/readiness
belongs to the allocator, and no handler path may fall back to direct lifecycle tooling.
Withhold captureScreenshot, setSetting, readClipboard, writeClipboard and openApplication
alongside the existing withheld groups. The wrapper's doc comment now names the pre-binding
readiness gap explicitly as the same class of follow-up, rather than folding it into a retained-
cells caveat that no longer applies. MANAGED_RETAINED_OPERATION moves to tapPoint, the cell the
fixture-based regression tests now use to prove something survives the wrapper.
* chore: retrigger CI (stale synchronize event after rebase)
* fix(runtime): lazy-load the managed owner wrapper to satisfy the eager-closure no-growth gate
Main's eager-closure budget gate (the merge-base ratchet) replaced the hand-tracked
HUB_BUDGETS map with an automatic no-growth-vs-merge-base check: src/platform-runtime.ts
is a hub with no growth allowed at all, not a number bumped by hand with a justifying
comment. The static import of createManagedLocalRuntimeOwner in platform-runtime-gateway.ts
added one module to that hub's closure (47 -> 48), which now fails
scripts/__tests__/eager-closure-budgets.test.ts outright rather than needing a manual bump.
Move the value import into loadManaged's dynamic `await import`, matching how the rest of
this file's owner loaders defer their leaf modules. Only the managed-local arm reaches this
path, so an ordinary bind never pays for it, same as before -- the wrapper module itself was
simply the wrong side of the eager/lazy line.
|
||
|
|
e2ce98556b |
chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings (#2257)
* refactor(closure): walk a source tree through a reader seam The eager-import-closure walker read the working tree directly through fs, so every consumer could only ask about the checkout in front of it. Closure computation now takes a SourceTreeReader; the working tree stays the default, and a committed git tree answers the same four questions for any tree-ish without checking it out -- one `git ls-tree` for the tracked set and one long-lived `git cat-file --batch` for the sources the walker can reach. Per-tree memoization of package directories and direct edges, plus a content-keyed parse cache, keep a second tree paying only for what differs. * chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings The 202 façade and 6 hub numeric pins are gone. The six platform façades stay exact at one module, every other existing entry may evaluate no more than the same file evaluated at the merge-base with origin/main (renames followed), and an entry that did not exist there fits a per-category ceiling derived from its path, or carries an APPROVED_OVER_CEILING row naming issue, reason and owner. Shrinking now needs no gate edit, and a stale approval fails. The standing denial -- a façade closure never reaches a concrete platform implementation before discovery or binding selects an owner -- is unchanged. * chore(gates): scope stale approvals to introduced entries and keep readers in sync Address review findings on the eager-closure merge-base ratchet. - docs/agents/testing.md: drop the new bullet. The file was 386 bytes over the 10,000-byte focused-doc budget, and the gate module's header already owns the invariant, so the prose was duplication the ownership rule forbids. - The closure walker's relative resolver no longer tries a .tsx suffix. The repo defines a production source as .ts (tracked-sources.ts pathspecs and isProductionSourceFile), so the committed-tree reader never loads .tsx content; resolving one produced an edge that reader could not read, crashing the ratchet instead of failing it. - The APPROVED_OVER_CEILING staleness check now looks only at entries still first-introduced. Once the merge-base carries an entry, the no-growth rule governs it and nothing reads its row again, so the row is stale for the same reason a shrunk entry's row is. |
||
|
|
cafaded8f6 | fix(security): bump test-app overrides for xmldom, browserslist, decode-uri-component (#2268) | ||
|
|
4946e66078 | fix(security): bump fast-uri override to 3.1.6 (#2267) | ||
|
|
e3c44ea4c3 |
test(vitest): retire the subprocess-stub kill-criterion experiment (#2255)
* test(vitest): record the subprocess-stub kill-criterion outcome
#1823's kill criterion was met (~64 consecutive genuine Coverage-job
completions since
|
||
|
|
fbf914b907 |
chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch (#2251)
* chore(bench): move iOS snapshot benchmark evidence to the evidence/ios-snapshot branch The three hash-named raw results under scripts/ios-snapshot-benchmark/evidence/ are measurement output, not fixtures. They now live on the orphan branch evidence/ios-snapshot (commit |
||
|
|
3726f027ce |
test(platform): extract Apple perf and Android runtime fixtures (#2253)
* test(platform): extract shared fixtures from the Apple perf and Android runtime suites Move the duplicated host-command routing, app-bundle writers, and ps handlers behind packages/platform-apple/src/core/__tests__/perf.fixtures.ts, and the Android runtime host, device, and ordinary-bind builders behind packages/platform-android/src/runtime.fixtures.ts. Every test keeps its title and its own assertions; test and assertion counts are unchanged. * chore(gates): exclude .fixtures.ts modules from changed-line coverage runtime.fixtures.ts (packages/platform-android/src) sits outside any __tests__ dir, so vitest's coverage.include picked it up as production source and the changed-line gate scored its 20 unreachable construction lines directly, failing at 39.39% against the 70% threshold. Add '**/*.fixtures.ts' to coverage.exclude (repo-wide convention: 40+ fixture modules, all test support, never production). The file disappears from lcov and scripts/coverage-changed/model.ts's existing excluded-path fallback reports it non-gating instead, with no duplicate classifier needed there. Planted red (targeted run against the real diff/model, not committed): BEFORE (file present in lcov, all added lines uncovered): totalLines=110 coveredLines=0 pct=0 passed=false. AFTER (file absent from lcov post-fix): totalLines=0 excludedTotal=99 excludedReason=excluded-path passed=true. Confirmed against a real `vitest run --coverage` + `check:coverage-changed` pass: runtime.fixtures.ts absent from coverage/lcov.info, gate PASS, 99 lines reported under excluded/excluded-path. |
||
|
|
9941330dcc |
docs(agents): PR diff budget, move PRs, gates commit, validation lifecycle (#2247)
* docs(agents): PR diff budget, move PRs, gates commit, validation lifecycle * docs(agents): merge-base diff budget, subprocess lane facts, gate-stage validation * docs(agents): keep volatile test topology at its declaration |
||
|
|
0c3d369567 |
docs(adr): ADR-0019 end-state numbers for the daemon composition root (#2242)
* docs(adr): add ADR-0019 end-state numbers for the daemon composition root
Record the measured daemon top-level file count (199) and entry-to-platform
hop counts (press/Android 38, snapshot/iOS 29) at
|
||
|
|
4e9820e46e |
ci(android): wait for the producer's platform job and keep the fallback build alive (#2261)
Classify the fixture producer by its platform job (Android Release / iOS Release) instead of the run's aggregate status: the run flickers to "queued" in the gap between its fingerprint job and its platform job, which made the Android consumer bail into an inline build almost instantly (run 33550746596). Both queued and in_progress on the platform job now mean keep waiting. Raise android.yml's wait-for-artifact-seconds to 1800s (matching the iOS workflow's own budget) so a real wait can play out now that the premature "queued" bail is gone. Raise the Android Gradle daemon heap (org.gradle.jvmargs=-Xmx4g) for both the producer build and the inline fallback build: the producer's own Android Release job OOM'd in :app:compileReleaseArtProfile (run 33728318738), and both prior inline fallbacks OOM'd in :app:mergeDexRelease. |
||
|
|
79391375c6 |
test(android-e2e): make an alert-dismiss failure self-explaining (#2260)
* test(android-e2e): make an alert-dismiss failure self-explaining
The Android smoke scenario asserted the post-alert canary text with a
screen-wide `wait text`, whose timeout report is a top-6-label surface
dump that may not include the element in question at all. Assert the
canary through the specific automation-alert-result element instead, so
a failure states its actual current value directly.
Also record the tapped alert button's coordinates alongside its label
(already recorded) in the Android alert-handled result, and capture a
screenshot artifact when an e2e `wait` step times out, so a flake has
more to go on than the surface dump.
* review: extract shared alert-test fixtures, trim narrated docblock
- Extract node/text/button RawSnapshotNode builders (near-duplicated
between alert.test.ts and alert-detection.test.ts) into a sibling
alert-fixtures.ts, reconciling the two button() signatures by keeping
the optional `permission` param. Both suites now import from it.
Planted red: bumped the shared button() rect width and reran both
suites — 2 of 7 tests failed on the changed tap coordinates
(alert.test.ts's dismiss/accept cases), confirming the fixtures are
live-wired; reverted, suite back to 7/7 green.
- Trim captureWaitTimeoutScreenshot's docblock in runtime.ts to the
caller-facing contract only ("never throws, returns undefined on a
failed capture"); the surface-dump rationale already lives in the PR
body's Summary.
|
||
|
|
b820953886 |
test(daemon): session-relaunch-close through the request seam (#2262)
Route open/close relaunch scenarios through the production request router (createRequestHandler + lifecycleDeviceRuntimeGateway) instead of the bypassed handler-level harness. Tests now bind fake runtime facts and facets, matching sibling router-level suites, rather than mocking core/dispatch-resolve.ts and friends directly. |
||
|
|
a4f625c774 |
feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling * fix: keep wait absent coverage gates green * fix: preserve wait absent restart diagnostics |
||
|
|
ed76c9c848 |
fix(ci): fingerprint the test app from its own directory (#2256)
@expo/fingerprint hashes process.cwd() as the project root, but resolve-artifact-name.sh invoked it from the workspace root: the fixture cache key was the whole repo's fingerprint, not examples/test-app's native sources. Run fingerprint:generate with cwd examples/test-app instead; the script's stdout contract (fingerprint.<hash>.<platform>) is unchanged. |
||
|
|
a5a7f6dfa1 |
docs(layering): kill criteria on every rule module (#2244)
Adds a four-line Catches/Evidence/Cost/Kill-criterion header to every layering rule module for R2, R4-R7, R9-R14, R16, R18, R19, R65-R73, and the rule-id uniqueness gate, so each structural check states what it catches, why no other gate sees it, its LOC cost, and the concrete condition under which it gets deleted. No behavior change. |
||
|
|
8021503c66 |
test(replay): extract shared fixtures from the target-verification suite (#2249)
The 26 scenarios in session-replay-target-verification-runtime.test.ts each rebuilt the same temp root, iOS app session, written .ad script, and request-recording invoke by hand, and repeated the same one-button XCTest capture literal. Those setup pieces now live as named exports in the sibling session-replay-scenario.fixtures.ts (replayScriptScene, saveButtonCapture, emptyCapture, and the annotation constants), built on the existing test-utils builders (mkdtempForTestSync, makeIosAppSession) and the replay runtime fixtures. Every test keeps its title, comments, and expect lines, so each still reads as its own scenario. Test count (26) and expect count (128) are unchanged. jscpd (--min-tokens 80 --min-lines 8) duplicated lines within the suite drop from 1,046 (25 clone pairs) to 125 (6 pairs). The divergence and repair-empty-tail suites are left for a follow-up: this extraction alone is ~950 gross diff lines. |
||
|
|
2371ba9bff |
feat: add strict native absence assertion (#2245)
* feat: add strict native absence assertion * fix: address absence assertion review feedback |