Skip to content

Commit 909c140

Browse files
committed
refactor: centralize daemon command registry
1 parent 6babdfb commit 909c140

25 files changed

Lines changed: 711 additions & 495 deletions

AGENTS.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect
6666

6767
## Routing
6868
- Keep `src/daemon.ts` as a thin router.
69-
- Keep command names and daemon routing groups centralized in `src/command-catalog.ts`; do not re-create command string sets in handlers or request policy modules.
69+
- Keep command names centralized in `src/command-catalog.ts`; do not re-create command identity sets in handlers or request policy modules.
70+
- Keep daemon routing and request-policy traits centralized in `src/daemon/daemon-command-registry.ts`; request modules should consume its predicates instead of recreating command string sets. See `docs/adr/0003-daemon-command-registry.md`.
7071
- Keep command input/output contracts in the command modules:
7172
- command surface and shared schemas: `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/command-input.ts`
7273
- typed client command execution: `src/commands/client-command-contracts.ts`
@@ -76,7 +77,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect
7677
- CLI/client/runtime output projection: `src/commands/cli-output.ts`, `src/commands/client-output.ts`, `src/commands/runtime-output.ts`
7778
- Do not reintroduce CLI-shaped command adapters or schemas as a second source of truth. CLI, Node.js, and MCP should project from command contracts.
7879
- Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch.
79-
- New daemon handler-family commands must update the relevant `DAEMON_COMMAND_GROUPS.*Handler` entry and the handler module's exported `*_COMMAND_HANDLERS` coverage table; `src/daemon/__tests__/request-handler-catalog.test.ts` guards drift and overlap.
80+
- New daemon handler-family commands must update `src/daemon/daemon-command-registry.ts` with the route and request-policy traits. `src/daemon/__tests__/daemon-command-registry.test.ts` guards route and policy traits; handler catalog tests keep executable handler sanity checks.
8081
- Put request policies in focused request modules:
8182
- tenant/lease/selector/lock admission: `src/daemon/request-admission.ts`
8283
- artifact/error finalization: `src/daemon/request-finalization.ts`
@@ -89,7 +90,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect
8990
- snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
9091
- find: `src/daemon/handlers/find.ts`
9192
- record/trace: `src/daemon/handlers/record-trace.ts`
92-
- Generic passthrough (press/scroll/type) is daemon fallback only after handlers return null.
93+
- Commands routed as generic in `src/daemon/daemon-command-registry.ts` fall through to daemon fallback dispatch after specialized handlers return null.
9394

9495
## Toolchain Snapshot
9596
- Package manager: `pnpm` only. Do not add or restore `package-lock.json`.
@@ -274,9 +275,9 @@ Command-only flags (like `find --first`) that do not flow to the platform layer
274275
- Shared action helpers: `src/daemon/action-utils.ts`
275276
- Snapshot shaping + labels: `src/daemon/snapshot-processing.ts`
276277
- Handler context helpers: `src/daemon/context.ts`, `src/daemon/device-ready.ts`
277-
- Request routing/policy: `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts`
278+
- Request routing/policy: `src/daemon/daemon-command-registry.ts`, `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts`
278279
- Dispatcher + capability map: `src/core/dispatch.ts`, `src/core/dispatch-context.ts`, `src/core/dispatch-interactions.ts`, `src/core/capabilities.ts`
279-
- Command catalog + command surface: `src/command-catalog.ts`, `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/client-command-contracts.ts`
280+
- Command identity + command surface: `src/command-catalog.ts`, `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/client-command-contracts.ts`
280281
- CLI grammar: `src/commands/cli-grammar.ts`, `src/commands/cli-grammar/*`
281282
- Daemon request projection: `src/commands/command-projection.ts`
282283
- Platform backends: `src/platforms/ios/*`, `ios-runner/*`, `src/platforms/android/*`

CONTEXT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
- Modality: broad supported device family, such as mobile, tv, or desktop.
1515
- Session: daemon-owned state for a selected target and opened app or surface.
1616
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
17-
- Runner command traits: the iOS XCTest runner's per-command-type classification across three independent axes — interaction (gates the foreground-guard and stabilization preflight), read-only (gates the session-invalidating retry; the alert command is read-only only for its `get` action), and runner-lifecycle (skips the app-activation preflight). One source of truth keyed by command type, distinct from the daemon-side Command surface.
17+
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
18+
- Runner command traits: the iOS XCTest runner's per-command-type classification across three independent axes — interaction (gates the foreground-guard and stabilization preflight), read-only (gates the session-invalidating retry; the alert command is read-only only for its `get` action), and runner-lifecycle (skips the app-activation preflight). One source of truth keyed by command type, distinct from the public command surface and daemon command registry.
1819

