Skip to content

feat: registry-driven exhaustive command dispatch — Phase 1 step 5#910

Merged
thymikee merged 1 commit into
mainfrom
feat/command-descriptor-dispatch-map
Jun 27, 2026
Merged

feat: registry-driven exhaustive command dispatch — Phase 1 step 5#910
thymikee merged 1 commit into
mainfrom
feat/command-descriptor-dispatch-map

Conversation

@thymikee

Copy link
Copy Markdown
Member

Phase 1 step 5 of the command-descriptor migration (ADR 0008). Stacked on #909.

What

Part A — enabler. In src/core/command-descriptor/registry.ts, the commandDescriptors table is now as const (RAW_COMMAND_DESCRIPTORS … as const satisfies readonly Omit<CommandDescriptor, 'mcpExposed'>[], with the as const flowing through the .map so each entry keeps its literal name). Exports the literal command-name union:

export type Command = (typeof commandDescriptors)[number]['name'];

The closure/function fields (supports, unsupportedHint, allowSessionlessDefaultDevice, …) are fine under as const; tsc stays clean. defineCommandDescriptor is no longer needed in the map (it widened name to string) and is dropped from the registry's usage.

Part B — exhaustive dispatch map. Replaces the dispatchKnownCommand switch (command) { case …: return handle*Command(…); default: throw … } with a const DISPATCH_HANDLERS: Record<DispatchCommand, DispatchHandler> lookup. Every command routes to the identical handler with the identical arguments and return value the switch used; the inline cases (close/back/home/rotate/app-switcher) are preserved verbatim as inline arrows.

How exhaustiveness is enforced

The Record<DispatchCommand, DispatchHandler> type forces every dispatch command to have a handler — a missing entry is now a COMPILE error (verified: deleting the back handler yields TS2741: Property 'back' is missing … but required in type 'Record<DispatchCommand, DispatchHandler>'). This replaces the runtime default: throw as the coverage safety net. A runtime guard remains: Object.hasOwn(DISPATCH_HANDLERS, command) gates the lookup and throws the same INVALID_ARGS Unknown command: ${command} error for unknown commands — and prevents inherited keys (e.g. toString) from resolving to a prototype method.

DispatchCommand is hand-authored to match the switch cases verbatim. It is deliberately NOT derived from the registry's generic daemon route: that set is both narrower (no open/type/read/etc.) and includes gesture (which dispatch never handled), and swipe-preset / read are not registry command names at all. The mismatch is noted in a doc comment on the type.

Behaviorless

Same routing, same handler args, same results, same error for unknown commands. No runtime behavior changed.

Verification

  • tsc -p tsconfig.json --noEmit → exit 0; missing-handler probe → compile error (TS2741); Command literal-union probe → invalid name rejected.
  • oxfmt --write + oxlint --deny-warnings on changed files → clean.
  • vitest run dispatch core/dispatch dispatch-interactions daemon core/command-descriptor → 109 files, 992 tests passed.
  • Layering Guard git grep → empty.

iOS e2e smoke pending reviewer.

@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.4 MB 1.4 MB +770 B
JS gzip 445.4 kB 445.5 kB +115 B
npm tarball 584.6 kB 584.7 kB +116 B
npm unpacked 2.0 MB 2.0 MB +770 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 24.3 ms 24.2 ms -0.1 ms
CLI --help 43.1 ms 43.1 ms +0.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/9722.js +770 B +115 B

@thymikee

Copy link
Copy Markdown
Member Author

iOS smoke (booted iPhone 17 Pro, source build of this tip): screenshot — which routes through the new DISPATCH_HANDLERS map — succeeded end-to-end (378 KB PNG), confirming registry-driven dispatch routes correctly. snapshot/scroll hit a runner-lease conflict from a leftover smoke daemon (iOS runner … already owned by another agent-device daemon — environment, not a routing regression; cleaned up afterward). Combined with the full dispatch+interaction+daemon suite (992 tests), compile-time exhaustiveness (deleting a handler → TS2741), and the Object.hasOwn guard preserving the identical unknown-command error, the flip is verified behaviorless.

@thymikee thymikee force-pushed the feat/command-descriptor-batch-flip branch from 7eecb1d to 9cffb43 Compare June 27, 2026 16:25
@thymikee thymikee force-pushed the feat/command-descriptor-dispatch-map branch from ce9217a to bae3034 Compare June 27, 2026 16:25
thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
@thymikee thymikee force-pushed the feat/command-descriptor-batch-flip branch from 9cffb43 to 7479e03 Compare June 27, 2026 18:07
@thymikee thymikee force-pushed the feat/command-descriptor-dispatch-map branch from bae3034 to 9be0b60 Compare June 27, 2026 18:07
thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
@thymikee thymikee force-pushed the feat/command-descriptor-batch-flip branch from 7479e03 to 6f5f872 Compare June 27, 2026 18:22
@thymikee thymikee force-pushed the feat/command-descriptor-dispatch-map branch from 9be0b60 to b3dd13c Compare June 27, 2026 18:24
thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
Base automatically changed from feat/command-descriptor-batch-flip to main June 27, 2026 18:39
Part A (enabler): make commandDescriptors `as const` so each entry keeps its
literal `name`, and export `Command` — the literal command-name union.

Part B: replace the dispatchKnownCommand switch with a
`Record<DispatchCommand, DispatchHandler>` lookup table. The Record type forces
every dispatch command to have a handler (a missing entry is now a COMPILE
error, replacing the runtime `default: throw` as the coverage net); an
`Object.hasOwn` guard preserves the identical INVALID_ARGS error for unknown
commands. DispatchCommand is hand-authored to match the switch cases verbatim
(it is not the registry `generic` route, and `swipe-preset`/`read` aren't
registry names). Strictly behaviorless: same routing, same handler args, same
results.
@thymikee thymikee force-pushed the feat/command-descriptor-dispatch-map branch from b3dd13c to 0c56eb2 Compare June 27, 2026 18:47
thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
@thymikee thymikee merged commit 6a65e61 into main Jun 27, 2026
20 checks passed
@thymikee thymikee deleted the feat/command-descriptor-dispatch-map branch June 27, 2026 18:59
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-27 18:59 UTC

thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
thymikee added a commit that referenced this pull request Jun 27, 2026
…lify #909) — Phase 1 step 7 (#912)

The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant