@wanbicoi/next-steps 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -50
- package/dist/cli.js +329 -595
- package/package.json +1 -1
- package/viz/template.html +106 -165
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync as existsSync3 } from "fs";
|
|
6
|
+
import { basename, join as join2 } from "path";
|
|
6
7
|
import { pathToFileURL } from "url";
|
|
7
8
|
import { parseArgs } from "util";
|
|
8
9
|
|
|
@@ -29,16 +30,13 @@ function table(headers, rows) {
|
|
|
29
30
|
return [render(headers), ...rows.map(render)].join(`
|
|
30
31
|
`);
|
|
31
32
|
}
|
|
32
|
-
function formatScope(scope) {
|
|
33
|
-
return scope.length > 0 ? scope.join(" > ") : "-";
|
|
34
|
-
}
|
|
35
33
|
function formatTime(iso) {
|
|
36
34
|
return iso.slice(0, 19).replace("T", " ");
|
|
37
35
|
}
|
|
38
36
|
function optionLine(option, index) {
|
|
39
|
-
const label = option.edge ? `${option.edge} -> ${option.node}` : option.node;
|
|
40
37
|
const title = option.node === END ? "" : option.title;
|
|
41
|
-
const
|
|
38
|
+
const review = option.reviewRequired ? " [review]" : "";
|
|
39
|
+
const head = ` ${index + 1}. ${option.node}${title ? ` ${title}` : ""}${review}`.trimEnd();
|
|
42
40
|
const lines = [head, ` ${option.description}`];
|
|
43
41
|
if (option.condition)
|
|
44
42
|
lines.push(` when: ${option.condition}`);
|
|
@@ -57,14 +55,12 @@ function formatNext(next) {
|
|
|
57
55
|
`);
|
|
58
56
|
}
|
|
59
57
|
function formatCurrent(next) {
|
|
60
|
-
if (next.done)
|
|
61
|
-
return `current: ${next.current ?? END} (finished)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return `current: (not started) scope: ${formatScope(next.scope)}`;
|
|
65
|
-
}
|
|
58
|
+
if (next.done)
|
|
59
|
+
return `current: ${next.current ?? END} (finished)`;
|
|
60
|
+
if (next.current === null)
|
|
61
|
+
return "current: (not started)";
|
|
66
62
|
const title = next.currentTitle ? ` (${next.currentTitle})` : "";
|
|
67
|
-
return `current: ${next.current}${title}
|
|
63
|
+
return `current: ${next.current}${title}`;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
66
|
// src/commands/shared.ts
|
|
@@ -83,8 +79,7 @@ function toJson(value) {
|
|
|
83
79
|
|
|
84
80
|
// src/commands/session.ts
|
|
85
81
|
function cmdCreate(service, input) {
|
|
86
|
-
const
|
|
87
|
-
const { session, workflowTitle, next } = service.create({ workflowId, key: input.key });
|
|
82
|
+
const { session, workflowTitle, next } = service.create({ key: input.key });
|
|
88
83
|
if (input.json) {
|
|
89
84
|
return toJson({
|
|
90
85
|
key: session.key,
|
|
@@ -92,7 +87,6 @@ function cmdCreate(service, input) {
|
|
|
92
87
|
workflowTitle,
|
|
93
88
|
status: session.status,
|
|
94
89
|
current: null,
|
|
95
|
-
scope: next.scope,
|
|
96
90
|
done: next.done,
|
|
97
91
|
next: next.next,
|
|
98
92
|
statePath: service.statePath
|
|
@@ -120,10 +114,10 @@ function cmdLog(service, input) {
|
|
|
120
114
|
statePath: service.statePath
|
|
121
115
|
});
|
|
122
116
|
}
|
|
123
|
-
const origin = step.from ? ` (from ${step.from}
|
|
117
|
+
const origin = step.from ? ` (from ${step.from})` : " (entry)";
|
|
124
118
|
return [
|
|
125
119
|
`logged: #${step.seq} ${step.node}${origin}`,
|
|
126
|
-
`
|
|
120
|
+
`status: ${session.status}`,
|
|
127
121
|
"",
|
|
128
122
|
formatNext(next)
|
|
129
123
|
].join(`
|
|
@@ -138,7 +132,6 @@ function cmdNext(service, input) {
|
|
|
138
132
|
workflowId: session.workflowId,
|
|
139
133
|
status: session.status,
|
|
140
134
|
current: next.current === null ? null : { node: next.current, title: next.currentTitle, at: session.updatedAt },
|
|
141
|
-
scope: next.scope,
|
|
142
135
|
done: next.done,
|
|
143
136
|
next: next.next,
|
|
144
137
|
statePath: service.statePath
|
|
@@ -156,31 +149,28 @@ function cmdSteps(service, input) {
|
|
|
156
149
|
workflowId: session.workflowId,
|
|
157
150
|
status: session.status,
|
|
158
151
|
current: session.current,
|
|
159
|
-
scope: session.stack.map((frame) => frame.workflowId),
|
|
160
152
|
steps,
|
|
161
153
|
statePath: service.statePath
|
|
162
154
|
});
|
|
163
155
|
}
|
|
164
|
-
const head =
|
|
165
|
-
`session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`,
|
|
166
|
-
`scope: ${formatScope(session.stack.map((frame) => frame.workflowId))}`,
|
|
167
|
-
""
|
|
168
|
-
].join(`
|
|
169
|
-
`);
|
|
156
|
+
const head = `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`;
|
|
170
157
|
if (steps.length === 0)
|
|
171
|
-
return `${head}
|
|
158
|
+
return `${head}
|
|
159
|
+
|
|
160
|
+
no steps yet`;
|
|
172
161
|
const rows = steps.map((step) => [
|
|
173
162
|
String(step.seq),
|
|
174
163
|
step.node,
|
|
175
164
|
step.edge ?? "-",
|
|
176
165
|
step.from ?? "-",
|
|
177
|
-
formatScope(step.scope),
|
|
178
166
|
formatTime(step.at)
|
|
179
167
|
]);
|
|
180
|
-
return head
|
|
168
|
+
return `${head}
|
|
169
|
+
|
|
170
|
+
${table(["SEQ", "NODE", "EDGE", "FROM", "AT"], rows)}`;
|
|
181
171
|
}
|
|
182
172
|
function cmdKeys(service, input) {
|
|
183
|
-
const sessions = service.keys(
|
|
173
|
+
const sessions = service.keys();
|
|
184
174
|
if (input.json) {
|
|
185
175
|
return toJson({ statePath: service.statePath, sessions });
|
|
186
176
|
}
|
|
@@ -242,19 +232,15 @@ function findTemplate() {
|
|
|
242
232
|
}
|
|
243
233
|
throw new AppError("viz_template_missing", "could not find viz/template.html next to the package", 3);
|
|
244
234
|
}
|
|
245
|
-
function renderVizHtml(template,
|
|
246
|
-
const
|
|
247
|
-
const data = JSON.stringify({ workflows, order }).replaceAll("<", "\\u003c");
|
|
235
|
+
function renderVizHtml(template, workflow) {
|
|
236
|
+
const data = JSON.stringify({ workflow }).replaceAll("<", "\\u003c");
|
|
248
237
|
return template.replace("__WORKFLOW_DATA__", data);
|
|
249
238
|
}
|
|
250
|
-
function startVizServer(
|
|
239
|
+
function startVizServer(workflow, port) {
|
|
251
240
|
const template = readFileSync(findTemplate(), "utf8");
|
|
252
241
|
const cytoscapeJs = readFileSync(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
|
|
253
242
|
const dagreJs = readFileSync(require2.resolve("cytoscape-dagre"), "utf8");
|
|
254
|
-
const
|
|
255
|
-
for (const workflow of registry.values())
|
|
256
|
-
workflows[workflow.id] = workflow;
|
|
257
|
-
const html = renderVizHtml(template, workflows);
|
|
243
|
+
const html = renderVizHtml(template, workflow);
|
|
258
244
|
const server = createServer((request, response) => {
|
|
259
245
|
const path = (request.url ?? "/").split("?")[0];
|
|
260
246
|
if (path === "/" || path === "/index.html") {
|
|
@@ -280,42 +266,143 @@ function startVizServer(registry, port) {
|
|
|
280
266
|
process.exitCode = 1;
|
|
281
267
|
});
|
|
282
268
|
server.listen(port, "127.0.0.1");
|
|
283
|
-
return { url: `http://127.0.0.1:${port}`,
|
|
269
|
+
return { url: `http://127.0.0.1:${port}`, workflow: workflow.id };
|
|
284
270
|
}
|
|
285
271
|
|
|
286
272
|
// src/commands/workflow.ts
|
|
287
273
|
import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
|
|
288
|
-
import {
|
|
274
|
+
import { dirname as dirname2 } from "node:path";
|
|
275
|
+
var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
276
|
+
function skeleton(id) {
|
|
277
|
+
return {
|
|
278
|
+
id,
|
|
279
|
+
title: id,
|
|
280
|
+
start: "start",
|
|
281
|
+
nodes: [
|
|
282
|
+
{
|
|
283
|
+
id: "start",
|
|
284
|
+
title: "Start",
|
|
285
|
+
description: "Describe the first step.",
|
|
286
|
+
next: [{ to: END, description: "Done." }]
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function cmdWorkflowNew(graphPath, id, json) {
|
|
292
|
+
if (existsSync2(graphPath)) {
|
|
293
|
+
throw usageError(`workflow file already exists: ${graphPath}`);
|
|
294
|
+
}
|
|
295
|
+
const workflowId = ID_PATTERN.test(id) ? id : "workflow";
|
|
296
|
+
mkdirSync(dirname2(graphPath), { recursive: true });
|
|
297
|
+
writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
|
|
298
|
+
`, "utf8");
|
|
299
|
+
if (json)
|
|
300
|
+
return toJson({ created: graphPath, workflowId });
|
|
301
|
+
return `created: ${graphPath}`;
|
|
302
|
+
}
|
|
303
|
+
function requireValid(load) {
|
|
304
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
305
|
+
throw new AppError("invalid_workflow", `workflow file is invalid: ${load.path}`, 5, {
|
|
306
|
+
errors: load.errors
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return load.workflow;
|
|
310
|
+
}
|
|
311
|
+
function cmdWorkflowShow(load, json) {
|
|
312
|
+
const wf = requireValid(load);
|
|
313
|
+
if (json)
|
|
314
|
+
return toJson({ workflow: wf, warnings: load.warnings });
|
|
315
|
+
const lines = [`${wf.id} ${wf.title}`, `start: ${wf.start}`];
|
|
316
|
+
let group;
|
|
317
|
+
for (const node of wf.nodes) {
|
|
318
|
+
if (node.group !== group) {
|
|
319
|
+
group = node.group;
|
|
320
|
+
if (group)
|
|
321
|
+
lines.push("", group);
|
|
322
|
+
}
|
|
323
|
+
const review = node.reviewRequired ? " [review]" : "";
|
|
324
|
+
lines.push(` ${node.id} ${node.title}${review}`);
|
|
325
|
+
for (const move of node.next) {
|
|
326
|
+
const when = move.condition ? ` [when: ${move.condition}]` : "";
|
|
327
|
+
lines.push(` -> ${move.to} ${move.description}${when}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return lines.join(`
|
|
331
|
+
`);
|
|
332
|
+
}
|
|
333
|
+
function cmdWorkflowCheck(load, json) {
|
|
334
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
335
|
+
throw new AppError("invalid_workflow", "workflow validation failed", 5, {
|
|
336
|
+
errors: load.errors
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const wf = load.workflow;
|
|
340
|
+
const moves = wf.nodes.reduce((count, node) => count + node.next.length, 0);
|
|
341
|
+
if (json) {
|
|
342
|
+
return toJson({
|
|
343
|
+
ok: true,
|
|
344
|
+
workflowId: wf.id,
|
|
345
|
+
title: wf.title,
|
|
346
|
+
nodes: wf.nodes.length,
|
|
347
|
+
moves,
|
|
348
|
+
warnings: load.warnings
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const lines = [`ok: ${wf.id} (${wf.nodes.length} nodes, ${moves} moves)`];
|
|
352
|
+
for (const warning of load.warnings)
|
|
353
|
+
lines.push(`warning: ${warning}`);
|
|
354
|
+
return lines.join(`
|
|
355
|
+
`);
|
|
356
|
+
}
|
|
289
357
|
|
|
290
358
|
// src/workflow/validate.ts
|
|
291
359
|
import { z } from "zod";
|
|
292
|
-
var
|
|
293
|
-
|
|
294
|
-
type: z.enum(["node", "subgraph"]),
|
|
295
|
-
title: z.string().min(1),
|
|
360
|
+
var moveSchema = z.object({
|
|
361
|
+
to: z.string().min(1),
|
|
296
362
|
description: z.string(),
|
|
297
|
-
|
|
363
|
+
condition: z.string().min(1).optional()
|
|
298
364
|
}).strict();
|
|
299
|
-
var
|
|
365
|
+
var nodeSchema = z.object({
|
|
300
366
|
id: z.string().min(1),
|
|
301
|
-
|
|
302
|
-
to: z.string().min(1),
|
|
367
|
+
title: z.string().min(1),
|
|
303
368
|
description: z.string(),
|
|
304
|
-
|
|
369
|
+
group: z.string().min(1).optional(),
|
|
370
|
+
reviewRequired: z.boolean().optional(),
|
|
371
|
+
next: z.array(moveSchema).min(1)
|
|
305
372
|
}).strict();
|
|
306
373
|
var workflowSchema = z.object({
|
|
307
374
|
id: z.string().min(1),
|
|
308
375
|
title: z.string().min(1),
|
|
309
376
|
start: z.string().min(1),
|
|
310
|
-
nodes: z.array(nodeSchema).min(1)
|
|
311
|
-
edges: z.array(edgeSchema)
|
|
377
|
+
nodes: z.array(nodeSchema).min(1)
|
|
312
378
|
}).strict();
|
|
379
|
+
function reportLegacyShape(raw, report) {
|
|
380
|
+
if (typeof raw !== "object" || raw === null)
|
|
381
|
+
return;
|
|
382
|
+
const file = raw;
|
|
383
|
+
if (Array.isArray(file.edges)) {
|
|
384
|
+
report(`edges are inline now: move each edge into its node's "next" list`);
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(file.nodes))
|
|
387
|
+
return;
|
|
388
|
+
for (const node of file.nodes) {
|
|
389
|
+
if (typeof node !== "object" || node === null)
|
|
390
|
+
continue;
|
|
391
|
+
const candidate = node;
|
|
392
|
+
if (candidate.type === "subgraph") {
|
|
393
|
+
report(`node "${String(candidate.id)}" is a subgraph: inline its nodes and use "group" for the label`);
|
|
394
|
+
} else if ("type" in candidate) {
|
|
395
|
+
report(`node "${String(candidate.id)}" has a "type" field; nodes are flat, drop it`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
313
399
|
function validateWorkflow(raw, source) {
|
|
314
400
|
const errors = [];
|
|
315
401
|
const warnings = [];
|
|
316
402
|
const report = (message) => {
|
|
317
403
|
errors.push(`${source}: ${message}`);
|
|
318
404
|
};
|
|
405
|
+
reportLegacyShape(raw, report);
|
|
319
406
|
const parsed = workflowSchema.safeParse(raw);
|
|
320
407
|
if (!parsed.success) {
|
|
321
408
|
for (const issue of parsed.error.issues) {
|
|
@@ -328,66 +415,46 @@ function validateWorkflow(raw, source) {
|
|
|
328
415
|
const nodes = new Map;
|
|
329
416
|
for (const node of wf.nodes) {
|
|
330
417
|
if (node.id === END) {
|
|
331
|
-
report(`node id "${END}" is reserved; point
|
|
418
|
+
report(`node id "${END}" is reserved; point a move at END instead`);
|
|
332
419
|
}
|
|
333
420
|
if (nodes.has(node.id)) {
|
|
334
421
|
report(`duplicate node id "${node.id}"`);
|
|
335
422
|
}
|
|
336
423
|
nodes.set(node.id, node);
|
|
337
|
-
if (node.type === "subgraph") {
|
|
338
|
-
if (!node.workflow) {
|
|
339
|
-
report(`subgraph node "${node.id}" needs a "workflow" field`);
|
|
340
|
-
} else if (node.workflow === wf.id) {
|
|
341
|
-
report(`subgraph node "${node.id}" references its own workflow "${wf.id}"`);
|
|
342
|
-
}
|
|
343
|
-
} else if (node.workflow) {
|
|
344
|
-
report(`node "${node.id}" is not a subgraph but has a "workflow" field`);
|
|
345
|
-
}
|
|
346
424
|
}
|
|
347
425
|
if (wf.start === END) {
|
|
348
426
|
report(`start must be a node, not END`);
|
|
349
427
|
} else if (!nodes.has(wf.start)) {
|
|
350
428
|
report(`start "${wf.start}" is not a node`);
|
|
351
429
|
}
|
|
352
|
-
|
|
353
|
-
const pairs = new Set;
|
|
354
|
-
for (const edge of wf.edges) {
|
|
355
|
-
if (edgeIds.has(edge.id)) {
|
|
356
|
-
report(`duplicate edge id "${edge.id}"`);
|
|
357
|
-
}
|
|
358
|
-
edgeIds.add(edge.id);
|
|
359
|
-
const pair = `${edge.from} -> ${edge.to}`;
|
|
360
|
-
if (pairs.has(pair)) {
|
|
361
|
-
report(`duplicate edge ${pair}; transitions must be unambiguous`);
|
|
362
|
-
}
|
|
363
|
-
pairs.add(pair);
|
|
364
|
-
if (!nodes.has(edge.from)) {
|
|
365
|
-
report(`edge "${edge.id}" starts at unknown node "${edge.from}"`);
|
|
366
|
-
}
|
|
367
|
-
if (edge.to !== END && !nodes.has(edge.to)) {
|
|
368
|
-
report(`edge "${edge.id}" targets unknown node "${edge.to}" (use END to finish)`);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
430
|
+
let reachesEnd = false;
|
|
371
431
|
for (const node of wf.nodes) {
|
|
372
|
-
|
|
373
|
-
|
|
432
|
+
const seen = new Set;
|
|
433
|
+
for (const move of node.next) {
|
|
434
|
+
if (move.to === END) {
|
|
435
|
+
reachesEnd = true;
|
|
436
|
+
} else if (!nodes.has(move.to)) {
|
|
437
|
+
report(`move from "${node.id}" targets unknown node "${move.to}" (use END to finish)`);
|
|
438
|
+
}
|
|
439
|
+
if (seen.has(move.to)) {
|
|
440
|
+
report(`duplicate move ${node.id} -> ${move.to}; transitions must be unambiguous`);
|
|
441
|
+
}
|
|
442
|
+
seen.add(move.to);
|
|
374
443
|
}
|
|
375
444
|
}
|
|
376
|
-
if (!
|
|
377
|
-
report(`workflow "${wf.id}" has no
|
|
445
|
+
if (!reachesEnd) {
|
|
446
|
+
report(`workflow "${wf.id}" has no move to END`);
|
|
378
447
|
}
|
|
379
448
|
if (errors.length === 0) {
|
|
380
449
|
const reachable = new Set([wf.start]);
|
|
381
450
|
const queue = [wf.start];
|
|
382
451
|
while (queue.length > 0) {
|
|
383
452
|
const id = queue.shift();
|
|
384
|
-
for (const
|
|
385
|
-
if (
|
|
386
|
-
continue;
|
|
387
|
-
if (edge.to === END || reachable.has(edge.to))
|
|
453
|
+
for (const move of movesFor(wf, id)) {
|
|
454
|
+
if (move.to === END || reachable.has(move.to))
|
|
388
455
|
continue;
|
|
389
|
-
reachable.add(
|
|
390
|
-
queue.push(
|
|
456
|
+
reachable.add(move.to);
|
|
457
|
+
queue.push(move.to);
|
|
391
458
|
}
|
|
392
459
|
}
|
|
393
460
|
for (const node of wf.nodes) {
|
|
@@ -396,19 +463,19 @@ function validateWorkflow(raw, source) {
|
|
|
396
463
|
}
|
|
397
464
|
}
|
|
398
465
|
const canFinish = new Set;
|
|
399
|
-
for (const
|
|
400
|
-
if (
|
|
401
|
-
canFinish.add(
|
|
466
|
+
for (const node of wf.nodes) {
|
|
467
|
+
if (node.next.some((move) => move.to === END))
|
|
468
|
+
canFinish.add(node.id);
|
|
402
469
|
}
|
|
403
470
|
let grew = true;
|
|
404
471
|
while (grew) {
|
|
405
472
|
grew = false;
|
|
406
|
-
for (const
|
|
407
|
-
if (
|
|
473
|
+
for (const node of wf.nodes) {
|
|
474
|
+
if (canFinish.has(node.id))
|
|
408
475
|
continue;
|
|
409
|
-
if (!
|
|
476
|
+
if (!node.next.some((move) => canFinish.has(move.to)))
|
|
410
477
|
continue;
|
|
411
|
-
canFinish.add(
|
|
478
|
+
canFinish.add(node.id);
|
|
412
479
|
grew = true;
|
|
413
480
|
}
|
|
414
481
|
}
|
|
@@ -420,305 +487,93 @@ function validateWorkflow(raw, source) {
|
|
|
420
487
|
}
|
|
421
488
|
return { workflow: wf, errors, warnings };
|
|
422
489
|
}
|
|
423
|
-
function
|
|
424
|
-
return wf.
|
|
490
|
+
function movesFor(wf, nodeId) {
|
|
491
|
+
return wf.nodes.find((node) => node.id === nodeId)?.next ?? [];
|
|
425
492
|
}
|
|
426
493
|
function onlyEnd(wf, nodeId) {
|
|
427
|
-
const
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// src/workflow/load.ts
|
|
432
|
-
function requireWorkflow(workflows, id) {
|
|
433
|
-
const workflow = workflows.get(id);
|
|
434
|
-
if (!workflow) {
|
|
435
|
-
throw new AppError("workflow_not_found", `workflow "${id}" not found in ./workflows`, 3, {
|
|
436
|
-
available: [...workflows.keys()].sort()
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
return workflow;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// src/commands/workflow.ts
|
|
443
|
-
var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
444
|
-
function skeleton(id) {
|
|
445
|
-
return {
|
|
446
|
-
id,
|
|
447
|
-
title: id,
|
|
448
|
-
start: "start",
|
|
449
|
-
nodes: [
|
|
450
|
-
{
|
|
451
|
-
id: "start",
|
|
452
|
-
type: "node",
|
|
453
|
-
title: "Start",
|
|
454
|
-
description: "Describe the first step."
|
|
455
|
-
}
|
|
456
|
-
],
|
|
457
|
-
edges: [
|
|
458
|
-
{
|
|
459
|
-
id: "e1",
|
|
460
|
-
from: "start",
|
|
461
|
-
to: END,
|
|
462
|
-
description: "Done."
|
|
463
|
-
}
|
|
464
|
-
]
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
function cmdWorkflowNew(workflowsDir, id, json) {
|
|
468
|
-
if (!id)
|
|
469
|
-
throw usageError("workflow id is required: next-steps workflow new <id>");
|
|
470
|
-
if (!ID_PATTERN.test(id)) {
|
|
471
|
-
throw usageError(`invalid workflow id "${id}"; use A-Z a-z 0-9 . _ - (max 64)`);
|
|
472
|
-
}
|
|
473
|
-
const file = join2(workflowsDir, `${id}.json`);
|
|
474
|
-
if (existsSync2(file)) {
|
|
475
|
-
throw usageError(`workflow file already exists: ${file}`);
|
|
476
|
-
}
|
|
477
|
-
mkdirSync(workflowsDir, { recursive: true });
|
|
478
|
-
writeFileSync(file, `${JSON.stringify(skeleton(id), null, 2)}
|
|
479
|
-
`, "utf8");
|
|
480
|
-
if (json)
|
|
481
|
-
return toJson({ created: file, workflowId: id });
|
|
482
|
-
return `created: ${file}`;
|
|
483
|
-
}
|
|
484
|
-
function cmdWorkflowList(load, json) {
|
|
485
|
-
if (json) {
|
|
486
|
-
return toJson({
|
|
487
|
-
dir: load.dir,
|
|
488
|
-
workflows: load.reports.map((report) => ({
|
|
489
|
-
file: report.file,
|
|
490
|
-
id: report.id,
|
|
491
|
-
title: report.title,
|
|
492
|
-
nodes: report.nodes,
|
|
493
|
-
edges: report.edges,
|
|
494
|
-
valid: report.valid,
|
|
495
|
-
errors: report.errors
|
|
496
|
-
})),
|
|
497
|
-
errors: load.errors,
|
|
498
|
-
warnings: load.warnings
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
if (load.reports.length === 0) {
|
|
502
|
-
return `no workflows in ${load.dir} (run "next-steps workflow new <id>")`;
|
|
503
|
-
}
|
|
504
|
-
const rows = load.reports.map((report) => [
|
|
505
|
-
report.id,
|
|
506
|
-
report.title,
|
|
507
|
-
String(report.nodes),
|
|
508
|
-
String(report.edges),
|
|
509
|
-
report.valid ? "ok" : "invalid"
|
|
510
|
-
]);
|
|
511
|
-
const body = table(["ID", "TITLE", "NODES", "EDGES", "STATUS"], rows);
|
|
512
|
-
const failures = load.reports.filter((report) => !report.valid);
|
|
513
|
-
if (failures.length === 0)
|
|
514
|
-
return body;
|
|
515
|
-
const details = failures.flatMap((report) => report.errors.map((error) => ` ${error}`));
|
|
516
|
-
return `${body}
|
|
517
|
-
|
|
518
|
-
invalid:
|
|
519
|
-
${details.join(`
|
|
520
|
-
`)}`;
|
|
521
|
-
}
|
|
522
|
-
function findReport(load, id) {
|
|
523
|
-
if (!id)
|
|
524
|
-
throw usageError("workflow id is required");
|
|
525
|
-
const report = load.reports.find((candidate) => candidate.id === id);
|
|
526
|
-
if (!report) {
|
|
527
|
-
throw new AppError("workflow_not_found", `workflow "${id}" not found in ${load.dir}`, 3, {
|
|
528
|
-
available: load.reports.map((candidate) => candidate.id).sort()
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
return report;
|
|
532
|
-
}
|
|
533
|
-
function cmdWorkflowShow(load, id, json) {
|
|
534
|
-
const report = findReport(load, id);
|
|
535
|
-
if (!report.valid) {
|
|
536
|
-
throw new AppError("invalid_workflow", `workflow "${report.id}" is invalid`, 5, {
|
|
537
|
-
errors: report.errors
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
const wf = requireWorkflow(load.registry, report.id);
|
|
541
|
-
if (json)
|
|
542
|
-
return toJson({ workflow: wf, warnings: report.warnings });
|
|
543
|
-
const nodeRows = wf.nodes.map((node) => [
|
|
544
|
-
node.id,
|
|
545
|
-
node.type,
|
|
546
|
-
node.type === "subgraph" ? `${node.title} -> ${node.workflow ?? "?"}` : node.title
|
|
547
|
-
]);
|
|
548
|
-
const edgeRows = wf.edges.map((edge) => [
|
|
549
|
-
edge.id,
|
|
550
|
-
`${edge.from} -> ${edge.to}`,
|
|
551
|
-
edge.condition ? `${edge.description} [when: ${edge.condition}]` : edge.description
|
|
552
|
-
]);
|
|
553
|
-
const indent = (block) => block.split(`
|
|
554
|
-
`).map((line) => ` ${line}`).join(`
|
|
555
|
-
`);
|
|
556
|
-
return [
|
|
557
|
-
`${wf.id} ${wf.title}`,
|
|
558
|
-
`start: ${wf.start}`,
|
|
559
|
-
"",
|
|
560
|
-
"nodes:",
|
|
561
|
-
indent(table(["ID", "TYPE", "TITLE"], nodeRows)),
|
|
562
|
-
"",
|
|
563
|
-
"edges:",
|
|
564
|
-
indent(table(["ID", "TRANSITION", "DESCRIPTION"], edgeRows))
|
|
565
|
-
].join(`
|
|
566
|
-
`);
|
|
567
|
-
}
|
|
568
|
-
function cmdWorkflowCheck(load, id, json) {
|
|
569
|
-
const reports = id ? [findReport(load, id)] : load.reports;
|
|
570
|
-
const errors = reports.flatMap((report) => report.errors);
|
|
571
|
-
const warnings = reports.flatMap((report) => report.warnings);
|
|
572
|
-
if (errors.length > 0) {
|
|
573
|
-
throw new AppError("invalid_workflow", "workflow validation failed", 5, { errors });
|
|
574
|
-
}
|
|
575
|
-
if (json) {
|
|
576
|
-
return toJson({
|
|
577
|
-
ok: true,
|
|
578
|
-
workflows: reports.map((report) => ({
|
|
579
|
-
id: report.id,
|
|
580
|
-
title: report.title,
|
|
581
|
-
nodes: report.nodes,
|
|
582
|
-
edges: report.edges
|
|
583
|
-
})),
|
|
584
|
-
warnings
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
if (reports.length === 0) {
|
|
588
|
-
return `no workflows in ${load.dir}`;
|
|
589
|
-
}
|
|
590
|
-
const lines = reports.map((report) => `ok: ${report.id} (${report.nodes} nodes, ${report.edges} edges)`);
|
|
591
|
-
for (const warning of warnings)
|
|
592
|
-
lines.push(`warning: ${warning}`);
|
|
593
|
-
return lines.join(`
|
|
594
|
-
`);
|
|
494
|
+
const moves = movesFor(wf, nodeId);
|
|
495
|
+
return moves.length === 1 && moves[0]?.to === END;
|
|
595
496
|
}
|
|
596
497
|
|
|
597
498
|
// src/workflow/graph.ts
|
|
598
499
|
function getNode(wf, id) {
|
|
599
500
|
return wf.nodes.find((node) => node.id === id);
|
|
600
501
|
}
|
|
601
|
-
function
|
|
602
|
-
const frames = stack.map((frame) => ({ ...frame }));
|
|
603
|
-
while (frames.length > 1) {
|
|
604
|
-
const top = frames[frames.length - 1];
|
|
605
|
-
if (!top || top.nodeId === null)
|
|
606
|
-
break;
|
|
607
|
-
const wf = lookup.get(top.workflowId);
|
|
608
|
-
if (!wf || !onlyEnd(wf, top.nodeId))
|
|
609
|
-
break;
|
|
610
|
-
frames.pop();
|
|
611
|
-
}
|
|
612
|
-
return frames;
|
|
613
|
-
}
|
|
614
|
-
function computeNext(lookup, session) {
|
|
502
|
+
function computeNext(wf, session) {
|
|
615
503
|
if (session.status === "done") {
|
|
616
|
-
return {
|
|
617
|
-
done: true,
|
|
618
|
-
current: session.current,
|
|
619
|
-
currentTitle: null,
|
|
620
|
-
scope: session.stack.map((frame) => frame.workflowId),
|
|
621
|
-
next: []
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
const frames = effectiveStack(lookup, session.stack);
|
|
625
|
-
const scope = frames.map((frame) => frame.workflowId);
|
|
626
|
-
const top = frames[frames.length - 1];
|
|
627
|
-
if (!top) {
|
|
628
|
-
return { done: true, current: null, currentTitle: null, scope, next: [] };
|
|
504
|
+
return { done: true, current: session.current, currentTitle: null, next: [] };
|
|
629
505
|
}
|
|
630
|
-
|
|
631
|
-
if (!wf) {
|
|
632
|
-
return { done: true, current: null, currentTitle: null, scope, next: [] };
|
|
633
|
-
}
|
|
634
|
-
if (top.nodeId === null) {
|
|
506
|
+
if (session.current === null) {
|
|
635
507
|
const entry = getNode(wf, wf.start);
|
|
636
508
|
return {
|
|
637
509
|
done: false,
|
|
638
510
|
current: null,
|
|
639
511
|
currentTitle: null,
|
|
640
|
-
scope,
|
|
641
512
|
next: [
|
|
642
513
|
{
|
|
643
514
|
edge: null,
|
|
644
515
|
node: wf.start,
|
|
645
516
|
title: entry?.title ?? wf.start,
|
|
646
|
-
description: entry?.description ?? ""
|
|
517
|
+
description: entry?.description ?? "",
|
|
518
|
+
reviewRequired: entry?.reviewRequired ?? false
|
|
647
519
|
}
|
|
648
520
|
]
|
|
649
521
|
};
|
|
650
522
|
}
|
|
651
|
-
const node = getNode(wf,
|
|
652
|
-
if (onlyEnd(wf,
|
|
523
|
+
const node = getNode(wf, session.current);
|
|
524
|
+
if (onlyEnd(wf, session.current)) {
|
|
653
525
|
return {
|
|
654
526
|
done: true,
|
|
655
|
-
current:
|
|
527
|
+
current: session.current,
|
|
656
528
|
currentTitle: node?.title ?? null,
|
|
657
|
-
scope,
|
|
658
529
|
next: []
|
|
659
530
|
};
|
|
660
531
|
}
|
|
661
|
-
const
|
|
662
|
-
|
|
532
|
+
const current = session.current;
|
|
533
|
+
const next = movesFor(wf, current).map((move) => {
|
|
534
|
+
const edge = `${current}->${move.to}`;
|
|
535
|
+
if (move.to === END) {
|
|
663
536
|
return {
|
|
664
|
-
edge
|
|
537
|
+
edge,
|
|
665
538
|
node: END,
|
|
666
539
|
title: "finish",
|
|
667
|
-
description:
|
|
668
|
-
...
|
|
540
|
+
description: move.description,
|
|
541
|
+
...move.condition ? { condition: move.condition } : {},
|
|
542
|
+
reviewRequired: false
|
|
669
543
|
};
|
|
670
544
|
}
|
|
671
|
-
const target = getNode(wf,
|
|
545
|
+
const target = getNode(wf, move.to);
|
|
672
546
|
return {
|
|
673
|
-
edge
|
|
674
|
-
node:
|
|
675
|
-
title: target?.title ??
|
|
676
|
-
description:
|
|
677
|
-
...
|
|
547
|
+
edge,
|
|
548
|
+
node: move.to,
|
|
549
|
+
title: target?.title ?? move.to,
|
|
550
|
+
description: move.description,
|
|
551
|
+
...move.condition ? { condition: move.condition } : {},
|
|
552
|
+
reviewRequired: target?.reviewRequired ?? false
|
|
678
553
|
};
|
|
679
554
|
});
|
|
680
555
|
return {
|
|
681
556
|
done: false,
|
|
682
|
-
current
|
|
557
|
+
current,
|
|
683
558
|
currentTitle: node?.title ?? null,
|
|
684
|
-
scope,
|
|
685
559
|
next
|
|
686
560
|
};
|
|
687
561
|
}
|
|
688
562
|
|
|
689
563
|
// src/runs/service.ts
|
|
690
564
|
var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
|
691
|
-
var MAX_DEPTH = 16;
|
|
692
|
-
function legalOptions(wf, current) {
|
|
693
|
-
if (current === null) {
|
|
694
|
-
const entry = getNode(wf, wf.start);
|
|
695
|
-
return [{ edge: null, node: wf.start, description: entry?.description ?? "" }];
|
|
696
|
-
}
|
|
697
|
-
return outgoingEdges(wf, current).map((edge) => ({
|
|
698
|
-
edge: edge.id,
|
|
699
|
-
node: edge.to,
|
|
700
|
-
description: edge.description
|
|
701
|
-
}));
|
|
702
|
-
}
|
|
703
|
-
function invalidTransition(wf, current, node, scope) {
|
|
704
|
-
return new AppError("invalid_transition", `cannot move to "${node}" from "${current ?? "(start)"}" in workflow "${wf.id}"`, 4, { current, node, scope, legal: legalOptions(wf, current) });
|
|
705
|
-
}
|
|
706
|
-
function keyNotFound(key) {
|
|
707
|
-
return new AppError("key_not_found", `session "${key}" does not exist`, 3);
|
|
708
|
-
}
|
|
709
565
|
|
|
710
566
|
class SessionService {
|
|
711
|
-
|
|
567
|
+
workflow;
|
|
712
568
|
store;
|
|
713
|
-
constructor(
|
|
714
|
-
this.
|
|
569
|
+
constructor(workflow, store) {
|
|
570
|
+
this.workflow = workflow;
|
|
715
571
|
this.store = store;
|
|
716
572
|
}
|
|
717
573
|
get statePath() {
|
|
718
574
|
return this.store.path;
|
|
719
575
|
}
|
|
720
|
-
create(input) {
|
|
721
|
-
const workflow = requireWorkflow(this.registry, input.workflowId);
|
|
576
|
+
create(input = {}) {
|
|
722
577
|
const key = (input.key ?? "").trim() || crypto.randomUUID();
|
|
723
578
|
if (!KEY_PATTERN.test(key)) {
|
|
724
579
|
throw new AppError("key_invalid", `key "${key}" is invalid; use 1-128 characters from A-Z a-z 0-9 . _ -`, 2);
|
|
@@ -726,10 +581,9 @@ class SessionService {
|
|
|
726
581
|
const now = new Date().toISOString();
|
|
727
582
|
const session = {
|
|
728
583
|
key,
|
|
729
|
-
workflowId: workflow.id,
|
|
584
|
+
workflowId: this.workflow.id,
|
|
730
585
|
status: "running",
|
|
731
586
|
current: null,
|
|
732
|
-
stack: [{ workflowId: workflow.id, nodeId: null }],
|
|
733
587
|
version: 0,
|
|
734
588
|
createdAt: now,
|
|
735
589
|
updatedAt: now
|
|
@@ -739,13 +593,14 @@ class SessionService {
|
|
|
739
593
|
}
|
|
740
594
|
return {
|
|
741
595
|
session,
|
|
742
|
-
workflowTitle: workflow.title,
|
|
743
|
-
next: computeNext(this.
|
|
596
|
+
workflowTitle: this.workflow.title,
|
|
597
|
+
next: computeNext(this.workflow, session)
|
|
744
598
|
};
|
|
745
599
|
}
|
|
746
600
|
log(key, node) {
|
|
747
601
|
return this.store.transaction(() => {
|
|
748
602
|
const session = this.requireSession(key);
|
|
603
|
+
const wf = this.workflow;
|
|
749
604
|
if (session.status === "done") {
|
|
750
605
|
throw new AppError("invalid_transition", `session "${key}" is already done`, 4, {
|
|
751
606
|
current: END,
|
|
@@ -753,82 +608,48 @@ class SessionService {
|
|
|
753
608
|
legal: []
|
|
754
609
|
});
|
|
755
610
|
}
|
|
756
|
-
const frames = effectiveStack(this.registry, session.stack);
|
|
757
|
-
const top = frames[frames.length - 1];
|
|
758
|
-
if (!top) {
|
|
759
|
-
throw new AppError("invalid_transition", `session "${key}" has no active scope`, 4);
|
|
760
|
-
}
|
|
761
|
-
const wf = requireWorkflow(this.registry, top.workflowId);
|
|
762
|
-
const scope = frames.map((frame) => frame.workflowId);
|
|
763
611
|
const now = new Date().toISOString();
|
|
764
|
-
const
|
|
612
|
+
const from = session.current;
|
|
765
613
|
let stepInput;
|
|
766
|
-
let current;
|
|
767
|
-
let endsRun = false;
|
|
768
614
|
if (node === END) {
|
|
769
|
-
if (
|
|
770
|
-
throw invalidTransition(
|
|
771
|
-
|
|
772
|
-
if (!edge)
|
|
773
|
-
throw invalidTransition(wf, top.nodeId, node, scope);
|
|
615
|
+
if (from === null || !movesFor(wf, from).some((move) => move.to === END)) {
|
|
616
|
+
throw this.invalidTransition(from, node);
|
|
617
|
+
}
|
|
774
618
|
stepInput = {
|
|
775
619
|
key,
|
|
776
620
|
node: END,
|
|
777
|
-
from
|
|
778
|
-
edge:
|
|
779
|
-
workflowId:
|
|
780
|
-
scope,
|
|
621
|
+
from,
|
|
622
|
+
edge: `${from}->${END}`,
|
|
623
|
+
workflowId: wf.id,
|
|
781
624
|
at: now
|
|
782
625
|
};
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
endsRun = true;
|
|
786
|
-
} else {
|
|
787
|
-
stack.pop();
|
|
788
|
-
current = stack[stack.length - 1]?.nodeId ?? null;
|
|
789
|
-
}
|
|
626
|
+
session.current = END;
|
|
627
|
+
session.status = "done";
|
|
790
628
|
} else {
|
|
791
629
|
const target = getNode(wf, node);
|
|
792
630
|
if (!target) {
|
|
793
|
-
throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: legalOptions(
|
|
631
|
+
throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: this.legalOptions(from) });
|
|
794
632
|
}
|
|
795
633
|
let edge = null;
|
|
796
|
-
if (
|
|
634
|
+
if (from === null) {
|
|
797
635
|
if (node !== wf.start)
|
|
798
|
-
throw invalidTransition(
|
|
636
|
+
throw this.invalidTransition(from, node);
|
|
799
637
|
} else {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
throw invalidTransition(wf, top.nodeId, node, scope);
|
|
803
|
-
edge = found;
|
|
804
|
-
}
|
|
805
|
-
const last = stack[stack.length - 1];
|
|
806
|
-
if (last)
|
|
807
|
-
last.nodeId = node;
|
|
808
|
-
if (target.type === "subgraph") {
|
|
809
|
-
if (stack.length >= MAX_DEPTH) {
|
|
810
|
-
throw new AppError("max_depth_exceeded", `subgraph nesting exceeded ${MAX_DEPTH} levels`, 4, { scope });
|
|
638
|
+
if (!movesFor(wf, from).some((move) => move.to === node)) {
|
|
639
|
+
throw this.invalidTransition(from, node);
|
|
811
640
|
}
|
|
812
|
-
|
|
813
|
-
|
|
641
|
+
edge = `${from}->${node}`;
|
|
642
|
+
}
|
|
643
|
+
stepInput = { key, node, from, edge, workflowId: wf.id, at: now };
|
|
644
|
+
session.current = node;
|
|
645
|
+
if (onlyEnd(wf, node)) {
|
|
646
|
+
session.current = END;
|
|
647
|
+
session.status = "done";
|
|
814
648
|
}
|
|
815
|
-
current = node;
|
|
816
|
-
stepInput = {
|
|
817
|
-
key,
|
|
818
|
-
node,
|
|
819
|
-
from: top.nodeId,
|
|
820
|
-
edge: edge?.id ?? null,
|
|
821
|
-
workflowId: top.workflowId,
|
|
822
|
-
scope,
|
|
823
|
-
at: now
|
|
824
|
-
};
|
|
825
649
|
}
|
|
826
|
-
session.stack = stack;
|
|
827
|
-
session.current = current;
|
|
828
|
-
session.status = endsRun ? "done" : "running";
|
|
829
650
|
session.version += 1;
|
|
830
651
|
session.updatedAt = now;
|
|
831
|
-
const next =
|
|
652
|
+
const next = computeNext(wf, session);
|
|
832
653
|
const step = this.store.appendStep(stepInput);
|
|
833
654
|
this.store.updateSession(session);
|
|
834
655
|
return { session, step, next };
|
|
@@ -836,40 +657,44 @@ class SessionService {
|
|
|
836
657
|
}
|
|
837
658
|
next(key) {
|
|
838
659
|
const session = this.requireSession(key);
|
|
839
|
-
return { session, next: computeNext(this.
|
|
660
|
+
return { session, next: computeNext(this.workflow, session) };
|
|
840
661
|
}
|
|
841
662
|
steps(key, query = {}) {
|
|
842
663
|
const session = this.requireSession(key);
|
|
843
664
|
return { session, steps: this.store.listSteps(key, query) };
|
|
844
665
|
}
|
|
845
|
-
keys(
|
|
846
|
-
|
|
847
|
-
requireWorkflow(this.registry, workflowId);
|
|
848
|
-
return this.store.listSessions(workflowId);
|
|
666
|
+
keys() {
|
|
667
|
+
return this.store.listSessions();
|
|
849
668
|
}
|
|
850
669
|
reset(key) {
|
|
851
670
|
this.requireSession(key);
|
|
852
671
|
return { key, deletedSteps: this.store.reset(key) };
|
|
853
672
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
return
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
673
|
+
legalOptions(from) {
|
|
674
|
+
if (from === null) {
|
|
675
|
+
const entry = getNode(this.workflow, this.workflow.start);
|
|
676
|
+
return [{ edge: null, node: this.workflow.start, description: entry?.description ?? "" }];
|
|
677
|
+
}
|
|
678
|
+
return movesFor(this.workflow, from).map((move) => ({
|
|
679
|
+
edge: `${from}->${move.to}`,
|
|
680
|
+
node: move.to,
|
|
681
|
+
description: move.description
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
invalidTransition(from, node) {
|
|
685
|
+
return new AppError("invalid_transition", `cannot move to "${node}" from "${from ?? "(start)"}" in workflow "${this.workflow.id}"`, 4, { current: from, node, legal: this.legalOptions(from) });
|
|
861
686
|
}
|
|
862
687
|
requireSession(key) {
|
|
863
688
|
const session = this.store.getSession(key);
|
|
864
689
|
if (!session)
|
|
865
|
-
throw
|
|
690
|
+
throw new AppError("key_not_found", `session "${key}" does not exist`, 3);
|
|
866
691
|
return session;
|
|
867
692
|
}
|
|
868
693
|
}
|
|
869
694
|
|
|
870
695
|
// src/runs/store.ts
|
|
871
|
-
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
872
|
-
import { dirname as
|
|
696
|
+
import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
|
|
697
|
+
import { dirname as dirname3 } from "node:path";
|
|
873
698
|
|
|
874
699
|
// src/runs/driver.ts
|
|
875
700
|
import { createRequire as createRequire2 } from "node:module";
|
|
@@ -948,7 +773,6 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
948
773
|
workflow_id TEXT NOT NULL,
|
|
949
774
|
status TEXT NOT NULL,
|
|
950
775
|
current TEXT,
|
|
951
|
-
stack TEXT NOT NULL,
|
|
952
776
|
version INTEGER NOT NULL DEFAULT 0,
|
|
953
777
|
created_at TEXT NOT NULL,
|
|
954
778
|
updated_at TEXT NOT NULL
|
|
@@ -961,18 +785,17 @@ CREATE TABLE IF NOT EXISTS steps (
|
|
|
961
785
|
from_node TEXT,
|
|
962
786
|
edge TEXT,
|
|
963
787
|
workflow_id TEXT NOT NULL,
|
|
964
|
-
scope TEXT NOT NULL,
|
|
965
788
|
at TEXT NOT NULL,
|
|
966
789
|
PRIMARY KEY (key, seq)
|
|
967
790
|
);
|
|
968
791
|
`;
|
|
792
|
+
var LEGACY_COLUMNS = new Set(["stack", "scope"]);
|
|
969
793
|
function toSession(row) {
|
|
970
794
|
return {
|
|
971
795
|
key: row.key,
|
|
972
796
|
workflowId: row.workflow_id,
|
|
973
797
|
status: row.status,
|
|
974
798
|
current: row.current,
|
|
975
|
-
stack: JSON.parse(row.stack),
|
|
976
799
|
version: row.version,
|
|
977
800
|
createdAt: row.created_at,
|
|
978
801
|
updatedAt: row.updated_at
|
|
@@ -986,7 +809,6 @@ function toStep(row) {
|
|
|
986
809
|
from: row.from_node,
|
|
987
810
|
edge: row.edge,
|
|
988
811
|
workflowId: row.workflow_id,
|
|
989
|
-
scope: JSON.parse(row.scope),
|
|
990
812
|
at: row.at
|
|
991
813
|
};
|
|
992
814
|
}
|
|
@@ -997,25 +819,42 @@ class SqliteRunStore {
|
|
|
997
819
|
constructor(path) {
|
|
998
820
|
this.path = path;
|
|
999
821
|
if (path !== ":memory:") {
|
|
1000
|
-
mkdirSync2(
|
|
822
|
+
mkdirSync2(dirname3(path), { recursive: true });
|
|
1001
823
|
}
|
|
1002
824
|
this.db = openDriver(path);
|
|
1003
|
-
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
1004
|
-
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1005
825
|
if (path !== ":memory:") {
|
|
826
|
+
if (this.isOutdated())
|
|
827
|
+
this.recreate(path);
|
|
1006
828
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
1007
829
|
}
|
|
830
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
831
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1008
832
|
this.db.exec(SCHEMA);
|
|
1009
833
|
}
|
|
834
|
+
isOutdated() {
|
|
835
|
+
const columns = [
|
|
836
|
+
...this.db.all("PRAGMA table_info(runs)", []),
|
|
837
|
+
...this.db.all("PRAGMA table_info(steps)", [])
|
|
838
|
+
];
|
|
839
|
+
return columns.some((column) => LEGACY_COLUMNS.has(column.name));
|
|
840
|
+
}
|
|
841
|
+
recreate(path) {
|
|
842
|
+
this.db.close();
|
|
843
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
844
|
+
try {
|
|
845
|
+
unlinkSync(`${path}${suffix}`);
|
|
846
|
+
} catch {}
|
|
847
|
+
}
|
|
848
|
+
this.db = openDriver(path);
|
|
849
|
+
}
|
|
1010
850
|
createSession(session) {
|
|
1011
851
|
return this.db.run(`INSERT OR IGNORE INTO runs
|
|
1012
|
-
(key, workflow_id, status, current,
|
|
1013
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
852
|
+
(key, workflow_id, status, current, version, created_at, updated_at)
|
|
853
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
1014
854
|
session.key,
|
|
1015
855
|
session.workflowId,
|
|
1016
856
|
session.status,
|
|
1017
857
|
session.current,
|
|
1018
|
-
JSON.stringify(session.stack),
|
|
1019
858
|
session.version,
|
|
1020
859
|
session.createdAt,
|
|
1021
860
|
session.updatedAt
|
|
@@ -1027,32 +866,16 @@ class SqliteRunStore {
|
|
|
1027
866
|
}
|
|
1028
867
|
updateSession(session) {
|
|
1029
868
|
this.db.run(`UPDATE runs
|
|
1030
|
-
SET status = ?, current = ?,
|
|
1031
|
-
WHERE key = ?`, [
|
|
1032
|
-
session.status,
|
|
1033
|
-
session.current,
|
|
1034
|
-
JSON.stringify(session.stack),
|
|
1035
|
-
session.version,
|
|
1036
|
-
session.updatedAt,
|
|
1037
|
-
session.key
|
|
1038
|
-
]);
|
|
869
|
+
SET status = ?, current = ?, version = ?, updated_at = ?
|
|
870
|
+
WHERE key = ?`, [session.status, session.current, session.version, session.updatedAt, session.key]);
|
|
1039
871
|
}
|
|
1040
872
|
appendStep(step) {
|
|
1041
873
|
const row = this.db.get("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM steps WHERE key = ?", [
|
|
1042
874
|
step.key
|
|
1043
875
|
]);
|
|
1044
876
|
const seq = row?.next ?? 1;
|
|
1045
|
-
this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id,
|
|
1046
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
1047
|
-
step.key,
|
|
1048
|
-
seq,
|
|
1049
|
-
step.node,
|
|
1050
|
-
step.from,
|
|
1051
|
-
step.edge,
|
|
1052
|
-
step.workflowId,
|
|
1053
|
-
JSON.stringify(step.scope),
|
|
1054
|
-
step.at
|
|
1055
|
-
]);
|
|
877
|
+
this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, at)
|
|
878
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [step.key, seq, step.node, step.from, step.edge, step.workflowId, step.at]);
|
|
1056
879
|
return { ...step, seq };
|
|
1057
880
|
}
|
|
1058
881
|
listSteps(key, query = {}) {
|
|
@@ -1061,10 +884,8 @@ class SqliteRunStore {
|
|
|
1061
884
|
const rows = this.db.all("SELECT * FROM steps WHERE key = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [key, after, limit]);
|
|
1062
885
|
return rows.map(toStep);
|
|
1063
886
|
}
|
|
1064
|
-
listSessions(
|
|
1065
|
-
const rows =
|
|
1066
|
-
workflowId
|
|
1067
|
-
]) : this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
|
|
887
|
+
listSessions() {
|
|
888
|
+
const rows = this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
|
|
1068
889
|
return rows.map(toSession);
|
|
1069
890
|
}
|
|
1070
891
|
reset(key) {
|
|
@@ -1095,130 +916,49 @@ class AppError2 extends Error {
|
|
|
1095
916
|
}
|
|
1096
917
|
|
|
1097
918
|
// src/workflow/load.ts
|
|
1098
|
-
import {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
let raw;
|
|
1114
|
-
try {
|
|
1115
|
-
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
const message = `${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`;
|
|
1118
|
-
reports.push({
|
|
1119
|
-
file,
|
|
1120
|
-
id: "(unreadable)",
|
|
1121
|
-
title: "",
|
|
1122
|
-
nodes: 0,
|
|
1123
|
-
edges: 0,
|
|
1124
|
-
valid: false,
|
|
1125
|
-
errors: [message],
|
|
1126
|
-
warnings: []
|
|
1127
|
-
});
|
|
1128
|
-
continue;
|
|
1129
|
-
}
|
|
1130
|
-
const check = validateWorkflow(raw, file);
|
|
1131
|
-
if (check.workflow) {
|
|
1132
|
-
parsed.push({
|
|
1133
|
-
file,
|
|
1134
|
-
workflow: check.workflow,
|
|
1135
|
-
errors: check.errors,
|
|
1136
|
-
warnings: check.warnings
|
|
1137
|
-
});
|
|
1138
|
-
} else {
|
|
1139
|
-
reports.push({
|
|
1140
|
-
file,
|
|
1141
|
-
id: rawId(raw),
|
|
1142
|
-
title: "",
|
|
1143
|
-
nodes: 0,
|
|
1144
|
-
edges: 0,
|
|
1145
|
-
valid: false,
|
|
1146
|
-
errors: check.errors,
|
|
1147
|
-
warnings: check.warnings
|
|
1148
|
-
});
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
const byId = new Map;
|
|
1152
|
-
for (const entry of parsed) {
|
|
1153
|
-
const group = byId.get(entry.workflow.id) ?? [];
|
|
1154
|
-
group.push(entry);
|
|
1155
|
-
byId.set(entry.workflow.id, group);
|
|
1156
|
-
}
|
|
1157
|
-
for (const [id, group] of byId) {
|
|
1158
|
-
if (group.length < 2)
|
|
1159
|
-
continue;
|
|
1160
|
-
const message = `workflow id "${id}" is defined in more than one file: ${group.map((entry) => entry.file).join(", ")}`;
|
|
1161
|
-
for (const entry of group)
|
|
1162
|
-
entry.errors.push(message);
|
|
1163
|
-
}
|
|
1164
|
-
const knownIds = new Set(parsed.map((entry) => entry.workflow.id));
|
|
1165
|
-
for (const entry of parsed) {
|
|
1166
|
-
for (const node of entry.workflow.nodes) {
|
|
1167
|
-
if (node.type !== "subgraph" || !node.workflow)
|
|
1168
|
-
continue;
|
|
1169
|
-
if (!knownIds.has(node.workflow)) {
|
|
1170
|
-
entry.errors.push(`${entry.file}: subgraph node "${node.id}" references unknown workflow "${node.workflow}"`);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
for (const entry of parsed) {
|
|
1175
|
-
const valid = entry.errors.length === 0;
|
|
1176
|
-
reports.push({
|
|
1177
|
-
file: entry.file,
|
|
1178
|
-
id: entry.workflow.id,
|
|
1179
|
-
title: entry.workflow.title,
|
|
1180
|
-
nodes: entry.workflow.nodes.length,
|
|
1181
|
-
edges: entry.workflow.edges.length,
|
|
1182
|
-
valid,
|
|
1183
|
-
errors: entry.errors,
|
|
1184
|
-
warnings: entry.warnings
|
|
1185
|
-
});
|
|
1186
|
-
if (valid)
|
|
1187
|
-
registry.set(entry.workflow.id, entry.workflow);
|
|
919
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
920
|
+
function loadWorkflowFile(path) {
|
|
921
|
+
let raw;
|
|
922
|
+
try {
|
|
923
|
+
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
924
|
+
} catch (error) {
|
|
925
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
926
|
+
const missing = error.code === "ENOENT";
|
|
927
|
+
return {
|
|
928
|
+
path,
|
|
929
|
+
errors: [
|
|
930
|
+
missing ? `workflow file not found: ${path} (run "next-steps workflow new")` : `${path}: invalid JSON (${message})`
|
|
931
|
+
],
|
|
932
|
+
warnings: []
|
|
933
|
+
};
|
|
1188
934
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
registry,
|
|
1192
|
-
reports,
|
|
1193
|
-
errors: reports.flatMap((report) => report.errors),
|
|
1194
|
-
warnings: reports.flatMap((report) => report.warnings)
|
|
1195
|
-
};
|
|
935
|
+
const check = validateWorkflow(raw, path);
|
|
936
|
+
return { path, workflow: check.workflow, errors: check.errors, warnings: check.warnings };
|
|
1196
937
|
}
|
|
1197
938
|
|
|
1198
939
|
// src/cli.ts
|
|
1199
|
-
var VERSION = "0.1.
|
|
940
|
+
var VERSION = "0.1.1";
|
|
1200
941
|
var HELP = `next-steps \u2014 record the current step, ask what comes next
|
|
1201
942
|
|
|
1202
943
|
usage:
|
|
1203
|
-
next-steps create
|
|
944
|
+
next-steps create [--key <key>] [--json]
|
|
1204
945
|
next-steps log --key <key> --node <node> [--json]
|
|
1205
946
|
next-steps next --key <key> [--json]
|
|
1206
947
|
next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
|
|
1207
|
-
next-steps keys [--
|
|
948
|
+
next-steps keys [--json]
|
|
1208
949
|
next-steps reset --key <key> [--json]
|
|
1209
950
|
|
|
1210
|
-
next-steps workflow new
|
|
1211
|
-
next-steps workflow
|
|
1212
|
-
next-steps workflow
|
|
1213
|
-
next-steps workflow check [id] [--json]
|
|
951
|
+
next-steps workflow new [--json]
|
|
952
|
+
next-steps workflow show [--json]
|
|
953
|
+
next-steps workflow check [--json]
|
|
1214
954
|
|
|
1215
|
-
next-steps viz [--port <n>] [--json] (serve
|
|
955
|
+
next-steps viz [--port <n>] [--json] (serve the graph viewer on a local port)
|
|
1216
956
|
|
|
1217
957
|
next-steps help
|
|
1218
958
|
next-steps version
|
|
1219
959
|
|
|
1220
|
-
state: ./.next-steps/state.db
|
|
1221
|
-
|
|
960
|
+
state: ./.next-steps/state.db (created on first use, gitignored)
|
|
961
|
+
graph: ./.next-steps/workflow.json (one graph per project, committed)
|
|
1222
962
|
|
|
1223
963
|
exit codes:
|
|
1224
964
|
0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
|
|
@@ -1232,19 +972,25 @@ function parseOptions(args, options, allowPositionals = false) {
|
|
|
1232
972
|
function asString(value) {
|
|
1233
973
|
return typeof value === "string" ? value : undefined;
|
|
1234
974
|
}
|
|
1235
|
-
function
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
975
|
+
function requireGraphFile(graphPath) {
|
|
976
|
+
if (existsSync3(graphPath))
|
|
977
|
+
return;
|
|
978
|
+
throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
|
|
979
|
+
}
|
|
980
|
+
function requireWorkflow(graphPath) {
|
|
981
|
+
requireGraphFile(graphPath);
|
|
982
|
+
const load = loadWorkflowFile(graphPath);
|
|
983
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
984
|
+
throw new AppError2("invalid_workflow", "workflow file is invalid", 5, {
|
|
1239
985
|
errors: load.errors
|
|
1240
986
|
});
|
|
1241
987
|
}
|
|
1242
|
-
return load.
|
|
988
|
+
return load.workflow;
|
|
1243
989
|
}
|
|
1244
990
|
function execute(argv, options) {
|
|
1245
991
|
const cwd = options.cwd ?? process.cwd();
|
|
1246
|
-
const
|
|
1247
|
-
const dbPath = options.dbPath ??
|
|
992
|
+
const graphPath = options.graphPath ?? join2(cwd, ".next-steps", "workflow.json");
|
|
993
|
+
const dbPath = options.dbPath ?? join2(cwd, ".next-steps", "state.db");
|
|
1248
994
|
const [command, ...rest] = argv;
|
|
1249
995
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1250
996
|
return HELP;
|
|
@@ -1254,21 +1000,21 @@ function execute(argv, options) {
|
|
|
1254
1000
|
}
|
|
1255
1001
|
if (command === "workflow") {
|
|
1256
1002
|
const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
|
|
1257
|
-
const [subcommand
|
|
1003
|
+
const [subcommand] = positionals;
|
|
1258
1004
|
const json = Boolean(values.json);
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
return cmdWorkflowShow(load, id, json);
|
|
1267
|
-
case "check":
|
|
1268
|
-
return cmdWorkflowCheck(load, id, json);
|
|
1269
|
-
default:
|
|
1270
|
-
throw usageError2("usage: next-steps workflow <new|list|show|check> ...");
|
|
1005
|
+
if (subcommand === "new") {
|
|
1006
|
+
if (positionals.length > 1)
|
|
1007
|
+
throw usageError2(`unexpected argument "${positionals[1]}"`);
|
|
1008
|
+
return cmdWorkflowNew(graphPath, basename(cwd), json);
|
|
1009
|
+
}
|
|
1010
|
+
if (subcommand !== "show" && subcommand !== "check") {
|
|
1011
|
+
throw usageError2("usage: next-steps workflow <new|show|check> [--json]");
|
|
1271
1012
|
}
|
|
1013
|
+
if (positionals.length > 1)
|
|
1014
|
+
throw usageError2(`unexpected argument "${positionals[1]}"`);
|
|
1015
|
+
requireGraphFile(graphPath);
|
|
1016
|
+
const load = loadWorkflowFile(graphPath);
|
|
1017
|
+
return subcommand === "show" ? cmdWorkflowShow(load, json) : cmdWorkflowCheck(load, json);
|
|
1272
1018
|
}
|
|
1273
1019
|
if (command === "viz") {
|
|
1274
1020
|
const { values } = parseOptions(rest, { port: { type: "string" }, json: { type: "boolean" } });
|
|
@@ -1276,27 +1022,21 @@ function execute(argv, options) {
|
|
|
1276
1022
|
if (port < 1 || port > 65535) {
|
|
1277
1023
|
throw usageError2("--port must be between 1 and 65535");
|
|
1278
1024
|
}
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
throw new AppError2("no_workflows", `no workflows in ${workflowsDir}`, 3);
|
|
1282
|
-
}
|
|
1283
|
-
const result = startVizServer(registry2, port);
|
|
1284
|
-
return values.json ? toJson2({ url: result.url, workflows: result.workflows }) : `viewer: ${result.url}
|
|
1025
|
+
const result = startVizServer(requireWorkflow(graphPath), port);
|
|
1026
|
+
return values.json ? toJson2({ url: result.url, workflow: result.workflow }) : `viewer: ${result.url}
|
|
1285
1027
|
press Ctrl+C to stop`;
|
|
1286
1028
|
}
|
|
1287
|
-
const
|
|
1029
|
+
const workflow = requireWorkflow(graphPath);
|
|
1288
1030
|
const store = new SqliteRunStore(dbPath);
|
|
1289
1031
|
try {
|
|
1290
|
-
const service = new SessionService(
|
|
1032
|
+
const service = new SessionService(workflow, store);
|
|
1291
1033
|
switch (command) {
|
|
1292
1034
|
case "create": {
|
|
1293
1035
|
const { values } = parseOptions(rest, {
|
|
1294
|
-
workflow: { type: "string" },
|
|
1295
1036
|
key: { type: "string" },
|
|
1296
1037
|
json: { type: "boolean" }
|
|
1297
1038
|
});
|
|
1298
1039
|
return cmdCreate(service, {
|
|
1299
|
-
workflowId: asString(values.workflow),
|
|
1300
1040
|
key: asString(values.key),
|
|
1301
1041
|
json: Boolean(values.json)
|
|
1302
1042
|
});
|
|
@@ -1338,14 +1078,8 @@ press Ctrl+C to stop`;
|
|
|
1338
1078
|
});
|
|
1339
1079
|
}
|
|
1340
1080
|
case "keys": {
|
|
1341
|
-
const { values } = parseOptions(rest, {
|
|
1342
|
-
|
|
1343
|
-
json: { type: "boolean" }
|
|
1344
|
-
});
|
|
1345
|
-
return cmdKeys(service, {
|
|
1346
|
-
workflowId: asString(values.workflow),
|
|
1347
|
-
json: Boolean(values.json)
|
|
1348
|
-
});
|
|
1081
|
+
const { values } = parseOptions(rest, { json: { type: "boolean" } });
|
|
1082
|
+
return cmdKeys(service, { json: Boolean(values.json) });
|
|
1349
1083
|
}
|
|
1350
1084
|
case "reset": {
|
|
1351
1085
|
const { values } = parseOptions(rest, {
|