lazyclaw 3.88.0
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/LICENSE +21 -0
- package/README.md +186 -0
- package/cli.mjs +2648 -0
- package/config-validate.mjs +61 -0
- package/daemon.mjs +1451 -0
- package/logger.mjs +55 -0
- package/package.json +55 -0
- package/providers/anthropic.mjs +313 -0
- package/providers/cache.mjs +132 -0
- package/providers/fallback.mjs +90 -0
- package/providers/gemini.mjs +187 -0
- package/providers/ollama.mjs +148 -0
- package/providers/openai.mjs +243 -0
- package/providers/rates.mjs +85 -0
- package/providers/registry.mjs +144 -0
- package/providers/retry.mjs +103 -0
- package/ratelimit.mjs +65 -0
- package/rates-validate.mjs +58 -0
- package/sessions.mjs +177 -0
- package/skills.mjs +97 -0
- package/web/server.mjs +33 -0
- package/workflow/executor.mjs +358 -0
- package/workflow/persistent.mjs +369 -0
- package/workflow/summary.mjs +318 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// LazyClaw sequential workflow executor (phase 1).
|
|
2
|
+
// Plain ESM so it runs under bare node (CLI) and under @playwright/test (TS-aware).
|
|
3
|
+
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} WorkflowNode
|
|
8
|
+
* @property {string} id
|
|
9
|
+
* @property {string} type
|
|
10
|
+
* @property {(input: unknown) => Promise<unknown>} execute
|
|
11
|
+
* @property {(() => (Promise<void>|void))} [cleanup]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} NodeRunRecord
|
|
16
|
+
* @property {string} id
|
|
17
|
+
* @property {number} duration
|
|
18
|
+
* @property {unknown} output
|
|
19
|
+
* @property {'success'|'failed'} status
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} RunResult
|
|
24
|
+
* @property {boolean} success
|
|
25
|
+
* @property {NodeRunRecord[]} results
|
|
26
|
+
* @property {Record<string, unknown>} session
|
|
27
|
+
* @property {Error} [error]
|
|
28
|
+
* @property {string} [failedAt]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Race a promise against a timeout. Returns whatever the inner fn
|
|
33
|
+
* resolves with; rejects with `Error('TIMEOUT'){code:'TIMEOUT'}` after
|
|
34
|
+
* `ms` if the inner fn hasn't settled. Pass ms=0 / null / undefined
|
|
35
|
+
* to skip the timer entirely.
|
|
36
|
+
*
|
|
37
|
+
* Exported so callers (workflow nodes, daemon endpoints, ad-hoc
|
|
38
|
+
* scripts) can apply the same timeout shape without reinventing the
|
|
39
|
+
* race + cleanup pattern.
|
|
40
|
+
*/
|
|
41
|
+
export function runWithTimeout(fn, ms) {
|
|
42
|
+
if (!ms || ms <= 0) return fn();
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const t = setTimeout(() => {
|
|
45
|
+
const e = new Error('TIMEOUT');
|
|
46
|
+
e.code = 'TIMEOUT';
|
|
47
|
+
reject(e);
|
|
48
|
+
}, ms);
|
|
49
|
+
fn().then(
|
|
50
|
+
v => { clearTimeout(t); resolve(v); },
|
|
51
|
+
e => { clearTimeout(t); reject(e); },
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Per-node retry helper. Honored by both runSequential and runParallel:
|
|
58
|
+
* when `node.retry = { max: N, baseDelayMs: M }`, a throwing execute()
|
|
59
|
+
* is retried up to N times with exponential backoff (baseDelayMs × 2^attempt).
|
|
60
|
+
* Default baseDelayMs is 100. attempt counter is exposed via `attempts`
|
|
61
|
+
* so a node can branch on it if needed.
|
|
62
|
+
*
|
|
63
|
+
* Returns the eventual successful output, or rethrows the LAST error
|
|
64
|
+
* after exhausting retries — preserving the original error type so the
|
|
65
|
+
* outer engine's failure path is unchanged.
|
|
66
|
+
*
|
|
67
|
+
* Exported for unit testing without spinning up a full workflow.
|
|
68
|
+
*
|
|
69
|
+
* @param {() => Promise<unknown>} fn
|
|
70
|
+
* @param {{ max: number, baseDelayMs?: number, sleep?: (ms: number) => Promise<void> }} opts
|
|
71
|
+
*/
|
|
72
|
+
export async function retryWithBackoff(fn, opts) {
|
|
73
|
+
const max = Math.max(0, Number(opts.max) || 0);
|
|
74
|
+
const baseDelay = Number(opts.baseDelayMs) || 100;
|
|
75
|
+
const sleep = opts.sleep || (ms => new Promise(r => setTimeout(r, ms)));
|
|
76
|
+
let lastErr = null;
|
|
77
|
+
for (let attempt = 0; attempt <= max; attempt++) {
|
|
78
|
+
try { return await fn(); }
|
|
79
|
+
catch (err) {
|
|
80
|
+
lastErr = err;
|
|
81
|
+
if (attempt >= max) break;
|
|
82
|
+
await sleep(baseDelay * Math.pow(2, attempt));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw lastErr;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run a flat list of nodes sequentially, threading the output of each into
|
|
90
|
+
* the next. On any throw, run cleanup hooks on every started node and clear
|
|
91
|
+
* the in-memory session record.
|
|
92
|
+
*
|
|
93
|
+
* `opts.signal` (AbortSignal) is honored before each node starts AND
|
|
94
|
+
* passed to `node.execute(input, { signal })` so long-running nodes
|
|
95
|
+
* can react to outer cancellation. An aborted workflow runs cleanup
|
|
96
|
+
* on every started node and returns failure with code 'ABORT'.
|
|
97
|
+
*
|
|
98
|
+
* @param {WorkflowNode[]} nodes
|
|
99
|
+
* @param {unknown} [initialInput]
|
|
100
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
101
|
+
* @returns {Promise<RunResult>}
|
|
102
|
+
*/
|
|
103
|
+
export async function runSequential(nodes, initialInput = null, opts = {}) {
|
|
104
|
+
/** @type {Record<string, unknown>} */
|
|
105
|
+
const session = {};
|
|
106
|
+
/** @type {WorkflowNode[]} */
|
|
107
|
+
const started = [];
|
|
108
|
+
/** @type {NodeRunRecord[]} */
|
|
109
|
+
const results = [];
|
|
110
|
+
let input = initialInput;
|
|
111
|
+
const signal = opts.signal;
|
|
112
|
+
|
|
113
|
+
for (const node of nodes) {
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
// Cancellation between nodes: cleanup what we started, fail fast.
|
|
116
|
+
await Promise.allSettled(started.map(n => {
|
|
117
|
+
if (typeof n.cleanup !== 'function') return null;
|
|
118
|
+
try { return Promise.resolve(n.cleanup()); }
|
|
119
|
+
catch { return Promise.resolve(); }
|
|
120
|
+
}));
|
|
121
|
+
for (const k of Object.keys(session)) delete session[k];
|
|
122
|
+
const e = new Error('aborted');
|
|
123
|
+
/** @type {any} */ (e).code = 'ABORT';
|
|
124
|
+
return { success: false, results, error: e, failedAt: node.id, session };
|
|
125
|
+
}
|
|
126
|
+
started.push(node);
|
|
127
|
+
const t0 = performance.now();
|
|
128
|
+
try {
|
|
129
|
+
// Build the actual call: optional timeout wraps execute(); retry
|
|
130
|
+
// wraps the timed call. So a flaky 5-second op with `retry:{max:3}`
|
|
131
|
+
// and `timeoutMs:5000` gets up-to-3 attempts of up-to-5s each.
|
|
132
|
+
// The signal is forwarded to execute() so the node can react.
|
|
133
|
+
const call = () => runWithTimeout(() => node.execute(input, { signal }), node.timeoutMs);
|
|
134
|
+
const output = node.retry && Number.isFinite(node.retry.max) && node.retry.max > 0
|
|
135
|
+
? await retryWithBackoff(call, node.retry)
|
|
136
|
+
: await call();
|
|
137
|
+
const duration = performance.now() - t0;
|
|
138
|
+
results.push({ id: node.id, duration, output, status: 'success' });
|
|
139
|
+
session[node.id] = output;
|
|
140
|
+
input = output;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const duration = performance.now() - t0;
|
|
143
|
+
results.push({ id: node.id, duration, output: undefined, status: 'failed' });
|
|
144
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
145
|
+
// Cleanup runs in parallel via Promise.allSettled — for async cleanups
|
|
146
|
+
// (close a socket, flush a buffer) total time is max(t_cleanup) instead
|
|
147
|
+
// of sum(t_cleanup). Sync cleanups push their side-effects in array
|
|
148
|
+
// order (the iterator runs synchronously in the .map call) so the
|
|
149
|
+
// existing order-asserting tests keep passing without weakening.
|
|
150
|
+
// Errors are swallowed individually so a flaky cleanup can't mask the
|
|
151
|
+
// original failure that triggered cleanup in the first place.
|
|
152
|
+
await Promise.allSettled(started.map(n => {
|
|
153
|
+
if (typeof n.cleanup !== 'function') return null;
|
|
154
|
+
try { return Promise.resolve(n.cleanup()); }
|
|
155
|
+
catch (e) { return Promise.resolve(); }
|
|
156
|
+
}));
|
|
157
|
+
for (const k of Object.keys(session)) delete session[k];
|
|
158
|
+
return { success: false, results, error, failedAt: node.id, session };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { success: true, results, session };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compute topological levels (Kahn's algorithm). Returns an array of
|
|
166
|
+
* level groups; nodes within a group have no dependencies on each
|
|
167
|
+
* other and can run concurrently. Cycles produce an empty trailing
|
|
168
|
+
* remainder, which the caller turns into an error.
|
|
169
|
+
*
|
|
170
|
+
* @param {Array<{id: string, deps?: string[]}>} nodes
|
|
171
|
+
* @returns {{ levels: string[][], leftover: string[] }}
|
|
172
|
+
*/
|
|
173
|
+
export function topologicalLevels(nodes) {
|
|
174
|
+
const idToNode = new Map(nodes.map(n => [n.id, n]));
|
|
175
|
+
const indegree = new Map();
|
|
176
|
+
const reverse = new Map(); // id → [dependents]
|
|
177
|
+
for (const n of nodes) {
|
|
178
|
+
indegree.set(n.id, 0);
|
|
179
|
+
reverse.set(n.id, []);
|
|
180
|
+
}
|
|
181
|
+
for (const n of nodes) {
|
|
182
|
+
for (const d of n.deps || []) {
|
|
183
|
+
// Unknown dep — treated as a satisfied edge (don't lock the node out).
|
|
184
|
+
// Caller can validate up front if they want strict mode.
|
|
185
|
+
if (!idToNode.has(d)) continue;
|
|
186
|
+
indegree.set(n.id, (indegree.get(n.id) || 0) + 1);
|
|
187
|
+
reverse.get(d).push(n.id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const levels = [];
|
|
191
|
+
let frontier = nodes.filter(n => (indegree.get(n.id) || 0) === 0).map(n => n.id);
|
|
192
|
+
const visited = new Set();
|
|
193
|
+
while (frontier.length) {
|
|
194
|
+
levels.push(frontier);
|
|
195
|
+
for (const id of frontier) visited.add(id);
|
|
196
|
+
const next = [];
|
|
197
|
+
for (const id of frontier) {
|
|
198
|
+
for (const dep of reverse.get(id) || []) {
|
|
199
|
+
const left = (indegree.get(dep) || 0) - 1;
|
|
200
|
+
indegree.set(dep, left);
|
|
201
|
+
if (left === 0) next.push(dep);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
frontier = next;
|
|
205
|
+
}
|
|
206
|
+
const leftover = nodes.map(n => n.id).filter(id => !visited.has(id));
|
|
207
|
+
return { levels, leftover };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Run a DAG of nodes by topological level — within a level, every node
|
|
212
|
+
* runs concurrently via `Promise.all`. Each node receives a map of its
|
|
213
|
+
* declared deps' outputs as input (`{ depId: depOutput }`), so a fan-in
|
|
214
|
+
* node can see all its inputs at once.
|
|
215
|
+
*
|
|
216
|
+
* On any failure: stop scheduling further levels, run cleanup on every
|
|
217
|
+
* node that started (in any level), clear the session, return failure.
|
|
218
|
+
* Cleanups run via `Promise.allSettled` so a flaky cleanup can't mask
|
|
219
|
+
* the original error.
|
|
220
|
+
*
|
|
221
|
+
* `opts.signal` is checked between levels and forwarded to each
|
|
222
|
+
* `node.execute(input, { signal })`. An aborted signal between levels
|
|
223
|
+
* stops further scheduling, runs cleanup, and returns
|
|
224
|
+
* `{ success:false, error: ABORT }`.
|
|
225
|
+
*
|
|
226
|
+
* @param {Array<{
|
|
227
|
+
* id: string,
|
|
228
|
+
* type: string,
|
|
229
|
+
* deps?: string[],
|
|
230
|
+
* execute: (input: Record<string, unknown> | unknown, opts?: { signal?: AbortSignal }) => Promise<unknown>,
|
|
231
|
+
* cleanup?: (() => (Promise<void>|void)),
|
|
232
|
+
* retry?: { max: number, baseDelayMs?: number },
|
|
233
|
+
* timeoutMs?: number,
|
|
234
|
+
* }>} nodes
|
|
235
|
+
/**
|
|
236
|
+
* Bounded concurrency helper. Schedules an async mapper across an
|
|
237
|
+
* iterable, running at most `limit` tasks concurrently, returning
|
|
238
|
+
* a Promise.allSettled-shaped array preserving input order.
|
|
239
|
+
*
|
|
240
|
+
* Implementation: maintain `inflight` = active count + a sliding
|
|
241
|
+
* window of pending promises; each finished slot pulls the next
|
|
242
|
+
* input. Order preservation is via index-keyed slots.
|
|
243
|
+
*
|
|
244
|
+
* Exported so callers (workflow nodes that fan out internally,
|
|
245
|
+
* test helpers, ad-hoc scripts) can use the same primitive.
|
|
246
|
+
*
|
|
247
|
+
* @template T, R
|
|
248
|
+
* @param {Iterable<T>} items
|
|
249
|
+
* @param {(item: T, index: number) => Promise<R>} mapper
|
|
250
|
+
* @param {number} limit // 0 / non-finite → unbounded (Promise.all-style)
|
|
251
|
+
* @returns {Promise<Array<{ status: 'fulfilled', value: R } | { status: 'rejected', reason: unknown }>>}
|
|
252
|
+
*/
|
|
253
|
+
export async function settleWithConcurrency(items, mapper, limit) {
|
|
254
|
+
const arr = Array.from(items);
|
|
255
|
+
const out = new Array(arr.length);
|
|
256
|
+
if (!Number.isFinite(limit) || limit <= 0 || limit >= arr.length) {
|
|
257
|
+
// Fast path: no cap (or cap >= work). Identical to Promise.allSettled.
|
|
258
|
+
return Promise.allSettled(arr.map((it, i) => mapper(it, i)));
|
|
259
|
+
}
|
|
260
|
+
let cursor = 0;
|
|
261
|
+
// Worker drains the cursor until items run out. We launch `limit`
|
|
262
|
+
// workers; each handles one slot at a time.
|
|
263
|
+
const worker = async () => {
|
|
264
|
+
while (true) {
|
|
265
|
+
const i = cursor++;
|
|
266
|
+
if (i >= arr.length) return;
|
|
267
|
+
try {
|
|
268
|
+
const value = await mapper(arr[i], i);
|
|
269
|
+
out[i] = { status: 'fulfilled', value };
|
|
270
|
+
} catch (reason) {
|
|
271
|
+
out[i] = { status: 'rejected', reason };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const workers = Array.from({ length: limit }, () => worker());
|
|
276
|
+
await Promise.all(workers);
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {{ initialInput?: unknown, signal?: AbortSignal, concurrency?: number }} [opts]
|
|
282
|
+
* @returns {Promise<RunResult>}
|
|
283
|
+
*/
|
|
284
|
+
export async function runParallel(nodes, opts = {}) {
|
|
285
|
+
const session = {};
|
|
286
|
+
const started = [];
|
|
287
|
+
const results = [];
|
|
288
|
+
const idToNode = new Map(nodes.map(n => [n.id, n]));
|
|
289
|
+
const { levels, leftover } = topologicalLevels(nodes);
|
|
290
|
+
if (leftover.length > 0) {
|
|
291
|
+
const error = new Error(`workflow has a cycle or unreachable nodes: ${leftover.join(', ')}`);
|
|
292
|
+
return { success: false, results, error, failedAt: leftover[0], session };
|
|
293
|
+
}
|
|
294
|
+
const signal = opts.signal;
|
|
295
|
+
for (const level of levels) {
|
|
296
|
+
// Cancellation check between levels: don't start a new level if
|
|
297
|
+
// the caller has aborted. Cleanup runs over every node that
|
|
298
|
+
// actually started (which excludes future levels by definition).
|
|
299
|
+
if (signal?.aborted) {
|
|
300
|
+
await Promise.allSettled(started.map(n => {
|
|
301
|
+
if (typeof n.cleanup !== 'function') return null;
|
|
302
|
+
try { return Promise.resolve(n.cleanup()); }
|
|
303
|
+
catch { return Promise.resolve(); }
|
|
304
|
+
}));
|
|
305
|
+
for (const k of Object.keys(session)) delete session[k];
|
|
306
|
+
const e = new Error('aborted');
|
|
307
|
+
/** @type {any} */ (e).code = 'ABORT';
|
|
308
|
+
return { success: false, results, error: e, failedAt: level[0], session };
|
|
309
|
+
}
|
|
310
|
+
// Build the input record for each node in the level: each node sees
|
|
311
|
+
// a `{ depId: depOutput }` map, or `initialInput` when it has no deps.
|
|
312
|
+
// opts.concurrency caps how many nodes within a single level run at
|
|
313
|
+
// the same time. 0/missing/non-finite → unbounded (default behavior,
|
|
314
|
+
// every level node runs in parallel via Promise.allSettled).
|
|
315
|
+
const settled = await settleWithConcurrency(level, async (id) => {
|
|
316
|
+
const node = idToNode.get(id);
|
|
317
|
+
started.push(node);
|
|
318
|
+
const deps = node.deps || [];
|
|
319
|
+
const input = deps.length === 0
|
|
320
|
+
? (opts.initialInput ?? null)
|
|
321
|
+
: Object.fromEntries(deps.map(d => [d, session[d]]));
|
|
322
|
+
const t0 = performance.now();
|
|
323
|
+
try {
|
|
324
|
+
const call = () => runWithTimeout(() => node.execute(input, { signal }), node.timeoutMs);
|
|
325
|
+
const output = node.retry && Number.isFinite(node.retry.max) && node.retry.max > 0
|
|
326
|
+
? await retryWithBackoff(call, node.retry)
|
|
327
|
+
: await call();
|
|
328
|
+
const duration = performance.now() - t0;
|
|
329
|
+
return { ok: true, record: { id, duration, output, status: /** @type {'success'} */('success') } };
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const duration = performance.now() - t0;
|
|
332
|
+
return { ok: false, record: { id, duration, output: undefined, status: /** @type {'failed'} */('failed') }, err };
|
|
333
|
+
}
|
|
334
|
+
}, opts.concurrency);
|
|
335
|
+
let firstFailure = null;
|
|
336
|
+
for (const s of settled) {
|
|
337
|
+
// Promise.allSettled never rejects; the inner async function caught.
|
|
338
|
+
const v = s.status === 'fulfilled' ? s.value : { ok: false, record: { id: 'unknown', duration: 0, output: undefined, status: 'failed' }, err: s.reason };
|
|
339
|
+
results.push(v.record);
|
|
340
|
+
if (v.ok) {
|
|
341
|
+
session[v.record.id] = v.record.output;
|
|
342
|
+
} else if (!firstFailure) {
|
|
343
|
+
firstFailure = v;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (firstFailure) {
|
|
347
|
+
const error = firstFailure.err instanceof Error ? firstFailure.err : new Error(String(firstFailure.err));
|
|
348
|
+
await Promise.allSettled(started.map(n => {
|
|
349
|
+
if (typeof n.cleanup !== 'function') return null;
|
|
350
|
+
try { return Promise.resolve(n.cleanup()); }
|
|
351
|
+
catch { return Promise.resolve(); }
|
|
352
|
+
}));
|
|
353
|
+
for (const k of Object.keys(session)) delete session[k];
|
|
354
|
+
return { success: false, results, error, failedAt: firstFailure.record.id, session };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { success: true, results, session };
|
|
358
|
+
}
|