@shardworks/clerk-apparatus 0.1.222 → 0.1.224
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.
- package/README.md +88 -19
- package/dist/clerk.d.ts +2 -2
- package/dist/clerk.d.ts.map +1 -1
- package/dist/clerk.js +123 -47
- package/dist/clerk.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/tools/commission-post.js +6 -6
- package/dist/tools/commission-post.js.map +1 -1
- package/dist/tools/piece-add.js +2 -2
- package/dist/tools/piece-add.js.map +1 -1
- package/dist/tools/writ-cancel.js +1 -1
- package/dist/tools/writ-cancel.js.map +1 -1
- package/dist/tools/writ-complete.js +1 -1
- package/dist/tools/writ-complete.js.map +1 -1
- package/dist/tools/writ-edit.js +2 -2
- package/dist/tools/writ-edit.js.map +1 -1
- package/dist/tools/writ-fail.js +1 -1
- package/dist/tools/writ-fail.js.map +1 -1
- package/dist/tools/writ-list.d.ts +1 -1
- package/dist/tools/writ-list.js +4 -4
- package/dist/tools/writ-list.js.map +1 -1
- package/dist/tools/writ-publish.js +2 -2
- package/dist/tools/writ-publish.js.map +1 -1
- package/dist/tools/writ-show.js +4 -4
- package/dist/tools/writ-show.js.map +1 -1
- package/dist/types.d.ts +54 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/pages/writs/index.html +42 -42
- package/pages/writs/writs-hierarchy.test.js +46 -46
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# `@shardworks/clerk-apparatus`
|
|
2
2
|
|
|
3
|
-
The Clerk manages the lifecycle of **writs** — lightweight work orders that flow through a fixed
|
|
3
|
+
The Clerk manages the lifecycle of **writs** — lightweight work orders that flow through a fixed phase machine. Writs are created as commissions and ultimately completed, failed, or cancelled. Writs may also enter a `stuck` phase when their rig encounters an engine failure — a non-terminal "needs attention" phase that preserves the obligation for future retry. Writs can be organized into parent/child hierarchies for decomposing complex work.
|
|
4
|
+
|
|
5
|
+
Writ documents follow a Kubernetes-style spec/status split: **`phase`** is the Clerk-owned lifecycle state (the phase machine below), and **`status`** is a plugin-owned observation slot keyed by plugin id — a place for apparatuses like Spider to record side-channel observations (last rig, stuck cause, progress ratchets) without mutating the phase. See [Spec/Status Convention](#specstatus-convention) below.
|
|
4
6
|
|
|
5
7
|
The Clerk sits downstream of The Stacks: `stacks ← clerk`.
|
|
6
8
|
|
|
@@ -32,7 +34,7 @@ const clerk = guild().apparatus<ClerkApi>('clerk');
|
|
|
32
34
|
|
|
33
35
|
### `post(request): Promise<WritDoc>`
|
|
34
36
|
|
|
35
|
-
Post a new commission, creating a writ in `open`
|
|
37
|
+
Post a new commission, creating a writ in `open` phase.
|
|
36
38
|
|
|
37
39
|
```typescript
|
|
38
40
|
const writ = await clerk.post({
|
|
@@ -53,7 +55,7 @@ const writ = await clerk.post({
|
|
|
53
55
|
| `parentId` | `string` | Parent writ id for hierarchical decomposition (optional) |
|
|
54
56
|
|
|
55
57
|
When `parentId` is provided:
|
|
56
|
-
- The parent must exist and be in `new`, `open`, or `stuck`
|
|
58
|
+
- The parent must exist and be in `new`, `open`, or `stuck` phase.
|
|
57
59
|
- The child inherits the parent's `codex` if no explicit codex is provided.
|
|
58
60
|
- The entire operation is atomic.
|
|
59
61
|
|
|
@@ -68,13 +70,13 @@ Show a writ by id. Throws if not found.
|
|
|
68
70
|
List writs with optional filters, ordered by `createdAt` descending (newest first).
|
|
69
71
|
|
|
70
72
|
```typescript
|
|
71
|
-
const openWrits = await clerk.list({
|
|
73
|
+
const openWrits = await clerk.list({ phase: 'open', limit: 10 });
|
|
72
74
|
const children = await clerk.list({ parentId: parent.id });
|
|
73
75
|
```
|
|
74
76
|
|
|
75
77
|
| Filter | Type | Description |
|
|
76
78
|
|---|---|---|
|
|
77
|
-
| `
|
|
79
|
+
| `phase` | `WritPhase \| WritPhase[]` | Filter by phase (single or array — multiple values OR together) |
|
|
78
80
|
| `type` | `string` | Filter by writ type |
|
|
79
81
|
| `parentId` | `string` | Filter to children of this parent writ |
|
|
80
82
|
| `limit` | `number` | Maximum results (default: 20) |
|
|
@@ -85,12 +87,12 @@ const children = await clerk.list({ parentId: parent.id });
|
|
|
85
87
|
Count writs matching optional filters. Accepts the same filters as `list()` (except `limit` and `offset`).
|
|
86
88
|
|
|
87
89
|
```typescript
|
|
88
|
-
const total = await clerk.count({
|
|
90
|
+
const total = await clerk.count({ phase: 'open' });
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
### `listWritTypes(): WritTypeInfo[]`
|
|
92
94
|
|
|
93
|
-
List all registered writ types with full metadata — descriptions, source tracking, and default
|
|
95
|
+
List all registered writ types with full metadata — descriptions, source tracking, and default flag.
|
|
94
96
|
|
|
95
97
|
```typescript
|
|
96
98
|
const types = clerk.listWritTypes();
|
|
@@ -174,11 +176,11 @@ const edited = await clerk.edit({
|
|
|
174
176
|
| `type` | `string` | New writ type — must be declared or built-in (optional) |
|
|
175
177
|
| `codex` | `string` | New target codex name; empty string clears it (optional) |
|
|
176
178
|
|
|
177
|
-
At least one field besides `id` must be provided. Title and body can be edited in any
|
|
179
|
+
At least one field besides `id` must be provided. Title and body can be edited in any phase. Type and codex can only be changed while the writ is in `new` (draft) phase.
|
|
178
180
|
|
|
179
181
|
### `transition(id, to, fields?): Promise<WritDoc>`
|
|
180
182
|
|
|
181
|
-
Transition a writ to a new
|
|
183
|
+
Transition a writ to a new phase, optionally setting additional fields atomically.
|
|
182
184
|
|
|
183
185
|
```typescript
|
|
184
186
|
// Complete with resolution
|
|
@@ -191,13 +193,31 @@ await clerk.transition(id, 'failed', { resolution: 'Build pipeline broke' });
|
|
|
191
193
|
await clerk.transition(id, 'cancelled', { resolution: 'No longer needed' });
|
|
192
194
|
```
|
|
193
195
|
|
|
194
|
-
Throws if the transition is not legal for the writ's current
|
|
196
|
+
Throws if the transition is not legal for the writ's current phase.
|
|
197
|
+
|
|
198
|
+
`transition()` silently strips the phase machine's managed fields from the body — `id`, `phase`, `status`, `createdAt`, `updatedAt`, `resolvedAt`, and `parentId`. The observation slot `status` is writable only via `setWritStatus()` (the one sanctioned slot-write path), which performs a transactional read-modify-write on the sub-slot keyed by `pluginId` so sibling sub-slots are preserved under concurrent writers. See [Spec/Status Convention](#specstatus-convention).
|
|
195
199
|
|
|
196
200
|
**Cascade behavior:** When a writ with children transitions to `failed` or `cancelled`, all non-terminal children are automatically cancelled with resolution `'Automatically cancelled due to parent termination'` (exported as `CASCADE_PARENT_TERMINATION_RESOLUTION`). When a parent transitions to `completed`, non-terminal children are **not** cancelled — instead a warning is logged (their existence indicates an upstream bookkeeping gap). When a child fails and its parent is `open` or `stuck`, the parent is failed and remaining siblings are cancelled.
|
|
197
201
|
|
|
202
|
+
### `setWritStatus(writId, pluginId, value): Promise<WritDoc>`
|
|
203
|
+
|
|
204
|
+
Write (or overwrite) a plugin-owned sub-slot inside the writ's observation `status` map. The `status` field on `WritDoc` is a `Record<string, unknown>` keyed by plugin id — each plugin reads and writes under its own key.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
// Spider records stuck cause
|
|
208
|
+
await clerk.setWritStatus(writ.id, 'spider', { stuckCause: 'engine-failed', lastRig: 'rig-01' });
|
|
209
|
+
|
|
210
|
+
// Astrolabe records a progress ratchet in the same writ — does not clobber spider's slot.
|
|
211
|
+
await clerk.setWritStatus(writ.id, 'astrolabe', { planVersion: 3 });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The write runs in a Stacks transaction (read-modify-write), so concurrent writes from different plugins to different sub-slots are disjoint and safe. The slot is not cleared on terminal transitions — observations persist for post-mortem analysis. See [Spec/Status Convention](#specstatus-convention) for conventions and a worked example.
|
|
215
|
+
|
|
216
|
+
Throws if `writId` or `pluginId` is missing, or if the writ does not exist.
|
|
217
|
+
|
|
198
218
|
---
|
|
199
219
|
|
|
200
|
-
##
|
|
220
|
+
## Phase Machine
|
|
201
221
|
|
|
202
222
|
```
|
|
203
223
|
new ──► open ──┬──► completed
|
|
@@ -215,7 +235,7 @@ new ──► open ──┬──► completed
|
|
|
215
235
|
```
|
|
216
236
|
|
|
217
237
|
- `completed`, `failed`, and `cancelled` are **terminal** — no transitions out.
|
|
218
|
-
- `stuck` is **non-terminal** — a "needs attention"
|
|
238
|
+
- `stuck` is **non-terminal** — a "needs attention" phase for writs whose rig hit an engine failure. Recovery (future retry) transitions back to `open`; giving up transitions to `failed` or `cancelled`.
|
|
219
239
|
|
|
220
240
|
### Allowed transitions
|
|
221
241
|
|
|
@@ -233,7 +253,7 @@ new ──► open ──┬──► completed
|
|
|
233
253
|
|
|
234
254
|
Writs can be organized into parent/child relationships for decomposing complex work:
|
|
235
255
|
|
|
236
|
-
- **Creating children:** Pass `parentId` to `post()`. The parent stays in its current
|
|
256
|
+
- **Creating children:** Pass `parentId` to `post()`. The parent stays in its current phase. Parents in `new`, `open`, or `stuck` phase accept children.
|
|
237
257
|
- **Failure cascade:** When a child fails and the parent is `open` or `stuck`, the parent is failed and remaining non-terminal siblings are cancelled.
|
|
238
258
|
- **Cancellation cascade:** When a parent reaches `failed` or `cancelled`, all non-terminal children are cancelled. When a parent reaches `completed` with non-terminal children still present, the Clerk logs a warning and leaves the children alone — this signals an upstream bookkeeping gap rather than normal flow.
|
|
239
259
|
- **Codex inheritance:** Children inherit the parent's codex if none is specified.
|
|
@@ -241,6 +261,54 @@ Writs can be organized into parent/child relationships for decomposing complex w
|
|
|
241
261
|
|
|
242
262
|
---
|
|
243
263
|
|
|
264
|
+
## Spec/Status Convention
|
|
265
|
+
|
|
266
|
+
Clerk follows a Kubernetes-style spec/status split:
|
|
267
|
+
|
|
268
|
+
- **Spec fields** are the declared intent of the writ — `title`, `body`, `type`, `codex`, `parentId`, and the Clerk-owned lifecycle field `phase`. These describe *what should happen* and *where the writ currently sits on the phase machine*.
|
|
269
|
+
- **Status slot** (the `status` field on `WritDoc`) is a `Record<string, unknown>` — a free-form observation map keyed by plugin id. Each plugin owns one sub-slot and uses it to record side-channel observations about the writ: last rig used, stuck cause, progress ratchets, planner version, etc.
|
|
270
|
+
|
|
271
|
+
The slot is a soft convention rather than a hard enforcement boundary:
|
|
272
|
+
|
|
273
|
+
- No runtime guard stops a plugin from reading another plugin's sub-slot — the convention is *write only your own key*, and the `setWritStatus()` API makes the right thing easy.
|
|
274
|
+
- **One sanctioned slot-write path.** The observation slot is writable only via `setWritStatus(writId, pluginId, value)`, which performs a transactional read-modify-write on the sub-slot keyed by `pluginId` so sibling sub-slots are preserved under concurrent writers. `transition()` silently strips `status` from its body alongside the other managed fields (`id`, `phase`, `createdAt`, `updatedAt`, `resolvedAt`, `parentId`). The generic `put()` / `patch()` paths on the `clerk/writs` book are not supported slot-write mechanisms — every route other than `setWritStatus()` would wholesale-replace the slot and clobber sibling sub-slots.
|
|
275
|
+
- Concurrent writes from different plugins to different sub-slots are disjoint and safe: `setWritStatus()` runs its read-modify-write inside a Stacks transaction.
|
|
276
|
+
- Within a single plugin's sub-slot, concurrent writes are last-writer-wins at the sub-slot level — `setWritStatus()` replaces the plugin's sub-slot value wholesale. Per-key atomicity inside a sub-slot is deferred until real contention appears.
|
|
277
|
+
- Slot writes emit CDC events like any other field change. Downstream observers (page renderers, audits, further observation pipelines) can watch the writs book for `update` events and react to the new `status` contents.
|
|
278
|
+
- Terminal transitions do **not** clear the slot. Observations persist on the writ for post-mortem inspection.
|
|
279
|
+
|
|
280
|
+
### Worked example: `status.spider.stuckCause`
|
|
281
|
+
|
|
282
|
+
When Spider's engine fails for a writ, it transitions the writ to `stuck` (a phase change) *and* records the diagnostic cause in its sub-slot (an observation):
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// In Spider's engine-failure handler:
|
|
286
|
+
await clerk.transition(writ.id, 'stuck', { resolution: 'Engine "implement-loop" failed' });
|
|
287
|
+
await clerk.setWritStatus(writ.id, 'spider', {
|
|
288
|
+
stuckCause: 'engine-failed',
|
|
289
|
+
lastRig: rig.id,
|
|
290
|
+
failedEngine: 'implement-loop',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Later, a human or tool reads the slot to triage:
|
|
294
|
+
const writ = await clerk.show(writId);
|
|
295
|
+
const spiderStatus = (writ.status ?? {})['spider'] as
|
|
296
|
+
| { stuckCause?: string; lastRig?: string; failedEngine?: string }
|
|
297
|
+
| undefined;
|
|
298
|
+
|
|
299
|
+
if (spiderStatus?.stuckCause === 'engine-failed') {
|
|
300
|
+
console.log(`Stuck in rig ${spiderStatus.lastRig} at engine ${spiderStatus.failedEngine}`);
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The phase (`stuck`) is the authoritative lifecycle state — queries, cascades, and the phase machine all reason from it. The observation (`status.spider.stuckCause`) is diagnostic context that survives alongside the phase without becoming part of the state machine itself.
|
|
305
|
+
|
|
306
|
+
### Guild-wide extensibility
|
|
307
|
+
|
|
308
|
+
The spec/status split is guild-wide in intent, not a writs-only pattern. Other runtime objects — **rigs**, **engines**, **sessions**, **input requests**, **clicks**, and future apparatuses' primary objects — will adopt the same split on a per-consumer basis: the owning apparatus keeps the lifecycle field (renaming its current `status` to `phase` when the time comes), and a new plugin-keyed `status: Record<string, unknown>` slot appears when the first observation-slot consumer materializes. Until that trigger arrives, those objects keep their existing `status` field unchanged — the convention rolls out one object at a time, not in a big-bang migration. When you author a new apparatus whose primary object may gain observations, reach for the spec/status shape from day one to avoid the rename later.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
244
312
|
## Configuration
|
|
245
313
|
|
|
246
314
|
Configure The Clerk under the `"clerk"` key in your guild config:
|
|
@@ -276,7 +344,7 @@ The Clerk contributes books, tools, and pages to the guild:
|
|
|
276
344
|
|
|
277
345
|
| Book | Indexes | Contents |
|
|
278
346
|
|---|---|---|
|
|
279
|
-
| `writs` | `
|
|
347
|
+
| `writs` | `phase`, `type`, `createdAt`, `parentId`, `[phase, type]`, `[phase, createdAt]`, `[parentId, phase]` | Writ documents |
|
|
280
348
|
| `links` | `sourceId`, `targetId`, `label`, `[sourceId, label]`, `[targetId, label]` | Writ relationship links |
|
|
281
349
|
|
|
282
350
|
### Tools
|
|
@@ -285,8 +353,8 @@ The Clerk contributes books, tools, and pages to the guild:
|
|
|
285
353
|
|---|---|---|
|
|
286
354
|
| `commission-post` | `clerk:write` | Post a new commission (create a writ, optionally as child) |
|
|
287
355
|
| `writ-show` | `clerk:read` | Show full detail for a writ (includes parent/children context) |
|
|
288
|
-
| `writ-list` | `clerk:read` | List writs with optional filters (
|
|
289
|
-
| `writ-edit` | `clerk:write` | Edit a writ (title/body any
|
|
356
|
+
| `writ-list` | `clerk:read` | List writs with optional filters (phase, type, parentId) |
|
|
357
|
+
| `writ-edit` | `clerk:write` | Edit a writ (title/body any phase; type/codex draft only) |
|
|
290
358
|
| `writ-complete` | `clerk:write` | Complete a writ (open → completed) |
|
|
291
359
|
| `writ-fail` | `clerk:write` | Fail a writ (open/stuck → failed) |
|
|
292
360
|
| `writ-cancel` | `clerk:write` | Cancel a writ (new/open/stuck → cancelled) |
|
|
@@ -302,12 +370,12 @@ The Clerk contributes books, tools, and pages to the guild:
|
|
|
302
370
|
## Key Types
|
|
303
371
|
|
|
304
372
|
```typescript
|
|
305
|
-
type
|
|
373
|
+
type WritPhase = 'new' | 'open' | 'stuck' | 'completed' | 'failed' | 'cancelled';
|
|
306
374
|
|
|
307
375
|
interface WritDoc {
|
|
308
376
|
id: string; // ULID-like, prefixed "w-"
|
|
309
377
|
type: string; // declared or built-in type
|
|
310
|
-
|
|
378
|
+
phase: WritPhase; // Clerk-owned lifecycle state
|
|
311
379
|
title: string;
|
|
312
380
|
body: string;
|
|
313
381
|
codex?: string; // target codex name
|
|
@@ -316,6 +384,7 @@ interface WritDoc {
|
|
|
316
384
|
updatedAt: string; // ISO timestamp, updated on every mutation
|
|
317
385
|
resolvedAt?: string; // ISO timestamp, set on any terminal transition
|
|
318
386
|
resolution?: string; // summary of how the writ resolved
|
|
387
|
+
status?: Record<string, unknown>; // plugin-owned observation slot (keyed by plugin id)
|
|
319
388
|
}
|
|
320
389
|
|
|
321
390
|
interface PostCommissionRequest {
|
|
@@ -335,7 +404,7 @@ interface EditWritRequest {
|
|
|
335
404
|
}
|
|
336
405
|
|
|
337
406
|
interface WritFilters {
|
|
338
|
-
|
|
407
|
+
phase?: WritPhase | WritPhase[];
|
|
339
408
|
type?: string;
|
|
340
409
|
parentId?: string; // filter to children of this parent
|
|
341
410
|
limit?: number;
|
package/dist/clerk.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The Clerk — writ lifecycle management apparatus.
|
|
3
3
|
*
|
|
4
4
|
* The Clerk manages the lifecycle of writs: lightweight work orders that flow
|
|
5
|
-
* through a fixed
|
|
5
|
+
* through a fixed phase machine (new → open → completed/failed/cancelled,
|
|
6
6
|
* with stuck as a non-terminal "needs attention" state off open).
|
|
7
7
|
* Each writ has a type, a title, a body, and optional codex and resolution
|
|
8
8
|
* fields.
|
|
@@ -31,7 +31,7 @@ export interface ClerkKit {
|
|
|
31
31
|
/**
|
|
32
32
|
* Resolution string applied to non-terminal children that are cancelled by
|
|
33
33
|
* the downward cascade when their parent transitions to a terminal failure
|
|
34
|
-
* or cancellation
|
|
34
|
+
* or cancellation phase. Single source of truth — referenced by code,
|
|
35
35
|
* tests, and documentation. Modeled on `PIECE_EXECUTION_EPILOGUE` in the
|
|
36
36
|
* Spider plugin.
|
|
37
37
|
*/
|
package/dist/clerk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../src/clerk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,wBAAwB,CAAC;AAKrE,OAAO,KAAK,EAWV,aAAa,EACb,SAAS,EAEV,MAAM,YAAY,CAAC;AAsBpB,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACvB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;CACzB;AAQD;;;;;;GAMG;AACH,eAAO,MAAM,qCAAqC,sDACG,CAAC;AAoBtD,wBAAgB,WAAW,IAAI,MAAM,
|
|
1
|
+
{"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../src/clerk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,wBAAwB,CAAC;AAKrE,OAAO,KAAK,EAWV,aAAa,EACb,SAAS,EAEV,MAAM,YAAY,CAAC;AAsBpB,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACvB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;CACzB;AAQD;;;;;;GAMG;AACH,eAAO,MAAM,qCAAqC,sDACG,CAAC;AAoBtD,wBAAgB,WAAW,IAAI,MAAM,CAiyBpC"}
|
package/dist/clerk.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The Clerk — writ lifecycle management apparatus.
|
|
3
3
|
*
|
|
4
4
|
* The Clerk manages the lifecycle of writs: lightweight work orders that flow
|
|
5
|
-
* through a fixed
|
|
5
|
+
* through a fixed phase machine (new → open → completed/failed/cancelled,
|
|
6
6
|
* with stuck as a non-terminal "needs attention" state off open).
|
|
7
7
|
* Each writ has a type, a title, a body, and optional codex and resolution
|
|
8
8
|
* fields.
|
|
@@ -23,12 +23,12 @@ const BUILTIN_TYPES = new Set(['mandate']);
|
|
|
23
23
|
/**
|
|
24
24
|
* Resolution string applied to non-terminal children that are cancelled by
|
|
25
25
|
* the downward cascade when their parent transitions to a terminal failure
|
|
26
|
-
* or cancellation
|
|
26
|
+
* or cancellation phase. Single source of truth — referenced by code,
|
|
27
27
|
* tests, and documentation. Modeled on `PIECE_EXECUTION_EPILOGUE` in the
|
|
28
28
|
* Spider plugin.
|
|
29
29
|
*/
|
|
30
30
|
export const CASCADE_PARENT_TERMINATION_RESOLUTION = 'Automatically cancelled due to parent termination';
|
|
31
|
-
// ──
|
|
31
|
+
// ── Phase machine ────────────────────────────────────────────────────
|
|
32
32
|
const ALLOWED_FROM = {
|
|
33
33
|
open: ['new', 'stuck'],
|
|
34
34
|
stuck: ['open'],
|
|
@@ -37,10 +37,10 @@ const ALLOWED_FROM = {
|
|
|
37
37
|
cancelled: ['new', 'open', 'stuck'],
|
|
38
38
|
new: [],
|
|
39
39
|
};
|
|
40
|
-
const
|
|
40
|
+
const TERMINAL_PHASES = new Set(['completed', 'failed', 'cancelled']);
|
|
41
41
|
// ── Factory ──────────────────────────────────────────────────────────
|
|
42
|
-
/** Parent
|
|
43
|
-
const
|
|
42
|
+
/** Parent phases that allow adding children. */
|
|
43
|
+
const CHILD_ALLOWED_PARENT_PHASES = new Set(['new', 'open', 'stuck']);
|
|
44
44
|
export function createClerk() {
|
|
45
45
|
let stacks;
|
|
46
46
|
let writs;
|
|
@@ -71,13 +71,13 @@ export function createClerk() {
|
|
|
71
71
|
}
|
|
72
72
|
function buildWhereClause(filters) {
|
|
73
73
|
const conditions = [];
|
|
74
|
-
if (filters?.
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
conditions.push(['
|
|
74
|
+
if (filters?.phase) {
|
|
75
|
+
const phases = Array.isArray(filters.phase) ? filters.phase : [filters.phase];
|
|
76
|
+
if (phases.length === 1) {
|
|
77
|
+
conditions.push(['phase', '=', phases[0]]);
|
|
78
78
|
}
|
|
79
|
-
else if (
|
|
80
|
-
conditions.push(['
|
|
79
|
+
else if (phases.length > 1) {
|
|
80
|
+
conditions.push(['phase', 'IN', phases]);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
if (filters?.type) {
|
|
@@ -178,13 +178,13 @@ export function createClerk() {
|
|
|
178
178
|
// Wrap in a transaction for atomicity
|
|
179
179
|
return stacks.transaction(async (tx) => {
|
|
180
180
|
const txWrits = tx.book('clerk', 'writs');
|
|
181
|
-
// Validate parent exists and is in an allowed
|
|
181
|
+
// Validate parent exists and is in an allowed phase
|
|
182
182
|
const parent = await txWrits.get(request.parentId);
|
|
183
183
|
if (!parent) {
|
|
184
184
|
throw new Error(`Parent writ "${request.parentId}" not found.`);
|
|
185
185
|
}
|
|
186
|
-
if (!
|
|
187
|
-
throw new Error(`Cannot add children to writ "${request.parentId}":
|
|
186
|
+
if (!CHILD_ALLOWED_PARENT_PHASES.has(parent.phase)) {
|
|
187
|
+
throw new Error(`Cannot add children to writ "${request.parentId}": phase is "${parent.phase}", expected one of: ${[...CHILD_ALLOWED_PARENT_PHASES].join(', ')}.`);
|
|
188
188
|
}
|
|
189
189
|
// Defensive self-parenting check
|
|
190
190
|
if (request.parentId === childId) {
|
|
@@ -197,7 +197,7 @@ export function createClerk() {
|
|
|
197
197
|
const writ = {
|
|
198
198
|
id: childId,
|
|
199
199
|
type,
|
|
200
|
-
|
|
200
|
+
phase: request.draft === true ? 'new' : 'open',
|
|
201
201
|
title: request.title,
|
|
202
202
|
body: request.body,
|
|
203
203
|
...(codex !== undefined ? { codex } : {}),
|
|
@@ -212,7 +212,7 @@ export function createClerk() {
|
|
|
212
212
|
const writ = {
|
|
213
213
|
id: childId,
|
|
214
214
|
type,
|
|
215
|
-
|
|
215
|
+
phase: request.draft === true ? 'new' : 'open',
|
|
216
216
|
title: request.title,
|
|
217
217
|
body: request.body,
|
|
218
218
|
...(codex !== undefined ? { codex } : {}),
|
|
@@ -348,12 +348,12 @@ export function createClerk() {
|
|
|
348
348
|
throw new Error(`Writ "${request.id}" not found.`);
|
|
349
349
|
}
|
|
350
350
|
// Type and codex can only be changed while the writ is still a draft
|
|
351
|
-
if (writ.
|
|
351
|
+
if (writ.phase !== 'new') {
|
|
352
352
|
if (request.type !== undefined) {
|
|
353
|
-
throw new Error(`Cannot change type on writ "${request.id}":
|
|
353
|
+
throw new Error(`Cannot change type on writ "${request.id}": phase is "${writ.phase}". Type can only be changed while the writ is in "new" phase.`);
|
|
354
354
|
}
|
|
355
355
|
if (request.codex !== undefined) {
|
|
356
|
-
throw new Error(`Cannot change codex on writ "${request.id}":
|
|
356
|
+
throw new Error(`Cannot change codex on writ "${request.id}": phase is "${writ.phase}". Codex can only be changed while the writ is in "new" phase.`);
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
359
|
// Validate type if provided
|
|
@@ -389,31 +389,67 @@ export function createClerk() {
|
|
|
389
389
|
throw new Error(`Writ "${id}" not found.`);
|
|
390
390
|
}
|
|
391
391
|
const allowedFrom = ALLOWED_FROM[to];
|
|
392
|
-
if (!allowedFrom.includes(writ.
|
|
393
|
-
throw new Error(`Cannot transition writ "${id}" to "${to}":
|
|
392
|
+
if (!allowedFrom.includes(writ.phase)) {
|
|
393
|
+
throw new Error(`Cannot transition writ "${id}" to "${to}": phase is "${writ.phase}", expected one of: ${allowedFrom.join(', ')}.`);
|
|
394
394
|
}
|
|
395
395
|
const now = new Date().toISOString();
|
|
396
|
-
const isTerminal =
|
|
397
|
-
// Strip managed fields — callers cannot override id,
|
|
398
|
-
//
|
|
399
|
-
|
|
396
|
+
const isTerminal = TERMINAL_PHASES.has(to);
|
|
397
|
+
// Strip managed fields — callers cannot override id, phase, the
|
|
398
|
+
// plugin-owned observation slot `status`, or timestamps controlled
|
|
399
|
+
// by the phase machine.
|
|
400
|
+
//
|
|
401
|
+
// The observation slot is a plugin-keyed map (`Record<PluginId,
|
|
402
|
+
// unknown>`) whose sub-slots are owned by different plugins. The
|
|
403
|
+
// only slot-write path that preserves sibling sub-slots under
|
|
404
|
+
// concurrent writers is ClerkApi.setWritStatus(writId, pluginId,
|
|
405
|
+
// value), which performs a transactional read-modify-write on the
|
|
406
|
+
// sub-slot keyed by pluginId. Because patch() is a top-level
|
|
407
|
+
// shallow merge, a `status` value smuggled through transition()
|
|
408
|
+
// would wholesale-replace the slot and silently clobber sibling
|
|
409
|
+
// sub-slots — so `status` is silently dropped here alongside the
|
|
410
|
+
// other managed fields. There is exactly one sanctioned slot-write
|
|
411
|
+
// path, and it is setWritStatus().
|
|
412
|
+
const { id: _id, phase: _phase, status: _status, createdAt: _c, updatedAt: _u, resolvedAt: _r, parentId: _p, ...safeFields } = (fields ?? {});
|
|
400
413
|
const patch = {
|
|
401
|
-
|
|
414
|
+
phase: to,
|
|
402
415
|
updatedAt: now,
|
|
403
416
|
...(isTerminal ? { resolvedAt: now } : {}),
|
|
404
417
|
...safeFields,
|
|
405
418
|
};
|
|
406
419
|
return writs.patch(id, patch);
|
|
407
420
|
},
|
|
421
|
+
async setWritStatus(writId, pluginId, value) {
|
|
422
|
+
if (!writId) {
|
|
423
|
+
throw new Error('setWritStatus: writId is required.');
|
|
424
|
+
}
|
|
425
|
+
if (!pluginId) {
|
|
426
|
+
throw new Error('setWritStatus: pluginId is required.');
|
|
427
|
+
}
|
|
428
|
+
// Read-modify-write in a single transaction so we do not clobber
|
|
429
|
+
// sibling sub-slots written concurrently by other plugins.
|
|
430
|
+
return stacks.transaction(async (tx) => {
|
|
431
|
+
const txWrits = tx.book('clerk', 'writs');
|
|
432
|
+
const existing = await txWrits.get(writId);
|
|
433
|
+
if (!existing) {
|
|
434
|
+
throw new Error(`Writ "${writId}" not found.`);
|
|
435
|
+
}
|
|
436
|
+
const prevStatus = (existing.status ?? {});
|
|
437
|
+
const nextStatus = { ...prevStatus, [pluginId]: value };
|
|
438
|
+
return txWrits.patch(writId, {
|
|
439
|
+
status: nextStatus,
|
|
440
|
+
updatedAt: new Date().toISOString(),
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
},
|
|
408
444
|
};
|
|
409
445
|
// ── CDC cascade handlers ─────────────────────────────────────────
|
|
410
446
|
async function handleChildTerminal(child) {
|
|
411
447
|
if (!child.parentId)
|
|
412
448
|
return;
|
|
413
449
|
const parent = await writs.get(child.parentId);
|
|
414
|
-
if (!parent || (parent.
|
|
450
|
+
if (!parent || (parent.phase !== 'open' && parent.phase !== 'stuck'))
|
|
415
451
|
return;
|
|
416
|
-
if (child.
|
|
452
|
+
if (child.phase === 'failed') {
|
|
417
453
|
const childResolution = child.resolution ?? 'unknown';
|
|
418
454
|
await api.transition(parent.id, 'failed', {
|
|
419
455
|
resolution: `Child "${child.id}" failed: ${childResolution}`,
|
|
@@ -424,18 +460,18 @@ export function createClerk() {
|
|
|
424
460
|
const children = await writs.find({ where: [['parentId', '=', parent.id]] });
|
|
425
461
|
if (children.length === 0)
|
|
426
462
|
return;
|
|
427
|
-
const nonTerminalChildren = children.filter((c) => !
|
|
463
|
+
const nonTerminalChildren = children.filter((c) => !TERMINAL_PHASES.has(c.phase));
|
|
428
464
|
if (nonTerminalChildren.length === 0)
|
|
429
465
|
return;
|
|
430
466
|
// When the parent reached `completed`, non-terminal children shouldn't
|
|
431
467
|
// exist — their presence indicates an upstream bookkeeping gap (e.g. a
|
|
432
468
|
// child-writ transition lost a race). Warn loudly rather than masking
|
|
433
469
|
// the discrepancy by cancelling.
|
|
434
|
-
if (parent.
|
|
470
|
+
if (parent.phase === 'completed') {
|
|
435
471
|
for (const child of nonTerminalChildren) {
|
|
436
472
|
console.warn(`[clerk] Parent writ "${parent.id}" transitioned to "completed" but ` +
|
|
437
|
-
`child writ "${child.id}" is still in non-terminal
|
|
438
|
-
`"${child.
|
|
473
|
+
`child writ "${child.id}" is still in non-terminal phase ` +
|
|
474
|
+
`"${child.phase}". Leaving the child as-is; this indicates an ` +
|
|
439
475
|
`upstream bookkeeping gap that should be investigated.`);
|
|
440
476
|
}
|
|
441
477
|
return;
|
|
@@ -468,7 +504,7 @@ export function createClerk() {
|
|
|
468
504
|
supportKit: {
|
|
469
505
|
books: {
|
|
470
506
|
writs: {
|
|
471
|
-
indexes: ['
|
|
507
|
+
indexes: ['phase', 'type', 'createdAt', 'parentId', ['phase', 'type'], ['phase', 'createdAt'], ['parentId', 'phase']],
|
|
472
508
|
},
|
|
473
509
|
links: {
|
|
474
510
|
indexes: ['sourceId', 'targetId', 'label', ['sourceId', 'label'], ['targetId', 'label']],
|
|
@@ -526,32 +562,72 @@ export function createClerk() {
|
|
|
526
562
|
return;
|
|
527
563
|
const writ = event.entry;
|
|
528
564
|
const prev = event.prev;
|
|
529
|
-
// Only act on
|
|
530
|
-
if (writ.
|
|
565
|
+
// Only act on phase changes
|
|
566
|
+
if (writ.phase === prev.phase)
|
|
531
567
|
return;
|
|
532
568
|
// ── Upward cascade: child → parent ──
|
|
533
|
-
if (writ.parentId &&
|
|
569
|
+
if (writ.parentId && TERMINAL_PHASES.has(writ.phase)) {
|
|
534
570
|
await handleChildTerminal(writ);
|
|
535
571
|
}
|
|
536
572
|
// ── Downward cascade: parent → children ──
|
|
537
|
-
if (
|
|
573
|
+
if (TERMINAL_PHASES.has(writ.phase)) {
|
|
538
574
|
await handleParentTerminal(writ);
|
|
539
575
|
}
|
|
540
576
|
}, { failOnError: true });
|
|
541
|
-
// ── One-shot migration:
|
|
577
|
+
// ── One-shot migration: rename `status` → `phase`, subsume legacy values ──
|
|
542
578
|
// Safe to run inside start(): stacks only seals the CDC registry
|
|
543
579
|
// at phase:started (after every apparatus has started), so these
|
|
544
580
|
// writes don't lock out downstream apparatuses that register
|
|
545
581
|
// watchers in their own start().
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
582
|
+
//
|
|
583
|
+
// Every pre-rename row carries its lifecycle value in `status`.
|
|
584
|
+
// Post-rename rows carry `phase` instead, and `status` becomes the
|
|
585
|
+
// plugin-owned observation slot. We iterate every row, compute a
|
|
586
|
+
// clean post-rename document, and `put()` it back — `patch()` can't
|
|
587
|
+
// remove a field, so the full rewrite is required.
|
|
588
|
+
//
|
|
589
|
+
// Legacy lifecycle values (`ready`, `active`, `waiting`) are
|
|
590
|
+
// collapsed into `open` along the way (this subsumes the older
|
|
591
|
+
// `legacyStatuses` migration). Any unrecognized value aborts
|
|
592
|
+
// startup — unknown phase is a data-integrity issue.
|
|
593
|
+
const LEGACY_COLLAPSE = {
|
|
594
|
+
ready: 'open',
|
|
595
|
+
active: 'open',
|
|
596
|
+
waiting: 'open',
|
|
597
|
+
};
|
|
598
|
+
const VALID_PHASES = new Set([
|
|
599
|
+
'new', 'open', 'stuck', 'completed', 'failed', 'cancelled',
|
|
600
|
+
]);
|
|
601
|
+
const allWrits = await writs.find({});
|
|
602
|
+
for (const row of allWrits) {
|
|
603
|
+
// Already migrated — skip (idempotent).
|
|
604
|
+
if (typeof row.phase === 'string')
|
|
605
|
+
continue;
|
|
606
|
+
const legacyStatus = row.status;
|
|
607
|
+
if (typeof legacyStatus !== 'string') {
|
|
608
|
+
throw new Error(`[clerk] Migration: writ "${row.id}" has neither \`phase\` nor a string \`status\` field; cannot migrate (got ${legacyStatus === undefined ? 'undefined' : typeof legacyStatus}).`);
|
|
609
|
+
}
|
|
610
|
+
let nextPhase;
|
|
611
|
+
if (LEGACY_COLLAPSE[legacyStatus]) {
|
|
612
|
+
nextPhase = LEGACY_COLLAPSE[legacyStatus];
|
|
613
|
+
}
|
|
614
|
+
else if (VALID_PHASES.has(legacyStatus)) {
|
|
615
|
+
nextPhase = legacyStatus;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
throw new Error(`[clerk] Migration: writ "${row.id}" has unrecognized status value "${legacyStatus}". Expected one of: ${[...VALID_PHASES].join(', ')} (or legacy: ${Object.keys(LEGACY_COLLAPSE).join(', ')}).`);
|
|
619
|
+
}
|
|
620
|
+
// Build a clean post-rename document — no `status` key, `phase`
|
|
621
|
+
// set. `updatedAt` is preserved exactly as stored (this is a
|
|
622
|
+
// storage-format change, not a logical edit).
|
|
623
|
+
const migrated = {};
|
|
624
|
+
for (const [k, v] of Object.entries(row)) {
|
|
625
|
+
if (k === 'status')
|
|
626
|
+
continue;
|
|
627
|
+
migrated[k] = v;
|
|
554
628
|
}
|
|
629
|
+
migrated.phase = nextPhase;
|
|
630
|
+
await writs.put(migrated);
|
|
555
631
|
}
|
|
556
632
|
// ── One-shot migration: normalize link rows ──────────────────
|
|
557
633
|
// Two-pass flow per D7:
|