@wopr-network/defcon 0.2.0 → 0.2.2

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 (102) hide show
  1. package/dist/src/execution/cli.js +0 -0
  2. package/package.json +3 -2
  3. package/dist/api/router.d.ts +0 -24
  4. package/dist/api/router.js +0 -44
  5. package/dist/api/server.d.ts +0 -13
  6. package/dist/api/server.js +0 -280
  7. package/dist/api/wire-types.d.ts +0 -46
  8. package/dist/api/wire-types.js +0 -5
  9. package/dist/config/db-path.d.ts +0 -1
  10. package/dist/config/db-path.js +0 -1
  11. package/dist/config/exporter.d.ts +0 -3
  12. package/dist/config/exporter.js +0 -87
  13. package/dist/config/index.d.ts +0 -4
  14. package/dist/config/index.js +0 -4
  15. package/dist/config/seed-loader.d.ts +0 -10
  16. package/dist/config/seed-loader.js +0 -108
  17. package/dist/config/zod-schemas.d.ts +0 -165
  18. package/dist/config/zod-schemas.js +0 -283
  19. package/dist/cors.d.ts +0 -8
  20. package/dist/cors.js +0 -21
  21. package/dist/engine/constants.d.ts +0 -1
  22. package/dist/engine/constants.js +0 -1
  23. package/dist/engine/engine.d.ts +0 -69
  24. package/dist/engine/engine.js +0 -485
  25. package/dist/engine/event-emitter.d.ts +0 -9
  26. package/dist/engine/event-emitter.js +0 -19
  27. package/dist/engine/event-types.d.ts +0 -105
  28. package/dist/engine/event-types.js +0 -1
  29. package/dist/engine/flow-spawner.d.ts +0 -8
  30. package/dist/engine/flow-spawner.js +0 -28
  31. package/dist/engine/gate-command-validator.d.ts +0 -6
  32. package/dist/engine/gate-command-validator.js +0 -46
  33. package/dist/engine/gate-evaluator.d.ts +0 -12
  34. package/dist/engine/gate-evaluator.js +0 -233
  35. package/dist/engine/handlebars.d.ts +0 -9
  36. package/dist/engine/handlebars.js +0 -51
  37. package/dist/engine/index.d.ts +0 -12
  38. package/dist/engine/index.js +0 -7
  39. package/dist/engine/invocation-builder.d.ts +0 -18
  40. package/dist/engine/invocation-builder.js +0 -58
  41. package/dist/engine/on-enter.d.ts +0 -8
  42. package/dist/engine/on-enter.js +0 -102
  43. package/dist/engine/ssrf-guard.d.ts +0 -22
  44. package/dist/engine/ssrf-guard.js +0 -159
  45. package/dist/engine/state-machine.d.ts +0 -12
  46. package/dist/engine/state-machine.js +0 -74
  47. package/dist/execution/active-runner.d.ts +0 -45
  48. package/dist/execution/active-runner.js +0 -165
  49. package/dist/execution/admin-schemas.d.ts +0 -116
  50. package/dist/execution/admin-schemas.js +0 -125
  51. package/dist/execution/cli.d.ts +0 -57
  52. package/dist/execution/cli.js +0 -498
  53. package/dist/execution/handlers/admin.d.ts +0 -67
  54. package/dist/execution/handlers/admin.js +0 -200
  55. package/dist/execution/handlers/flow.d.ts +0 -25
  56. package/dist/execution/handlers/flow.js +0 -289
  57. package/dist/execution/handlers/query.d.ts +0 -31
  58. package/dist/execution/handlers/query.js +0 -64
  59. package/dist/execution/index.d.ts +0 -4
  60. package/dist/execution/index.js +0 -3
  61. package/dist/execution/mcp-helpers.d.ts +0 -42
  62. package/dist/execution/mcp-helpers.js +0 -23
  63. package/dist/execution/mcp-server.d.ts +0 -33
  64. package/dist/execution/mcp-server.js +0 -1020
  65. package/dist/execution/provision-worktree.d.ts +0 -16
  66. package/dist/execution/provision-worktree.js +0 -123
  67. package/dist/execution/tool-schemas.d.ts +0 -40
  68. package/dist/execution/tool-schemas.js +0 -44
  69. package/dist/logger.d.ts +0 -8
  70. package/dist/logger.js +0 -12
  71. package/dist/main.d.ts +0 -14
  72. package/dist/main.js +0 -28
  73. package/dist/repositories/drizzle/entity.repo.d.ts +0 -27
  74. package/dist/repositories/drizzle/entity.repo.js +0 -190
  75. package/dist/repositories/drizzle/event.repo.d.ts +0 -12
  76. package/dist/repositories/drizzle/event.repo.js +0 -24
  77. package/dist/repositories/drizzle/flow.repo.d.ts +0 -22
  78. package/dist/repositories/drizzle/flow.repo.js +0 -364
  79. package/dist/repositories/drizzle/gate.repo.d.ts +0 -16
  80. package/dist/repositories/drizzle/gate.repo.js +0 -98
  81. package/dist/repositories/drizzle/index.d.ts +0 -6
  82. package/dist/repositories/drizzle/index.js +0 -7
  83. package/dist/repositories/drizzle/invocation.repo.d.ts +0 -23
  84. package/dist/repositories/drizzle/invocation.repo.js +0 -199
  85. package/dist/repositories/drizzle/schema.d.ts +0 -1932
  86. package/dist/repositories/drizzle/schema.js +0 -155
  87. package/dist/repositories/drizzle/transition-log.repo.d.ts +0 -11
  88. package/dist/repositories/drizzle/transition-log.repo.js +0 -42
  89. package/dist/repositories/interfaces.d.ts +0 -321
  90. package/dist/repositories/interfaces.js +0 -2
  91. package/dist/utils/redact.d.ts +0 -2
  92. package/dist/utils/redact.js +0 -62
  93. package/gates/blocking-graph.d.ts +0 -26
  94. package/gates/blocking-graph.js +0 -102
  95. package/gates/test/bad-return-gate.d.ts +0 -1
  96. package/gates/test/bad-return-gate.js +0 -4
  97. package/gates/test/passing-gate.d.ts +0 -2
  98. package/gates/test/passing-gate.js +0 -3
  99. package/gates/test/slow-gate.d.ts +0 -2
  100. package/gates/test/slow-gate.js +0 -5
  101. package/gates/test/throwing-gate.d.ts +0 -1
  102. package/gates/test/throwing-gate.js +0 -3
