lazyclaw 5.4.4 → 6.0.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.
Files changed (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -0,0 +1,661 @@
1
+ // Workflow lifecycle commands (run / resume / inspect / clear / validate /
2
+ // graph), extracted from cli.mjs in Phase D3. Fully self-contained: depends
3
+ // only on node builtins and the workflow/ engine modules, no registry/config/
4
+ // TUI coupling. main() routes its six cases here through dispatch(cmd, rest).
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ async function loadEngine() {
10
+ return import('../workflow/persistent.mjs');
11
+ }
12
+
13
+ async function importWorkflow(file) {
14
+ const abs = path.resolve(file);
15
+ const url = pathToFileURL(abs).href;
16
+ const mod = await import(url);
17
+ if (!mod.nodes || !Array.isArray(mod.nodes)) {
18
+ throw new Error(`Workflow file ${file} must export 'nodes' array`);
19
+ }
20
+ return mod.nodes;
21
+ }
22
+
23
+ // Wire SIGINT/SIGTERM to an AbortController so a workflow run aborts
24
+ // at the next node/level boundary (or sooner if execute() subscribed
25
+ // to the signal). Returns { signal, dispose } — the caller MUST call
26
+ // dispose() in a finally so we don't leak listeners across REPL turns.
27
+ //
28
+ // Exit-code semantics:
29
+ // - normal success → 0
30
+ // - normal failure → 1
31
+ // - ABORT (signal-driven cancellation) → 130 (conventional Ctrl+C)
32
+ function makeRunSignal() {
33
+ const ac = new AbortController();
34
+ let received = null;
35
+ const onSig = (sig) => {
36
+ if (!received) {
37
+ received = sig;
38
+ ac.abort();
39
+ } else {
40
+ // Second signal: bail immediately without waiting for the engine.
41
+ // Same "I really mean it" semantic the daemon uses.
42
+ process.exit(130);
43
+ }
44
+ };
45
+ const onSigint = () => onSig('SIGINT');
46
+ const onSigterm = () => onSig('SIGTERM');
47
+ process.on('SIGINT', onSigint);
48
+ process.on('SIGTERM', onSigterm);
49
+ return {
50
+ signal: ac.signal,
51
+ dispose() {
52
+ process.off('SIGINT', onSigint);
53
+ process.off('SIGTERM', onSigterm);
54
+ },
55
+ wasAborted() { return ac.signal.aborted; },
56
+ };
57
+ }
58
+
59
+ function exitCodeFor(result, sig) {
60
+ if (sig.wasAborted() || result?.code === 'ABORT' || result?.error?.code === 'ABORT') return 130;
61
+ return result?.success ? 0 : 1;
62
+ }
63
+
64
+ async function cmdRun(sessionId, file, opts = {}) {
65
+ const nodes = await importWorkflow(file);
66
+ const dir = opts.dir || '.workflow-state';
67
+ const sig = makeRunSignal();
68
+ try {
69
+ if (opts['parallel-persistent']) {
70
+ // --parallel-persistent: DAG with checkpoint + resume. Same state
71
+ // file shape as the sequential path so a session id collision is
72
+ // observable, not silently corrupting.
73
+ const { runPersistentDag } = await loadEngine();
74
+ const r = await runPersistentDag(nodes, { sessionId, dir, timeoutMs: opts.timeoutMs, signal: sig.signal, concurrency: opts.concurrency });
75
+ console.log(JSON.stringify({
76
+ success: r.success,
77
+ executedNodes: r.executedNodes || [],
78
+ failedAt: r.failedAt || null,
79
+ mode: 'parallel-persistent',
80
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
81
+ error: r.error || null,
82
+ }));
83
+ process.exit(exitCodeFor(r, sig));
84
+ }
85
+ if (opts.parallel) {
86
+ // --parallel: schedule by `deps`. No state persistence — `runParallel`
87
+ // is a one-shot DAG run; resume semantics belong to runPersistent or
88
+ // runPersistentDag. failedAt + executedNodes are derived from results
89
+ // so the JSON shape stays compatible with the sequential path.
90
+ const { runParallel } = await import('../workflow/executor.mjs');
91
+ const r = await runParallel(nodes, { signal: sig.signal, concurrency: opts.concurrency });
92
+ const executedNodes = r.results.filter(x => x.status === 'success').map(x => x.id);
93
+ console.log(JSON.stringify({
94
+ success: r.success,
95
+ executedNodes,
96
+ failedAt: r.failedAt || null,
97
+ mode: 'parallel',
98
+ aborted: r.error?.code === 'ABORT' || sig.wasAborted() || undefined,
99
+ error: r.error?.message || null,
100
+ }));
101
+ process.exit(exitCodeFor(r, sig));
102
+ }
103
+ const { runPersistent } = await loadEngine();
104
+ const r = await runPersistent(nodes, { sessionId, dir, maxRetries: opts.maxRetries ?? 3, signal: sig.signal });
105
+ console.log(JSON.stringify({
106
+ success: r.success,
107
+ executedNodes: r.executedNodes,
108
+ failedAt: r.failedAt,
109
+ mode: 'sequential',
110
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
111
+ }));
112
+ process.exit(exitCodeFor(r, sig));
113
+ } finally {
114
+ sig.dispose();
115
+ }
116
+ }
117
+
118
+ // Pure transformation over a persisted state file — no execution.
119
+ // The shape mirrors the on-disk state plus a derived `summary` block
120
+ // so a script can decide "should I resume?" without parsing per-node
121
+ // statuses itself.
122
+ //
123
+ // With no sessionId, lists every state file in `dir` with a summary
124
+ // block per session — sorted by updatedAt descending so the most
125
+ // recently touched run sits at the top.
126
+ //
127
+ // Exit codes (single-session mode):
128
+ // 0 — state found and printed
129
+ // 1 — workflow completed (all nodes success, no work left to resume)
130
+ // 2 — state file not found
131
+ // 3 — workflow failed and is NOT resumable as-is (terminal failure
132
+ // with retries exhausted; user must edit the workflow or state)
133
+ //
134
+ // Exit codes (list mode):
135
+ // 0 — listing produced (even if empty — empty dir is valid state)
136
+ // 2 — `dir` does not exist
137
+ async function cmdInspect(sessionId, opts = {}) {
138
+ const dir = opts.dir || '.workflow-state';
139
+ const { loadState } = await loadEngine();
140
+ const { summarizeState, listSessions, aggregateNodeStats } = await import('../workflow/summary.mjs');
141
+
142
+ // --aggregate (list mode): per-node statistics across every
143
+ // session in the state dir — count, success/failed/pending/running
144
+ // counts, and min/max/avg/total durations. Answers "which node
145
+ // tends to be slow or fail across all my runs?" — a question
146
+ // single-session inspect can't answer.
147
+ if (!sessionId && opts.aggregate) {
148
+ let stats;
149
+ try {
150
+ stats = aggregateNodeStats(dir, { filter: opts.filter });
151
+ } catch (e) {
152
+ if (e?.code === 'ENOENT') {
153
+ console.error(`State directory ${dir} does not exist`);
154
+ process.exit(2);
155
+ }
156
+ throw e;
157
+ }
158
+ // --aggregate --node <id>: drill into one node's cross-session
159
+ // stats. Useful when you've already identified the bottleneck
160
+ // and want to track its trend across runs without scrolling
161
+ // the full table.
162
+ if (opts.node) {
163
+ const nodeStat = stats.nodeStats[opts.node];
164
+ if (!nodeStat) {
165
+ console.error(`No node "${opts.node}" found across sessions in ${dir} (known: ${Object.keys(stats.nodeStats).join(', ') || 'none'})`);
166
+ process.exit(2);
167
+ }
168
+ console.log(JSON.stringify({
169
+ dir,
170
+ filter: opts.filter || null,
171
+ sessionCount: stats.sessionCount,
172
+ nodeId: opts.node,
173
+ ...nodeStat,
174
+ }, null, 2));
175
+ process.exit(0);
176
+ }
177
+ console.log(JSON.stringify({ dir, filter: opts.filter || null, ...stats }, null, 2));
178
+ process.exit(0);
179
+ }
180
+
181
+ // List mode — no sessionId given. Walks the state directory and
182
+ // emits a summary per session. Per-node `nodes` map is omitted —
183
+ // run with a session id for full detail.
184
+ //
185
+ // --status filters the listing by lifecycle: done, resumable,
186
+ // failed, or running. Mutually exclusive — passing more than one
187
+ // is an error rather than silent overlap so a script can rely on
188
+ // the predicate it asked for.
189
+ if (!sessionId) {
190
+ let sessions;
191
+ try {
192
+ sessions = listSessions(dir);
193
+ } catch (e) {
194
+ if (e?.code === 'ENOENT') {
195
+ console.error(`State directory ${dir} does not exist`);
196
+ process.exit(2);
197
+ }
198
+ throw e;
199
+ }
200
+ const status = opts.status;
201
+ if (status) {
202
+ const valid = new Set(['done', 'resumable', 'failed', 'running']);
203
+ if (!valid.has(status)) {
204
+ console.error(`invalid --status: ${status} (expected one of: ${[...valid].join(', ')})`);
205
+ process.exit(2);
206
+ }
207
+ sessions = sessions.filter(s => {
208
+ if (status === 'done') return s.summary.done;
209
+ if (status === 'resumable') return s.summary.resumable;
210
+ if (status === 'failed') return s.summary.failed > 0;
211
+ if (status === 'running') return s.summary.running > 0;
212
+ return true;
213
+ });
214
+ }
215
+ // --filter <substr>: case-insensitive sessionId substring (same
216
+ // semantic as v3.33's sessions/skills list filter).
217
+ // --limit <N>: post-filter cap. Composes with --status (status
218
+ // first, then filter, then limit).
219
+ if (opts.filter) {
220
+ const f = String(opts.filter).toLowerCase();
221
+ sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
222
+ }
223
+ if (opts.limit !== undefined) {
224
+ const n = parseInt(opts.limit, 10);
225
+ if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
226
+ }
227
+ console.log(JSON.stringify({ dir, status: status || null, sessions }, null, 2));
228
+ process.exit(0);
229
+ }
230
+
231
+ const state = loadState(sessionId, dir);
232
+ if (!state) {
233
+ console.error(`No state for session ${sessionId} in ${dir}`);
234
+ process.exit(2);
235
+ }
236
+ // --node <id>: drill into one node's state. Useful for scripts
237
+ // checking a specific node ("did node 'classify' succeed?")
238
+ // without reading the full state body. Exit codes mirror the
239
+ // node's status:
240
+ // 0 — node exists and status is success or pending or running
241
+ // 1 — node exists and status is failed (script-friendly red)
242
+ // 2 — node doesn't exist in this session (typo or wrong workflow)
243
+ if (opts.node) {
244
+ const ns = state.nodes?.[opts.node];
245
+ if (!ns) {
246
+ console.error(`No node "${opts.node}" in session ${sessionId} (known: ${Object.keys(state.nodes || {}).join(', ')})`);
247
+ process.exit(2);
248
+ }
249
+ console.log(JSON.stringify({
250
+ sessionId: state.sessionId,
251
+ nodeId: opts.node,
252
+ ...ns,
253
+ }, null, 2));
254
+ process.exit(ns.status === 'failed' ? 1 : 0);
255
+ }
256
+ // --slowest <N>: top N nodes by durationMs. Pure state-file
257
+ // analysis — no workflow file needed (deps are irrelevant to
258
+ // "which node took the longest"). Sorted descending; ties
259
+ // broken by id ascending so the output is deterministic.
260
+ if (opts.slowest !== undefined) {
261
+ const n = parseInt(opts.slowest, 10);
262
+ if (!Number.isFinite(n) || n <= 0) {
263
+ console.error(`--slowest must be a positive integer (got ${JSON.stringify(opts.slowest)})`);
264
+ process.exit(2);
265
+ }
266
+ const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
267
+ id,
268
+ status: ns?.status || 'pending',
269
+ durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
270
+ attempts: ns?.attempts ?? 0,
271
+ }));
272
+ entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
273
+ console.log(JSON.stringify({
274
+ sessionId: state.sessionId,
275
+ top: entries.slice(0, n),
276
+ }, null, 2));
277
+ process.exit(0);
278
+ }
279
+ // --critical-path <workflow.mjs>: compute the longest weighted path
280
+ // through the DAG using each node's recorded durationMs. Useful for
281
+ // "where's the bottleneck" analysis after a slow run. Requires the
282
+ // workflow file because the state file doesn't persist deps.
283
+ if (opts.criticalPath) {
284
+ let workflowNodes;
285
+ try {
286
+ workflowNodes = await importWorkflow(opts.criticalPath);
287
+ } catch (e) {
288
+ console.error(`critical-path: ${e?.message || e}`);
289
+ process.exit(2);
290
+ }
291
+ const { criticalPath } = await import('../workflow/summary.mjs');
292
+ const result = criticalPath(workflowNodes, state.nodes || {});
293
+ console.log(JSON.stringify({
294
+ sessionId: state.sessionId,
295
+ ...result,
296
+ }, null, 2));
297
+ process.exit(0);
298
+ }
299
+ const { summary, failedNodes } = summarizeState(state);
300
+ // --summary trims the per-node `nodes` map and `order` from the
301
+ // single-session output, leaving only `summary` + `failedNodes` +
302
+ // timestamps. Useful for "I just want the headline" — the same
303
+ // shape list-mode produces per session, so a script can normalize
304
+ // output across both modes by passing --summary in single mode.
305
+ const compact = !!opts.summary;
306
+ const out = compact
307
+ ? {
308
+ sessionId: state.sessionId,
309
+ dir,
310
+ summary,
311
+ failedNodes,
312
+ startedAt: state.startedAt,
313
+ updatedAt: state.updatedAt,
314
+ }
315
+ : {
316
+ sessionId: state.sessionId,
317
+ dir,
318
+ summary,
319
+ failedNodes,
320
+ order: state.order,
321
+ nodes: state.nodes,
322
+ startedAt: state.startedAt,
323
+ updatedAt: state.updatedAt,
324
+ };
325
+ console.log(JSON.stringify(out, null, 2));
326
+ if (summary.done) process.exit(1);
327
+ if (summary.failed > 0) process.exit(3);
328
+ process.exit(0);
329
+ }
330
+
331
+ // Delete a persisted workflow state file. Idempotent — same shape
332
+ // as DELETE /workflows/<id> on the daemon. Confined to the state
333
+ // dir; a sessionId that resolves outside is rejected.
334
+ //
335
+ // Exit codes:
336
+ // 0 — file existed and was deleted (or didn't exist; either way ok)
337
+ // 1 — sessionId escapes the state dir / unsafe (refused)
338
+ // 2 — state directory does not exist (nothing to clear)
339
+ async function cmdClear(sessionId, opts = {}) {
340
+ const dir = opts.dir || '.workflow-state';
341
+ if (!fs.existsSync(dir)) {
342
+ console.error(`State directory ${dir} does not exist`);
343
+ process.exit(2);
344
+ }
345
+ const file = path.join(dir, `${sessionId}.json`);
346
+ const resolvedDir = path.resolve(dir);
347
+ const resolvedFile = path.resolve(file);
348
+ if (!resolvedFile.startsWith(resolvedDir + path.sep) && resolvedFile !== resolvedDir) {
349
+ console.error(`invalid sessionId: ${sessionId}`);
350
+ process.exit(1);
351
+ }
352
+ const existed = fs.existsSync(resolvedFile);
353
+ if (existed) fs.unlinkSync(resolvedFile);
354
+ console.log(JSON.stringify({ ok: true, sessionId, removed: existed }));
355
+ process.exit(0);
356
+ }
357
+
358
+ // Static validation of a workflow file. No execution — pure shape +
359
+ // topology check. Useful for CI:
360
+ // $ lazyclaw validate ./flow.mjs && lazyclaw run job ./flow.mjs
361
+ //
362
+ // Checks (in order; the first hard failure short-circuits the rest
363
+ // for a fast CI signal, but soft warnings collect into `warnings`):
364
+ // 1. file imports cleanly and exports `nodes` (hard)
365
+ // 2. each node has a string `id` and an `execute` function (hard)
366
+ // 3. ids are unique (hard — duplicate is a silent bug)
367
+ // 4. deps reference known ids (warn — unknown deps are treated as
368
+ // satisfied edges by topologicalLevels, so this is not fatal
369
+ // but almost always a typo)
370
+ // 5. no cycles (hard — `topologicalLevels` returns `leftover` non-empty)
371
+ //
372
+ // Output JSON includes:
373
+ // - ok: bool
374
+ // - issues: hard-failure messages
375
+ // - warnings: soft messages (still ok=true)
376
+ // - levels: topological levels (one per concurrent batch)
377
+ // - maxParallelism: max level width (informational — what the user's
378
+ // `--concurrency` flag should at most be set to)
379
+ //
380
+ // Exit codes:
381
+ // 0 — valid (warnings ok)
382
+ // 1 — hard failure
383
+ // 2 — file path / import error (couldn't read or eval the file)
384
+ async function cmdValidate(file) {
385
+ if (!file) { console.error('Usage: lazyclaw validate <workflow.mjs>'); process.exit(2); }
386
+ let nodes;
387
+ try {
388
+ nodes = await importWorkflow(file);
389
+ } catch (e) {
390
+ console.error(`validate: ${e?.message || e}`);
391
+ process.exit(2);
392
+ }
393
+ const issues = [];
394
+ const warnings = [];
395
+ // Per-node shape validation. We continue past per-node failures so
396
+ // the user sees every issue at once, not one-per-edit-cycle.
397
+ const ids = new Set();
398
+ for (let i = 0; i < nodes.length; i++) {
399
+ const n = nodes[i];
400
+ const where = `nodes[${i}]`;
401
+ if (!n || typeof n !== 'object') { issues.push(`${where}: must be an object`); continue; }
402
+ if (typeof n.id !== 'string' || n.id.length === 0) { issues.push(`${where}: missing or non-string id`); continue; }
403
+ if (typeof n.execute !== 'function') issues.push(`${where} (id=${n.id}): execute is not a function`);
404
+ if (ids.has(n.id)) issues.push(`${where}: duplicate id "${n.id}"`);
405
+ ids.add(n.id);
406
+ if (n.deps !== undefined && !Array.isArray(n.deps)) {
407
+ issues.push(`${where} (id=${n.id}): deps must be an array of strings`);
408
+ }
409
+ }
410
+ // Dep reference check (warnings — topologicalLevels tolerates them).
411
+ for (const n of nodes) {
412
+ for (const d of n?.deps || []) {
413
+ if (!ids.has(d)) warnings.push(`node "${n.id}": dep "${d}" not found in this workflow (will be treated as satisfied)`);
414
+ }
415
+ }
416
+ // Topology / cycle check — only meaningful when shape passed.
417
+ let levels = null;
418
+ let maxParallelism = 0;
419
+ if (issues.length === 0) {
420
+ const { topologicalLevels } = await import('../workflow/executor.mjs');
421
+ const { levels: lvls, leftover } = topologicalLevels(nodes);
422
+ levels = lvls;
423
+ maxParallelism = lvls.reduce((m, l) => Math.max(m, l.length), 0);
424
+ if (leftover.length > 0) {
425
+ issues.push(`workflow has a cycle or unreachable nodes: ${leftover.join(', ')}`);
426
+ }
427
+ }
428
+ const ok = issues.length === 0;
429
+ console.log(JSON.stringify({
430
+ ok, file, nodeCount: nodes.length, issues, warnings,
431
+ levels, maxParallelism,
432
+ }, null, 2));
433
+ process.exit(ok ? 0 : 1);
434
+ }
435
+
436
+ // Emit a workflow's DAG as Mermaid syntax. Useful for docs, code
437
+ // review, and quick visual debugging — Mermaid renders inline in
438
+ // GitHub markdown, GitLab, Notion, Obsidian, and most modern note
439
+ // tools, so the output is paste-ready.
440
+ //
441
+ // Direction is top-down (`graph TD`) by default; --lr flag flips it
442
+ // to left-right which is more readable for wide DAGs.
443
+ //
444
+ // Output goes to stdout as plain text (the Mermaid block contents,
445
+ // no fenced ```mermaid wrapper). The user adds the fence when
446
+ // embedding so the same output works for the editors that DON'T
447
+ // render markdown.
448
+ //
449
+ // Each node id is sanitized to a Mermaid-safe identifier (letters,
450
+ // digits, underscores) for the LHS reference, with the original id
451
+ // in brackets as the visible label. So `fetch-data` becomes
452
+ // `fetch_data[fetch-data]` in the output — Mermaid's id rules are
453
+ // stricter than ours.
454
+ async function cmdGraph(file, opts = {}) {
455
+ if (!file) { console.error('Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]'); process.exit(2); }
456
+ let nodes;
457
+ try {
458
+ nodes = await importWorkflow(file);
459
+ } catch (e) {
460
+ console.error(`graph: ${e?.message || e}`);
461
+ process.exit(2);
462
+ }
463
+ // --state <session-id> overlays current run status onto each node
464
+ // (success/running/failed/pending). Without a state, every node
465
+ // gets a neutral declaration. With state, nodes are tagged with a
466
+ // CSS class via Mermaid's classDef + class syntax — paste-able
467
+ // straight into a render, and renders that don't support classDef
468
+ // (rare) just ignore the styling and show the raw graph.
469
+ let state = null;
470
+ if (opts.state) {
471
+ const dir = opts.dir || '.workflow-state';
472
+ const { loadState } = await loadEngine();
473
+ state = loadState(opts.state, dir);
474
+ if (!state) {
475
+ console.error(`graph: no state for session ${opts.state} in ${dir}`);
476
+ process.exit(2);
477
+ }
478
+ }
479
+ const direction = opts.lr ? 'LR' : 'TD';
480
+ const lines = [`graph ${direction}`];
481
+ // Mermaid node ids must match /[a-zA-Z][a-zA-Z0-9_]*/ — anything
482
+ // else needs the bracketed-label form. We always emit the bracket
483
+ // label so the visible text is the user's actual id (no ambiguity)
484
+ // while the LHS identifier is always Mermaid-safe.
485
+ const safeId = (id) => {
486
+ const s = String(id).replace(/[^a-zA-Z0-9_]/g, '_');
487
+ return /^[a-zA-Z]/.test(s) ? s : `n_${s}`;
488
+ };
489
+ // Per-status visual cues. Unicode glyph in the label + classDef
490
+ // class for color. The glyph alone works in plain markdown
491
+ // viewers; the classDef adds color for Mermaid renders.
492
+ const statusGlyph = {
493
+ success: ' ✓',
494
+ running: ' ⏳',
495
+ failed: ' ✗',
496
+ pending: '',
497
+ };
498
+ const declared = new Set();
499
+ const classedNodes = { success: [], running: [], failed: [], pending: [] };
500
+ const declare = (id) => {
501
+ if (declared.has(id)) return;
502
+ let label = id;
503
+ let cls = null;
504
+ if (state) {
505
+ const ns = state.nodes?.[id];
506
+ const st = ns?.status || 'pending';
507
+ label = id + (statusGlyph[st] || '');
508
+ cls = st;
509
+ classedNodes[st]?.push(safeId(id));
510
+ }
511
+ lines.push(` ${safeId(id)}[${label}]`);
512
+ declared.add(id);
513
+ };
514
+ for (const n of nodes) declare(n.id);
515
+ for (const n of nodes) {
516
+ for (const d of n.deps || []) {
517
+ // Edge: dep → node. Mermaid syntax `a --> b`.
518
+ lines.push(` ${safeId(d)} --> ${safeId(n.id)}`);
519
+ }
520
+ }
521
+ if (state) {
522
+ // GitHub's Mermaid theme renders these well in both light/dark
523
+ // mode. Operators rendering in their own theme can override.
524
+ lines.push(' classDef success fill:#9f6,stroke:#363,stroke-width:1px;');
525
+ lines.push(' classDef running fill:#fc6,stroke:#963,stroke-width:1px;');
526
+ lines.push(' classDef failed fill:#f66,stroke:#933,stroke-width:1px;');
527
+ lines.push(' classDef pending fill:#ddd,stroke:#666,stroke-width:1px;');
528
+ for (const [cls, ids] of Object.entries(classedNodes)) {
529
+ if (ids.length === 0) continue;
530
+ // `class id1,id2,id3 className` — Mermaid syntax for batch class assignment.
531
+ lines.push(` class ${ids.join(',')} ${cls};`);
532
+ }
533
+ }
534
+ console.log(lines.join('\n'));
535
+ process.exit(0);
536
+ }
537
+
538
+ async function cmdResume(sessionId, file, opts = {}) {
539
+ const { runPersistent, runPersistentDag, loadState } = await loadEngine();
540
+ const dir = opts.dir || '.workflow-state';
541
+ const prior = loadState(sessionId, dir);
542
+ if (!prior) {
543
+ console.error(`No state for session ${sessionId} in ${dir}`);
544
+ process.exit(2);
545
+ }
546
+ const nodes = await importWorkflow(file);
547
+ const sig = makeRunSignal();
548
+ try {
549
+ // --parallel-persistent picks the DAG engine. Sequential by default
550
+ // — same flag the run command uses, so the resume invocation
551
+ // mirrors the original run invocation. (We can't auto-detect the
552
+ // engine from the state file alone; both engines write the same
553
+ // shape. The user knows which mode they originally ran.)
554
+ if (opts['parallel-persistent']) {
555
+ const r = await runPersistentDag(nodes, {
556
+ sessionId, dir, timeoutMs: opts.timeoutMs,
557
+ signal: sig.signal, concurrency: opts.concurrency,
558
+ });
559
+ console.log(JSON.stringify({
560
+ success: r.success,
561
+ executedNodes: r.executedNodes || [],
562
+ failedAt: r.failedAt || null,
563
+ resumed: true,
564
+ mode: 'parallel-persistent',
565
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
566
+ error: r.error || null,
567
+ }));
568
+ process.exit(exitCodeFor(r, sig));
569
+ }
570
+ const r = await runPersistent(nodes, { sessionId, dir, maxRetries: opts.maxRetries ?? 3, signal: sig.signal });
571
+ console.log(JSON.stringify({
572
+ success: r.success,
573
+ executedNodes: r.executedNodes,
574
+ failedAt: r.failedAt,
575
+ resumed: true,
576
+ mode: 'sequential',
577
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
578
+ }));
579
+ process.exit(exitCodeFor(r, sig));
580
+ } finally {
581
+ sig.dispose();
582
+ }
583
+ }
584
+
585
+ // Route main()'s run/resume/inspect/clear/validate/graph cases here. Owns the
586
+ // arg-glue (positional/flag extraction) that previously lived inline in the
587
+ // cli.mjs switch, so the entrypoint case collapses to a one-line lazy import.
588
+ export async function dispatch(cmd, rest) {
589
+ switch (cmd) {
590
+ case 'run': {
591
+ const [sessionId, file] = rest.positional;
592
+ if (!sessionId || !file) { console.error('Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]'); process.exit(2); }
593
+ // --concurrency caps in-flight nodes within a single level for
594
+ // both --parallel and --parallel-persistent. Sequential mode
595
+ // ignores it (only one node runs at a time anyway).
596
+ const concurrency = rest.flags.concurrency !== undefined
597
+ ? Math.max(0, parseInt(rest.flags.concurrency, 10) || 0)
598
+ : undefined;
599
+ await cmdRun(sessionId, file, {
600
+ dir: rest.flags.dir,
601
+ parallel: !!rest.flags.parallel,
602
+ 'parallel-persistent': !!rest.flags['parallel-persistent'],
603
+ concurrency,
604
+ });
605
+ break;
606
+ }
607
+ case 'resume': {
608
+ const [sessionId, file] = rest.positional;
609
+ if (!sessionId || !file) { console.error('Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]'); process.exit(2); }
610
+ const concurrency = rest.flags.concurrency !== undefined
611
+ ? Math.max(0, parseInt(rest.flags.concurrency, 10) || 0)
612
+ : undefined;
613
+ await cmdResume(sessionId, file, {
614
+ dir: rest.flags.dir,
615
+ 'parallel-persistent': !!rest.flags['parallel-persistent'],
616
+ concurrency,
617
+ });
618
+ break;
619
+ }
620
+ case 'inspect': {
621
+ // No-arg form lists every persisted session in the state dir.
622
+ // Pass the empty positional through; cmdInspect's list mode
623
+ // handles it.
624
+ const [sessionId] = rest.positional;
625
+ await cmdInspect(sessionId, {
626
+ dir: rest.flags.dir,
627
+ status: rest.flags.status,
628
+ summary: !!rest.flags.summary,
629
+ filter: rest.flags.filter,
630
+ limit: rest.flags.limit,
631
+ node: rest.flags.node,
632
+ criticalPath: rest.flags['critical-path'],
633
+ slowest: rest.flags.slowest,
634
+ aggregate: !!rest.flags.aggregate,
635
+ });
636
+ break;
637
+ }
638
+ case 'clear': {
639
+ const [sessionId] = rest.positional;
640
+ if (!sessionId) { console.error('Usage: lazyclaw clear <session-id> [--dir <state-dir>]'); process.exit(2); }
641
+ await cmdClear(sessionId, { dir: rest.flags.dir });
642
+ break;
643
+ }
644
+ case 'validate': {
645
+ const [file] = rest.positional;
646
+ await cmdValidate(file);
647
+ break;
648
+ }
649
+ case 'graph': {
650
+ const [file] = rest.positional;
651
+ await cmdGraph(file, {
652
+ lr: !!rest.flags.lr,
653
+ state: rest.flags.state,
654
+ dir: rest.flags.dir,
655
+ });
656
+ break;
657
+ }
658
+ default:
659
+ throw new Error(`workflow.dispatch: unknown command "${cmd}"`);
660
+ }
661
+ }