Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
# `@suaveplan/api-worker-host` — worker-side runtime hosting one plugin
**Package**: `@suaveplan/api-worker-host`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 3 (Worker isolation infrastructure)
## Why
`isolated` and `sandboxed` plugins run in their own `Worker` thread (design doc §2.2). Something inside that worker has to: load the plugin module, build a `PluginContext` whose capability slots are RPC proxies back to the main host, drive the plugin's own lifecycle hooks, and surface crashes / OOM / wall-clock-budget exhaustion back to the main host's `LifecycleDriver`. `api-worker-host` is that something. It's the worker-side counterpart to `api-host`: where `api-host` runs in the main process and orchestrates plugins, `api-worker-host` runs inside each worker and hosts exactly one plugin.
Splitting this out from `api-host` keeps the main-process bundle free of worker-only code and lets us evolve worker-side concerns (heap limits, AbortController plumbing, crash telemetry) independently.
## What changes
Net-new package. Server-tier (`worker_threads` API). Single binary entry-point: a Worker boot script that the main host's loader spawns with `new Worker(...)`.
**ADDED exports:**
- `./worker-boot` — script entry; reads `workerData` (manifest, plugin module specifier, capability port table); imports the plugin module via `await import(specifier)`; wires every capability slot from its `MessagePort` via `api-worker-rpc`'s `createRpcCaller`; constructs `PluginContext` + `RequestContext`; signals readiness back to main.
- `./lifecycle-receiver` — receives lifecycle commands (`install`, `start`, `stop`, `uninstall`, `reload`) over a dedicated control port; dispatches to the plugin's hooks; propagates outcomes (success / `SuaveplanError` / unhandled throw) back to main.
- `./crash-detector` — `process.on("uncaughtException")` / `process.on("unhandledRejection")` handlers; converts to a final `WORKER_CRASHED` envelope sent over the control port; calls `process.exit(1)` so the main host sees the worker die.
- `./budget-guard` — periodic check of `process.memoryUsage().heapUsed` against `host.config.worker.heapLimitBytes`; CPU sampling via `performance.now()` deltas; wall-clock-budget enforcement on `start`/`stop`/`install`/`uninstall` hooks. Exceeding any budget triggers a controlled `WORKER_BUDGET_EXCEEDED` exit.
- `./request-dispatcher` — receives marshalled requests from `api-router-worker-bridge`; constructs a per-request `RequestContext`; runs the handler under the plugin's `AbortSignal`; serialises the response back.
- `./error-codes` — `WORKER_*` codes (`WORKER_CRASHED`, `WORKER_BUDGET_EXCEEDED`, `WORKER_HEAP_LIMIT`, `WORKER_CPU_BUDGET`, `WORKER_WALL_CLOCK_BUDGET`, `WORKER_BOOT_FAILED`, `WORKER_MODULE_IMPORT_FAILED`).
- `./testing` — in-process worker simulator (uses `MessageChannel` instead of a real `Worker`) so conformance suites run without spawning real OS threads.
## Out of scope
- **Sandbox isolation primitives** — `vm.Context` guards and WASI permission gates live in `api-sandbox-vm` / `api-sandbox-wasm` (Stage 8). This package hosts the plugin module; the sandbox wraps the host.
- **Capability marshalling protocol** — that's `api-worker-rpc`. This package CONSUMES it.
- **Main-host spawning** — `new Worker(...)` and worker pool management live in `api-host`'s `LifecycleDriver`. This package only provides the script that runs INSIDE the worker.
- **HTTP request marshalling** — `api-router-worker-bridge` owns the request/response boundary; this package only receives the already-marshalled handler invocation.
## Source mapping
Genesis-original. Design doc sections: §2.2 (isolation tiers), §5.2a (worker-RPC variants), §4 (`crashed` state propagation), §8.5 (`forceTimeoutMs` on stragglers).
| Requirement | Design doc section |
|---|---|
| Worker hosts exactly one plugin | §2.2 |
| Plugin author writes the same `definePlugin` regardless of tier | §2.2 |
| Crash detection propagates to main → `crashed` state | §2.2, §4 |
| Auto-restart `crashRestartMax` × triggered by main | §2.2 |
| Wall-clock / heap / CPU budgets | §2.2 ("CPU/mem budgets") |
| Capability slots are RPC proxies via `MessagePort` | §5.2a |
## Verification approach
- `src/worker-boot/worker-boot.test.ts` — boots the worker simulator with a trivial plugin module; readiness signal observed; capability slots present.
- `src/lifecycle-receiver/lifecycle-receiver.test.ts` — every command dispatches to the right hook; throws propagate as `SuaveplanError`.
- `src/crash-detector/crash-detector.test.ts` — `uncaughtException` becomes `WORKER_CRASHED` envelope; non-zero exit.
- `src/budget-guard/budget-guard.test.ts` — heap-limit triggers `WORKER_HEAP_LIMIT`; wall-clock exceeded on `start` triggers `WORKER_WALL_CLOCK_BUDGET`.
- `src/request-dispatcher/request-dispatcher.test.ts` — request envelope decoded → handler runs → response encoded; abort signal fires on main-side cancel.
- `tests/integration/real-worker.test.ts` — spawns a real `Worker` (gated `INTEGRATION=1`); end-to-end boot + lifecycle + crash propagation (timeout 30000).
## Migration impact
None — additive only. `api-host` will depend on this for spawning workers in Stage 4 wiring.

