@orkestrel/scaffold 0.0.66 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +8 -8
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1509 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +311 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +437 -6
  62. package/dist/src/core/index.cjs +44 -22
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +43 -23
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +9 -9
@@ -0,0 +1,615 @@
1
+ # Worker
2
+
3
+ > A resource-backed job worker: a thin facade composing a `Queue` (`@orkestrel/queue`) with
4
+ > a `Pool` (`@orkestrel/pool`), where each job's handler runs against an automatically
5
+ > acquired pooled resource released when the job settles.
6
+
7
+ All concurrency, retries, per-attempt timeout, and lifecycle are the Queue's, and all
8
+ resource lifecycle (idle reuse, `max` backpressure, FIFO waiting) is the Pool's; the facade
9
+ adds only the resource pairing, and it reimplements neither primitive. At most one resource
10
+ exists per in-flight job by default, and idle resources are reused across jobs. Each job
11
+ acquires over the attempt's `context.signal`, so an abort or a timeout while waiting for a
12
+ resource rejects the acquire cleanly (no token to release). For what construction captures,
13
+ when each value is validated, and which validator a runtime `null` reaches, see
14
+ `## Contract`.
15
+
16
+ The worker is observable through its own `emitter` — see [Observing](#observing). For CPU
17
+ parallelism, `createNodeWorker` (`@orkestrel/worker/server`) specializes
18
+ `createWorker` over a pool of `node:worker_threads`, with `serveWorker` as the worker-side
19
+ entry; the structured-clone boundary is narrowed by `input` / `result` guards with no
20
+ `as`. Source: [`src/core`](../src/core) (the `Worker` facade) and [`src/server`](../src/server)
21
+ (the thread pool and the worker-side entry). Surfaced through the `@orkestrel/worker` and
22
+ `@orkestrel/worker/server` exports.
23
+
24
+ ## Surface
25
+
26
+ Create a worker over a resource lifecycle and a handler, then `enqueue` inputs and await
27
+ their results:
28
+
29
+ ```ts
30
+ import { createWorker } from '@orkestrel/worker'
31
+
32
+ const worker = createWorker<Query, Connection, Rows>({
33
+ pool: { create: () => connect(), destroy: (connection) => connection.close() },
34
+ handler: (query, connection, { signal }) => connection.run(query, signal),
35
+ concurrency: 4, // up to four jobs in flight; the pool defaults its `max` to match
36
+ retries: 1,
37
+ })
38
+
39
+ const rows = await worker.enqueue(query)
40
+ await worker.destroy() // awaits queue cleanup, then pool cleanup, then emitter teardown
41
+ ```
42
+
43
+ ### Factories
44
+
45
+ The package publishes these factories. `createWorker` is the `@orkestrel/worker` export;
46
+ `createJSONQueueStore`, `createNodeWorker`, and `serveWorker` are the
47
+ `@orkestrel/worker/server` exports.
48
+
49
+ | API | Kind | Summary |
50
+ | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `createWorker` | function | Creates a resource-backed job worker — a `Queue` (`@orkestrel/queue`) composed with a `Pool` (`@orkestrel/pool`), where each enqueued input runs through the handler against an automatically acquired pooled resource released when the job settles. |
52
+ | `createJSONQueueStore` | function | Creates a persistent JSON-file `QueueStoreInterface` — the core `createDatabaseQueueStore` over a server `createJSONDriver`. |
53
+ | `createNodeWorker` | function | Creates a CPU-parallel worker over `node:worker_threads` — a thin specialization of the core `createWorker` whose pooled resource is a worker thread. |
54
+ | `serveWorker` | function | Registers a worker-thread handler — the worker-side half of `createNodeWorker`. |
55
+
56
+ ### Threads
57
+
58
+ The lower-level `node:worker_threads` machinery `createNodeWorker` composes over
59
+ (`@orkestrel/worker/server`) — the thread-pool lifecycle hooks behind the public factory. Use
60
+ these to drive one thread yourself; `createNodeWorker` is the entry point for pooled, queued
61
+ work. Driving a thread by hand:
62
+
63
+ ```ts
64
+ import { createThread, Dispatch, isReply } from '@orkestrel/worker/server'
65
+
66
+ const isNumber = (value: unknown): value is number => typeof value === 'number'
67
+
68
+ const thread = await createThread(new URL('./double.ts', import.meta.url))
69
+ const controller = new AbortController()
70
+ try {
71
+ const job = new Dispatch(thread, 21, { id: 'job-1', signal: controller.signal }, isNumber)
72
+ console.log(await job.promise) // 42
73
+ console.log(isReply({ id: 'reply-1', ok: true, value: 42 }, 'reply-1')) // true
74
+ console.log(isReply({ id: 'reply-1', ok: true, value: 42 }, 'other')) // false
75
+ } finally {
76
+ await thread.worker.terminate()
77
+ }
78
+ ```
79
+
80
+ The thread-level functions behind `createNodeWorker`:
81
+
82
+ | API | Kind | Summary |
83
+ | -------------- | -------- | --------------------------------------------------------------------------------------- |
84
+ | `createThread` | function | Creates one live worker thread and resolves it as a `NodeThread` after it comes online. |
85
+ | `isReply` | function | Narrows an inbound `message` to a `Reply` for a given correlation `id` — no assertion. |
86
+
87
+ ### Classes
88
+
89
+ The classes the core and server faces export:
90
+
91
+ | API | Kind | Summary |
92
+ | ---------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `Dispatch` | class | Represents one dispatched worker-thread job — the lifecycle entity behind a job posted to a leased `NodeThread`, whose `promise` settles with the narrowed reply. |
94
+ | `Worker` | class | Represents a resource-backed job worker — a thin facade composing a `Queue` (`@orkestrel/queue`) with a `Pool` (`@orkestrel/pool`). |
95
+
96
+ ### Types
97
+
98
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
99
+
100
+ Each type and interface the core and server faces publish:
101
+
102
+ | Type | Kind | Shape | Summary |
103
+ | -------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `WorkerHandler` | type | `(input: TInput, resource: TResource, context: QueueContext) => Promise<TResult> \| TResult` | Runs one worker job with a leased pool resource. |
105
+ | `WorkerOptions` | interface | `{ on?, error?, handler, pool, concurrency?, retries?, timeout?, store? }` | Configures `createWorker`. |
106
+ | `WorkerInterface` | interface | `{ emitter, count, active, paused, stopped } plus enqueue, restore, start, stop, pause, resume, abort, clear, destroy` | Represents the job-worker contract a consumer holds — a `Queue` whose handler runs each job against a pooled resource. |
107
+ | `WorkerEventMap` | type | `{ enqueue, start, retry, success, failure, abort, drain }` | Represents the push observation surface of a `WorkerInterface` — the job lifecycle a fire-and-forget observer subscribes to. |
108
+ | `NodeWorkerOptions` | interface | `{ on?, error?, script, input, result, workerData?, concurrency?, retries?, timeout?, store? }` | Configures `createNodeWorker` — a CPU-parallel worker over `node:worker_threads`. |
109
+ | `ServeWorkerOptions` | interface | `{ input, handler }` | Configures `serveWorker` — the worker-side entry a thread script registers. |
110
+ | `NodeThread` | interface | `{ worker, alive, death }` | Represents a live worker thread plus its latched liveness state — the pooled resource a `createNodeWorker` leases per job. |
111
+ | `Reply` | type | `{ id, ok, value } \| { id, ok, error }` | Represents a thread→main reply envelope — a success carrying an opaque `value`, or a failure with a message — the reply half of the wire protocol `createNodeWorker` posts and `serveWorker` answers. |
112
+
113
+ The `emitter` / `count` / `active` / `paused` / `stopped` members of `WorkerInterface` are
114
+ `readonly` data members (Surface rows, earlier) — `emitter` is the typed push observation
115
+ surface (see [Observing](#observing)); the call-signature methods are documented under
116
+ [Methods](#methods). `Queue` / `Pool` themselves — and their own options, event maps, and
117
+ stores — are documented in their own packages: [queue.md](queue.md) / [pool.md](pool.md).
118
+
119
+ ## Methods
120
+
121
+ The public methods of `WorkerInterface` — every call-signature member listed (its
122
+ `readonly` data members stay Surface rows). `Worker` implements `WorkerInterface`
123
+ exactly, so this doubles as the class's instance-method surface.
124
+
125
+ #### `WorkerInterface`
126
+
127
+ | Method | Returns | Summary |
128
+ | --------- | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
129
+ | `enqueue` | `Promise<TResult>` | Submits one job in FIFO order; the handler runs against an acquired resource, released when the job settles. |
130
+ | `restore` | `Promise<void>` | Re-enqueues the store's outstanding entries through the underlying queue; no-op without a store. |
131
+ | `start` | `void` | Starts or restarts the underlying queue's worker loops. |
132
+ | `stop` | `Promise<void>` | Stops the queue, rejects pending work, and awaits current-loop and durable cleanup quiescence. |
133
+ | `pause` | `void` | Suspends dequeuing through the underlying queue, leaving in-flight jobs untouched. |
134
+ | `resume` | `void` | Continues a paused worker through the underlying queue. |
135
+ | `abort` | `Promise<void>` | Cancels in-flight work, rejects pending work, and awaits queue-owned cleanup; an aborted attempt is never retried. |
136
+ | `clear` | `Promise<void>` | Drops pending jobs and awaits their durable cleanup, leaving in-flight jobs untouched. |
137
+ | `destroy` | `Promise<void>` | Tears down the queue, then the pool, and finally the worker emitter, behind one stable barrier. |
138
+
139
+ `stop`, `abort`, and `clear` return the exact cleanup promises supplied by the underlying
140
+ queue. `destroy` is its own stable barrier: every call, including a call reentered
141
+ synchronously from the queue's `abort` event, returns the same promise. Queue cleanup always
142
+ settles before pool cleanup begins, and the worker emitter is destroyed only after both have
143
+ settled. No cleanup failure prevents the next owned layer from being attempted. One failure
144
+ rejects the barrier with that exact value; failures from both queue and pool reject with a
145
+ native `AggregateError` whose errors are ordered queue first, pool second.
146
+
147
+ ## Contract
148
+
149
+ These invariants hold across `src/core` ↔ `worker.md`:
150
+
151
+ 1. **Doc ↔ source bijection.** Every `function` / `class` / `interface` / `type` row in
152
+ the `## Surface` tables is a real export of the worker module, and every export
153
+ appears as a Surface row — exhaustive, both directions.
154
+ 2. **Composition, not reimplementation.** A `Worker` does not reimplement concurrency /
155
+ retry / lifecycle — it composes a `Queue` (`@orkestrel/queue`) with a `Pool`
156
+ (`@orkestrel/pool`). Its queue handler `acquire`s a resource over the attempt's
157
+ `context.signal`, runs the caller's handler against it, and `release`s it in a
158
+ `finally` (so a throwing or aborted handler still frees the resource). Because the
159
+ `finally` runs only when the handler actually settles, a handler that ignores its
160
+ `context.signal` keeps its leased resource until it returns — so on a timeout /
161
+ abort the resource can outlive the freed queue slot; a cooperative handler that
162
+ honours the signal releases promptly. Construction snapshots every caller-owned
163
+ top-level option once. Only `undefined` defaults `concurrency` or pool `max`; runtime
164
+ `null` reaches Queue or Pool validation. Queue's integer timeout contract is preserved:
165
+ `timeout` must be in `0..2_147_483_647` milliseconds, and `0` disables the deadline.
166
+ Queue is constructed successfully before the
167
+ pool option is read, and every declared pool member (`max`, `on`, `error`, `create`,
168
+ `destroy`, `validate`) is then captured once by direct access, preserving inherited and
169
+ non-enumerable structural options. Pool `max` still defaults to concurrency, so resources
170
+ match the jobs in flight by default. `stop` / `abort` / `clear` return the
171
+ queue's exact cleanup barriers. `destroy` installs one stable barrier before it invokes
172
+ queue teardown (including synchronous abort-event reentry), awaits queue then pool
173
+ settlement serially, aggregates the queue's failure and the pool's in that order, and
174
+ destroys the worker emitter last.
175
+ 3. **Observable — the queue lifecycle re-exposed.** The `Worker` owns a typed `Emitter`
176
+ exposed as `readonly emitter` carrying `WorkerEventMap<TResult>`
177
+ (`enqueue` / `start` / `retry` / `success` / `failure` / `abort` / `drain`), bridged
178
+ from the underlying queue's own emitter at construction — each bridge listener
179
+ re-emits directly on the worker's emitter and never throws, so the inner queue's own
180
+ emit stays balanced regardless of what a worker observer does. **Emitting is
181
+ observation-only** — the emitter isolates a listener throw (routing it to the `error`
182
+ handler, never a domain event) and every event sits strictly after the relevant
183
+ queue transition, so a buggy observer can never corrupt the queue or pool. The pool's
184
+ own `create` / `acquire` / `release` / `destroy` events stay the pool's internal
185
+ concern — observe a `Pool` directly for those.
186
+ 4. **Doc ↔ source method bijection.** The `## Methods` table lists exactly the public
187
+ methods of `WorkerInterface` — exhaustive, both directions — and `Worker` exposes
188
+ the same public methods as its interface, no more.
189
+ 5. **`createNodeWorker` is a thread specialization of `createWorker` (`@orkestrel/worker/server`).**
190
+ It does not reimplement concurrency / retry / timeout / lifecycle — it calls
191
+ `createWorker` with a `Pool` whose resource is a `node:worker_threads` thread
192
+ (`create` = the spawn `createThread` publishes, `destroy` = `terminate()`,
193
+ `validate` = `alive && threadId > 0`) and an internal handler that narrows the input
194
+ through `options.input` then runs a `Dispatch` against the leased thread. `TInput` and
195
+ `TResult` infer from the `input` and `result` guards, so a call site needs no type
196
+ argument. The structured-clone boundary is crossed with no `as`: a `Dispatch` narrows each reply
197
+ value through `options.result` (a value that fails it rejects with `'reply did not
198
+ satisfy result guard'`), and the worker side narrows each payload through
199
+ `options.input` (a bad input replies `'input did not satisfy input guard'`) —
200
+ `TInput` / `TResult` are reconstructed by validation, never asserted. A throwing
201
+ caller guard is contained and rejects only that job; the pooled worker remains usable.
202
+ The run/abort/reply protocol is published as `Reply` and `isReply`: main → thread posts
203
+ `{ id, job, command: 'run', input }` / `{ id, command: 'abort' }`; thread → main posts
204
+ `{ id, ok: true, value }` / `{ id, ok: false, error }`. The run `id` is a fresh
205
+ per-dispatch correlation key used exclusively for replies, aborts, controllers, and
206
+ listeners; `job` is the Queue entry's stable `QueueContext.id`, preserved across retry
207
+ attempts and crash restore and exposed to the thread handler as `context.id`. A run
208
+ without a string `job` fails closed: the handler is not invoked and no reply is sent.
209
+ 6. **Abort terminates and evicts the thread without losing its cause.** Because CPU-bound
210
+ work cannot honour an `AbortSignal`, an `abort` / `timeout` posts the cooperative
211
+ `abort`, flips `alive = false` for a thread this package produced, and observes
212
+ `terminate()` settlement. Successful
213
+ termination rejects with the exact `context.signal.reason`. If the cooperative post
214
+ fails, that reason remains first in `AggregateError.errors`, followed by the notification
215
+ failure, with message `worker abort notification failed`. A termination failure rejects
216
+ with `AggregateError.errors` ordered as reason, optional notification failure, then
217
+ termination failure, with message `worker termination failed`. Neither failure escapes
218
+ its event callback. The freed pool slot spawns a fresh thread on the next job.
219
+ 7. **A thread death settles its job under every event ordering — the `death` latch.**
220
+ Every spawned thread carries persistent `error` / `messageerror` / `exit` listeners that
221
+ flip `alive = false`
222
+ and latch the first terminal event on `NodeThread.death`; a `Dispatch` checks that latch
223
+ synchronously at construction, so a job dispatched onto a thread that already died rejects
224
+ immediately. A thread can become terminal before the readiness promise continuation
225
+ attaches dispatch listeners, and those events will never fire again; without the latch
226
+ the job would wait forever. A death mid-flight still rejects through the dispatch's own
227
+ `error` / `exit` listeners; exit rejects with that exact already-latched `death` object,
228
+ whose message retains the exit code (for example, `worker thread exited (code 1)`). An
229
+ inbound `messageerror` also evicts and terminates the thread, rejects the
230
+ dispatch, and is detached with the other per-job listeners. A death before `online`
231
+ rejects the spawn itself.
232
+ 8. **`serveWorker` captures registration options once.** After the main-thread
233
+ `parentPort === null` no-op, registration reads `options.input` and then
234
+ `options.handler` exactly once. Getter failure is therefore fail-fast during registration,
235
+ the handler getter is not read after an input-getter failure, and every later job retains
236
+ the initially captured guard and handler identities. The main-thread no-op reads neither.
237
+ 9. **Structured-clone constraints.** `TInput` / `TResult` / `workerData` must be
238
+ structured-cloneable (no functions / Promises / `AbortSignal`). A non-cloneable result
239
+ cannot strand a dispatch: `serveWorker` replaces the failed success post with a clone-safe
240
+ failure envelope, and closes the parent port if even that fallback cannot be posted so the
241
+ main side observes exit. A matching-id malformed reply similarly rejects the dispatch,
242
+ terminates and evicts the tainted thread, and leaves id-less / foreign-id chatter ignored.
243
+ Raw TypeScript is unflagged on Node 22.18+ and Node 23.6+; Node 22.12–22.17 and Node
244
+ 23.0–23.5 require `--experimental-strip-types`. A built `.js` / `.mjs` script is an
245
+ alternative across supported Node versions. The worker script's
246
+ module must call `serveWorker` — the worker-side entry
247
+ that runs the handler and answers the protocol; it is self-contained and imports only
248
+ `node:worker_threads` at runtime,
249
+ so it loads as a raw module in a spawned thread. `createNodeWorker` returns the plain
250
+ `WorkerInterface`, so it inherits the Worker's `emitter` unchanged (clause 3).
251
+ 10. **`createJSONQueueStore` is `@orkestrel/queue`'s `createDatabaseQueueStore` over a
252
+ server JSON driver.** A queue's durable state is a `@orkestrel/database` table,
253
+ so JSON persistence reuses the existing driver rather than a bespoke store — see
254
+ [queue.md](queue.md) for the `QueueStoreInterface` contract itself.
255
+
256
+ ## NodeWorker
257
+
258
+ `createNodeWorker` (`@orkestrel/worker/server`) runs jobs on a pool of `node:worker_threads` threads —
259
+ true CPU parallelism for work that would otherwise block the event loop. It is a thin
260
+ specialization of [`createWorker`](#surface): the pooled resource is a worker thread, and
261
+ it returns the plain `WorkerInterface`, so its methods, lifecycle, concurrency, retries,
262
+ timeout, and durability are exactly the Worker's (see [Methods](#methods)). The only
263
+ additions are the thread pairing and the zero-`as` wire bridge.
264
+
265
+ The `input` and `result` guards define the boundary and the inference: `input` narrows each
266
+ payload (and fail-fasts a bad one before it crosses to a thread), and `result` narrows each
267
+ reply value. `TInput` and `TResult` infer from these, so call sites pass no type arguments:
268
+
269
+ ```ts
270
+ import { createNodeWorker } from '@orkestrel/worker/server'
271
+
272
+ const isNumber = (value: unknown): value is number => typeof value === 'number'
273
+
274
+ const worker = createNodeWorker({
275
+ script: new URL('./double.js', import.meta.url), // built JavaScript works across supported Node
276
+ input: isNumber, // narrows + infers TInput; fail-fasts a bad input before posting
277
+ result: isNumber, // narrows each reply — the zero-`as` type bridge; infers TResult
278
+ concurrency: 4, // up to four threads run jobs in parallel
279
+ retries: 1,
280
+ })
281
+
282
+ const doubled = await worker.enqueue(21) // 42, computed on a worker thread
283
+ await worker.destroy() // awaits termination of every thread
284
+ ```
285
+
286
+ The worker script registers its handler with `serveWorker`, which must be the thread
287
+ module's entry. On a worker thread, registration captures `input` then `handler` exactly
288
+ once and retains those identities for every job; on the main thread it reads neither.
289
+ It receives the narrowed input and the Queue's `{ id, signal }` context. `id` is the stable
290
+ Queue idempotency key across retries and crash restore; it identifies the work, not its caller,
291
+ and is not authentication or authorization evidence. `signal` is per attempt and fires on
292
+ a cooperative abort. The handler's resolved value is the reply:
293
+
294
+ ```ts
295
+ // double.ts — the worker script
296
+ import { serveWorker } from '@orkestrel/worker/server'
297
+
298
+ serveWorker<number, number>({
299
+ input: (value): value is number => typeof value === 'number',
300
+ handler: (value, { id, signal }) => {
301
+ console.log(`running ${id}`)
302
+ if (signal.aborted) throw signal.reason
303
+ return value * 2
304
+ },
305
+ })
306
+ ```
307
+
308
+ Per-job consumer context is explicit, structured-cloneable `TInput`. Ambient main-thread
309
+ state, including `AsyncLocalStorage`, does not cross a worker-thread structured-clone boundary;
310
+ there is no implicit caller transport.
311
+
312
+ Because CPU-bound work cannot honour its signal, an `abort` or a per-attempt `timeout`
313
+ **terminates** the in-flight thread and evicts it from the pool — the next job spawns a
314
+ fresh one. Successful termination preserves the exact signal reason; notification and
315
+ termination failures aggregate after that primary cause in protocol order. A thread that
316
+ dies (a crash, a script that fails to load or even to resolve) always settles its job with
317
+ the exact latched `NodeThread.death`, including its exit-code message, so even a job
318
+ dispatched after the death — its events already fired — rejects immediately rather than
319
+ waiting on a reply that never comes. `workerData`, the input, and the result must all be
320
+ structured-cloneable (no functions, Promises, or `AbortSignal`s cross the boundary). The
321
+ `workerData` key mirrors the `node:worker_threads` `Worker` constructor option of the same
322
+ name, and the thread reads it back from `node:worker_threads`.
323
+
324
+ A thread worker takes the same `on` and `error` hooks as the core worker: pass `on` to wire
325
+ initial `WorkerEventMap` listeners at construction, and `error` to receive a listener's throw
326
+ (see [Observing](#observing)).
327
+
328
+ ## Persistence
329
+
330
+ `createJSONQueueStore` (`@orkestrel/worker/server`) builds a `QueueStoreInterface` over a JSON file —
331
+ `@orkestrel/queue`'s `createDatabaseQueueStore` composed with `@orkestrel/database`'s
332
+ `createJSONDriver` — so a fresh store over the same path resumes a prior process's
333
+ outstanding work:
334
+
335
+ ```ts
336
+ import { stringShape } from '@orkestrel/contract'
337
+ import { createJSONQueueStore } from '@orkestrel/worker/server'
338
+
339
+ const store = createJSONQueueStore('data/worker.json', stringShape())
340
+ await store.save({ id: 'job-1', input: 'https://example.com', attempts: 0 })
341
+
342
+ // A later process — the entries persisted to the file are still outstanding:
343
+ const resumed = createJSONQueueStore('data/worker.json', stringShape())
344
+ const work = await resumed.load() // [{ id: 'job-1', input: 'https://example.com', attempts: 0 }]
345
+ ```
346
+
347
+ Pass the resulting `store` to `createWorker`'s `store` option to wire it into a worker's
348
+ underlying queue — see [queue.md](queue.md) for the `QueueStoreInterface` contract, the
349
+ `save` / `remove` / `load` / `clear` semantics, and what a store must persist across restarts.
350
+
351
+ ## Observing
352
+
353
+ The `Worker` exposes a typed `emitter` carrying the job lifecycle it
354
+ re-exposes from its underlying queue — logging, metrics, tracing. Subscribe through
355
+ `worker.emitter.on(...)`, or wire initial listeners through the reserved `on?` option;
356
+ supply an `error?` handler to receive a listener's throw.
357
+
358
+ ```ts
359
+ import { createWorker } from '@orkestrel/worker'
360
+
361
+ const worker = createWorker({
362
+ pool: { create: () => connect() },
363
+ handler: (job, connection) => run(job, connection),
364
+ on: { drain: () => console.log('worker idle') }, // initial listener at construction
365
+ })
366
+
367
+ worker.emitter.on('success', (id, result) => metrics.record(id, result))
368
+ worker.emitter.on('failure', (id, error) => log.warn(`job ${id} failed`, error))
369
+ ```
370
+
371
+ The events the worker's emitter carries:
372
+
373
+ | Event map | Events |
374
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
375
+ | `WorkerEventMap<TResult>` | `enqueue(id)` · `start(id)` · `retry(id, attempt)` · `success(id, result)` · `failure(id, error)` · `abort(reason)` · `drain()` |
376
+
377
+ The worker forwards queue event payloads unchanged. In particular, `abort(reason)` carries
378
+ the queue's coded `QueueError` with code `aborted`; its `cause` retains the caller-supplied
379
+ abort reason.
380
+
381
+ See [queue.md](queue.md) / [pool.md](pool.md) for the underlying
382
+ `Queue` / `Pool` event vocabulary and their listener isolation, which holds here too: one
383
+ listener's throw never prevents a sibling listener, and the throw reaches the emitter's
384
+ `error` handler, so a buggy worker observer leaves the inner queue and pool intact.
385
+
386
+ ## Patterns
387
+
388
+ ### A resource-backed worker
389
+
390
+ Pair a resource lifecycle with a handler and let the queue drive both:
391
+
392
+ ```ts
393
+ import { createWorker } from '@orkestrel/worker'
394
+
395
+ // A Queue whose handler runs each job against a pooled resource (acquired before the
396
+ // handler, released after it — even on throw). The pool's `max` defaults to `concurrency`.
397
+ const worker = createWorker<Query, Connection, Rows>({
398
+ pool: { create: () => connect(), destroy: (connection) => connection.close() },
399
+ handler: (query, connection, { signal }) => connection.run(query, signal),
400
+ concurrency: 4,
401
+ retries: 1,
402
+ })
403
+
404
+ const rows = await worker.enqueue(query)
405
+ await worker.destroy() // awaits queue cleanup, pool cleanup, then emitter teardown
406
+ ```
407
+
408
+ ### CPU-parallel jobs over threads
409
+
410
+ Run the same job shape across a pool of worker threads:
411
+
412
+ ```ts
413
+ import { createNodeWorker } from '@orkestrel/worker/server'
414
+
415
+ const isNumber = (value: unknown): value is number => typeof value === 'number'
416
+
417
+ const worker = createNodeWorker({
418
+ script: new URL('./double.ts', import.meta.url),
419
+ input: isNumber,
420
+ result: isNumber,
421
+ concurrency: 4,
422
+ })
423
+
424
+ const doubled = await worker.enqueue(21) // 42
425
+ await worker.destroy()
426
+ ```
427
+
428
+ ### Durable jobs across restarts
429
+
430
+ Back the queue with a store so outstanding work survives a restart:
431
+
432
+ ```ts
433
+ import { stringShape } from '@orkestrel/contract'
434
+ import { createWorker } from '@orkestrel/worker'
435
+ import { createJSONQueueStore } from '@orkestrel/worker/server'
436
+
437
+ const store = createJSONQueueStore('data/worker.json', stringShape())
438
+ const worker = createWorker({ store, pool: { create: () => connect() }, handler: run })
439
+
440
+ await worker.enqueue('https://example.com') // durably saved before it runs
441
+
442
+ // …after a crash + restart, a fresh worker over the same store resumes the work:
443
+ const resumed = createWorker({ store, pool: { create: () => connect() }, handler: run })
444
+ await resumed.restore() // re-enqueues every still-outstanding entry, then runs it
445
+ ```
446
+
447
+ ### Pause, drain, and shut down
448
+
449
+ Enqueue work, wait for the `drain` event, then drive the lifecycle and close the worker down:
450
+
451
+ ```ts
452
+ import { createWorker } from '@orkestrel/worker'
453
+
454
+ const idle = Promise.withResolvers<void>()
455
+ const worker = createWorker({
456
+ pool: { create: () => connect() },
457
+ handler: run,
458
+ on: { drain: () => idle.resolve() }, // fires when nothing is pending and nothing in flight
459
+ })
460
+
461
+ await worker.enqueue('https://example.com')
462
+ await idle.promise // the queue has drained
463
+
464
+ worker.pause() // suspends dequeuing; jobs already in flight keep running
465
+ worker.resume() // continues where the pause left off
466
+
467
+ await worker.clear() // drops pending jobs and awaits their durable cleanup
468
+ await worker.stop() // rejects pending work and awaits cleanup quiescence
469
+ worker.start() // restarts the loops a stop halted
470
+
471
+ await worker.abort('shutting down') // cancels in-flight jobs; `start` cannot revive the worker
472
+ await worker.destroy() // queue cleanup, then pool cleanup, then emitter teardown
473
+ ```
474
+
475
+ ### Practices
476
+
477
+ Follow these practices when you run a worker in production:
478
+
479
+ - **Honour `context.signal`** — pass it through to the resource's operation and bail
480
+ out when it fires, so timeouts and aborts actually stop work rather than abandoning
481
+ its result.
482
+ - **Size the pool through `concurrency`** — the pool's `max` defaults to it; override
483
+ `pool.max` explicitly only when the resource cap must diverge from the job cap.
484
+ Concurrency must be a positive safe integer; invalid values are rejected by Queue and
485
+ are never normalized.
486
+ - **`abort` is terminal** — a worker-level abort cancels in-flight work and stops the
487
+ underlying queue; await its cleanup barrier, then create a new worker to start over.
488
+ - **Await lifecycle cleanup** — `stop`, `abort`, `clear`, and `destroy` are completion
489
+ barriers. `destroy` is stable across repeated/reentrant calls and completes only after
490
+ serial queue then pool cleanup and emitter teardown.
491
+ - **Observe, don't drive** — subscribe to `worker.emitter` for lifecycle moments (see
492
+ [Observing](#observing)); emitting is a pure side-channel.
493
+ - **CPU-parallel work needs `createNodeWorker`** — the core `createWorker` runs its
494
+ handler on the same event loop (a resource pool, not a thread pool); reach for
495
+ `createNodeWorker` only when the work is genuinely CPU-bound and would otherwise
496
+ block.
497
+
498
+ ## Tests
499
+
500
+ These tests pin the behaviour this guide documents:
501
+
502
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the
503
+ `## Surface` ↔ `src/core` / `src/server` bijection (value + type exports), the
504
+ `WorkerInterface` ↔ `Worker` method bijection, and the equality gate: every `Summary`
505
+ cell against its declaration's description paragraph, the titled
506
+ `A resource-backed worker` fence against the `@example` block of that title (pinned so
507
+ the titled pair cannot be retired silently), and the README pitch against this guide's
508
+ tagline. It also transcribes the Threads, NodeWorker, Persistence, CPU-parallel, and
509
+ lifecycle fences, running each against the real exports: it checks every trailing comment
510
+ that names a returned value against that value, and checks the lifecycle fence's claims —
511
+ the `drain` hook, dequeuing suspended while in-flight work runs, the loops restarted after
512
+ `stop`, and the worker left terminal by `abort`.
513
+ - [`tests/policy.test.ts`](../tests/policy.test.ts) — the fleet placement sweep over
514
+ `src`: every module function sits in a function-kind file, every centralized declaration
515
+ is exported, types sit in `types.ts`, classes match their file, and every module test
516
+ under `tests/src` mirrors a real source module.
517
+ - [`tests/config.test.ts`](../tests/config.test.ts) — the root configuration resolves every
518
+ declared alias to a real entry, registers every workspace project with its fixed include
519
+ and setup files, and builds each face to its declared output.
520
+ - [`tests/src/core/Worker.test.ts`](../tests/src/core/Worker.test.ts) — the handler
521
+ runs against a pooled resource; resources are reused across jobs and never exceed the
522
+ pool max; the resource is released even when the handler throws (a later job reuses
523
+ it); the lifecycle (`pause` / `resume` / `abort` / `stop` / `clear` / `destroy`)
524
+ returns the queue's cleanup barriers; constructor options and every declared pool option are
525
+ captured once (including inherited / non-enumerable structural options), only explicit
526
+ `undefined` defaults, runtime `null` reaches the Queue/Pool diagnostic,
527
+ and invalid Queue concurrency wins before the pool option is read; strict concurrency
528
+ rejects zero, negative, fractional, `NaN`, and infinite values instead of normalizing
529
+ them; `destroy` keeps one
530
+ promise identity across repeated and abort-event-reentrant calls, waits for serial queue
531
+ then pool cleanup, destroys the worker emitter last, and preserves a sole pool failure;
532
+ and durability passthrough — a `store` persists a job and `restore()` re-runs it against
533
+ a fresh resource (delegated to the queue), a no-op without a store.
534
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) —
535
+ `createWorker` returns a working, typed instance end to end and honours its options +
536
+ status surface.
537
+ - [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) —
538
+ `createJSONQueueStore` over a real temp file: entries persist across store instances on
539
+ the same path (a second store `load`s the first's work), a nested-object input survives
540
+ the JSON round-trip, and a `remove` is reflected across a reopen; `createThread` resolving
541
+ a live thread that clones its `workerData` across, spawning with that argument omitted, and
542
+ rejecting a script that dies before `online`; plus a `createNodeWorker` round-trip smoke (a
543
+ job over a real thread, then teardown), the `on` hooks wired at construction with a
544
+ listener throw routed to `error`, and the option snapshot retained across jobs.
545
+ - [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the
546
+ main-side worker-thread machinery (`createThread` / `Dispatch`), driven
547
+ through `createNodeWorker` over real worker threads (no mocking): a round-trip and a
548
+ batch over a small pool; the concurrency cap and the live-thread cap + idle reuse; a
549
+ throwing handler rejecting with its error, and re-running under `retries`; explicit enqueue
550
+ ids reaching the thread handler, retry attempts retaining one stable job id while using fresh
551
+ correlation ids, and a real pre-populated `MemoryQueueStore` restore retaining its stored id; a
552
+ per-attempt `timeout` rejecting and terminating the uncooperative thread, with a later
553
+ job served on a fresh thread; a direct in-flight abort preserving the caller's exact
554
+ reason object; a code-1 crash rejecting with the exact latched `NodeThread.death` and
555
+ exit-code message; each terminal path evicting its thread; `workerData` cloned through
556
+ to the worker side (and a non-cloneable `workerData` surfacing a clear error, never a
557
+ hang); a large array
558
+ input + result round-trip; a broken worker script rejecting the job cleanly with the
559
+ pool recovering across retries + a fresh worker; stray / foreign-id chatter ignored while
560
+ the correct reply still resolves; a matching-id malformed reply rejecting, evicting its
561
+ tainted thread, and allowing a later job on a replacement; an already-aborted enqueue signal
562
+ short-circuiting; a non-cloneable result rejecting without a hang and a later job on the
563
+ same concurrency-1 worker succeeding; false and throwing `input` / `result` guards
564
+ rejecting without wedging the worker; a consumer-supplied `NodeThread` whose job the abort
565
+ rejects and whose `worker` it terminates while the implementer's own `alive` stays
566
+ untouched; `messageerror` listener attachment and settlement
567
+ cleanup; `destroy` with multiple threads mid-job terminating
568
+ all; rapid enqueue/abort churn settling every job with no thread leak; and `destroy`
569
+ terminating every thread so the process exits. Plus the total `isReply` predicate over
570
+ valid success/failure envelopes, foreign ids, malformed or incomplete envelopes,
571
+ non-records, hostile getters, and stray messages.
572
+ - [`tests/src/server/handlers.test.ts`](../tests/src/server/handlers.test.ts) —
573
+ `serveWorker` driven manually over a raw `node:worker_threads` thread (post a run/abort
574
+ envelope, await the reply): reply correlation remaining distinct from the stable job id,
575
+ missing / non-string job ids plus revoked proxies and throwing job getters invoking no handler
576
+ and producing no reply, abort routing by correlation id when the stable job id differs, the
577
+ success envelope, false and throwing input-guard
578
+ rejection envelopes, a non-cloneable success falling back to a clone-safe error while a
579
+ later job succeeds on the same thread, a handler-throw error envelope (a synchronous throw and an
580
+ asynchronous rejection both reported as `{ ok: false }`), a `{ command: 'abort' }` firing the
581
+ handler's signal, an abort for an unknown id
582
+ being a no-op, an unknown message `command` and a malformed (no-`id`) message both
583
+ ignored without crashing the thread, and object / array / null / boolean result shapes
584
+ round-tripping through the `{ ok: true, value }` envelope; registration reading `input`
585
+ then `handler` once across successive real jobs, failing before the handler read when the input
586
+ getter throws, and the main-thread no-op reading neither option.
587
+ - The worker fixtures under
588
+ [`tests/src/server/fixtures`](../tests/src/server/fixtures) (`double` / `fail` /
589
+ `slow` / `bad-result` / `abortable` / `crash` / `identify` / `execution` / `identity` /
590
+ `echo-data` / `sum` /
591
+ `stray` / `malformed` / `throw-async` / `load-throw` / `echo` / `noncloneable-result` /
592
+ `serve-options`) are
593
+ real `.ts` worker scripts. The Vitest projects supply no type-stripping flag, so the
594
+ `src:server` and `guides` suites load the fixtures through Node's unflagged type stripping
595
+ and run on Node 22.18+ and Node 23.6+ — a narrower floor than the `>=22.12.0` engine range
596
+ in `package.json`. Ordinary fixtures import `serveWorker` by
597
+ relative-to-source path; `malformed` and `identity` speak the internal envelope protocol
598
+ directly. `malformed` keeps a tainted thread alive after its invalid reply; `identity` observes
599
+ the fresh correlation id and stable job id across a retry. Every fixture contains no diagnostic
600
+ suppression and is included in the root TypeScript project while remaining outside test
601
+ discovery (not a `*.test.ts`).
602
+
603
+ ## See also
604
+
605
+ Read these guides next:
606
+
607
+ - [`queue.md`](queue.md) — the `Queue` engine a `Worker` composes (cooperative wake-park
608
+ loop, retries, timeout, durability) — the `QueueStoreInterface` contract for
609
+ `createJSONQueueStore` / the `store` option.
610
+ - [`pool.md`](pool.md) — the `Pool` engine a `Worker` composes (idle reuse, `max`
611
+ backpressure, FIFO abort-able wait).
612
+ - [`contract.md`](contract.md) — the `Guard<T>` / shape vocabulary threaded through the
613
+ structured-clone boundary (`input` / `result` on `createNodeWorker`).
614
+ - [`database.md`](database.md) — the storage layer `createJSONQueueStore` builds on.
615
+ - [`README.md`](README.md) — the guides index.