@wanbicoi/next-steps 0.1.0 → 0.1.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.
- package/README.md +124 -50
- package/dist/cli.js +444 -621
- package/docs/AGENTS.sample.md +120 -0
- package/package.json +2 -1
- package/viz/template.html +106 -165
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync as existsSync5 } from "fs";
|
|
6
|
+
import { basename, dirname as dirname5, join as join3 } from "path";
|
|
6
7
|
import { pathToFileURL } from "url";
|
|
7
8
|
import { parseArgs } from "util";
|
|
8
9
|
|
|
10
|
+
// src/commands/agents.ts
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
9
15
|
// src/types.ts
|
|
10
16
|
var END = "END";
|
|
11
17
|
|
|
@@ -22,6 +28,40 @@ class AppError extends Error {
|
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
// src/commands/shared.ts
|
|
32
|
+
function usageError(message) {
|
|
33
|
+
return new AppError("usage", message, 2);
|
|
34
|
+
}
|
|
35
|
+
function requireString(value, flag) {
|
|
36
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
37
|
+
throw usageError(`--${flag} is required`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function toJson(value) {
|
|
42
|
+
return JSON.stringify(value, null, 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/commands/agents.ts
|
|
46
|
+
var SAMPLE = join("docs", "AGENTS.sample.md");
|
|
47
|
+
function findSample() {
|
|
48
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
for (let depth = 0;depth < 6; depth++) {
|
|
50
|
+
const candidate = join(dir, SAMPLE);
|
|
51
|
+
if (existsSync(candidate))
|
|
52
|
+
return candidate;
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir)
|
|
55
|
+
break;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
throw new AppError("agents_sample_missing", "could not find docs/AGENTS.sample.md next to the package", 3);
|
|
59
|
+
}
|
|
60
|
+
function cmdAgents(json) {
|
|
61
|
+
const markdown = readFileSync(findSample(), "utf8");
|
|
62
|
+
return json ? toJson({ markdown }) : markdown.trimEnd();
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
// src/format.ts
|
|
26
66
|
function table(headers, rows) {
|
|
27
67
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
@@ -29,16 +69,13 @@ function table(headers, rows) {
|
|
|
29
69
|
return [render(headers), ...rows.map(render)].join(`
|
|
30
70
|
`);
|
|
31
71
|
}
|
|
32
|
-
function formatScope(scope) {
|
|
33
|
-
return scope.length > 0 ? scope.join(" > ") : "-";
|
|
34
|
-
}
|
|
35
72
|
function formatTime(iso) {
|
|
36
73
|
return iso.slice(0, 19).replace("T", " ");
|
|
37
74
|
}
|
|
38
75
|
function optionLine(option, index) {
|
|
39
|
-
const label = option.edge ? `${option.edge} -> ${option.node}` : option.node;
|
|
40
76
|
const title = option.node === END ? "" : option.title;
|
|
41
|
-
const
|
|
77
|
+
const review = option.reviewRequired ? " [review]" : "";
|
|
78
|
+
const head = ` ${index + 1}. ${option.node}${title ? ` ${title}` : ""}${review}`.trimEnd();
|
|
42
79
|
const lines = [head, ` ${option.description}`];
|
|
43
80
|
if (option.condition)
|
|
44
81
|
lines.push(` when: ${option.condition}`);
|
|
@@ -57,34 +94,17 @@ function formatNext(next) {
|
|
|
57
94
|
`);
|
|
58
95
|
}
|
|
59
96
|
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
|
-
}
|
|
97
|
+
if (next.done)
|
|
98
|
+
return `current: ${next.current ?? END} (finished)`;
|
|
99
|
+
if (next.current === null)
|
|
100
|
+
return "current: (not started)";
|
|
66
101
|
const title = next.currentTitle ? ` (${next.currentTitle})` : "";
|
|
67
|
-
return `current: ${next.current}${title}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// src/commands/shared.ts
|
|
71
|
-
function usageError(message) {
|
|
72
|
-
return new AppError("usage", message, 2);
|
|
73
|
-
}
|
|
74
|
-
function requireString(value, flag) {
|
|
75
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
76
|
-
throw usageError(`--${flag} is required`);
|
|
77
|
-
}
|
|
78
|
-
return value;
|
|
79
|
-
}
|
|
80
|
-
function toJson(value) {
|
|
81
|
-
return JSON.stringify(value, null, 2);
|
|
102
|
+
return `current: ${next.current}${title}`;
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
// src/commands/session.ts
|
|
85
106
|
function cmdCreate(service, input) {
|
|
86
|
-
const
|
|
87
|
-
const { session, workflowTitle, next } = service.create({ workflowId, key: input.key });
|
|
107
|
+
const { session, workflowTitle, next } = service.create({ key: input.key });
|
|
88
108
|
if (input.json) {
|
|
89
109
|
return toJson({
|
|
90
110
|
key: session.key,
|
|
@@ -92,7 +112,6 @@ function cmdCreate(service, input) {
|
|
|
92
112
|
workflowTitle,
|
|
93
113
|
status: session.status,
|
|
94
114
|
current: null,
|
|
95
|
-
scope: next.scope,
|
|
96
115
|
done: next.done,
|
|
97
116
|
next: next.next,
|
|
98
117
|
statePath: service.statePath
|
|
@@ -107,10 +126,12 @@ function cmdCreate(service, input) {
|
|
|
107
126
|
].join(`
|
|
108
127
|
`);
|
|
109
128
|
}
|
|
110
|
-
function cmdLog(service, input) {
|
|
129
|
+
function cmdLog(service, input, onLogged) {
|
|
111
130
|
const key = requireString(input.key, "key");
|
|
112
131
|
const node = requireString(input.node, "node");
|
|
113
|
-
const
|
|
132
|
+
const result = service.log(key, node);
|
|
133
|
+
const { session, step, next } = result;
|
|
134
|
+
onLogged?.(result);
|
|
114
135
|
if (input.json) {
|
|
115
136
|
return toJson({
|
|
116
137
|
step,
|
|
@@ -120,10 +141,10 @@ function cmdLog(service, input) {
|
|
|
120
141
|
statePath: service.statePath
|
|
121
142
|
});
|
|
122
143
|
}
|
|
123
|
-
const origin = step.from ? ` (from ${step.from}
|
|
144
|
+
const origin = step.from ? ` (from ${step.from})` : " (entry)";
|
|
124
145
|
return [
|
|
125
146
|
`logged: #${step.seq} ${step.node}${origin}`,
|
|
126
|
-
`
|
|
147
|
+
`status: ${session.status}`,
|
|
127
148
|
"",
|
|
128
149
|
formatNext(next)
|
|
129
150
|
].join(`
|
|
@@ -138,7 +159,6 @@ function cmdNext(service, input) {
|
|
|
138
159
|
workflowId: session.workflowId,
|
|
139
160
|
status: session.status,
|
|
140
161
|
current: next.current === null ? null : { node: next.current, title: next.currentTitle, at: session.updatedAt },
|
|
141
|
-
scope: next.scope,
|
|
142
162
|
done: next.done,
|
|
143
163
|
next: next.next,
|
|
144
164
|
statePath: service.statePath
|
|
@@ -156,31 +176,28 @@ function cmdSteps(service, input) {
|
|
|
156
176
|
workflowId: session.workflowId,
|
|
157
177
|
status: session.status,
|
|
158
178
|
current: session.current,
|
|
159
|
-
scope: session.stack.map((frame) => frame.workflowId),
|
|
160
179
|
steps,
|
|
161
180
|
statePath: service.statePath
|
|
162
181
|
});
|
|
163
182
|
}
|
|
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
|
-
`);
|
|
183
|
+
const head = `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`;
|
|
170
184
|
if (steps.length === 0)
|
|
171
|
-
return `${head}
|
|
185
|
+
return `${head}
|
|
186
|
+
|
|
187
|
+
no steps yet`;
|
|
172
188
|
const rows = steps.map((step) => [
|
|
173
189
|
String(step.seq),
|
|
174
190
|
step.node,
|
|
175
191
|
step.edge ?? "-",
|
|
176
192
|
step.from ?? "-",
|
|
177
|
-
formatScope(step.scope),
|
|
178
193
|
formatTime(step.at)
|
|
179
194
|
]);
|
|
180
|
-
return head
|
|
195
|
+
return `${head}
|
|
196
|
+
|
|
197
|
+
${table(["SEQ", "NODE", "EDGE", "FROM", "AT"], rows)}`;
|
|
181
198
|
}
|
|
182
199
|
function cmdKeys(service, input) {
|
|
183
|
-
const sessions = service.keys(
|
|
200
|
+
const sessions = service.keys();
|
|
184
201
|
if (input.json) {
|
|
185
202
|
return toJson({ statePath: service.statePath, sessions });
|
|
186
203
|
}
|
|
@@ -223,38 +240,34 @@ function toJson2(value) {
|
|
|
223
240
|
}
|
|
224
241
|
|
|
225
242
|
// src/commands/viz.ts
|
|
226
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
243
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
227
244
|
import { createServer } from "node:http";
|
|
228
245
|
import { createRequire } from "node:module";
|
|
229
|
-
import { dirname, join } from "node:path";
|
|
230
|
-
import { fileURLToPath } from "node:url";
|
|
246
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
247
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
231
248
|
var require2 = createRequire(import.meta.url);
|
|
232
249
|
function findTemplate() {
|
|
233
|
-
let dir =
|
|
250
|
+
let dir = dirname2(fileURLToPath2(import.meta.url));
|
|
234
251
|
for (let depth = 0;depth < 6; depth++) {
|
|
235
|
-
const candidate =
|
|
236
|
-
if (
|
|
252
|
+
const candidate = join2(dir, "viz", "template.html");
|
|
253
|
+
if (existsSync2(candidate))
|
|
237
254
|
return candidate;
|
|
238
|
-
const parent =
|
|
255
|
+
const parent = dirname2(dir);
|
|
239
256
|
if (parent === dir)
|
|
240
257
|
break;
|
|
241
258
|
dir = parent;
|
|
242
259
|
}
|
|
243
260
|
throw new AppError("viz_template_missing", "could not find viz/template.html next to the package", 3);
|
|
244
261
|
}
|
|
245
|
-
function renderVizHtml(template,
|
|
246
|
-
const
|
|
247
|
-
const data = JSON.stringify({ workflows, order }).replaceAll("<", "\\u003c");
|
|
262
|
+
function renderVizHtml(template, workflow) {
|
|
263
|
+
const data = JSON.stringify({ workflow }).replaceAll("<", "\\u003c");
|
|
248
264
|
return template.replace("__WORKFLOW_DATA__", data);
|
|
249
265
|
}
|
|
250
|
-
function startVizServer(
|
|
251
|
-
const template =
|
|
252
|
-
const cytoscapeJs =
|
|
253
|
-
const dagreJs =
|
|
254
|
-
const
|
|
255
|
-
for (const workflow of registry.values())
|
|
256
|
-
workflows[workflow.id] = workflow;
|
|
257
|
-
const html = renderVizHtml(template, workflows);
|
|
266
|
+
function startVizServer(workflow, port) {
|
|
267
|
+
const template = readFileSync2(findTemplate(), "utf8");
|
|
268
|
+
const cytoscapeJs = readFileSync2(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
|
|
269
|
+
const dagreJs = readFileSync2(require2.resolve("cytoscape-dagre"), "utf8");
|
|
270
|
+
const html = renderVizHtml(template, workflow);
|
|
258
271
|
const server = createServer((request, response) => {
|
|
259
272
|
const path = (request.url ?? "/").split("?")[0];
|
|
260
273
|
if (path === "/" || path === "/index.html") {
|
|
@@ -280,42 +293,147 @@ function startVizServer(registry, port) {
|
|
|
280
293
|
process.exitCode = 1;
|
|
281
294
|
});
|
|
282
295
|
server.listen(port, "127.0.0.1");
|
|
283
|
-
return { url: `http://127.0.0.1:${port}`,
|
|
296
|
+
return { url: `http://127.0.0.1:${port}`, workflow: workflow.id };
|
|
284
297
|
}
|
|
285
298
|
|
|
286
299
|
// src/commands/workflow.ts
|
|
287
|
-
import { existsSync as
|
|
288
|
-
import {
|
|
300
|
+
import { existsSync as existsSync3, mkdirSync, writeFileSync } from "node:fs";
|
|
301
|
+
import { dirname as dirname3 } from "node:path";
|
|
302
|
+
var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
303
|
+
function skeleton(id) {
|
|
304
|
+
return {
|
|
305
|
+
id,
|
|
306
|
+
title: id,
|
|
307
|
+
start: "start",
|
|
308
|
+
nodes: [
|
|
309
|
+
{
|
|
310
|
+
id: "start",
|
|
311
|
+
title: "Start",
|
|
312
|
+
description: "Describe the first step.",
|
|
313
|
+
next: [{ to: END, description: "Done." }]
|
|
314
|
+
}
|
|
315
|
+
]
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function cmdWorkflowNew(graphPath, id, json) {
|
|
319
|
+
if (existsSync3(graphPath)) {
|
|
320
|
+
throw usageError(`workflow file already exists: ${graphPath}`);
|
|
321
|
+
}
|
|
322
|
+
const workflowId = ID_PATTERN.test(id) ? id : "workflow";
|
|
323
|
+
mkdirSync(dirname3(graphPath), { recursive: true });
|
|
324
|
+
writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
|
|
325
|
+
`, "utf8");
|
|
326
|
+
if (json)
|
|
327
|
+
return toJson({ created: graphPath, workflowId });
|
|
328
|
+
return `created: ${graphPath}`;
|
|
329
|
+
}
|
|
330
|
+
function requireValid(load) {
|
|
331
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
332
|
+
throw new AppError("invalid_workflow", `workflow file is invalid: ${load.path}`, 5, {
|
|
333
|
+
errors: load.errors
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return load.workflow;
|
|
337
|
+
}
|
|
338
|
+
function cmdWorkflowShow(load, json) {
|
|
339
|
+
const wf = requireValid(load);
|
|
340
|
+
if (json)
|
|
341
|
+
return toJson({ workflow: wf, warnings: load.warnings });
|
|
342
|
+
const lines = [`${wf.id} ${wf.title}`, `start: ${wf.start}`];
|
|
343
|
+
let group;
|
|
344
|
+
for (const node of wf.nodes) {
|
|
345
|
+
if (node.group !== group) {
|
|
346
|
+
group = node.group;
|
|
347
|
+
if (group)
|
|
348
|
+
lines.push("", group);
|
|
349
|
+
}
|
|
350
|
+
const review = node.reviewRequired ? " [review]" : "";
|
|
351
|
+
lines.push(` ${node.id} ${node.title}${review}`);
|
|
352
|
+
for (const move of node.next) {
|
|
353
|
+
const when = move.condition ? ` [when: ${move.condition}]` : "";
|
|
354
|
+
lines.push(` -> ${move.to} ${move.description}${when}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return lines.join(`
|
|
358
|
+
`);
|
|
359
|
+
}
|
|
360
|
+
function cmdWorkflowCheck(load, json) {
|
|
361
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
362
|
+
throw new AppError("invalid_workflow", "workflow validation failed", 5, {
|
|
363
|
+
errors: load.errors
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const wf = load.workflow;
|
|
367
|
+
const moves = wf.nodes.reduce((count, node) => count + node.next.length, 0);
|
|
368
|
+
if (json) {
|
|
369
|
+
return toJson({
|
|
370
|
+
ok: true,
|
|
371
|
+
workflowId: wf.id,
|
|
372
|
+
title: wf.title,
|
|
373
|
+
nodes: wf.nodes.length,
|
|
374
|
+
moves,
|
|
375
|
+
warnings: load.warnings
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const lines = [`ok: ${wf.id} (${wf.nodes.length} nodes, ${moves} moves)`];
|
|
379
|
+
for (const warning of load.warnings)
|
|
380
|
+
lines.push(`warning: ${warning}`);
|
|
381
|
+
return lines.join(`
|
|
382
|
+
`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/runs/hook.ts
|
|
386
|
+
import { spawnSync } from "node:child_process";
|
|
387
|
+
import { existsSync as existsSync4, statSync } from "node:fs";
|
|
289
388
|
|
|
290
389
|
// src/workflow/validate.ts
|
|
291
390
|
import { z } from "zod";
|
|
292
|
-
var
|
|
293
|
-
|
|
294
|
-
type: z.enum(["node", "subgraph"]),
|
|
295
|
-
title: z.string().min(1),
|
|
391
|
+
var moveSchema = z.object({
|
|
392
|
+
to: z.string().min(1),
|
|
296
393
|
description: z.string(),
|
|
297
|
-
|
|
394
|
+
condition: z.string().min(1).optional()
|
|
298
395
|
}).strict();
|
|
299
|
-
var
|
|
396
|
+
var nodeSchema = z.object({
|
|
300
397
|
id: z.string().min(1),
|
|
301
|
-
|
|
302
|
-
to: z.string().min(1),
|
|
398
|
+
title: z.string().min(1),
|
|
303
399
|
description: z.string(),
|
|
304
|
-
|
|
400
|
+
group: z.string().min(1).optional(),
|
|
401
|
+
reviewRequired: z.boolean().optional(),
|
|
402
|
+
next: z.array(moveSchema).min(1)
|
|
305
403
|
}).strict();
|
|
306
404
|
var workflowSchema = z.object({
|
|
307
405
|
id: z.string().min(1),
|
|
308
406
|
title: z.string().min(1),
|
|
309
407
|
start: z.string().min(1),
|
|
310
|
-
nodes: z.array(nodeSchema).min(1)
|
|
311
|
-
edges: z.array(edgeSchema)
|
|
408
|
+
nodes: z.array(nodeSchema).min(1)
|
|
312
409
|
}).strict();
|
|
410
|
+
function reportLegacyShape(raw, report) {
|
|
411
|
+
if (typeof raw !== "object" || raw === null)
|
|
412
|
+
return;
|
|
413
|
+
const file = raw;
|
|
414
|
+
if (Array.isArray(file.edges)) {
|
|
415
|
+
report(`edges are inline now: move each edge into its node's "next" list`);
|
|
416
|
+
}
|
|
417
|
+
if (!Array.isArray(file.nodes))
|
|
418
|
+
return;
|
|
419
|
+
for (const node of file.nodes) {
|
|
420
|
+
if (typeof node !== "object" || node === null)
|
|
421
|
+
continue;
|
|
422
|
+
const candidate = node;
|
|
423
|
+
if (candidate.type === "subgraph") {
|
|
424
|
+
report(`node "${String(candidate.id)}" is a subgraph: inline its nodes and use "group" for the label`);
|
|
425
|
+
} else if ("type" in candidate) {
|
|
426
|
+
report(`node "${String(candidate.id)}" has a "type" field; nodes are flat, drop it`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
313
430
|
function validateWorkflow(raw, source) {
|
|
314
431
|
const errors = [];
|
|
315
432
|
const warnings = [];
|
|
316
433
|
const report = (message) => {
|
|
317
434
|
errors.push(`${source}: ${message}`);
|
|
318
435
|
};
|
|
436
|
+
reportLegacyShape(raw, report);
|
|
319
437
|
const parsed = workflowSchema.safeParse(raw);
|
|
320
438
|
if (!parsed.success) {
|
|
321
439
|
for (const issue of parsed.error.issues) {
|
|
@@ -328,66 +446,46 @@ function validateWorkflow(raw, source) {
|
|
|
328
446
|
const nodes = new Map;
|
|
329
447
|
for (const node of wf.nodes) {
|
|
330
448
|
if (node.id === END) {
|
|
331
|
-
report(`node id "${END}" is reserved; point
|
|
449
|
+
report(`node id "${END}" is reserved; point a move at END instead`);
|
|
332
450
|
}
|
|
333
451
|
if (nodes.has(node.id)) {
|
|
334
452
|
report(`duplicate node id "${node.id}"`);
|
|
335
453
|
}
|
|
336
454
|
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
455
|
}
|
|
347
456
|
if (wf.start === END) {
|
|
348
457
|
report(`start must be a node, not END`);
|
|
349
458
|
} else if (!nodes.has(wf.start)) {
|
|
350
459
|
report(`start "${wf.start}" is not a node`);
|
|
351
460
|
}
|
|
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
|
-
}
|
|
461
|
+
let reachesEnd = false;
|
|
371
462
|
for (const node of wf.nodes) {
|
|
372
|
-
|
|
373
|
-
|
|
463
|
+
const seen = new Set;
|
|
464
|
+
for (const move of node.next) {
|
|
465
|
+
if (move.to === END) {
|
|
466
|
+
reachesEnd = true;
|
|
467
|
+
} else if (!nodes.has(move.to)) {
|
|
468
|
+
report(`move from "${node.id}" targets unknown node "${move.to}" (use END to finish)`);
|
|
469
|
+
}
|
|
470
|
+
if (seen.has(move.to)) {
|
|
471
|
+
report(`duplicate move ${node.id} -> ${move.to}; transitions must be unambiguous`);
|
|
472
|
+
}
|
|
473
|
+
seen.add(move.to);
|
|
374
474
|
}
|
|
375
475
|
}
|
|
376
|
-
if (!
|
|
377
|
-
report(`workflow "${wf.id}" has no
|
|
476
|
+
if (!reachesEnd) {
|
|
477
|
+
report(`workflow "${wf.id}" has no move to END`);
|
|
378
478
|
}
|
|
379
479
|
if (errors.length === 0) {
|
|
380
480
|
const reachable = new Set([wf.start]);
|
|
381
481
|
const queue = [wf.start];
|
|
382
482
|
while (queue.length > 0) {
|
|
383
483
|
const id = queue.shift();
|
|
384
|
-
for (const
|
|
385
|
-
if (
|
|
484
|
+
for (const move of movesFor(wf, id)) {
|
|
485
|
+
if (move.to === END || reachable.has(move.to))
|
|
386
486
|
continue;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
reachable.add(edge.to);
|
|
390
|
-
queue.push(edge.to);
|
|
487
|
+
reachable.add(move.to);
|
|
488
|
+
queue.push(move.to);
|
|
391
489
|
}
|
|
392
490
|
}
|
|
393
491
|
for (const node of wf.nodes) {
|
|
@@ -396,19 +494,19 @@ function validateWorkflow(raw, source) {
|
|
|
396
494
|
}
|
|
397
495
|
}
|
|
398
496
|
const canFinish = new Set;
|
|
399
|
-
for (const
|
|
400
|
-
if (
|
|
401
|
-
canFinish.add(
|
|
497
|
+
for (const node of wf.nodes) {
|
|
498
|
+
if (node.next.some((move) => move.to === END))
|
|
499
|
+
canFinish.add(node.id);
|
|
402
500
|
}
|
|
403
501
|
let grew = true;
|
|
404
502
|
while (grew) {
|
|
405
503
|
grew = false;
|
|
406
|
-
for (const
|
|
407
|
-
if (
|
|
504
|
+
for (const node of wf.nodes) {
|
|
505
|
+
if (canFinish.has(node.id))
|
|
408
506
|
continue;
|
|
409
|
-
if (!
|
|
507
|
+
if (!node.next.some((move) => canFinish.has(move.to)))
|
|
410
508
|
continue;
|
|
411
|
-
canFinish.add(
|
|
509
|
+
canFinish.add(node.id);
|
|
412
510
|
grew = true;
|
|
413
511
|
}
|
|
414
512
|
}
|
|
@@ -420,305 +518,145 @@ function validateWorkflow(raw, source) {
|
|
|
420
518
|
}
|
|
421
519
|
return { workflow: wf, errors, warnings };
|
|
422
520
|
}
|
|
423
|
-
function
|
|
424
|
-
return wf.
|
|
521
|
+
function movesFor(wf, nodeId) {
|
|
522
|
+
return wf.nodes.find((node) => node.id === nodeId)?.next ?? [];
|
|
425
523
|
}
|
|
426
524
|
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
|
-
`);
|
|
525
|
+
const moves = movesFor(wf, nodeId);
|
|
526
|
+
return moves.length === 1 && moves[0]?.to === END;
|
|
595
527
|
}
|
|
596
528
|
|
|
597
529
|
// src/workflow/graph.ts
|
|
598
530
|
function getNode(wf, id) {
|
|
599
531
|
return wf.nodes.find((node) => node.id === id);
|
|
600
532
|
}
|
|
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) {
|
|
533
|
+
function computeNext(wf, session) {
|
|
615
534
|
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
|
-
};
|
|
535
|
+
return { done: true, current: session.current, currentTitle: null, next: [] };
|
|
623
536
|
}
|
|
624
|
-
|
|
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: [] };
|
|
629
|
-
}
|
|
630
|
-
const wf = lookup.get(top.workflowId);
|
|
631
|
-
if (!wf) {
|
|
632
|
-
return { done: true, current: null, currentTitle: null, scope, next: [] };
|
|
633
|
-
}
|
|
634
|
-
if (top.nodeId === null) {
|
|
537
|
+
if (session.current === null) {
|
|
635
538
|
const entry = getNode(wf, wf.start);
|
|
636
539
|
return {
|
|
637
540
|
done: false,
|
|
638
541
|
current: null,
|
|
639
542
|
currentTitle: null,
|
|
640
|
-
scope,
|
|
641
543
|
next: [
|
|
642
544
|
{
|
|
643
545
|
edge: null,
|
|
644
546
|
node: wf.start,
|
|
645
547
|
title: entry?.title ?? wf.start,
|
|
646
|
-
description: entry?.description ?? ""
|
|
548
|
+
description: entry?.description ?? "",
|
|
549
|
+
reviewRequired: entry?.reviewRequired ?? false
|
|
647
550
|
}
|
|
648
551
|
]
|
|
649
552
|
};
|
|
650
553
|
}
|
|
651
|
-
const node = getNode(wf,
|
|
652
|
-
if (onlyEnd(wf,
|
|
554
|
+
const node = getNode(wf, session.current);
|
|
555
|
+
if (onlyEnd(wf, session.current)) {
|
|
653
556
|
return {
|
|
654
557
|
done: true,
|
|
655
|
-
current:
|
|
558
|
+
current: session.current,
|
|
656
559
|
currentTitle: node?.title ?? null,
|
|
657
|
-
scope,
|
|
658
560
|
next: []
|
|
659
561
|
};
|
|
660
562
|
}
|
|
661
|
-
const
|
|
662
|
-
|
|
563
|
+
const current = session.current;
|
|
564
|
+
const next = movesFor(wf, current).map((move) => {
|
|
565
|
+
const edge = `${current}->${move.to}`;
|
|
566
|
+
if (move.to === END) {
|
|
663
567
|
return {
|
|
664
|
-
edge
|
|
568
|
+
edge,
|
|
665
569
|
node: END,
|
|
666
570
|
title: "finish",
|
|
667
|
-
description:
|
|
668
|
-
...
|
|
571
|
+
description: move.description,
|
|
572
|
+
...move.condition ? { condition: move.condition } : {},
|
|
573
|
+
reviewRequired: false
|
|
669
574
|
};
|
|
670
575
|
}
|
|
671
|
-
const target = getNode(wf,
|
|
576
|
+
const target = getNode(wf, move.to);
|
|
672
577
|
return {
|
|
673
|
-
edge
|
|
674
|
-
node:
|
|
675
|
-
title: target?.title ??
|
|
676
|
-
description:
|
|
677
|
-
...
|
|
578
|
+
edge,
|
|
579
|
+
node: move.to,
|
|
580
|
+
title: target?.title ?? move.to,
|
|
581
|
+
description: move.description,
|
|
582
|
+
...move.condition ? { condition: move.condition } : {},
|
|
583
|
+
reviewRequired: target?.reviewRequired ?? false
|
|
678
584
|
};
|
|
679
585
|
});
|
|
680
586
|
return {
|
|
681
587
|
done: false,
|
|
682
|
-
current
|
|
588
|
+
current,
|
|
683
589
|
currentTitle: node?.title ?? null,
|
|
684
|
-
scope,
|
|
685
590
|
next
|
|
686
591
|
};
|
|
687
592
|
}
|
|
688
593
|
|
|
689
|
-
// src/runs/
|
|
690
|
-
var
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
return
|
|
594
|
+
// src/runs/hook.ts
|
|
595
|
+
var TIMEOUT_MS = 5000;
|
|
596
|
+
function isExecutable(path) {
|
|
597
|
+
try {
|
|
598
|
+
return (statSync(path).mode & 73) !== 0;
|
|
599
|
+
} catch {
|
|
600
|
+
return false;
|
|
696
601
|
}
|
|
697
|
-
return outgoingEdges(wf, current).map((edge) => ({
|
|
698
|
-
edge: edge.id,
|
|
699
|
-
node: edge.to,
|
|
700
|
-
description: edge.description
|
|
701
|
-
}));
|
|
702
602
|
}
|
|
703
|
-
function
|
|
704
|
-
|
|
603
|
+
function warn(message) {
|
|
604
|
+
process.stderr.write(`hook: ${message}
|
|
605
|
+
`);
|
|
705
606
|
}
|
|
706
|
-
function
|
|
707
|
-
|
|
607
|
+
function runPostLogHook(config, result) {
|
|
608
|
+
if (!existsSync4(config.path))
|
|
609
|
+
return;
|
|
610
|
+
if (!isExecutable(config.path)) {
|
|
611
|
+
warn(`${config.path} is not executable (chmod +x)`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const { done, next } = result.next;
|
|
615
|
+
const review = next.filter((option) => option.reviewRequired);
|
|
616
|
+
const payload = {
|
|
617
|
+
event: "log",
|
|
618
|
+
key: result.session.key,
|
|
619
|
+
status: done ? "done" : review.length > 0 ? "review" : "running",
|
|
620
|
+
last: {
|
|
621
|
+
id: result.step.node,
|
|
622
|
+
title: getNode(config.workflow, result.step.node)?.title ?? result.step.node
|
|
623
|
+
},
|
|
624
|
+
next: next.map((option) => ({
|
|
625
|
+
id: option.node,
|
|
626
|
+
title: option.title,
|
|
627
|
+
reviewRequired: option.reviewRequired
|
|
628
|
+
}))
|
|
629
|
+
};
|
|
630
|
+
const run = spawnSync(config.path, [], {
|
|
631
|
+
cwd: config.cwd,
|
|
632
|
+
input: JSON.stringify(payload),
|
|
633
|
+
encoding: "utf8",
|
|
634
|
+
timeout: TIMEOUT_MS
|
|
635
|
+
});
|
|
636
|
+
if (run.error) {
|
|
637
|
+
warn(`${config.path} failed: ${run.error.message}`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (run.status !== 0) {
|
|
641
|
+
const detail = (run.stderr ?? "").trim();
|
|
642
|
+
warn(`${config.path} exited ${run.status}${detail ? `: ${detail}` : ""}`);
|
|
643
|
+
}
|
|
708
644
|
}
|
|
709
645
|
|
|
646
|
+
// src/runs/service.ts
|
|
647
|
+
var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
|
648
|
+
|
|
710
649
|
class SessionService {
|
|
711
|
-
|
|
650
|
+
workflow;
|
|
712
651
|
store;
|
|
713
|
-
constructor(
|
|
714
|
-
this.
|
|
652
|
+
constructor(workflow, store) {
|
|
653
|
+
this.workflow = workflow;
|
|
715
654
|
this.store = store;
|
|
716
655
|
}
|
|
717
656
|
get statePath() {
|
|
718
657
|
return this.store.path;
|
|
719
658
|
}
|
|
720
|
-
create(input) {
|
|
721
|
-
const workflow = requireWorkflow(this.registry, input.workflowId);
|
|
659
|
+
create(input = {}) {
|
|
722
660
|
const key = (input.key ?? "").trim() || crypto.randomUUID();
|
|
723
661
|
if (!KEY_PATTERN.test(key)) {
|
|
724
662
|
throw new AppError("key_invalid", `key "${key}" is invalid; use 1-128 characters from A-Z a-z 0-9 . _ -`, 2);
|
|
@@ -726,10 +664,9 @@ class SessionService {
|
|
|
726
664
|
const now = new Date().toISOString();
|
|
727
665
|
const session = {
|
|
728
666
|
key,
|
|
729
|
-
workflowId: workflow.id,
|
|
667
|
+
workflowId: this.workflow.id,
|
|
730
668
|
status: "running",
|
|
731
669
|
current: null,
|
|
732
|
-
stack: [{ workflowId: workflow.id, nodeId: null }],
|
|
733
670
|
version: 0,
|
|
734
671
|
createdAt: now,
|
|
735
672
|
updatedAt: now
|
|
@@ -739,13 +676,14 @@ class SessionService {
|
|
|
739
676
|
}
|
|
740
677
|
return {
|
|
741
678
|
session,
|
|
742
|
-
workflowTitle: workflow.title,
|
|
743
|
-
next: computeNext(this.
|
|
679
|
+
workflowTitle: this.workflow.title,
|
|
680
|
+
next: computeNext(this.workflow, session)
|
|
744
681
|
};
|
|
745
682
|
}
|
|
746
683
|
log(key, node) {
|
|
747
684
|
return this.store.transaction(() => {
|
|
748
685
|
const session = this.requireSession(key);
|
|
686
|
+
const wf = this.workflow;
|
|
749
687
|
if (session.status === "done") {
|
|
750
688
|
throw new AppError("invalid_transition", `session "${key}" is already done`, 4, {
|
|
751
689
|
current: END,
|
|
@@ -753,82 +691,48 @@ class SessionService {
|
|
|
753
691
|
legal: []
|
|
754
692
|
});
|
|
755
693
|
}
|
|
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
694
|
const now = new Date().toISOString();
|
|
764
|
-
const
|
|
695
|
+
const from = session.current;
|
|
765
696
|
let stepInput;
|
|
766
|
-
let current;
|
|
767
|
-
let endsRun = false;
|
|
768
697
|
if (node === END) {
|
|
769
|
-
if (
|
|
770
|
-
throw invalidTransition(
|
|
771
|
-
|
|
772
|
-
if (!edge)
|
|
773
|
-
throw invalidTransition(wf, top.nodeId, node, scope);
|
|
698
|
+
if (from === null || !movesFor(wf, from).some((move) => move.to === END)) {
|
|
699
|
+
throw this.invalidTransition(from, node);
|
|
700
|
+
}
|
|
774
701
|
stepInput = {
|
|
775
702
|
key,
|
|
776
703
|
node: END,
|
|
777
|
-
from
|
|
778
|
-
edge:
|
|
779
|
-
workflowId:
|
|
780
|
-
scope,
|
|
704
|
+
from,
|
|
705
|
+
edge: `${from}->${END}`,
|
|
706
|
+
workflowId: wf.id,
|
|
781
707
|
at: now
|
|
782
708
|
};
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
endsRun = true;
|
|
786
|
-
} else {
|
|
787
|
-
stack.pop();
|
|
788
|
-
current = stack[stack.length - 1]?.nodeId ?? null;
|
|
789
|
-
}
|
|
709
|
+
session.current = END;
|
|
710
|
+
session.status = "done";
|
|
790
711
|
} else {
|
|
791
712
|
const target = getNode(wf, node);
|
|
792
713
|
if (!target) {
|
|
793
|
-
throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: legalOptions(
|
|
714
|
+
throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: this.legalOptions(from) });
|
|
794
715
|
}
|
|
795
716
|
let edge = null;
|
|
796
|
-
if (
|
|
717
|
+
if (from === null) {
|
|
797
718
|
if (node !== wf.start)
|
|
798
|
-
throw invalidTransition(
|
|
719
|
+
throw this.invalidTransition(from, node);
|
|
799
720
|
} 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 });
|
|
721
|
+
if (!movesFor(wf, from).some((move) => move.to === node)) {
|
|
722
|
+
throw this.invalidTransition(from, node);
|
|
811
723
|
}
|
|
812
|
-
|
|
813
|
-
|
|
724
|
+
edge = `${from}->${node}`;
|
|
725
|
+
}
|
|
726
|
+
stepInput = { key, node, from, edge, workflowId: wf.id, at: now };
|
|
727
|
+
session.current = node;
|
|
728
|
+
if (onlyEnd(wf, node)) {
|
|
729
|
+
session.current = END;
|
|
730
|
+
session.status = "done";
|
|
814
731
|
}
|
|
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
732
|
}
|
|
826
|
-
session.stack = stack;
|
|
827
|
-
session.current = current;
|
|
828
|
-
session.status = endsRun ? "done" : "running";
|
|
829
733
|
session.version += 1;
|
|
830
734
|
session.updatedAt = now;
|
|
831
|
-
const next =
|
|
735
|
+
const next = computeNext(wf, session);
|
|
832
736
|
const step = this.store.appendStep(stepInput);
|
|
833
737
|
this.store.updateSession(session);
|
|
834
738
|
return { session, step, next };
|
|
@@ -836,40 +740,44 @@ class SessionService {
|
|
|
836
740
|
}
|
|
837
741
|
next(key) {
|
|
838
742
|
const session = this.requireSession(key);
|
|
839
|
-
return { session, next: computeNext(this.
|
|
743
|
+
return { session, next: computeNext(this.workflow, session) };
|
|
840
744
|
}
|
|
841
745
|
steps(key, query = {}) {
|
|
842
746
|
const session = this.requireSession(key);
|
|
843
747
|
return { session, steps: this.store.listSteps(key, query) };
|
|
844
748
|
}
|
|
845
|
-
keys(
|
|
846
|
-
|
|
847
|
-
requireWorkflow(this.registry, workflowId);
|
|
848
|
-
return this.store.listSessions(workflowId);
|
|
749
|
+
keys() {
|
|
750
|
+
return this.store.listSessions();
|
|
849
751
|
}
|
|
850
752
|
reset(key) {
|
|
851
753
|
this.requireSession(key);
|
|
852
754
|
return { key, deletedSteps: this.store.reset(key) };
|
|
853
755
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
return
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
756
|
+
legalOptions(from) {
|
|
757
|
+
if (from === null) {
|
|
758
|
+
const entry = getNode(this.workflow, this.workflow.start);
|
|
759
|
+
return [{ edge: null, node: this.workflow.start, description: entry?.description ?? "" }];
|
|
760
|
+
}
|
|
761
|
+
return movesFor(this.workflow, from).map((move) => ({
|
|
762
|
+
edge: `${from}->${move.to}`,
|
|
763
|
+
node: move.to,
|
|
764
|
+
description: move.description
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
invalidTransition(from, node) {
|
|
768
|
+
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
769
|
}
|
|
862
770
|
requireSession(key) {
|
|
863
771
|
const session = this.store.getSession(key);
|
|
864
772
|
if (!session)
|
|
865
|
-
throw
|
|
773
|
+
throw new AppError("key_not_found", `session "${key}" does not exist`, 3);
|
|
866
774
|
return session;
|
|
867
775
|
}
|
|
868
776
|
}
|
|
869
777
|
|
|
870
778
|
// src/runs/store.ts
|
|
871
|
-
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
872
|
-
import { dirname as
|
|
779
|
+
import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
|
|
780
|
+
import { dirname as dirname4 } from "node:path";
|
|
873
781
|
|
|
874
782
|
// src/runs/driver.ts
|
|
875
783
|
import { createRequire as createRequire2 } from "node:module";
|
|
@@ -948,7 +856,6 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
948
856
|
workflow_id TEXT NOT NULL,
|
|
949
857
|
status TEXT NOT NULL,
|
|
950
858
|
current TEXT,
|
|
951
|
-
stack TEXT NOT NULL,
|
|
952
859
|
version INTEGER NOT NULL DEFAULT 0,
|
|
953
860
|
created_at TEXT NOT NULL,
|
|
954
861
|
updated_at TEXT NOT NULL
|
|
@@ -961,18 +868,17 @@ CREATE TABLE IF NOT EXISTS steps (
|
|
|
961
868
|
from_node TEXT,
|
|
962
869
|
edge TEXT,
|
|
963
870
|
workflow_id TEXT NOT NULL,
|
|
964
|
-
scope TEXT NOT NULL,
|
|
965
871
|
at TEXT NOT NULL,
|
|
966
872
|
PRIMARY KEY (key, seq)
|
|
967
873
|
);
|
|
968
874
|
`;
|
|
875
|
+
var LEGACY_COLUMNS = new Set(["stack", "scope"]);
|
|
969
876
|
function toSession(row) {
|
|
970
877
|
return {
|
|
971
878
|
key: row.key,
|
|
972
879
|
workflowId: row.workflow_id,
|
|
973
880
|
status: row.status,
|
|
974
881
|
current: row.current,
|
|
975
|
-
stack: JSON.parse(row.stack),
|
|
976
882
|
version: row.version,
|
|
977
883
|
createdAt: row.created_at,
|
|
978
884
|
updatedAt: row.updated_at
|
|
@@ -986,7 +892,6 @@ function toStep(row) {
|
|
|
986
892
|
from: row.from_node,
|
|
987
893
|
edge: row.edge,
|
|
988
894
|
workflowId: row.workflow_id,
|
|
989
|
-
scope: JSON.parse(row.scope),
|
|
990
895
|
at: row.at
|
|
991
896
|
};
|
|
992
897
|
}
|
|
@@ -997,25 +902,42 @@ class SqliteRunStore {
|
|
|
997
902
|
constructor(path) {
|
|
998
903
|
this.path = path;
|
|
999
904
|
if (path !== ":memory:") {
|
|
1000
|
-
mkdirSync2(
|
|
905
|
+
mkdirSync2(dirname4(path), { recursive: true });
|
|
1001
906
|
}
|
|
1002
907
|
this.db = openDriver(path);
|
|
1003
|
-
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
1004
|
-
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1005
908
|
if (path !== ":memory:") {
|
|
909
|
+
if (this.isOutdated())
|
|
910
|
+
this.recreate(path);
|
|
1006
911
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
1007
912
|
}
|
|
913
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
914
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1008
915
|
this.db.exec(SCHEMA);
|
|
1009
916
|
}
|
|
917
|
+
isOutdated() {
|
|
918
|
+
const columns = [
|
|
919
|
+
...this.db.all("PRAGMA table_info(runs)", []),
|
|
920
|
+
...this.db.all("PRAGMA table_info(steps)", [])
|
|
921
|
+
];
|
|
922
|
+
return columns.some((column) => LEGACY_COLUMNS.has(column.name));
|
|
923
|
+
}
|
|
924
|
+
recreate(path) {
|
|
925
|
+
this.db.close();
|
|
926
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
927
|
+
try {
|
|
928
|
+
unlinkSync(`${path}${suffix}`);
|
|
929
|
+
} catch {}
|
|
930
|
+
}
|
|
931
|
+
this.db = openDriver(path);
|
|
932
|
+
}
|
|
1010
933
|
createSession(session) {
|
|
1011
934
|
return this.db.run(`INSERT OR IGNORE INTO runs
|
|
1012
|
-
(key, workflow_id, status, current,
|
|
1013
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
935
|
+
(key, workflow_id, status, current, version, created_at, updated_at)
|
|
936
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
1014
937
|
session.key,
|
|
1015
938
|
session.workflowId,
|
|
1016
939
|
session.status,
|
|
1017
940
|
session.current,
|
|
1018
|
-
JSON.stringify(session.stack),
|
|
1019
941
|
session.version,
|
|
1020
942
|
session.createdAt,
|
|
1021
943
|
session.updatedAt
|
|
@@ -1027,32 +949,16 @@ class SqliteRunStore {
|
|
|
1027
949
|
}
|
|
1028
950
|
updateSession(session) {
|
|
1029
951
|
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
|
-
]);
|
|
952
|
+
SET status = ?, current = ?, version = ?, updated_at = ?
|
|
953
|
+
WHERE key = ?`, [session.status, session.current, session.version, session.updatedAt, session.key]);
|
|
1039
954
|
}
|
|
1040
955
|
appendStep(step) {
|
|
1041
956
|
const row = this.db.get("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM steps WHERE key = ?", [
|
|
1042
957
|
step.key
|
|
1043
958
|
]);
|
|
1044
959
|
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
|
-
]);
|
|
960
|
+
this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, at)
|
|
961
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [step.key, seq, step.node, step.from, step.edge, step.workflowId, step.at]);
|
|
1056
962
|
return { ...step, seq };
|
|
1057
963
|
}
|
|
1058
964
|
listSteps(key, query = {}) {
|
|
@@ -1061,10 +967,8 @@ class SqliteRunStore {
|
|
|
1061
967
|
const rows = this.db.all("SELECT * FROM steps WHERE key = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [key, after, limit]);
|
|
1062
968
|
return rows.map(toStep);
|
|
1063
969
|
}
|
|
1064
|
-
listSessions(
|
|
1065
|
-
const rows =
|
|
1066
|
-
workflowId
|
|
1067
|
-
]) : this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
|
|
970
|
+
listSessions() {
|
|
971
|
+
const rows = this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
|
|
1068
972
|
return rows.map(toSession);
|
|
1069
973
|
}
|
|
1070
974
|
reset(key) {
|
|
@@ -1095,130 +999,51 @@ class AppError2 extends Error {
|
|
|
1095
999
|
}
|
|
1096
1000
|
|
|
1097
1001
|
// 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);
|
|
1002
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1003
|
+
function loadWorkflowFile(path) {
|
|
1004
|
+
let raw;
|
|
1005
|
+
try {
|
|
1006
|
+
raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1009
|
+
const missing = error.code === "ENOENT";
|
|
1010
|
+
return {
|
|
1011
|
+
path,
|
|
1012
|
+
errors: [
|
|
1013
|
+
missing ? `workflow file not found: ${path} (run "next-steps workflow new")` : `${path}: invalid JSON (${message})`
|
|
1014
|
+
],
|
|
1015
|
+
warnings: []
|
|
1016
|
+
};
|
|
1188
1017
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
registry,
|
|
1192
|
-
reports,
|
|
1193
|
-
errors: reports.flatMap((report) => report.errors),
|
|
1194
|
-
warnings: reports.flatMap((report) => report.warnings)
|
|
1195
|
-
};
|
|
1018
|
+
const check = validateWorkflow(raw, path);
|
|
1019
|
+
return { path, workflow: check.workflow, errors: check.errors, warnings: check.warnings };
|
|
1196
1020
|
}
|
|
1197
1021
|
|
|
1198
1022
|
// src/cli.ts
|
|
1199
|
-
var VERSION = "0.1.
|
|
1023
|
+
var VERSION = "0.1.1";
|
|
1200
1024
|
var HELP = `next-steps \u2014 record the current step, ask what comes next
|
|
1201
1025
|
|
|
1202
1026
|
usage:
|
|
1203
|
-
next-steps create
|
|
1027
|
+
next-steps create [--key <key>] [--json]
|
|
1204
1028
|
next-steps log --key <key> --node <node> [--json]
|
|
1205
1029
|
next-steps next --key <key> [--json]
|
|
1206
1030
|
next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
|
|
1207
|
-
next-steps keys [--
|
|
1031
|
+
next-steps keys [--json]
|
|
1208
1032
|
next-steps reset --key <key> [--json]
|
|
1033
|
+
next-steps agents [--json] (print the agent playbook)
|
|
1209
1034
|
|
|
1210
|
-
next-steps workflow new
|
|
1211
|
-
next-steps workflow
|
|
1212
|
-
next-steps workflow
|
|
1213
|
-
next-steps workflow check [id] [--json]
|
|
1035
|
+
next-steps workflow new [--json]
|
|
1036
|
+
next-steps workflow show [--json]
|
|
1037
|
+
next-steps workflow check [--json]
|
|
1214
1038
|
|
|
1215
|
-
next-steps viz [--port <n>] [--json] (serve
|
|
1039
|
+
next-steps viz [--port <n>] [--json] (serve the graph viewer on a local port)
|
|
1216
1040
|
|
|
1217
1041
|
next-steps help
|
|
1218
1042
|
next-steps version
|
|
1219
1043
|
|
|
1220
|
-
state: ./.next-steps/state.db
|
|
1221
|
-
|
|
1044
|
+
state: ./.next-steps/state.db (created on first use, gitignored)
|
|
1045
|
+
graph: ./.next-steps/workflow.json (one graph per project, committed)
|
|
1046
|
+
hook: ./.next-steps/hook (optional, runs after each log)
|
|
1222
1047
|
|
|
1223
1048
|
exit codes:
|
|
1224
1049
|
0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
|
|
@@ -1232,19 +1057,25 @@ function parseOptions(args, options, allowPositionals = false) {
|
|
|
1232
1057
|
function asString(value) {
|
|
1233
1058
|
return typeof value === "string" ? value : undefined;
|
|
1234
1059
|
}
|
|
1235
|
-
function
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1060
|
+
function requireGraphFile(graphPath) {
|
|
1061
|
+
if (existsSync5(graphPath))
|
|
1062
|
+
return;
|
|
1063
|
+
throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
|
|
1064
|
+
}
|
|
1065
|
+
function requireWorkflow(graphPath) {
|
|
1066
|
+
requireGraphFile(graphPath);
|
|
1067
|
+
const load = loadWorkflowFile(graphPath);
|
|
1068
|
+
if (load.errors.length > 0 || !load.workflow) {
|
|
1069
|
+
throw new AppError2("invalid_workflow", "workflow file is invalid", 5, {
|
|
1239
1070
|
errors: load.errors
|
|
1240
1071
|
});
|
|
1241
1072
|
}
|
|
1242
|
-
return load.
|
|
1073
|
+
return load.workflow;
|
|
1243
1074
|
}
|
|
1244
1075
|
function execute(argv, options) {
|
|
1245
1076
|
const cwd = options.cwd ?? process.cwd();
|
|
1246
|
-
const
|
|
1247
|
-
const dbPath = options.dbPath ??
|
|
1077
|
+
const graphPath = options.graphPath ?? join3(cwd, ".next-steps", "workflow.json");
|
|
1078
|
+
const dbPath = options.dbPath ?? join3(cwd, ".next-steps", "state.db");
|
|
1248
1079
|
const [command, ...rest] = argv;
|
|
1249
1080
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1250
1081
|
return HELP;
|
|
@@ -1252,23 +1083,27 @@ function execute(argv, options) {
|
|
|
1252
1083
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
1253
1084
|
return `next-steps ${VERSION}`;
|
|
1254
1085
|
}
|
|
1086
|
+
if (command === "agents") {
|
|
1087
|
+
const { values } = parseOptions(rest, { json: { type: "boolean" } });
|
|
1088
|
+
return cmdAgents(Boolean(values.json));
|
|
1089
|
+
}
|
|
1255
1090
|
if (command === "workflow") {
|
|
1256
1091
|
const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
|
|
1257
|
-
const [subcommand
|
|
1092
|
+
const [subcommand] = positionals;
|
|
1258
1093
|
const json = Boolean(values.json);
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
case "list":
|
|
1264
|
-
return cmdWorkflowList(load, json);
|
|
1265
|
-
case "show":
|
|
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> ...");
|
|
1094
|
+
if (subcommand === "new") {
|
|
1095
|
+
if (positionals.length > 1)
|
|
1096
|
+
throw usageError2(`unexpected argument "${positionals[1]}"`);
|
|
1097
|
+
return cmdWorkflowNew(graphPath, basename(cwd), json);
|
|
1271
1098
|
}
|
|
1099
|
+
if (subcommand !== "show" && subcommand !== "check") {
|
|
1100
|
+
throw usageError2("usage: next-steps workflow <new|show|check> [--json]");
|
|
1101
|
+
}
|
|
1102
|
+
if (positionals.length > 1)
|
|
1103
|
+
throw usageError2(`unexpected argument "${positionals[1]}"`);
|
|
1104
|
+
requireGraphFile(graphPath);
|
|
1105
|
+
const load = loadWorkflowFile(graphPath);
|
|
1106
|
+
return subcommand === "show" ? cmdWorkflowShow(load, json) : cmdWorkflowCheck(load, json);
|
|
1272
1107
|
}
|
|
1273
1108
|
if (command === "viz") {
|
|
1274
1109
|
const { values } = parseOptions(rest, { port: { type: "string" }, json: { type: "boolean" } });
|
|
@@ -1276,27 +1111,21 @@ function execute(argv, options) {
|
|
|
1276
1111
|
if (port < 1 || port > 65535) {
|
|
1277
1112
|
throw usageError2("--port must be between 1 and 65535");
|
|
1278
1113
|
}
|
|
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}
|
|
1114
|
+
const result = startVizServer(requireWorkflow(graphPath), port);
|
|
1115
|
+
return values.json ? toJson2({ url: result.url, workflow: result.workflow }) : `viewer: ${result.url}
|
|
1285
1116
|
press Ctrl+C to stop`;
|
|
1286
1117
|
}
|
|
1287
|
-
const
|
|
1118
|
+
const workflow = requireWorkflow(graphPath);
|
|
1288
1119
|
const store = new SqliteRunStore(dbPath);
|
|
1289
1120
|
try {
|
|
1290
|
-
const service = new SessionService(
|
|
1121
|
+
const service = new SessionService(workflow, store);
|
|
1291
1122
|
switch (command) {
|
|
1292
1123
|
case "create": {
|
|
1293
1124
|
const { values } = parseOptions(rest, {
|
|
1294
|
-
workflow: { type: "string" },
|
|
1295
1125
|
key: { type: "string" },
|
|
1296
1126
|
json: { type: "boolean" }
|
|
1297
1127
|
});
|
|
1298
1128
|
return cmdCreate(service, {
|
|
1299
|
-
workflowId: asString(values.workflow),
|
|
1300
1129
|
key: asString(values.key),
|
|
1301
1130
|
json: Boolean(values.json)
|
|
1302
1131
|
});
|
|
@@ -1311,7 +1140,7 @@ press Ctrl+C to stop`;
|
|
|
1311
1140
|
key: asString(values.key),
|
|
1312
1141
|
node: asString(values.node),
|
|
1313
1142
|
json: Boolean(values.json)
|
|
1314
|
-
});
|
|
1143
|
+
}, (result) => runPostLogHook({ path: join3(dirname5(graphPath), "hook"), cwd, workflow }, result));
|
|
1315
1144
|
}
|
|
1316
1145
|
case "next": {
|
|
1317
1146
|
const { values } = parseOptions(rest, {
|
|
@@ -1338,14 +1167,8 @@ press Ctrl+C to stop`;
|
|
|
1338
1167
|
});
|
|
1339
1168
|
}
|
|
1340
1169
|
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
|
-
});
|
|
1170
|
+
const { values } = parseOptions(rest, { json: { type: "boolean" } });
|
|
1171
|
+
return cmdKeys(service, { json: Boolean(values.json) });
|
|
1349
1172
|
}
|
|
1350
1173
|
case "reset": {
|
|
1351
1174
|
const { values } = parseOptions(rest, {
|