View File

@@ -0,0 +1,106 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-worker-host (api-worker-host)
**Package**: `@suaveplan/api-worker-host`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Worker hosts exactly one plugin
The package SHALL provide a worker-boot script that loads exactly ONE plugin module per Worker thread. The script SHALL read `workerData` for `{ manifest, moduleSpecifier, controlPort, capabilityPorts }`, import the module via `await import(moduleSpecifier)`, validate that the module's default export is a `PluginModule` produced by `definePlugin`, and emit a `WorkerReady` envelope over `controlPort`. Multiple plugins per worker MUST be rejected with `WORKER_BOOT_FAILED`.
#### Scenario: trivial plugin module boots and signals ready
- **WHEN** the simulator is started with a one-route plugin module
- **THEN** the `WorkerReady` envelope is observed on the control port within 100ms
#### Scenario: module without `definePlugin` default export rejects
- **WHEN** the simulator boots with a module whose default export is a plain object
- **THEN** the boot phase throws `WORKER_BOOT_FAILED` AND the worker exits non-zero
**Verified by:** `src/worker-boot/worker-boot.test.ts::"trivial plugin module boots and signals ready"` AND `src/worker-boot/worker-boot.test.ts::"non-definePlugin export rejects with WORKER_BOOT_FAILED"`
---
### Requirement: Lifecycle commands dispatch to plugin hooks over the control port
The package SHALL receive lifecycle command envelopes (`install`, `start`, `stop`, `uninstall`, `reload`) over the dedicated control port. Each command SHALL dispatch to the corresponding hook on the loaded plugin with a freshly-constructed `PluginContext` whose capability slots are RPC proxies wired in `worker-boot`. Hook outcomes SHALL be reported back: success → `LifecycleAck{ok}`, `SuaveplanError` → `LifecycleAck{error}` with the error reconstructed via `api-worker-rpc`'s error-reconstruction, anything else → wrapped in `WORKER_REMOTE_THROW`.
#### Scenario: install hook runs and acks success
- **WHEN** the receiver dispatches `install` AND the plugin's `install` hook returns
- **THEN** a `LifecycleAck{ok: true}` envelope is sent back
#### Scenario: stop hook throw is reconstructed
- **WHEN** the plugin's `stop` hook throws `SuaveplanError("PLUGIN.X.Y")`
- **THEN** the ack envelope carries an error with `code === "PLUGIN.X.Y"`
**Verified by:** `src/lifecycle-receiver/lifecycle-receiver.test.ts::"install hook runs and acks"` AND `src/lifecycle-receiver/lifecycle-receiver.test.ts::"stop hook throw is reconstructed"`
---
### Requirement: Uncaught exceptions surface as WORKER_CRASHED and exit non-zero
The package SHALL install `process.on("uncaughtException")` and `process.on("unhandledRejection")` handlers. Each handler SHALL: (a) emit a `WORKER_CRASHED` envelope over the control port carrying the original `name`, `message`, and stack as `details`, (b) flush the port, (c) call `process.exit(1)`. Silent crashes MUST NOT be possible — main-host's `LifecycleDriver` relies on these envelopes to transition plugins to the `crashed` state.
#### Scenario: uncaught exception emits envelope before exit
- **WHEN** the simulator throws an uncaught exception inside a handler
- **THEN** a `WORKER_CRASHED` envelope is observed on the control port AND the worker process exits with code 1
**Verified by:** `src/crash-detector/crash-detector.test.ts::"uncaught exception emits WORKER_CRASHED envelope"`
---
### Requirement: Heap / CPU / wall-clock budgets trigger controlled exit
The package SHALL sample `process.memoryUsage().heapUsed` at a configurable interval (default 1s) and reject when it exceeds `host.config.worker.heapLimitBytes` with a `WORKER_HEAP_LIMIT` envelope followed by `process.exit(1)`. The package SHALL track CPU consumption via `performance.now()` deltas across handler invocations and trigger `WORKER_CPU_BUDGET` when the rolling window exceeds the configured budget. The package SHALL enforce per-hook wall-clock budgets — a `start` / `stop` / `install` / `uninstall` hook that exceeds its budget MUST surface `WORKER_WALL_CLOCK_BUDGET` AND the hook's promise MUST reject with `AbortError`.
#### Scenario: heap-limit exceeded triggers WORKER_HEAP_LIMIT
- **WHEN** the simulator allocates past `heapLimitBytes`
- **THEN** a `WORKER_HEAP_LIMIT` envelope is observed AND the worker exits
#### Scenario: wall-clock-budget exceeded aborts the hook
- **WHEN** a `start` hook configured with `wallClockBudgetMs: 100` runs for 500ms
- **THEN** the hook's promise rejects with `AbortError` AND `WORKER_WALL_CLOCK_BUDGET` is sent
**Verified by:** `src/budget-guard/budget-guard.test.ts::"heap limit triggers WORKER_HEAP_LIMIT"` AND `src/budget-guard/budget-guard.test.ts::"wall-clock budget aborts hook with AbortError"`
---
### Requirement: Request dispatcher runs handler under plugin AbortSignal
The package SHALL accept marshalled request envelopes (the validated `body` / `params` / `query` per design doc §6, NOT the raw `Request`). It SHALL construct a `RequestContext` with the plugin's capability proxies + a fresh per-request `AbortSignal`, run the route handler, and serialise the response back. Main-side cancellation of the request MUST propagate through `api-worker-rpc`'s abort wiring and fire the request's `AbortSignal`.
#### Scenario: handler runs with validated body in context
- **WHEN** a dispatcher receives an envelope with `body: { name: "x" }`
- **THEN** the handler observes `ctx.request.body.name === "x"` AND a response envelope is sent back
#### Scenario: main-side cancel fires handler abort signal
- **WHEN** main sends an `RpcAbort` mid-dispatch
- **THEN** the handler's `ctx.abortSignal.aborted` becomes `true`
**Verified by:** `src/request-dispatcher/request-dispatcher.test.ts::"handler runs with validated body in context"` AND `src/request-dispatcher/request-dispatcher.test.ts::"main-side cancel fires AbortSignal"`
---
### Requirement: Plugin code cannot distinguish trusted from isolated tier
The package SHALL construct a `PluginContext` whose surface is structurally identical to the one a `trusted`-tier plugin receives directly from `api-host`. The capability slots SHALL be RPC proxies but their method names, return-type wrapping (always `Promise`), and field set MUST match exactly. A plugin author SHALL be able to write `definePlugin({ ... })` once and have it run under any tier without code change.
#### Scenario: structurally-identical PluginContext
- **WHEN** the same plugin module is run under the simulator AND under a direct in-process construction
- **THEN** `Object.keys(ctx)` is identical AND every capability slot has the same method names
**Verified by:** `tests/integration/real-worker.test.ts::"plugin context surface matches direct construction"`

