@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.
- package/CHANGELOG.md +71 -0
- package/README.md +3 -3
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -607
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- 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
|
+
}
|