@graphorin/server 0.6.1 → 0.7.0

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 (181) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +35 -607
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +8 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +19 -24
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +1 -1
  59. package/dist/package.js.map +1 -1
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.js +19 -12
  99. package/dist/ws/dispatcher.js.map +1 -1
  100. package/dist/ws/index.d.ts +2 -2
  101. package/dist/ws/index.js +3 -3
  102. package/dist/ws/replay-buffer.d.ts +35 -1
  103. package/dist/ws/replay-buffer.d.ts.map +1 -1
  104. package/dist/ws/replay-buffer.js +35 -3
  105. package/dist/ws/replay-buffer.js.map +1 -1
  106. package/dist/ws/upgrade.d.ts.map +1 -1
  107. package/dist/ws/upgrade.js +24 -18
  108. package/dist/ws/upgrade.js.map +1 -1
  109. package/package.json +30 -29
  110. package/src/app-audit-binding.ts +227 -0
  111. package/src/app-daemons.ts +73 -0
  112. package/src/app-lifecycle.ts +476 -0
  113. package/src/app-metrics.ts +144 -0
  114. package/src/app-middleware.ts +149 -0
  115. package/src/app-routes.ts +305 -0
  116. package/src/app-ws.ts +111 -0
  117. package/src/app.ts +317 -0
  118. package/src/commentary/audit-bridge.ts +135 -0
  119. package/src/commentary/built-in-patterns.ts +79 -0
  120. package/src/commentary/index.ts +32 -0
  121. package/src/commentary/sanitizer.ts +238 -0
  122. package/src/commentary/types.ts +130 -0
  123. package/src/config.ts +472 -0
  124. package/src/consolidator/daemon.ts +141 -0
  125. package/src/consolidator/index.ts +14 -0
  126. package/src/errors/index.ts +247 -0
  127. package/src/health/checks.ts +322 -0
  128. package/src/health/index.ts +34 -0
  129. package/src/health/routes.ts +154 -0
  130. package/src/index.ts +243 -0
  131. package/src/internal/context.ts +77 -0
  132. package/src/internal/ids.ts +17 -0
  133. package/src/internal/json.ts +47 -0
  134. package/src/internal/wire-error.ts +44 -0
  135. package/src/lifecycle/hooks.ts +66 -0
  136. package/src/lifecycle/index.ts +16 -0
  137. package/src/lifecycle/pre-bind.ts +138 -0
  138. package/src/metrics/catalog.ts +112 -0
  139. package/src/metrics/index.ts +12 -0
  140. package/src/metrics/registry.ts +337 -0
  141. package/src/metrics/tools-bridge.ts +124 -0
  142. package/src/middleware/audit.ts +114 -0
  143. package/src/middleware/auth.ts +195 -0
  144. package/src/middleware/cors.ts +72 -0
  145. package/src/middleware/csrf.ts +98 -0
  146. package/src/middleware/idempotency.ts +336 -0
  147. package/src/middleware/index.ts +38 -0
  148. package/src/middleware/rate-limit.ts +71 -0
  149. package/src/middleware/request-state.ts +79 -0
  150. package/src/middleware/scope.ts +161 -0
  151. package/src/registry/index.ts +278 -0
  152. package/src/replay/index.ts +14 -0
  153. package/src/replay/routes.ts +255 -0
  154. package/src/routes/agents.ts +363 -0
  155. package/src/routes/audit.ts +207 -0
  156. package/src/routes/auth.ts +80 -0
  157. package/src/routes/health.ts +42 -0
  158. package/src/routes/index.ts +16 -0
  159. package/src/routes/mcp.ts +97 -0
  160. package/src/routes/memory.ts +152 -0
  161. package/src/routes/sessions.ts +250 -0
  162. package/src/routes/skills.ts +91 -0
  163. package/src/routes/tokens.ts +166 -0
  164. package/src/routes/workflows.ts +616 -0
  165. package/src/runtime/retention.ts +284 -0
  166. package/src/runtime/run-state.ts +422 -0
  167. package/src/sse/events.ts +303 -0
  168. package/src/sse/index.ts +7 -0
  169. package/src/tools-audit-bridge.ts +120 -0
  170. package/src/triggers/daemon.ts +198 -0
  171. package/src/triggers/index.ts +16 -0
  172. package/src/triggers/routes.ts +139 -0
  173. package/src/workflows/timer-daemon.ts +102 -0
  174. package/src/ws/dispatcher.ts +537 -0
  175. package/src/ws/index.ts +45 -0
  176. package/src/ws/replay-buffer.ts +209 -0
  177. package/src/ws/subjects.ts +162 -0
  178. package/src/ws/ticket.ts +174 -0
  179. package/src/ws/upgrade.ts +507 -0
  180. package/dist/lifecycle/index.js +0 -3
  181. package/dist/routes/index.js +0 -12
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Workflow REST routes.
3
+ *
4
+ * POST /workflows/:id/execute (idempotent; scope `workflows:execute`)
5
+ * POST /workflows/:id/resume (scope `workflows:resume[:id]`;
6
+ * `name` targets an awakeable/approval, W-119)
7
+ * POST /workflows/:id/retry (scope `workflows:resume[:id]`, W-119)
8
+ * POST /workflows/:id/tick (scope `workflows:resume[:id]`, W-119)
9
+ * GET /workflows/:id/state (scope `workflows:read`)
10
+ * GET /workflows/:id/checkpoints (scope `workflows:read`)
11
+ * POST /workflows/:id/fork (scope `workflows:execute`; real fork, W-119)
12
+ * DELETE /workflows/:id/threads/:threadId (scope `workflows:delete`)
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import type { Context } from 'hono';
18
+ import { Hono } from 'hono';
19
+ import { z } from 'zod';
20
+
21
+ import { WorkflowNotFoundError } from '../errors/index.js';
22
+ import type { ServerVariables } from '../internal/context.js';
23
+ import { newRequestId } from '../internal/ids.js';
24
+ import { toWireError } from '../internal/wire-error.js';
25
+ import { createScopeMiddleware } from '../middleware/scope.js';
26
+ import type { WorkflowRegistry } from '../registry/index.js';
27
+ import type { RunStateTracker } from '../runtime/run-state.js';
28
+
29
+ /**
30
+ * @stable
31
+ */
32
+ export interface WorkflowRoutesDeps {
33
+ readonly workflows: WorkflowRegistry;
34
+ readonly runs: RunStateTracker;
35
+ readonly newRunId?: () => string;
36
+ /** Streaming dispatcher (IP-2): workflow events reach the run subject. */
37
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
38
+ }
39
+
40
+ const ExecuteBodySchema = z
41
+ .object({
42
+ input: z.unknown().optional(),
43
+ threadId: z.string().min(1).optional(),
44
+ sessionId: z.string().min(1).optional(),
45
+ userId: z.string().min(1).optional(),
46
+ })
47
+ .strict()
48
+ .default({});
49
+
50
+ const ResumeBodySchema = z
51
+ .object({
52
+ threadId: z.string().min(1),
53
+ resume: z.unknown().optional(),
54
+ /**
55
+ * W-119: awakeable/approval name. When present the resume routes
56
+ * through `resolveAwakeable(threadId, name, resume)` - `approve` is
57
+ * the same primitive, no alias route needed.
58
+ */
59
+ name: z.string().min(1).optional(),
60
+ })
61
+ .strict();
62
+
63
+ const ThreadBodySchema = z
64
+ .object({
65
+ threadId: z.string().min(1),
66
+ })
67
+ .strict();
68
+
69
+ const ForkBodySchema = z
70
+ .object({
71
+ fromThreadId: z.string().min(1),
72
+ fromCheckpointId: z.string().min(1).optional(),
73
+ })
74
+ .strict();
75
+
76
+ /**
77
+ * @stable
78
+ */
79
+ export function createWorkflowRoutes(
80
+ deps: WorkflowRoutesDeps,
81
+ ): Hono<{ Variables: ServerVariables }> {
82
+ const app = new Hono<{ Variables: ServerVariables }>();
83
+ const newRunId = deps.newRunId ?? newRequestId;
84
+
85
+ app.get('/', createScopeMiddleware('workflows:read'), (c) =>
86
+ c.json({ workflows: deps.workflows.list() }),
87
+ );
88
+
89
+ app.post(
90
+ '/:id/execute',
91
+ createScopeMiddleware((_path, params) => `workflows:execute:${params.id}`),
92
+ async (c) => {
93
+ const id = c.req.param('id');
94
+ const workflow = deps.workflows.get(id);
95
+ if (workflow === undefined) {
96
+ const err = new WorkflowNotFoundError(id);
97
+ return c.json({ error: err.kind, message: err.message }, 404);
98
+ }
99
+ const parsed = ExecuteBodySchema.safeParse(await safelyParseJson(c));
100
+ if (!parsed.success) {
101
+ return c.json(
102
+ {
103
+ error: 'config-invalid',
104
+ message: 'Invalid execute body.',
105
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
106
+ },
107
+ 400,
108
+ );
109
+ }
110
+ const runId = newRunId();
111
+ const tracker = deps.runs.start(runId, {
112
+ kind: 'workflow',
113
+ workflowId: id,
114
+ ...(parsed.data.threadId !== undefined ? { threadId: parsed.data.threadId } : {}),
115
+ ...(parsed.data.sessionId !== undefined ? { sessionId: parsed.data.sessionId } : {}),
116
+ ...(parsed.data.userId !== undefined ? { userId: parsed.data.userId } : {}),
117
+ });
118
+ // Workflows are streaming-first; the REST endpoint kicks off the
119
+ // run in the background and returns 202 + runId. The actual
120
+ // event stream is consumed via WebSocket / SSE in Phase 14b.
121
+ const subject = `workflow:${id}/runs/${runId}/events`;
122
+ backgroundExecute(workflow, parsed.data, tracker, deps.runs, runId, {
123
+ subject,
124
+ ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}),
125
+ });
126
+ return c.json(
127
+ {
128
+ runId,
129
+ status: 'running',
130
+ subscribe: {
131
+ // IP-2: the previously-advertised SSE URL was never
132
+ // mounted; the WS subject (now in the grammar) delivers.
133
+ websocket: subject,
134
+ },
135
+ },
136
+ 202,
137
+ );
138
+ },
139
+ );
140
+
141
+ app.post(
142
+ '/:id/resume',
143
+ createScopeMiddleware((_path, params) => `workflows:resume:${params.id}`),
144
+ async (c) => {
145
+ const id = c.req.param('id');
146
+ const workflow = deps.workflows.get(id);
147
+ if (workflow === undefined) {
148
+ const err = new WorkflowNotFoundError(id);
149
+ return c.json({ error: err.kind, message: err.message }, 404);
150
+ }
151
+ const parsed = ResumeBodySchema.safeParse(await safelyParseJson(c));
152
+ if (!parsed.success) {
153
+ return c.json(
154
+ {
155
+ error: 'config-invalid',
156
+ message: 'Invalid resume body.',
157
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
158
+ },
159
+ 400,
160
+ );
161
+ }
162
+ if (parsed.data.name !== undefined && workflow.resolveAwakeable === undefined) {
163
+ return c.json(
164
+ {
165
+ error: 'workflow-resume-unsupported',
166
+ message: `Workflow '${id}' does not implement resolveAwakeable().`,
167
+ },
168
+ 400,
169
+ );
170
+ }
171
+ if (parsed.data.name === undefined && workflow.resume === undefined) {
172
+ return c.json(
173
+ {
174
+ error: 'workflow-resume-unsupported',
175
+ message: `Workflow '${id}' does not implement resume().`,
176
+ },
177
+ 400,
178
+ );
179
+ }
180
+ const runId = newRunId();
181
+ // periphery-01: the old handler declared the run and returned 202
182
+ // WITHOUT ever calling `workflow.resume(...)` - the run sat
183
+ // `pending` forever and the advertised SSE URL was never mounted.
184
+ // Resume now mirrors execute: background-iterate + emit on the WS
185
+ // subject; the tracker completes the run.
186
+ const tracker = deps.runs.start(runId, {
187
+ kind: 'workflow',
188
+ workflowId: id,
189
+ threadId: parsed.data.threadId,
190
+ });
191
+ const subject = `workflow:${id}/runs/${runId}/events`;
192
+ backgroundResume(workflow, parsed.data, tracker, deps.runs, runId, {
193
+ subject,
194
+ ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}),
195
+ });
196
+ return c.json(
197
+ {
198
+ runId,
199
+ threadId: parsed.data.threadId,
200
+ status: 'running',
201
+ subscribe: {
202
+ websocket: subject,
203
+ },
204
+ },
205
+ 202,
206
+ );
207
+ },
208
+ );
209
+
210
+ // W-119: replay a failed/aborted thread. Background-iterated like
211
+ // resume; the run subject carries the events.
212
+ app.post(
213
+ '/:id/retry',
214
+ createScopeMiddleware((_path, params) => `workflows:resume:${params.id}`),
215
+ async (c) => {
216
+ const id = c.req.param('id');
217
+ const workflow = deps.workflows.get(id);
218
+ if (workflow === undefined) {
219
+ const err = new WorkflowNotFoundError(id);
220
+ return c.json({ error: err.kind, message: err.message }, 404);
221
+ }
222
+ const parsed = ThreadBodySchema.safeParse(await safelyParseJson(c));
223
+ if (!parsed.success) {
224
+ return c.json(
225
+ {
226
+ error: 'config-invalid',
227
+ message: 'Invalid retry body.',
228
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
229
+ },
230
+ 400,
231
+ );
232
+ }
233
+ if (workflow.retry === undefined) {
234
+ return c.json(
235
+ {
236
+ error: 'workflow-retry-unsupported',
237
+ message: `Workflow '${id}' does not implement retry().`,
238
+ },
239
+ 400,
240
+ );
241
+ }
242
+ const runId = newRunId();
243
+ const tracker = deps.runs.start(runId, {
244
+ kind: 'workflow',
245
+ workflowId: id,
246
+ threadId: parsed.data.threadId,
247
+ });
248
+ const subject = `workflow:${id}/runs/${runId}/events`;
249
+ const retryFn = workflow.retry.bind(workflow);
250
+ backgroundIterate(
251
+ () => retryFn(parsed.data.threadId, { signal: tracker.signal }),
252
+ tracker,
253
+ deps.runs,
254
+ runId,
255
+ { subject, ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}) },
256
+ );
257
+ return c.json(
258
+ {
259
+ runId,
260
+ threadId: parsed.data.threadId,
261
+ status: 'running',
262
+ subscribe: { websocket: subject },
263
+ },
264
+ 202,
265
+ );
266
+ },
267
+ );
268
+
269
+ // W-119: fire a due durable timer synchronously. Long node bodies
270
+ // hold the HTTP connection - documented; wire the timer daemon
271
+ // (createServer({ workflowTimers })) for regular firing instead.
272
+ app.post(
273
+ '/:id/tick',
274
+ createScopeMiddleware((_path, params) => `workflows:resume:${params.id}`),
275
+ async (c) => {
276
+ const id = c.req.param('id');
277
+ const workflow = deps.workflows.get(id);
278
+ if (workflow === undefined) {
279
+ const err = new WorkflowNotFoundError(id);
280
+ return c.json({ error: err.kind, message: err.message }, 404);
281
+ }
282
+ const parsed = ThreadBodySchema.safeParse(await safelyParseJson(c));
283
+ if (!parsed.success) {
284
+ return c.json(
285
+ {
286
+ error: 'config-invalid',
287
+ message: 'Invalid tick body.',
288
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
289
+ },
290
+ 400,
291
+ );
292
+ }
293
+ if (workflow.tick === undefined) {
294
+ return c.json(
295
+ {
296
+ error: 'workflow-tick-unsupported',
297
+ message: `Workflow '${id}' does not implement tick().`,
298
+ },
299
+ 400,
300
+ );
301
+ }
302
+ try {
303
+ const result = await workflow.tick(parsed.data.threadId);
304
+ return c.json({
305
+ workflowId: id,
306
+ threadId: parsed.data.threadId,
307
+ fired: result.fired,
308
+ nextWakeAt: result.nextWakeAt,
309
+ });
310
+ } catch (err) {
311
+ const wire = toWireError(err);
312
+ return c.json(
313
+ { error: wire.code, message: wire.message },
314
+ wire.code === 'thread-not-found' ? 404 : 400,
315
+ );
316
+ }
317
+ },
318
+ );
319
+
320
+ app.get(
321
+ '/:id/state',
322
+ createScopeMiddleware((_path, params) => `workflows:read:${params.id}`),
323
+ async (c) => {
324
+ const id = c.req.param('id');
325
+ const workflow = deps.workflows.get(id);
326
+ if (workflow === undefined) {
327
+ const err = new WorkflowNotFoundError(id);
328
+ return c.json({ error: err.kind, message: err.message }, 404);
329
+ }
330
+ const threadId = c.req.query('threadId');
331
+ if (threadId === undefined || threadId.length === 0) {
332
+ return c.json(
333
+ { error: 'config-invalid', message: 'Query parameter `threadId` is required.' },
334
+ 400,
335
+ );
336
+ }
337
+ if (workflow.getState === undefined) {
338
+ return c.json(
339
+ {
340
+ error: 'workflow-state-unsupported',
341
+ message: 'Workflow does not implement getState().',
342
+ },
343
+ 400,
344
+ );
345
+ }
346
+ const state = await workflow.getState(threadId);
347
+ return c.json({ workflowId: id, threadId, state });
348
+ },
349
+ );
350
+
351
+ app.get(
352
+ '/:id/checkpoints',
353
+ createScopeMiddleware((_path, params) => `workflows:read:${params.id}`),
354
+ async (c) => {
355
+ const id = c.req.param('id');
356
+ const workflow = deps.workflows.get(id);
357
+ if (workflow === undefined) {
358
+ const err = new WorkflowNotFoundError(id);
359
+ return c.json({ error: err.kind, message: err.message }, 404);
360
+ }
361
+ const threadId = c.req.query('threadId');
362
+ if (threadId === undefined || threadId.length === 0) {
363
+ return c.json(
364
+ { error: 'config-invalid', message: 'Query parameter `threadId` is required.' },
365
+ 400,
366
+ );
367
+ }
368
+ if (workflow.listCheckpoints === undefined) {
369
+ return c.json(
370
+ {
371
+ error: 'workflow-checkpoints-unsupported',
372
+ message: 'Workflow does not implement listCheckpoints().',
373
+ },
374
+ 400,
375
+ );
376
+ }
377
+ const checkpoints = await workflow.listCheckpoints(threadId);
378
+ return c.json({ workflowId: id, threadId, checkpoints });
379
+ },
380
+ );
381
+
382
+ // W-005: the reachable per-thread erasure lever. 204 on success (the
383
+ // store delete is idempotent - deleting an unknown thread is a no-op),
384
+ // 404 for an unknown workflow, 400 when the registered entry does not
385
+ // expose deleteThread.
386
+ app.delete(
387
+ '/:id/threads/:threadId',
388
+ createScopeMiddleware((_path, params) => `workflows:delete:${params.id}`),
389
+ async (c) => {
390
+ const id = c.req.param('id');
391
+ const threadId = c.req.param('threadId');
392
+ const workflow = deps.workflows.get(id);
393
+ if (workflow === undefined) {
394
+ const err = new WorkflowNotFoundError(id);
395
+ return c.json({ error: err.kind, message: err.message }, 404);
396
+ }
397
+ if (workflow.deleteThread === undefined) {
398
+ return c.json(
399
+ {
400
+ error: 'workflow-delete-thread-unsupported',
401
+ message: 'Workflow does not implement deleteThread().',
402
+ },
403
+ 400,
404
+ );
405
+ }
406
+ await workflow.deleteThread(threadId);
407
+ return c.body(null, 204);
408
+ },
409
+ );
410
+
411
+ app.post(
412
+ '/:id/fork',
413
+ createScopeMiddleware((_path, params) => `workflows:execute:${params.id}`),
414
+ async (c) => {
415
+ const id = c.req.param('id');
416
+ const workflow = deps.workflows.get(id);
417
+ if (workflow === undefined) {
418
+ const err = new WorkflowNotFoundError(id);
419
+ return c.json({ error: err.kind, message: err.message }, 404);
420
+ }
421
+ const parsed = ForkBodySchema.safeParse(await safelyParseJson(c));
422
+ if (!parsed.success) {
423
+ return c.json(
424
+ {
425
+ error: 'config-invalid',
426
+ message: 'Invalid fork body.',
427
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
428
+ },
429
+ 400,
430
+ );
431
+ }
432
+ // W-119: the fork primitive is now threaded through the registry -
433
+ // the periphery-01 honest-501 kept its promise and retires here.
434
+ if (workflow.fork === undefined) {
435
+ return c.json(
436
+ {
437
+ error: 'workflow-fork-unsupported',
438
+ message: `Workflow '${id}' does not implement fork().`,
439
+ },
440
+ 400,
441
+ );
442
+ }
443
+ try {
444
+ let fromCheckpointId = parsed.data.fromCheckpointId;
445
+ if (fromCheckpointId === undefined) {
446
+ if (workflow.getState === undefined) {
447
+ return c.json(
448
+ {
449
+ error: 'config-invalid',
450
+ message:
451
+ 'fromCheckpointId is required: this workflow does not expose getState() to derive the latest checkpoint.',
452
+ },
453
+ 400,
454
+ );
455
+ }
456
+ const state = (await workflow.getState(parsed.data.fromThreadId)) as {
457
+ readonly checkpointId?: unknown;
458
+ };
459
+ if (typeof state?.checkpointId !== 'string') {
460
+ return c.json(
461
+ {
462
+ error: 'checkpoint-not-found',
463
+ message: `Thread '${parsed.data.fromThreadId}' has no checkpoint to fork from.`,
464
+ },
465
+ 404,
466
+ );
467
+ }
468
+ fromCheckpointId = state.checkpointId;
469
+ }
470
+ const forked = await workflow.fork(parsed.data.fromThreadId, fromCheckpointId);
471
+ return c.json(
472
+ {
473
+ workflowId: id,
474
+ fromThreadId: parsed.data.fromThreadId,
475
+ fromCheckpointId,
476
+ newThreadId: forked.newThreadId,
477
+ },
478
+ 201,
479
+ );
480
+ } catch (err) {
481
+ const wire = toWireError(err);
482
+ return c.json(
483
+ { error: wire.code, message: wire.message },
484
+ wire.code === 'thread-not-found' || wire.code === 'checkpoint-not-found' ? 404 : 400,
485
+ );
486
+ }
487
+ },
488
+ );
489
+
490
+ return app;
491
+ }
492
+
493
+ function backgroundExecute(
494
+ workflow: NonNullable<ReturnType<WorkflowRegistry['get']>>,
495
+ body: z.infer<typeof ExecuteBodySchema>,
496
+ tracker: { readonly signal: AbortSignal },
497
+ runs: RunStateTracker,
498
+ runId: string,
499
+ streaming: {
500
+ readonly subject: string;
501
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
502
+ },
503
+ ): void {
504
+ const opts: { signal?: AbortSignal; threadId?: string } = { signal: tracker.signal };
505
+ if (body.threadId !== undefined) opts.threadId = body.threadId;
506
+ void (async () => {
507
+ try {
508
+ // IP-2: every workflow event reaches the dispatcher - the old
509
+ // loop iterated the stream and threw each event away.
510
+ for await (const ev of workflow.execute(body.input ?? {}, opts)) {
511
+ if (tracker.signal.aborted) break;
512
+ const type =
513
+ typeof (ev as { type?: unknown }).type === 'string'
514
+ ? (ev as { type: string }).type
515
+ : 'workflow.event';
516
+ streaming.dispatcher?.emit(streaming.subject, { type, payload: ev });
517
+ }
518
+ runs.complete(runId, tracker.signal.aborted ? 'aborted' : 'completed');
519
+ } catch (err) {
520
+ // W-052: the frame carries the normalized machine-readable code
521
+ // next to the unchanged message, so clients can retry
522
+ // `checkpoint-version-conflict` and abandon
523
+ // `node-execution-failed` without parsing prose.
524
+ streaming.dispatcher?.emit(streaming.subject, {
525
+ type: 'workflow.error',
526
+ payload: { runId, ...toWireError(err) },
527
+ });
528
+ runs.complete(runId, tracker.signal.aborted ? 'aborted' : 'failed', err);
529
+ }
530
+ })();
531
+ }
532
+
533
+ /**
534
+ * periphery-01 / W-119: mirror of {@link backgroundExecute} for the
535
+ * resume path. A `name` in the body routes through
536
+ * `resolveAwakeable(threadId, name, resume)`; a plain resume goes
537
+ * through `resume(threadId, {resume})`. Both receive the tracker's
538
+ * AbortSignal so `runs.abort(runId)` actually cancels the iteration
539
+ * (parity with backgroundExecute - the old path dropped it).
540
+ */
541
+ function backgroundResume(
542
+ workflow: NonNullable<ReturnType<WorkflowRegistry['get']>>,
543
+ body: z.infer<typeof ResumeBodySchema>,
544
+ tracker: { readonly signal: AbortSignal },
545
+ runs: RunStateTracker,
546
+ runId: string,
547
+ streaming: {
548
+ readonly subject: string;
549
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
550
+ },
551
+ ): void {
552
+ const { resume, resolveAwakeable } = workflow;
553
+ const name = body.name;
554
+ const factory =
555
+ name !== undefined && resolveAwakeable !== undefined
556
+ ? () =>
557
+ resolveAwakeable.call(workflow, body.threadId, name, body.resume, {
558
+ signal: tracker.signal,
559
+ })
560
+ : resume !== undefined
561
+ ? () =>
562
+ resume.call(
563
+ workflow,
564
+ body.threadId,
565
+ body.resume !== undefined ? { resume: body.resume } : {},
566
+ { signal: tracker.signal },
567
+ )
568
+ : undefined;
569
+ if (factory === undefined) return;
570
+ backgroundIterate(factory, tracker, runs, runId, streaming);
571
+ }
572
+
573
+ /**
574
+ * W-119: shared background iteration for resume / named resume / retry -
575
+ * emit every event on the run subject, complete the tracked run, wrap
576
+ * failures in the W-052 wire envelope.
577
+ */
578
+ function backgroundIterate(
579
+ factory: () => AsyncIterable<unknown>,
580
+ tracker: { readonly signal: AbortSignal },
581
+ runs: RunStateTracker,
582
+ runId: string,
583
+ streaming: {
584
+ readonly subject: string;
585
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
586
+ },
587
+ ): void {
588
+ void (async () => {
589
+ try {
590
+ for await (const ev of factory()) {
591
+ if (tracker.signal.aborted) break;
592
+ const type =
593
+ typeof (ev as { type?: unknown }).type === 'string'
594
+ ? (ev as { type: string }).type
595
+ : 'workflow.event';
596
+ streaming.dispatcher?.emit(streaming.subject, { type, payload: ev });
597
+ }
598
+ runs.complete(runId, tracker.signal.aborted ? 'aborted' : 'completed');
599
+ } catch (err) {
600
+ // W-052: same envelope as the execute path.
601
+ streaming.dispatcher?.emit(streaming.subject, {
602
+ type: 'workflow.error',
603
+ payload: { runId, ...toWireError(err) },
604
+ });
605
+ runs.complete(runId, tracker.signal.aborted ? 'aborted' : 'failed', err);
606
+ }
607
+ })();
608
+ }
609
+
610
+ async function safelyParseJson(c: Context<{ Variables: ServerVariables }>): Promise<unknown> {
611
+ try {
612
+ return await c.req.json();
613
+ } catch {
614
+ return {};
615
+ }
616
+ }