@@ -1,1020 +0,0 @@
1
- // MCP server — passive mode (flow.claim, flow.report, query.*)
2
- import { createHash, timingSafeEqual } from "node:crypto";
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
- import { DEFAULT_TIMEOUT_PROMPT } from "../engine/constants.js";
7
- import { isTerminal } from "../engine/state-machine.js";
8
- import { consoleLogger } from "../logger.js";
9
- import { AdminFlowCreateSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, } from "./admin-schemas.js";
10
- import { FlowClaimSchema, FlowFailSchema, FlowGetPromptSchema, FlowReportSchema, FlowSeedSchema, QueryEntitiesSchema, QueryEntitySchema, QueryFlowSchema, QueryInvocationsSchema, } from "./tool-schemas.js";
11
- function getSystemDefaultGateTimeoutMs() {
12
- const parsed = parseInt(process.env.DEFCON_DEFAULT_GATE_TIMEOUT_MS ?? "", 10);
13
- return !Number.isNaN(parsed) && parsed > 0 ? parsed : 300000;
14
- }
15
- const SYSTEM_DEFAULT_GATE_TIMEOUT_MS = getSystemDefaultGateTimeoutMs();
16
- const TOOL_DEFINITIONS = [
17
- {
18
- name: "flow.claim",
19
- description: "Claim the next available work item for a given discipline role. DEFCON selects the highest-priority entity across all matching flows. Returns entity_id, invocation_id, flow, stage, prompt — or a check_back response with retry_after_ms if no work is available.",
20
- inputSchema: {
21
- type: "object",
22
- properties: {
23
- worker_id: { type: "string", description: "Unique worker identifier for affinity tracking" },
24
- role: { type: "string", description: "Discipline role (e.g. engineering, devops, qa, security)" },
25
- flow: { type: "string", description: "Optional flow name to restrict claim to a single flow" },
26
- },
27
- required: ["role"],
28
- },
29
- },
30
- {
31
- name: "flow.get_prompt",
32
- description: "Get the current prompt and context for an entity. Useful if agent needs to re-read its assignment.",
33
- inputSchema: {
34
- type: "object",
35
- properties: {
36
- entity_id: { type: "string", description: "Entity ID" },
37
- },
38
- required: ["entity_id"],
39
- },
40
- },
41
- {
42
- name: "flow.report",
43
- description: "Report completion of work on an entity. **This call blocks until gate evaluation completes** — " +
44
- "which may take milliseconds (trivial gate) or many minutes (CI pipeline). Do not set a short client-side " +
45
- "timeout on this call. For HTTP/SSE transports, configure a long timeout (24h is safe). " +
46
- "The stdio transport has no timeout by default. " +
47
- 'Returns next_action: "continue" (next prompt ready), "waiting" (gate failed — stop, do not retry), ' +
48
- '"check_back" (gate timed out — not an error, call flow.report again with the same arguments after retry_after_ms), ' +
49
- 'or "completed" (terminal state). ' +
50
- "Set gate timeout_ms to the maximum time you are willing to wait, not an expected duration — " +
51
- "the call returns as soon as the gate resolves. " +
52
- "gates_passed contains gate names (not IDs).",
53
- inputSchema: {
54
- type: "object",
55
- properties: {
56
- entity_id: { type: "string", description: "Entity ID" },
57
- signal: {
58
- type: "string",
59
- description: "Completion signal (matches transition trigger)",
60
- },
61
- artifacts: { type: "object", description: "Optional artifacts to attach" },
62
- worker_id: { type: "string", description: "Optional stable worker identifier for affinity routing" },
63
- },
64
- required: ["entity_id", "signal"],
65
- },
66
- },
67
- {
68
- name: "flow.fail",
69
- description: "Report failure on an entity. Marks the current invocation as failed.",
70
- inputSchema: {
71
- type: "object",
72
- properties: {
73
- entity_id: { type: "string", description: "Entity ID" },
74
- error: { type: "string", description: "Error message" },
75
- },
76
- required: ["entity_id", "error"],
77
- },
78
- },
79
- {
80
- name: "query.entity",
81
- description: "Get full entity details including history.",
82
- inputSchema: {
83
- type: "object",
84
- properties: {
85
- id: { type: "string", description: "Entity ID" },
86
- },
87
- required: ["id"],
88
- },
89
- },
90
- {
91
- name: "query.entities",
92
- description: "Search entities by flow and state.",
93
- inputSchema: {
94
- type: "object",
95
- properties: {
96
- flow: { type: "string", description: "Flow name to filter by" },
97
- state: { type: "string", description: "State to filter by" },
98
- limit: { type: "number", description: "Max results (default 50)" },
99
- },
100
- required: ["flow", "state"],
101
- },
102
- },
103
- {
104
- name: "query.invocations",
105
- description: "Get all invocations for an entity.",
106
- inputSchema: {
107
- type: "object",
108
- properties: {
109
- entity_id: { type: "string", description: "Entity ID" },
110
- },
111
- required: ["entity_id"],
112
- },
113
- },
114
- {
115
- name: "query.flow",
116
- description: "Get a flow definition with its states and transitions.",
117
- inputSchema: {
118
- type: "object",
119
- properties: {
120
- name: { type: "string", description: "Flow name" },
121
- },
122
- required: ["name"],
123
- },
124
- },
125
- {
126
- name: "query.flows",
127
- description: "List all available flow definitions.",
128
- inputSchema: {
129
- type: "object",
130
- properties: {},
131
- required: [],
132
- },
133
- },
134
- // ─── Admin Tools ───
135
- {
136
- name: "admin.flow.create",
137
- description: "Create a new flow definition with its initial states.",
138
- inputSchema: {
139
- type: "object",
140
- properties: {
141
- name: { type: "string", description: "Unique flow name" },
142
- initialState: { type: "string", description: "Name of the initial state (must be in states array)" },
143
- discipline: {
144
- type: "string",
145
- description: "Discipline role required to claim work in this flow (e.g. engineering, devops). Null means any role can claim.",
146
- },
147
- defaultModelTier: {
148
- type: "string",
149
- description: "Default model tier for states that don't specify one (opus, sonnet, haiku)",
150
- },
151
- description: { type: "string", description: "Flow description" },
152
- entitySchema: { type: "object", description: "JSON schema for entity data" },
153
- maxConcurrent: { type: "number", description: "Max concurrent entities (0=unlimited)" },
154
- maxConcurrentPerRepo: { type: "number", description: "Max concurrent per repo (0=unlimited)" },
155
- affinityWindowMs: { type: "number", description: "Worker affinity window duration in ms (default 300000)" },
156
- gateTimeoutMs: {
157
- type: "number",
158
- description: `Default gate timeout in ms for all gates in this flow (default ${SYSTEM_DEFAULT_GATE_TIMEOUT_MS})`,
159
- },
160
- createdBy: { type: "string", description: "Creator identifier" },
161
- states: {
162
- type: "array",
163
- description: "State definitions (at least one required; must include initialState)",
164
- items: { type: "object" },
165
- },
166
- },
167
- required: ["name", "initialState", "states"],
168
- },
169
- },
170
- {
171
- name: "admin.flow.update",
172
- description: "Update a flow's metadata (description, concurrency limits, etc.).",
173
- inputSchema: {
174
- type: "object",
175
- properties: {
176
- flow_name: { type: "string", description: "Flow name to update" },
177
- description: { type: "string" },
178
- discipline: { type: "string", description: "Discipline role required to claim work in this flow" },
179
- defaultModelTier: { type: "string", description: "Default model tier for states that don't specify one" },
180
- maxConcurrent: { type: "number" },
181
- maxConcurrentPerRepo: { type: "number" },
182
- affinityWindowMs: { type: "number", description: "Worker affinity window duration in ms (default 300000)" },
183
- gateTimeoutMs: { type: "number", description: "Default gate timeout in ms for all gates in this flow" },
184
- initialState: { type: "string" },
185
- },
186
- required: ["flow_name"],
187
- },
188
- },
189
- {
190
- name: "admin.state.create",
191
- description: "Add a state to an existing flow.",
192
- inputSchema: {
193
- type: "object",
194
- properties: {
195
- flow_name: { type: "string", description: "Flow name" },
196
- name: { type: "string", description: "State name" },
197
- modelTier: { type: "string" },
198
- mode: { type: "string", description: "passive or active" },
199
- promptTemplate: { type: "string" },
200
- constraints: { type: "object" },
201
- },
202
- required: ["flow_name", "name"],
203
- },
204
- },
205
- {
206
- name: "admin.state.update",
207
- description: "Update fields on an existing state.",
208
- inputSchema: {
209
- type: "object",
210
- properties: {
211
- flow_name: { type: "string", description: "Flow name" },
212
- state_name: { type: "string", description: "State name to update" },
213
- modelTier: { type: "string" },
214
- mode: { type: "string" },
215
- promptTemplate: { type: "string" },
216
- constraints: { type: "object" },
217
- },
218
- required: ["flow_name", "state_name"],
219
- },
220
- },
221
- {
222
- name: "admin.transition.create",
223
- description: "Add a transition rule between two states.",
224
- inputSchema: {
225
- type: "object",
226
- properties: {
227
- flow_name: { type: "string" },
228
- fromState: { type: "string" },
229
- toState: { type: "string" },
230
- trigger: { type: "string" },
231
- gateName: { type: "string" },
232
- condition: { type: "string" },
233
- priority: { type: "number" },
234
- spawnFlow: { type: "string" },
235
- spawnTemplate: { type: "string" },
236
- },
237
- required: ["flow_name", "fromState", "toState", "trigger"],
238
- },
239
- },
240
- {
241
- name: "admin.transition.update",
242
- description: "Update an existing transition rule.",
243
- inputSchema: {
244
- type: "object",
245
- properties: {
246
- flow_name: { type: "string" },
247
- transition_id: { type: "string" },
248
- fromState: { type: "string" },
249
- toState: { type: "string" },
250
- trigger: { type: "string" },
251
- gateName: { type: "string" },
252
- condition: { type: "string" },
253
- priority: { type: "number" },
254
- spawnFlow: { type: "string" },
255
- spawnTemplate: { type: "string" },
256
- },
257
- required: ["flow_name", "transition_id"],
258
- },
259
- },
260
- {
261
- name: "admin.gate.create",
262
- description: "Create a gate definition (command, function, or api).",
263
- inputSchema: {
264
- type: "object",
265
- properties: {
266
- name: { type: "string" },
267
- type: { type: "string", description: "command | function | api" },
268
- command: { type: "string" },
269
- functionRef: { type: "string" },
270
- apiConfig: { type: "object" },
271
- timeoutMs: { type: "number" },
272
- failurePrompt: { type: "string" },
273
- timeoutPrompt: { type: "string" },
274
- },
275
- required: ["name", "type"],
276
- },
277
- },
278
- {
279
- name: "admin.gate.attach",
280
- description: "Attach a gate to a transition.",
281
- inputSchema: {
282
- type: "object",
283
- properties: {
284
- flow_name: { type: "string" },
285
- transition_id: { type: "string" },
286
- gate_name: { type: "string" },
287
- },
288
- required: ["flow_name", "transition_id", "gate_name"],
289
- },
290
- },
291
- {
292
- name: "admin.flow.snapshot",
293
- description: "Create a versioned snapshot of the current flow state.",
294
- inputSchema: {
295
- type: "object",
296
- properties: {
297
- flow_name: { type: "string" },
298
- },
299
- required: ["flow_name"],
300
- },
301
- },
302
- {
303
- name: "admin.flow.restore",
304
- description: "Restore a flow to a previously snapshotted version.",
305
- inputSchema: {
306
- type: "object",
307
- properties: {
308
- flow_name: { type: "string" },
309
- version: { type: "number" },
310
- },
311
- required: ["flow_name", "version"],
312
- },
313
- },
314
- {
315
- name: "admin.entity.create",
316
- description: "Create a new entity in a flow (seed). Equivalent to POST /api/entities. " +
317
- "Creates the entity at the flow's initial state and generates the first invocation if the initial state has an agent role. " +
318
- "Returns the full Entity object plus an optional invocation_id if the initial state created an invocation.",
319
- // inputSchema mirrors FlowSeedSchema in src/execution/tool-schemas.ts — keep in sync
320
- inputSchema: {
321
- type: "object",
322
- properties: {
323
- flow: { type: "string", description: "Flow name to create the entity in" },
324
- refs: {
325
- type: "object",
326
- description: "Optional external references. Keys are ref names, values are objects with at least { adapter: string, id: string }",
327
- additionalProperties: {
328
- type: "object",
329
- properties: {
330
- adapter: { type: "string" },
331
- id: { type: "string" },
332
- },
333
- required: ["adapter", "id"],
334
- },
335
- },
336
- },
337
- required: ["flow"],
338
- },
339
- },
340
- ];
341
- export function createMcpServer(deps, opts) {
342
- const server = new Server({ name: "agentic-flow", version: "0.1.0" }, { capabilities: { tools: {} } });
343
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
344
- tools: TOOL_DEFINITIONS,
345
- }));
346
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
347
- const { name, arguments: args } = request.params;
348
- const safeArgs = (args ?? {});
349
- return callToolHandler(deps, name, safeArgs, opts);
350
- });
351
- return server;
352
- }
353
- export async function callToolHandler(deps, name, safeArgs, opts) {
354
- try {
355
- // Auth gate: admin.* tools require a valid token when one is configured
356
- if (name.startsWith("admin.")) {
357
- const configuredToken = opts?.adminToken || undefined; // treat "" as unset
358
- if (configuredToken && !opts?.stdioTrusted) {
359
- const callerToken = opts?.callerToken;
360
- if (!callerToken || !constantTimeEqual(configuredToken, callerToken)) {
361
- return errorResult("Unauthorized: admin tools require authentication. Check server configuration.");
362
- }
363
- }
364
- }
365
- // Auth gate: flow.* tools and query.flows require a valid worker token when one is configured
366
- if (name.startsWith("flow.") || name === "query.flows") {
367
- const configuredToken = opts?.workerToken?.trim() || undefined; // treat "" or whitespace-only as unset
368
- if (configuredToken && !opts?.stdioTrusted) {
369
- const callerToken = opts?.callerToken;
370
- if (!callerToken || !constantTimeEqual(configuredToken, callerToken)) {
371
- return errorResult("Unauthorized: worker tools require authentication. Check server configuration.");
372
- }
373
- }
374
- }
375
- switch (name) {
376
- case "flow.claim":
377
- return await handleFlowClaim(deps, safeArgs);
378
- case "flow.get_prompt":
379
- return await handleFlowGetPrompt(deps, safeArgs);
380
- case "flow.report":
381
- return await handleFlowReport(deps, safeArgs);
382
- case "flow.fail":
383
- return await handleFlowFail(deps, safeArgs);
384
- case "query.entity":
385
- return await handleQueryEntity(deps, safeArgs);
386
- case "query.entities":
387
- return await handleQueryEntities(deps, safeArgs);
388
- case "query.invocations":
389
- return await handleQueryInvocations(deps, safeArgs);
390
- case "query.flow":
391
- return await handleQueryFlow(deps, safeArgs);
392
- case "query.flows":
393
- return await handleQueryFlows(deps);
394
- case "admin.flow.create":
395
- return await handleAdminFlowCreate(deps, safeArgs);
396
- case "admin.flow.update":
397
- return await handleAdminFlowUpdate(deps, safeArgs);
398
- case "admin.state.create":
399
- return await handleAdminStateCreate(deps, safeArgs);
400
- case "admin.state.update":
401
- return await handleAdminStateUpdate(deps, safeArgs);
402
- case "admin.transition.create":
403
- return await handleAdminTransitionCreate(deps, safeArgs);
404
- case "admin.transition.update":
405
- return await handleAdminTransitionUpdate(deps, safeArgs);
406
- case "admin.gate.create":
407
- return await handleAdminGateCreate(deps, safeArgs);
408
- case "admin.gate.attach":
409
- return await handleAdminGateAttach(deps, safeArgs);
410
- case "admin.flow.snapshot":
411
- return await handleAdminFlowSnapshot(deps, safeArgs);
412
- case "admin.flow.restore":
413
- return await handleAdminFlowRestore(deps, safeArgs);
414
- case "admin.entity.create":
415
- return await handleAdminEntityCreate(deps, safeArgs);
416
- default:
417
- return errorResult(`Unknown tool: ${name}`);
418
- }
419
- }
420
- catch (err) {
421
- const message = err instanceof Error ? err.message : String(err);
422
- return errorResult(message);
423
- }
424
- }
425
- function jsonResult(data) {
426
- return {
427
- content: [{ type: "text", text: JSON.stringify(data) }],
428
- };
429
- }
430
- function errorResult(message) {
431
- return {
432
- content: [{ type: "text", text: message }],
433
- isError: true,
434
- };
435
- }
436
- const RETRY_SHORT_MS = 30_000; // entities exist but all claimed
437
- const RETRY_LONG_MS = 300_000; // backlog empty
438
- function noWorkResult(retryAfterMs, role) {
439
- return jsonResult({
440
- next_action: "check_back",
441
- retry_after_ms: retryAfterMs,
442
- message: `No work available for role '${role}' right now. Call flow.claim again after the retry delay.`,
443
- });
444
- }
445
- function constantTimeEqual(a, b) {
446
- const hashA = createHash("sha256").update(a.trim()).digest();
447
- const hashB = createHash("sha256").update(b.trim()).digest();
448
- return timingSafeEqual(hashA, hashB);
449
- }
450
- // ─── Tool Handlers ───
451
- const AFFINITY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
452
- async function handleFlowClaim(deps, args) {
453
- const v = validateInput(FlowClaimSchema, args);
454
- if (!v.ok)
455
- return v.result;
456
- const log = deps.logger ?? consoleLogger;
457
- const { worker_id, role, flow: flowName } = v.data;
458
- // 1. Find candidate flows filtered by discipline
459
- let candidateFlows = [];
460
- if (flowName) {
461
- const flow = await deps.flows.getByName(flowName);
462
- if (!flow)
463
- return errorResult(`Flow not found: ${flowName}`);
464
- // Discipline must match — null discipline flows are claimable by any role
465
- if (flow.discipline !== null && flow.discipline !== role)
466
- return noWorkResult(RETRY_LONG_MS, role);
467
- candidateFlows = [flow];
468
- }
469
- else {
470
- const allFlows = await deps.flows.list();
471
- candidateFlows = allFlows.filter((f) => f.discipline === null || f.discipline === role);
472
- }
473
- if (candidateFlows.length === 0)
474
- return noWorkResult(RETRY_LONG_MS, role);
475
- const allCandidates = [];
476
- for (const flow of candidateFlows) {
477
- const unclaimed = await deps.invocations.findUnclaimedByFlow(flow.id);
478
- allCandidates.push(...unclaimed);
479
- }
480
- if (allCandidates.length === 0) {
481
- // Determine if entities exist but are all claimed (short retry) vs empty backlog (long retry).
482
- // Use hasAnyInFlowAndState (SELECT 1 LIMIT 1) to avoid loading full entity rows across all states.
483
- let hasEntities = false;
484
- for (const flow of candidateFlows) {
485
- const stateNames = flow.states.filter((s) => s.promptTemplate !== null).map((s) => s.name);
486
- if (await deps.entities.hasAnyInFlowAndState(flow.id, stateNames)) {
487
- hasEntities = true;
488
- break;
489
- }
490
- }
491
- return noWorkResult(hasEntities ? RETRY_SHORT_MS : RETRY_LONG_MS, role);
492
- }
493
- // 3. Load entities for priority sorting
494
- const entityMap = new Map();
495
- const uniqueEntityIds = [...new Set(allCandidates.map((inv) => inv.entityId))];
496
- await Promise.all(uniqueEntityIds.map(async (eid) => {
497
- const entity = await deps.entities.get(eid);
498
- if (entity)
499
- entityMap.set(eid, entity);
500
- }));
501
- // 4. Check affinity for each entity
502
- const flowByIdEarly = new Map(candidateFlows.map((f) => [f.id, f]));
503
- const affinitySet = new Set();
504
- const now = Date.now();
505
- if (worker_id) {
506
- await Promise.all(uniqueEntityIds.map(async (eid) => {
507
- const entity = entityMap.get(eid);
508
- const flow = entity ? flowByIdEarly.get(entity.flowId) : undefined;
509
- const windowMs = flow?.affinityWindowMs ?? AFFINITY_WINDOW_MS;
510
- const invocations = await deps.invocations.findByEntity(eid);
511
- const lastCompleted = invocations
512
- .filter((inv) => inv.completedAt !== null && inv.claimedBy === worker_id)
513
- .sort((a, b) => (b.completedAt?.getTime() ?? 0) - (a.completedAt?.getTime() ?? 0));
514
- if (lastCompleted.length > 0) {
515
- const elapsed = now - (lastCompleted[0].completedAt?.getTime() ?? 0);
516
- if (elapsed < windowMs) {
517
- affinitySet.add(eid);
518
- }
519
- }
520
- }));
521
- }
522
- // 5. Sort candidates by priority algorithm
523
- allCandidates.sort((a, b) => {
524
- const entityA = entityMap.get(a.entityId);
525
- const entityB = entityMap.get(b.entityId);
526
- // Tier 1: Affinity (has affinity sorts first)
527
- const affinityA = affinitySet.has(a.entityId) ? 1 : 0;
528
- const affinityB = affinitySet.has(b.entityId) ? 1 : 0;
529
- if (affinityA !== affinityB)
530
- return affinityB - affinityA;
531
- // Tier 2: Entity priority (higher priority sorts first)
532
- const priA = entityA?.priority ?? 0;
533
- const priB = entityB?.priority ?? 0;
534
- if (priA !== priB)
535
- return priB - priA;
536
- // Tier 3: Time in state (longest waiting sorts first — earlier createdAt as stable proxy)
537
- const timeA = entityA?.createdAt?.getTime() ?? now;
538
- const timeB = entityB?.createdAt?.getTime() ?? now;
539
- return timeA - timeB;
540
- });
541
- // 6. Build a flow lookup map
542
- const flowById = new Map(candidateFlows.map((f) => [f.id, f]));
543
- // 7. Try claiming in priority order (handle race conditions)
544
- for (const invocation of allCandidates) {
545
- let claimed;
546
- try {
547
- claimed = await deps.invocations.claim(invocation.id, worker_id ?? `agent:${role}`);
548
- }
549
- catch (err) {
550
- log.error(`Failed to claim invocation ${invocation.id}:`, err);
551
- continue;
552
- }
553
- if (claimed) {
554
- const entity = entityMap.get(claimed.entityId);
555
- if (entity) {
556
- let claimedEntity;
557
- try {
558
- claimedEntity = await deps.entities.claimById(entity.id, worker_id ?? `agent:${role}`);
559
- }
560
- catch (err) {
561
- log.error(`Failed to claimById for entity ${entity.id}:`, err);
562
- // Release the invocation claim so it can be reclaimed rather than orphaned.
563
- await deps.invocations.releaseClaim(claimed.id);
564
- continue;
565
- }
566
- if (!claimedEntity) {
567
- // Race condition: another worker claimed this entity first.
568
- // Release the invocation claim so it can be picked up by another worker.
569
- await deps.invocations.releaseClaim(claimed.id);
570
- continue;
571
- }
572
- }
573
- const flow = entity ? flowById.get(entity.flowId) : undefined;
574
- // Record affinity for the claiming worker
575
- if (worker_id && entity && flow) {
576
- const windowMs = flow.affinityWindowMs ?? 300000;
577
- try {
578
- await deps.entities.setAffinity(claimed.entityId, worker_id, role, new Date(Date.now() + windowMs));
579
- }
580
- catch (err) {
581
- // Affinity is non-critical — log and continue with the successful claim.
582
- log.error(`Failed to set affinity for entity ${claimed.entityId} worker ${worker_id}:`, err);
583
- }
584
- }
585
- return jsonResult({
586
- worker_id: worker_id,
587
- entity_id: claimed.entityId,
588
- invocation_id: claimed.id,
589
- flow: flow?.name ?? null,
590
- stage: claimed.stage,
591
- prompt: claimed.prompt,
592
- context: claimed.context,
593
- });
594
- }
595
- }
596
- return noWorkResult(RETRY_SHORT_MS, role);
597
- }
598
- async function handleFlowGetPrompt(deps, args) {
599
- const v = validateInput(FlowGetPromptSchema, args);
600
- if (!v.ok)
601
- return v.result;
602
- const { entity_id: entityId } = v.data;
603
- const entity = await deps.entities.get(entityId);
604
- if (!entity)
605
- return errorResult(`Entity not found: ${entityId}`);
606
- const invocationList = await deps.invocations.findByEntity(entityId);
607
- if (invocationList.length === 0) {
608
- return errorResult(`No invocations found for entity: ${entityId}`);
609
- }
610
- // Return the active (claimed, not completed) invocation rather than the last by insertion order
611
- const active = invocationList.find((inv) => inv.claimedAt !== null && inv.completedAt === null && inv.failedAt === null) ??
612
- invocationList[invocationList.length - 1];
613
- return jsonResult({
614
- prompt: active.prompt,
615
- context: active.context,
616
- });
617
- }
618
- async function handleFlowReport(deps, args) {
619
- const v = validateInput(FlowReportSchema, args);
620
- if (!v.ok)
621
- return v.result;
622
- const log = deps.logger ?? consoleLogger;
623
- const { entity_id: entityId, signal, artifacts, worker_id } = v.data;
624
- const invocationList = await deps.invocations.findByEntity(entityId);
625
- const activeInvocation = invocationList.find((inv) => inv.claimedAt !== null && inv.completedAt === null && inv.failedAt === null);
626
- if (!activeInvocation) {
627
- return errorResult(`No active invocation found for entity: ${entityId}`);
628
- }
629
- if (!deps.engine) {
630
- return errorResult("Engine not available — MCP server started without engine dependency");
631
- }
632
- // Complete the current invocation BEFORE calling processSignal so the
633
- // concurrency check inside the engine doesn't count it as still-active.
634
- await deps.invocations.complete(activeInvocation.id, signal, artifacts);
635
- // Delegate to the engine — it handles gate evaluation, transition, event
636
- // emission, invocation creation, concurrency checks, and spawn logic.
637
- let result;
638
- try {
639
- result = await deps.engine.processSignal(entityId, signal, artifacts, activeInvocation.id);
640
- }
641
- catch (err) {
642
- const message = err instanceof Error ? err.message : String(err);
643
- // processSignal failed after we already completed the invocation — create a
644
- // replacement so the entity can be reclaimed rather than being permanently orphaned.
645
- // First re-fetch to check whether the engine already created a new invocation
646
- // (partial success before the throw) to avoid a duplicate.
647
- const currentInvocations = await deps.invocations.findByEntity(entityId);
648
- const hasActiveOrUnclaimed = currentInvocations.some((inv) => inv.completedAt === null && inv.failedAt === null && inv.id !== activeInvocation.id);
649
- if (!hasActiveOrUnclaimed) {
650
- // Re-fetch the entity to check if processSignal partially advanced state
651
- // to a terminal state before throwing. If so, do not create a replacement —
652
- // the entity is done and no further invocations should be created.
653
- const currentEntity = await deps.entities.get(entityId);
654
- const flow = currentEntity ? await deps.flows.get(currentEntity.flowId) : null;
655
- const entityIsTerminal = currentEntity && flow ? isTerminal(flow, currentEntity.state) : false;
656
- if (!entityIsTerminal) {
657
- const replacement = await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
658
- // Claim the replacement for the same worker so it can retry immediately.
659
- if (worker_id && replacement) {
660
- try {
661
- await deps.invocations.claim(replacement.id, worker_id);
662
- }
663
- catch (claimErr) {
664
- log.error(`Failed to claim replacement invocation ${replacement.id} for worker ${worker_id}:`, claimErr);
665
- }
666
- }
667
- }
668
- }
669
- return errorResult(message);
670
- }
671
- // Set affinity on completion for passive-mode invocations, after processSignal succeeds
672
- if (worker_id && activeInvocation.mode === "passive") {
673
- try {
674
- const entity = await deps.entities.get(entityId);
675
- if (entity) {
676
- const flow = await deps.flows.get(entity.flowId);
677
- const windowMs = flow?.affinityWindowMs ?? 300000;
678
- const affinityRole = flow?.discipline;
679
- if (affinityRole) {
680
- await deps.entities.setAffinity(entityId, worker_id, affinityRole, new Date(Date.now() + windowMs));
681
- }
682
- }
683
- }
684
- catch (err) {
685
- log.error(`Failed to set affinity for entity ${entityId} worker ${worker_id}:`, err);
686
- }
687
- }
688
- // Gate blocked — create a replacement unclaimed invocation so the entity
689
- // can be reclaimed; without it the entity would be permanently orphaned.
690
- if (result.gated) {
691
- await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
692
- if (result.gateTimedOut) {
693
- const renderedPrompt = result.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
694
- return jsonResult({
695
- next_action: "check_back",
696
- message: renderedPrompt,
697
- retry_after_ms: 30000,
698
- timeout_prompt: renderedPrompt,
699
- });
700
- }
701
- return jsonResult({
702
- new_state: null,
703
- gated: true,
704
- gate_output: result.gateOutput,
705
- gateName: result.gateName,
706
- next_action: "waiting",
707
- failure_prompt: result.failurePrompt ?? null,
708
- });
709
- }
710
- // If the engine created a next invocation, fetch its prompt
711
- let nextPrompt = null;
712
- let nextContext = null;
713
- if (result.invocationId) {
714
- const nextInvocation = await deps.invocations.get(result.invocationId);
715
- if (nextInvocation) {
716
- nextPrompt = nextInvocation.prompt;
717
- nextContext = nextInvocation.context;
718
- }
719
- }
720
- const nextAction = result.terminal ? "completed" : result.invocationId ? "continue" : "waiting";
721
- return jsonResult({
722
- new_state: result.newState,
723
- gates_passed: result.gatesPassed,
724
- next_action: nextAction,
725
- prompt: nextPrompt,
726
- context: nextContext,
727
- });
728
- }
729
- async function handleFlowFail(deps, args) {
730
- const v = validateInput(FlowFailSchema, args);
731
- if (!v.ok)
732
- return v.result;
733
- const { entity_id: entityId, error } = v.data;
734
- const invocationList = await deps.invocations.findByEntity(entityId);
735
- const activeInvocation = invocationList.find((inv) => inv.claimedAt !== null && inv.completedAt === null && inv.failedAt === null);
736
- if (!activeInvocation) {
737
- return errorResult(`No active invocation found for entity: ${entityId}`);
738
- }
739
- await deps.invocations.fail(activeInvocation.id, error);
740
- return jsonResult({ acknowledged: true });
741
- }
742
- async function handleQueryEntity(deps, args) {
743
- const v = validateInput(QueryEntitySchema, args);
744
- if (!v.ok)
745
- return v.result;
746
- const { id } = v.data;
747
- const entity = await deps.entities.get(id);
748
- if (!entity)
749
- return errorResult(`Entity not found: ${id}`);
750
- const history = await deps.transitions.historyFor(id);
751
- return jsonResult({ ...entity, history });
752
- }
753
- async function handleQueryEntities(deps, args) {
754
- const v = validateInput(QueryEntitiesSchema, args);
755
- if (!v.ok)
756
- return v.result;
757
- const { flow: flowName, state, limit } = v.data;
758
- const effectiveLimit = limit ?? 50;
759
- const flow = await deps.flows.getByName(flowName);
760
- if (!flow)
761
- return errorResult(`Flow not found: ${flowName}`);
762
- const results = await deps.entities.findByFlowAndState(flow.id, state);
763
- return jsonResult(results.slice(0, effectiveLimit));
764
- }
765
- async function handleQueryInvocations(deps, args) {
766
- const v = validateInput(QueryInvocationsSchema, args);
767
- if (!v.ok)
768
- return v.result;
769
- const { entity_id: entityId } = v.data;
770
- const results = await deps.invocations.findByEntity(entityId);
771
- return jsonResult(results);
772
- }
773
- async function handleQueryFlow(deps, args) {
774
- const v = validateInput(QueryFlowSchema, args);
775
- if (!v.ok)
776
- return v.result;
777
- const { name } = v.data;
778
- const flow = await deps.flows.getByName(name);
779
- if (!flow)
780
- return errorResult(`Flow not found: ${name}`);
781
- return jsonResult(flow);
782
- }
783
- async function handleQueryFlows(deps) {
784
- const flows = await deps.flows.list();
785
- return jsonResult(flows.map((f) => ({
786
- id: f.id,
787
- name: f.name,
788
- description: f.description,
789
- initialState: f.initialState,
790
- discipline: f.discipline,
791
- version: f.version,
792
- states: f.states.map(({ id, flowId, name, modelTier, mode, constraints, onEnter }) => ({
793
- id,
794
- flowId,
795
- name,
796
- modelTier,
797
- mode,
798
- constraints,
799
- onEnter,
800
- })),
801
- transitions: f.transitions,
802
- })));
803
- }
804
- async function handleAdminEntityCreate(deps, args) {
805
- const v = validateInput(FlowSeedSchema, args);
806
- if (!v.ok)
807
- return v.result;
808
- const { flow: flowName, refs } = v.data;
809
- if (!deps.engine) {
810
- return errorResult("Engine not available — MCP server started without engine dependency");
811
- }
812
- const entity = await deps.engine.createEntity(flowName, refs);
813
- const invocations = await deps.invocations.findByEntity(entity.id);
814
- const activeInvocation = invocations.find((inv) => !inv.completedAt && !inv.failedAt);
815
- const result = { ...entity };
816
- if (activeInvocation) {
817
- result.invocation_id = activeInvocation.id;
818
- }
819
- return jsonResult(result);
820
- }
821
- /** Start the MCP server on stdio transport. */
822
- export async function startStdioServer(deps, opts) {
823
- const server = createMcpServer(deps, opts);
824
- const transport = new StdioServerTransport();
825
- await server.connect(transport);
826
- }
827
- // ─── Admin Helpers ───
828
- function validateInput(schema, args) {
829
- const parsed = schema.safeParse(args);
830
- if (!parsed.success) {
831
- return { ok: false, result: errorResult(`Validation error: ${JSON.stringify(parsed.error?.issues)}`) };
832
- }
833
- return { ok: true, data: parsed.data };
834
- }
835
- function emitDefinitionChanged(eventRepo, flowId, tool, payload) {
836
- void eventRepo.emitDefinitionChanged(flowId, tool, payload);
837
- }
838
- // ─── Admin Tool Handlers ───
839
- async function handleAdminFlowCreate(deps, args) {
840
- const v = validateInput(AdminFlowCreateSchema, args);
841
- if (!v.ok)
842
- return v.result;
843
- const { states, ...flowInput } = v.data;
844
- if (states !== undefined) {
845
- const stateNames = states.map((s) => s.name);
846
- if (!stateNames.includes(flowInput.initialState)) {
847
- return errorResult(`initialState '${flowInput.initialState}' must be included in the states array`);
848
- }
849
- }
850
- const flow = await deps.flows.create(flowInput);
851
- for (const stateDef of states ?? []) {
852
- await deps.flows.addState(flow.id, stateDef);
853
- }
854
- const fullFlow = await deps.flows.get(flow.id);
855
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.create", { name: flow.name });
856
- return jsonResult(fullFlow);
857
- }
858
- async function handleAdminFlowUpdate(deps, args) {
859
- const v = validateInput(AdminFlowUpdateSchema, args);
860
- if (!v.ok)
861
- return v.result;
862
- const { flow_name, ...changes } = v.data;
863
- const flow = await deps.flows.getByName(flow_name);
864
- if (!flow)
865
- return errorResult(`Flow not found: ${flow_name}`);
866
- await deps.flows.snapshot(flow.id);
867
- const updated = await deps.flows.update(flow.id, changes);
868
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.update", { name: flow_name, changes });
869
- return jsonResult(updated);
870
- }
871
- async function handleAdminStateCreate(deps, args) {
872
- const v = validateInput(AdminStateCreateSchema, args);
873
- if (!v.ok)
874
- return v.result;
875
- const { flow_name, ...stateInput } = v.data;
876
- const flow = await deps.flows.getByName(flow_name);
877
- if (!flow)
878
- return errorResult(`Flow not found: ${flow_name}`);
879
- await deps.flows.snapshot(flow.id);
880
- const state = await deps.flows.addState(flow.id, stateInput);
881
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.state.create", { name: state.name });
882
- return jsonResult(state);
883
- }
884
- async function handleAdminStateUpdate(deps, args) {
885
- const v = validateInput(AdminStateUpdateSchema, args);
886
- if (!v.ok)
887
- return v.result;
888
- const { flow_name, state_name, ...changes } = v.data;
889
- const flow = await deps.flows.getByName(flow_name);
890
- if (!flow)
891
- return errorResult(`Flow not found: ${flow_name}`);
892
- const stateDef = flow.states.find((s) => s.name === state_name);
893
- if (!stateDef)
894
- return errorResult(`State not found: ${state_name} in flow ${flow_name}`);
895
- await deps.flows.snapshot(flow.id);
896
- const updated = await deps.flows.updateState(stateDef.id, changes);
897
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.state.update", { name: state_name, changes });
898
- return jsonResult(updated);
899
- }
900
- async function handleAdminTransitionCreate(deps, args) {
901
- const v = validateInput(AdminTransitionCreateSchema, args);
902
- if (!v.ok)
903
- return v.result;
904
- const { flow_name, gateName, ...transitionInput } = v.data;
905
- const flow = await deps.flows.getByName(flow_name);
906
- if (!flow)
907
- return errorResult(`Flow not found: ${flow_name}`);
908
- const stateNames = flow.states.map((s) => s.name);
909
- if (!stateNames.includes(transitionInput.fromState)) {
910
- return errorResult(`State not found: '${transitionInput.fromState}' in flow '${flow_name}'`);
911
- }
912
- if (!stateNames.includes(transitionInput.toState)) {
913
- return errorResult(`State not found: '${transitionInput.toState}' in flow '${flow_name}'`);
914
- }
915
- await deps.flows.snapshot(flow.id);
916
- let gateId;
917
- if (gateName) {
918
- const gate = await deps.gates.getByName(gateName);
919
- if (!gate)
920
- return errorResult(`Gate not found: ${gateName}`);
921
- gateId = gate.id;
922
- }
923
- const transition = await deps.flows.addTransition(flow.id, { ...transitionInput, gateId });
924
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.transition.create", {
925
- fromState: transitionInput.fromState,
926
- toState: transitionInput.toState,
927
- trigger: transitionInput.trigger,
928
- });
929
- return jsonResult(transition);
930
- }
931
- async function handleAdminTransitionUpdate(deps, args) {
932
- const v = validateInput(AdminTransitionUpdateSchema, args);
933
- if (!v.ok)
934
- return v.result;
935
- const { flow_name, transition_id, gateName, ...changes } = v.data;
936
- const flow = await deps.flows.getByName(flow_name);
937
- if (!flow)
938
- return errorResult(`Flow not found: ${flow_name}`);
939
- const existing = flow.transitions.find((t) => t.id === transition_id);
940
- if (!existing)
941
- return errorResult(`Transition not found: ${transition_id} in flow ${flow_name}`);
942
- const stateNames = flow.states.map((s) => s.name);
943
- if (changes.fromState !== undefined && !stateNames.includes(changes.fromState)) {
944
- return errorResult(`State not found: '${changes.fromState}' in flow '${flow_name}'`);
945
- }
946
- if (changes.toState !== undefined && !stateNames.includes(changes.toState)) {
947
- return errorResult(`State not found: '${changes.toState}' in flow '${flow_name}'`);
948
- }
949
- await deps.flows.snapshot(flow.id);
950
- const updateChanges = { ...changes };
951
- if (gateName !== undefined) {
952
- if (gateName) {
953
- const gate = await deps.gates.getByName(gateName);
954
- if (!gate)
955
- return errorResult(`Gate not found: ${gateName}`);
956
- updateChanges.gateId = gate.id;
957
- }
958
- else {
959
- updateChanges.gateId = null;
960
- }
961
- }
962
- const updated = await deps.flows.updateTransition(transition_id, updateChanges);
963
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.transition.update", { transition_id });
964
- return jsonResult(updated);
965
- }
966
- async function handleAdminGateCreate(deps, args) {
967
- const v = validateInput(AdminGateCreateSchema, args);
968
- if (!v.ok)
969
- return v.result;
970
- const gate = await deps.gates.create(v.data);
971
- emitDefinitionChanged(deps.eventRepo, null, "admin.gate.create", { name: gate.name });
972
- return jsonResult(gate);
973
- }
974
- async function handleAdminGateAttach(deps, args) {
975
- const v = validateInput(AdminGateAttachSchema, args);
976
- if (!v.ok)
977
- return v.result;
978
- const { flow_name, transition_id, gate_name } = v.data;
979
- const flow = await deps.flows.getByName(flow_name);
980
- if (!flow)
981
- return errorResult(`Flow not found: ${flow_name}`);
982
- const existing = flow.transitions.find((t) => t.id === transition_id);
983
- if (!existing)
984
- return errorResult(`Transition not found: ${transition_id} in flow ${flow_name}`);
985
- const gate = await deps.gates.getByName(gate_name);
986
- if (!gate)
987
- return errorResult(`Gate not found: ${gate_name}`);
988
- await deps.flows.snapshot(flow.id);
989
- const updated = await deps.flows.updateTransition(transition_id, { gateId: gate.id });
990
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.gate.attach", { transition_id, gate_name });
991
- return jsonResult(updated);
992
- }
993
- async function handleAdminFlowSnapshot(deps, args) {
994
- const v = validateInput(AdminFlowSnapshotSchema, args);
995
- if (!v.ok)
996
- return v.result;
997
- const flow = await deps.flows.getByName(v.data.flow_name);
998
- if (!flow)
999
- return errorResult(`Flow not found: ${v.data.flow_name}`);
1000
- const version = await deps.flows.snapshot(flow.id);
1001
- return jsonResult(version);
1002
- }
1003
- async function handleAdminFlowRestore(deps, args) {
1004
- const v = validateInput(AdminFlowRestoreSchema, args);
1005
- if (!v.ok)
1006
- return v.result;
1007
- const flow = await deps.flows.getByName(v.data.flow_name);
1008
- if (!flow)
1009
- return errorResult(`Flow not found: ${v.data.flow_name}`);
1010
- // Refuse to restore over a live flow — active invocations mean entities are
1011
- // currently being processed and restoring the definition would corrupt them.
1012
- const activeCount = await deps.invocations.countActiveByFlow(flow.id);
1013
- if (activeCount > 0) {
1014
- return errorResult(`Cannot restore flow "${v.data.flow_name}": ${activeCount} active invocation(s) in progress. Wait for them to complete before restoring.`);
1015
- }
1016
- await deps.flows.snapshot(flow.id);
1017
- await deps.flows.restore(flow.id, v.data.version);
1018
- emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.restore", { version: v.data.version });
1019
- return jsonResult({ restored: true, version: v.data.version });
1020
- }