Initial Commit
This commit is contained in:
106
openspec/specs/api-worker-host/spec.md
Normal file
106
openspec/specs/api-worker-host/spec.md
Normal 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"`
|
||||
Reference in New Issue
Block a user