1920
## Testing Principles
2021

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# ADR 0003: Daemon Command Registry
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
Daemon request handling depends on command traits that are not part of the public command surface:
10+
which handler route owns a command, whether tenant lease admission applies, whether session
11+
execution should lock, whether selector validation applies, whether replay can run an action in the
12+
current session scope, whether invalid recordings block the request, whether Android blocking-dialog
13+
recovery applies, and how request-scoped providers resolve a device.
14+
15+
Those traits used to be spread across `src/command-catalog.ts`, request-policy modules, and
16+
handler-local coverage tables. That made `src/command-catalog.ts` carry daemon-only behavior next
17+
to public command identity, and it required duplicate command sets to stay aligned by convention.
18+
19+
## Decision
20+
21+
Keep public command identity in `src/command-catalog.ts` and public input/output contracts in
22+
`src/commands/**`.
23+
24+
Add `src/daemon/daemon-command-registry.ts` as the daemon-side source of truth for command route
25+
ownership and daemon request-policy traits. Request modules consume predicate functions from the
26+
registry instead of recreating command string sets. Handler modules own execution logic only; they do
27+
not export duplicate coverage tables to prove route membership.
28+
29+
The daemon registry is internal-only. It must not define CLI grammar, Node.js client options, MCP
30+
schemas, user-facing help, or platform capability support. Those remain owned by the command
31+
contract, projection, help, and capability modules.
32+
33+
## Alternatives Considered
34+
35+
- Keep daemon groups in `src/command-catalog.ts`: this keeps one command-name file, but it mixes
36+
public command identity with daemon runtime policy and makes the catalog grow for internal-only
37+
routing decisions.
38+
- Keep handler-local coverage tables: this makes each handler self-describing, but creates a second
39+
route membership source that can drift from the router and request-policy modules.
40+
- Put route checks directly in request modules: this is locally simple, but scatters command
41+
classification across admission, locking, provider scoping, replay, recording, and generic
42+
dispatch.
43+
44+
## Consequences
45+
46+
Adding or moving a daemon-handled command requires updating the daemon command registry with its
47+
route and request-policy traits. The registry tests pin the trait decisions, while provider-backed
48+
integration scenarios verify important request-policy behavior through the real daemon request path.
49+
50+
The registry file is intentionally a dense internal contract. Its interface should stay small:
51+
callers ask daemon-policy questions through named predicates rather than reading or mutating command
52+
sets.
53+
54+
`AGENTS.md` should contain only the operating rule and relevant file pointers for agents. This ADR
55+
owns the rationale so future changes do not need to infer it from agent instructions.

src/__tests__/cli-grammar.test.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { DAEMON_COMMAND_GROUPS, PUBLIC_COMMANDS } from '../command-catalog.ts';
43
import { readInputFromCli } from '../commands/cli-grammar.ts';
54
import type { CliFlags } from '../utils/cli-flags.ts';
65

@@ -10,12 +9,6 @@ const BASE_FLAGS: CliFlags = {
109
version: false,
1110
};
1211

