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.
- package/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- 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
|
+
}
|