Skip to content

Commit 93d5275

Browse files
authored
refactor: type-safe recording backends + exhaustive capability gating (#894)
* docs: add perfect-shape architecture roadmap Captures the target architecture (two-registry thesis: CommandDescriptor + PlatformPlugin over a clean folder DAG with a typed-result spine) and a sequenced, strangler-fig migration path, grounded in a survey of the current codebase. This PR implements the first two behaviorless Phase-0 items from that roadmap; the larger registry work is deliberately deferred to later, independently shippable PRs. * refactor: parametrize RecordingBackend by recording tag RecordingBackend is now generic over the recording's platform tag, so each backend's stop() receives an already-narrowed recording. This deletes all five 'recording as Extract<ActiveRecording, { platform: ... }>' casts — the textbook discriminated-union-narrowing-by-cast anti-pattern — and makes a backend/tag mismatch unrepresentable. start() stays wide (DaemonResponse | ActiveRecording) because a device platform does not map 1:1 to a recording tag (an iOS device resolves to either the 'ios' or 'ios-device-runner' recording). Device resolution returns a stop-less view (RecordingStartBackend); stop is dispatched per active recording via the new exhaustive stopActiveRecording(), replacing resolveRecordingBackendForRecording(). Behaviorless: pure type-level change, no runtime behavior change. * refactor: make capability platform selection exhaustive isCommandSupportedOnDevice resolved the per-platform capability bucket with an if/else ladder whose final branch funneled every unmatched platform into capability.web. That silently absorbs a future Platform with no compile error. Replace it with selectCapabilityForPlatform(), an exhaustive switch over the Platform union with a 'never' guard, so adding a new platform is a compile error here instead of a silent web mis-gate. Identical behavior for all five current platforms (ios/macos -> apple, android, linux, web). * docs(adr): amend ADR 0003 for the single-declaration/derivation model Ratifies the PR review caveat into the ADR itself: the daemon command registry boundary is about ownership + the predicate interface, not the physical file a trait is typed in. A derived/projected daemon registry is permitted only if it preserves four invariants (daemon-owned declaration, unchanged predicate interface, no leakage into public projections, one declaration per concern enforced by types). The original decision stands; collapsing daemon policy into a public command registry remains forbidden. * docs: refine command axis to facet composition (ADR 0003-aligned) - §2/§5.2: CommandDescriptor composes domain-owned facets (surface@commands, capability@core, daemon@src/daemon) and projects them — compose-with, not collapse-into. Adds the four ADR-0003 invariants. - §6: mark the two shipped Phase-0 items (generic RecordingBackend<P>, exhaustive capability selection); link the Apple plan from Phase 3. - §5.1: Apple as the first PlatformPlugin instance, owning an AppleOS leaf axis. - §8: before/after diagrams for the command axis + the two-axis summary. * docs: add apple-platform-consolidation plan (AppleOS leaf axis) One 'apple' Platform with an AppleOS discriminant (ios/ipados/tvos/watchos/ visionos/macos) rather than six Platform literals (which would collide with the cross-platform 'target' axis). Captures the 4-investigator survey: ~85% of platforms/ios is already the OS-agnostic Apple engine; the XCTest runner already builds ios|macos|tvos; macOS is included as a distinct AppKit leaf (already entangled). visionOS is scoped net-new work; watchOS is an unsupported sentinel (XCUITest can't drive it). Before/after diagrams, per-OS readiness, sequencing.
1 parent 78ca8fb commit 93d5275

6 files changed

Lines changed: 773 additions & 36 deletions

File tree

docs/adr/0003-daemon-command-registry.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,37 @@ sets.
5353

5454
`AGENTS.md` should contain only the operating rule and relevant file pointers for agents. This ADR
5555
owns the rationale so future changes do not need to infer it from agent instructions.
56+
57+
## Update (2026-06): single-declaration / derivation model
58+
59+
A later proposal (the `CommandDescriptor` direction in `plans/perfect-shape.md`) unifies a command's
60+
declarations so the public catalog, capability matrix, CLI/MCP projections, batch allowlist, and this
61+
daemon registry are *derived* from one registration site, to remove the cross-table drift that several
62+
of these surfaces are kept aligned against by convention.
63+
64+
**This ADR's decision stands.** Its boundary is about *ownership* and the *predicate interface*, not
65+
about the physical file a trait is typed in. "Separate source of truth" means separately owned and
66+
exposed through named predicates — a property that survives a projected/derived backing table. A
67+
derived daemon registry is therefore permitted **only if** it preserves all of the following invariants:
68+
69+
1. **Daemon-owned declaration.** Route and request-policy traits are declared in a daemon-owned facet
70+
(under `src/daemon/`) and *composed* into the registration — never inlined as fields on the public
71+
command contract in `src/commands/**` or `src/command-catalog.ts`. Co-locating a registration *call*
72+
is fine; co-locating *ownership* of daemon policy in the public surface is the contamination this ADR
73+
rejected (see Alternatives, "Keep daemon groups in `src/command-catalog.ts`").
74+
2. **Predicate interface unchanged.** Consumers keep asking daemon-policy questions through the named
75+
predicates (`getDaemonCommandRoute`, `isLeaseAdmissionExempt`, `shouldLockSessionExecution`, …). The
76+
daemon registry remains their sole exposer; derivation changes how the backing table is *built*, not
77+
how it is *read*.
78+
3. **No leakage into public projections.** The catalog/CLI/MCP/help/capability projections must be
79+
type-prevented from reading daemon-only traits, and the daemon registry must still not define CLI
80+
grammar, Node.js options, MCP schemas, user-facing help, or capability support.
81+
4. **One declaration per concern, enforced by types.** The single registration site must make a missing
82+
or duplicated daemon trait a *compile error* — replacing today's "aligned by convention". This is the
83+
structural improvement that justifies derivation over a separately hand-authored table.
84+
85+
A single flat public descriptor whose daemon fields leak into public views is **not** permitted — that is
86+
the "collapse daemon policy into a public command registry" failure this ADR exists to prevent. Compose
87+
facets owned by their domains; derive the registry from them. Until that derivation lands and is pinned
88+
by the registry tests, the hand-authored `src/daemon/daemon-command-registry.ts` remains the source of
89+
truth and the operating rule in `AGENTS.md` is unchanged.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Apple Platform Consolidation — `platforms/apple` with an `AppleOS` leaf axis
2+
3+
> The platform-axis half of the [perfect-shape roadmap](./perfect-shape.md): make the Apple plugin own
4+
> **iOS / iPadOS / tvOS / macOS** today and **visionOS / watchOS** as honest future leaves — a single
5+
> `apple` platform with an `AppleOS` discriminant. Grounded in a 4-investigator survey of the real code.
6+
7+
## TL;DR
8+
9+
`src/platforms/ios/` is **~85% an OS-agnostic Apple-XCTest engine that is merely misfiled and misnamed.**
10+
Of ~14.2k LOC, ~12k is OS-agnostic (the 6,136-LOC runner stack, tool-provider, discovery, snapshot/AX,
11+
screenshot, perf, debug-symbols), and `apple-runner-platform.ts` already models iOS/tvOS/macOS as
12+
first-class runner profiles. The XCTest runner **already builds `ios|macos|tvos` from one Xcode project**,
13+
and one `createAppleInteractor` already serves both `ios` and `macos`. So consolidation is overwhelmingly
14+
**relocate-and-rename, not rewrite** — for iOS/iPadOS/tvOS/macOS. visionOS is real-but-scoped net-new
15+
work; watchOS is **externally blocked** by Apple (no XCUITest UI automation).
16+
17+
**The taxonomy decision:** add an **`AppleOS` discriminant under one `apple` Platform** — do **not** promote
18+
each OS to its own `Platform` literal. Reasons from the code:
19+
- `DeviceTarget` (`mobile|tv|desktop`) is already **cross-platform** — Android TV uses `target:'tv'`, so a
20+
`tvos` Platform literal would collide with the form-factor axis. (tvOS is *currently* hacked onto
21+
`target:'tv'`; the fix is a dedicated `AppleOS` leaf, **not** more overloading of `target`.)
22+
- Promoting to literals explodes the ~15 `isApplePlatform` and ~52 `platform==='macos'` sites to enumerate
23+
six literals each, and breaks the single-bucket `apple` capability/selector model that already works.
24+
25+
## Before / after
26+
27+
```
28+
BEFORE — Apple support is smeared across an "iOS" folder that is really the Apple engine
29+
─────────────────────────────────────────────────────────────────────────────────────────
30+
31+
DeviceInfo (src/utils/device.ts)
32+
platform: ios | macos | android | linux | web ← macOS is its OWN literal …
33+
kind: simulator | emulator | device
34+
target?: mobile | tv | desktop ← … but tvOS = ios + target:'tv' (asymmetric!)
35+
'tv'/'desktop' also used by Android (cross-platform)
36+
37+
resolveApplePlatformName(target) ──► 'iOS' | 'tvOS' | 'macOS' ← OS name INFERRED late & lossily
38+
39+
core/interactors.ts: ios ─┐
40+
macos ─┴─► createAppleInteractor (one Apple owner already exists!)
41+
42+
┌─ src/platforms/ios/ (~14.2k LOC — the "iOS" name is a lie: ~85% is the Apple engine) ─────────┐
43+
│ ░ APPLE-SHARED ENGINE (OS-agnostic, ~12k) ░ │
44+
│ runner/ stack ........ 6,136 LOC / 17 files (speaks JSON to a Swift host w/ #if os()) │
45+
│ apple-runner-platform.ts ► RUNNER_PROFILES = { iOS, tvOS, macOS } (3 rows) │
46+
│ discovery · tool-provider · snapshot/xml · screenshot · perf · debug-symbols │
47+
│ ▓ iOS leaf ▓ touch synthesis · status-bar override · xctrace perf │
48+
│ ▓ tvOS leaf ▓ XCUIRemote focus / remotePress │
49+
│ ▒ macOS leaf — MISLABELED HERE (~797 LOC) ▒ macos-helper · macos-apps · host-provider · scroll │
50+
└───────────────────────────────▲─────────────────────────────────────────────────────────────────┘
51+
│ imports (dependency arrow points BACKWARD)
52+
src/platforms/macos/devices.ts = 19-LOC stub
53+
54+
Discovery: xcrun simctl list ─► filter admits only {ios, tvos} ─► watchOS, visionOS SILENTLY DROPPED
55+
Capabilities: ONE 'apple' bucket + scattered re-derivation
56+
isNotMacOs ×5 · isIosMobileSimulator · synthesisGestureUnsupportedHint · dispatch hard-throws ×3
57+
('macos' string in 52 files · 'tv' in 15)
58+
```
59+
60+
```
61+
AFTER — one 'apple' platform, an AppleOS leaf axis, the engine named for what it actually is
62+
─────────────────────────────────────────────────────────────────────────────────────────────
63+
64+
DeviceInfo
65+
platform: apple | android | linux | web ← ios + macos collapse into 'apple'
66+
appleOs: ios | ipados | tvos | watchos | visionos | macos ← NEW discriminant, stored at discovery
67+
kind: simulator | device
68+
target?: mobile | tv | desktop ← UNCHANGED, stays orthogonal (shared w/ Android)
69+
70+
resolveAppleOs(device) ──► reads appleOs (fallback: legacy target inference) ← single seam
71+
72+
Apple plugin = one instance of the PlatformPlugin registry (perfect-shape.md §5.1),
73+
owning every leaf OS via appleOs
74+
75+
┌─ src/platforms/apple/ ──────────────────────────────────────────────────────────────────────────┐
76+
│ core/ ░ OS-agnostic Apple engine — MOVED VERBATIM from platforms/ios ░ │
77+
│ runner/ (6,136 LOC) · tool-provider · discovery (absorbs the old stub, filter widened) │
78+
│ snapshot · screenshot · perf · debug-symbols │
79+
│ os-profiles.ts ► RUNNER_PROFILES = { iOS, iPadOS, tvOS, macOS, visionOS, watchOS✗ } (3 → 6) │
80+
│ interactor.ts (from core/interactors/apple.ts) │
81+
│ os/ ← leaf code ONLY (genuinely per-OS) │
82+
│ ios/ touch synthesis · status-bar · xctrace │
83+
│ ipados/ aliases ios (only if iPad-specific features are modeled) │
84+
│ tvos/ XCUIRemote focus — NO coordinate tap (contract differs; NOT flattened) │
85+
│ macos/ AppKit: helper binary · host-provider · desktop-scroll · menubar/desktop surfaces │
86+
│ visionos/ NEW — feasible: profile + build case + #if os(visionOS) + real spatial-input QA │
87+
│ watchos/ ⛔ unsupported sentinel — XCUITest can't drive watchOS (declared, gated at admission) │
88+
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
89+
90+
Capabilities: per-AppleOS DATA TABLE (mirrors os-profiles + the Swift #if os() guards 1:1)
91+
{ inputModel, multiTouch, gestures{pinch,rotate,transform}, surfaces, keyboard, orientation }
92+
⇒ scattered isNotMacOs / target!=='tv' predicates collapse into one lookup
93+
```
94+
95+
## Per-OS readiness (honest)
96+
97+
| OS | Status | Reality |
98+
|---|---|---|
99+
| **iOS** | works | Reference path. |
100+
| **iPadOS** | works | Rides iOS *identically* (matched by `/ipad/`). Zero runner work; splitting it is a naming/label concern — only worth it if Stage Manager / pointer / Pencil are actually modeled. |
101+
| **tvOS** | works | Functional but modeled as `ios + target:'tv'`. Promotion = **rename to a leaf**; XCUIRemote focus + no-coordinate-tap behavior already exists. |
102+
| **macOS** | works | Same XCUITest project (`build:xcuitest:macos`) **plus** a separate `agent-device-macos-helper` Swift binary for AX surfaces. ~797 LOC already (mis)lives in `platforms/ios`. AppKit, not UIKit — kept as a distinct leaf, not folded into the touch model. |
103+
| **visionOS** | feasible, net-new | XCUITest *does* support visionOS. Needs `xros` in `SUPPORTED_PLATFORMS`, a profile row, a build case, `#if os(visionOS)`, a widened discovery filter, **and real QA** of spatial input (look+pinch, no flat coordinates) + multi-window snapshot. Good first net-new OS to validate the leaf pattern. |
104+
| **watchOS** | blocked by Apple | **XCUITest cannot drive watchOS UI** (no `XCUIApplication`). Not a code gap. Model as an explicit *unsupported sentinel* — do not promise it from this runner. |
105+
106+
## On macOS (the one to think about)
107+
108+
macOS is **AppKit**, the odd one out at the UI-framework level — so it's reasonable to ask whether it
109+
belongs. The code says **include it, as a distinct leaf**, for two reasons:
110+
111+
1. It's already the **same XCUITest project** the iOS/tvOS runner uses (`build:xcuitest:macos`) — it is
112+
already in the Apple runner, not a separate harness.
113+
2. **~797 LOC of macOS code already lives *inside* `platforms/ios`** (`macos-helper`, `macos-apps`,
114+
`macos-host-provider`, `desktop-scroll`), and `platforms/macos/devices.ts` is a 19-LOC stub the iOS
115+
discovery imports — the dependency already points backwards.
116+
117+
So macOS is *already entangled* in the Apple stack; **excluding it would leave the mislabel in place**,
118+
which is worse. Consolidation **normalizes** macOS (today it's the only Apple OS with its own `Platform`
119+
literal while tvOS rides `target`) without **homogenizing** it: its AppKit specifics — the macos-helper
120+
backend, the menubar/desktop/frontmost-app surface model, coordinate-pinch, no multi-touch — stay in the
121+
`apple/os/macos/` leaf. The leaf boundary is exactly what protects the AppKit difference.
122+
123+
## Target shape
124+
125+
```
126+
src/platforms/apple/
127+
core/ ← the ~12k OS-agnostic engine, moved verbatim from platforms/ios
128+
runner/ (6,136 LOC, 17 files — never needed to know which Apple OS it drives)
129+
os-profiles.ts (apple-runner-platform.ts RUNNER_PROFILES, 3 → 6 rows)
130+
discovery.ts tool-provider/ snapshot/ screenshot/ perf/ debug-symbols/ apps.ts
131+
interactor.ts (from core/interactors/apple.ts)
132+
os/
133+
ios/ ipados/ tvos/ macos/ ← leaf code only (synthesis / focus / AppKit helper / surfaces)
134+
visionos/ (new, when pursued) watchos/ (unsupported sentinel)
135+
```
136+
137+
This is the Apple plugin from the roadmap, now owning *N OS leaves* via `AppleOS`. Capabilities become a
138+
**per-`AppleOS` data table** mirroring `RUNNER_PROFILES` and the Swift `#if os()` guards 1:1, replacing the
139+
scattered `target!=='tv'` / `platform!=='macos'` predicates.
140+
141+
## Sequencing (strangler-fig, low-risk first)
142+
143+
1. **Additive `appleOs`** (non-breaking): add `appleOs?: AppleOS` to `DeviceInfo`, populate it at discovery
144+
(the runtime/productType is already known there), and make `resolveApplePlatformName` /
145+
`resolveRunnerPlatformName` prefer it with the existing target inference as fallback. Extend
146+
`RUNNER_PROFILES` 3 → 6. Instantly makes iOS/iPadOS/tvOS/macOS first-class and unambiguous without
147+
touching the ~50 `macos`/`isApplePlatform` call sites. *(Aligns with AGENTS.md's "Apple-family target
148+
changes must keep device.ts, capabilities.ts, dispatch-resolve.ts, ios/devices.ts, ios/runner-xctestrun.ts
149+
in sync" rule — this step is what makes that rule a single seam instead of a five-file checklist.)*
150+
2. **Relocate macOS** out of `platforms/ios` into `apple/os/macos/` and invert the `macos/devices.ts` stub.
151+
Self-contained de-scatter; the single biggest mislabel removed.
152+
3. **Move the OS-agnostic core** (`runner/` stack, tool-provider, discovery, snapshot, screenshot, perf,
153+
debug-symbols, `os-profiles.ts`) `platforms/ios``platforms/apple/core` — pure move + re-export.
154+
Rename `ios-runner/``apple-runner/` (cosmetic).
155+
4. **Promote tvOS** from `ios + target:'tv'` to an `apple/os/tvos/` leaf (rename; behavior already exists).
156+
5. **visionOS** as the first net-new OS: profile row + `SUPPORTED_PLATFORMS += xros` + build case +
157+
`#if os(visionOS)` + widened discovery + **budgeted spatial-input/snapshot QA**.
158+
6. **watchOS** = explicit unsupported sentinel (declared, gated at admission), no runner work.
159+
7. **Per-`AppleOS` capability table** replaces the scattered predicates (after the leaves exist).
160+
161+
Steps 1–4 compose with the roadmap's **Phase 3 (platform plugin)** — the Apple plugin is the first real
162+
`PlatformPlugin`, and it owns the `AppleOS` leaves. Step 1 can land early as additive groundwork.
163+
164+
## Risks / do-not-flatten
165+
166+
- **tvOS has a different interaction contract** (focus-only; `tap(x,y)` returns `UNSUPPORTED` off the
167+
focused element). A uniform tap across Apple OSes is wrong by design — keep the per-OS capability gates.
168+
- **macOS is a hybrid backend** (XCTest runner *and* the macos-helper binary). Don't fold the helper into
169+
the runner.
170+
- **visionOS is feasible but unvalidated** — spatial windowing, ornaments, multi-window viewport inference,
171+
and look+pinch synthesis need real device/sim QA. "Just add a profile row" under-counts it.
172+
- **Snapshot fidelity is uneven** — the deep-RN-tree AX-server fallback is iOS-simulator-only; macOS / tvOS
173+
/ visionOS rely on public XCTest snapshots, so a unified "apple snapshot" has materially different
174+
reliability per OS.
175+
- **`watchos` must be gated unsupported**, or it surfaces a selectable device with no runner backend.
176+
- The relocation touches ~52 `macos` + ~15 `tv` references — mechanical (move + re-export) but high diff;
177+
stage as create-re-export → move-leaves → flip-the-stub-inversion-last to keep each step shippable.

0 commit comments

Comments
 (0)