13-
test('command catalog owns daemon routing groups', () => {
14-
assert.equal(DAEMON_COMMAND_GROUPS.snapshot.has(PUBLIC_COMMANDS.wait), true);
15-
assert.equal(DAEMON_COMMAND_GROUPS.observability.has(PUBLIC_COMMANDS.logs), true);
16-
assert.equal(DAEMON_COMMAND_GROUPS.replay.has(PUBLIC_COMMANDS.test), true);
17-
});
18-
1912
test('wait grammar preserves CLI bare text forms', () => {
2013
const options = readInputFromCli('wait', ['Continue', '1500'], BASE_FLAGS);
2114
assert.equal(options.text, 'Continue');

src/command-catalog.ts

Lines changed: 0 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -152,128 +152,6 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
152152
PUBLIC_COMMANDS.trace,
153153
);
154154

155-
export const DAEMON_COMMAND_GROUPS = {
156-
inventory: commandSet(
157-
INTERNAL_COMMANDS.sessionList,
158-
PUBLIC_COMMANDS.devices,
159-
PUBLIC_COMMANDS.apps,
160-
),
161-
state: commandSet(PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.appState),
162-
observability: commandSet(PUBLIC_COMMANDS.perf, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network),
163-
replay: commandSet(PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test),
164-
snapshot: commandSet(
165-
PUBLIC_COMMANDS.snapshot,
166-
PUBLIC_COMMANDS.diff,
167-
PUBLIC_COMMANDS.wait,
168-
PUBLIC_COMMANDS.alert,
169-
PUBLIC_COMMANDS.settings,
170-
),
171-
replayScopedAction: commandSet(
172-
PUBLIC_COMMANDS.alert,
173-
PUBLIC_COMMANDS.back,
174-
PUBLIC_COMMANDS.click,
175-
PUBLIC_COMMANDS.clipboard,
176-
PUBLIC_COMMANDS.diff,
177-
PUBLIC_COMMANDS.fill,
178-
PUBLIC_COMMANDS.find,
179-
PUBLIC_COMMANDS.gesture,
180-
PUBLIC_COMMANDS.get,
181-
PUBLIC_COMMANDS.home,
182-
PUBLIC_COMMANDS.is,
183-
PUBLIC_COMMANDS.keyboard,
184-
PUBLIC_COMMANDS.longPress,
185-
'pinch',
186-
PUBLIC_COMMANDS.press,
187-
PUBLIC_COMMANDS.record,
188-
PUBLIC_COMMANDS.reactNative,
189-
PUBLIC_COMMANDS.rotate,
190-
PUBLIC_COMMANDS.screenshot,
191-
PUBLIC_COMMANDS.scroll,
192-
PUBLIC_COMMANDS.settings,
193-
PUBLIC_COMMANDS.snapshot,
194-
PUBLIC_COMMANDS.swipe,
195-
PUBLIC_COMMANDS.type,
196-
PUBLIC_COMMANDS.wait,
197-
),
198-
androidBlockingDialogGuardedAction: commandSet(
199-
PUBLIC_COMMANDS.back,
200-
PUBLIC_COMMANDS.click,
201-
PUBLIC_COMMANDS.fill,
202-
PUBLIC_COMMANDS.focus,
203-
PUBLIC_COMMANDS.gesture,
204-
PUBLIC_COMMANDS.home,
205-
PUBLIC_COMMANDS.keyboard,
206-
PUBLIC_COMMANDS.longPress,
207-
'fling',
208-
'pan',
209-
'pinch',
210-
PUBLIC_COMMANDS.press,
211-
PUBLIC_COMMANDS.rotate,
212-
'rotate-gesture',
213-
PUBLIC_COMMANDS.scroll,
214-
PUBLIC_COMMANDS.swipe,
215-
'transform-gesture',
216-
PUBLIC_COMMANDS.type,
217-
),
218-
selectorValidationExempt: commandSet(
219-
INTERNAL_COMMANDS.sessionList,
220-
PUBLIC_COMMANDS.devices,
221-
INTERNAL_COMMANDS.releaseMaterializedPaths,
222-
),
223-
leaseAdmissionExempt: commandSet(
224-
INTERNAL_COMMANDS.sessionList,
225-
PUBLIC_COMMANDS.devices,
226-
INTERNAL_COMMANDS.releaseMaterializedPaths,
227-
INTERNAL_COMMANDS.leaseAllocate,
228-
INTERNAL_COMMANDS.leaseHeartbeat,
229-
INTERNAL_COMMANDS.leaseRelease,
230-
),
231-
// Specialized daemon handler families. Commands absent from these sets fall through to
232-
// request-generic-dispatch after request admission and provider scoping.
233-
leaseHandler: commandSet(
234-
INTERNAL_COMMANDS.leaseAllocate,
235-
INTERNAL_COMMANDS.leaseHeartbeat,
236-
INTERNAL_COMMANDS.leaseRelease,
237-
),
238-
sessionHandler: commandSet(
239-
INTERNAL_COMMANDS.installSource,
240-
INTERNAL_COMMANDS.releaseMaterializedPaths,
241-
INTERNAL_COMMANDS.sessionList,
242-
PUBLIC_COMMANDS.appState,
243-
PUBLIC_COMMANDS.apps,
244-
PUBLIC_COMMANDS.batch,
245-
PUBLIC_COMMANDS.boot,
246-
PUBLIC_COMMANDS.clipboard,
247-
PUBLIC_COMMANDS.close,
248-
PUBLIC_COMMANDS.devices,
249-
PUBLIC_COMMANDS.install,
250-
PUBLIC_COMMANDS.keyboard,
251-
PUBLIC_COMMANDS.logs,
252-
PUBLIC_COMMANDS.network,
253-
PUBLIC_COMMANDS.open,
254-
PUBLIC_COMMANDS.perf,
255-
PUBLIC_COMMANDS.prepare,
256-
PUBLIC_COMMANDS.push,
257-
PUBLIC_COMMANDS.reinstall,
258-
PUBLIC_COMMANDS.replay,
259-
PUBLIC_COMMANDS.test,
260-
PUBLIC_COMMANDS.triggerAppEvent,
261-
INTERNAL_COMMANDS.runtime,
262-
),
263-
reactNativeHandler: commandSet(PUBLIC_COMMANDS.reactNative),
264-
recordTraceHandler: commandSet(PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.trace),
265-
findHandler: commandSet(PUBLIC_COMMANDS.find),
266-
interactionHandler: commandSet(
267-
PUBLIC_COMMANDS.click,
268-
PUBLIC_COMMANDS.fill,
269-
PUBLIC_COMMANDS.get,
270-
PUBLIC_COMMANDS.is,
271-
PUBLIC_COMMANDS.longPress,
272-
PUBLIC_COMMANDS.press,
273-
PUBLIC_COMMANDS.type,
274-
),
275-
} as const;
276-
277155
function commandSet(...commands: readonly string[]): ReadonlySet<string> {
278156
return new Set(commands);
279157
}

0 commit comments

Comments
 (0)