View File

@@ -0,0 +1,72 @@
# Tasks — `@suaveplan/api-worker-host`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc §2.2, §4 (`crashed`), §5.2a, §8.5
- [x] 1.2 Read umbrella
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Draft `specs/api-worker-host/spec.md`
- [ ] 1.6 Every requirement carries `**Verified by:**`
## 2. Implementation
- [ ] 2.1 Scaffold `packages/api/api-worker-host/` (`0.1.0`, server-tier `engines.node`, deps: `@suaveplan/api-worker-rpc`, `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/logger`, `zod`)
- [ ] 2.2 `src/worker-boot/` — entry script reading `workerData`; imports module; wires capability proxies; emits readiness
- [ ] 2.3 `src/lifecycle-receiver/` — control-port command dispatch (install/start/stop/uninstall/reload)
- [ ] 2.4 `src/crash-detector/` — `uncaughtException` + `unhandledRejection` → `WORKER_CRASHED` envelope + `process.exit(1)`
- [ ] 2.5 `src/budget-guard/` — heap / CPU / wall-clock; configurable thresholds; controlled exit on exceed
- [ ] 2.6 `src/request-dispatcher/` — decode handler invocation, run with `AbortSignal`, encode response
- [ ] 2.7 `src/error-codes.ts` — every `WORKER_*` code
- [ ] 2.8 `src/testing/` — `MessageChannel`-based simulator (no real `Worker` spawn)
- [ ] 2.9 `src/index.ts` — namespace re-exports
- [ ] 2.10 `package.json` `bin` entry for the worker-boot script (so `api-host` can spawn it by name)
## 3. Tests
- [ ] 3.1 `src/worker-boot/worker-boot.test.ts` — simulator boots; readiness emitted; capability proxies wired
- [ ] 3.2 `src/lifecycle-receiver/lifecycle-receiver.test.ts` — each command dispatches; hook throw propagates as `SuaveplanError`
- [ ] 3.3 `src/crash-detector/crash-detector.test.ts` — `uncaughtException` → envelope + non-zero exit code
- [ ] 3.4 `src/budget-guard/budget-guard.test.ts` — heap / CPU / wall-clock each tested independently
- [ ] 3.5 `src/request-dispatcher/request-dispatcher.test.ts` — abort signal fires on main-side cancel
- [ ] 3.6 `tests/integration/real-worker.test.ts` — spawn real `Worker`; end-to-end boot → lifecycle → crash (timeout 30000; `INTEGRATION=1`)
- [ ] 3.7 Every `it(...)` carries `{ timeout: 5000 }` (30000 for integration)
## 4. Documentation
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples)
- [ ] 4.2 README links every co-located `.md`
- [ ] 4.3 Update CLAUDE.md
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0/0/0
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-worker-host` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-worker-host` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-worker-host` — exit 0
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-worker-host` — green
## 6. Pre-archive 7-row checklist
- [ ] 6.1 Generics — `PluginContext<Caps>` flows through worker boot
- [ ] 6.2 Zod at every wire boundary (control-port envelopes)
- [ ] 6.3 Every `WORKER_*` code thrown by at least one path
- [ ] 6.4 Strict-mode behaviors (budget thresholds configurable; off-by-default vs on)
- [ ] 6.5 OTel — N/A (main-host emits crash metrics; worker only signals)
- [ ] 6.6 Lifecycle dedup — N/A (main host owns FSM mutex)
- [ ] 6.7 No retroactive tick
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/api-worker-host/spec.md`
- [ ] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-worker-host/`
- [ ] 7.3 Tick Stage-3 row in umbrella
- [ ] 7.4 Refresh `MANIFEST.md`