@hasna/workflows 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 +75 -18
- package/dist/agent-tools.js +1937 -1
- package/dist/cli/index.js +2762 -7
- package/dist/handlers.js +96 -1
- package/dist/index.js +2062 -9
- package/dist/mcp/index.js +1994 -16
- package/dist/serve/index.js +1992 -9
- package/dist/service.js +1911 -7
- package/hasna.contract.json +2 -2
- package/package.json +7 -5
- package/types/agent-tools.d.ts +2 -6
- package/types/daemon.d.ts +119 -0
- package/types/expr.d.ts +58 -0
- package/types/graph.d.ts +80 -0
- package/types/handlers.d.ts +1 -5
- package/types/lanes/claude.d.ts +16 -0
- package/types/lanes/codex.d.ts +12 -0
- package/types/lanes/cursor.d.ts +13 -0
- package/types/lanes/grok.d.ts +16 -0
- package/types/lanes/index.d.ts +14 -0
- package/types/lanes/types.d.ts +64 -0
- package/types/render.d.ts +12 -0
- package/types/secrets.d.ts +30 -0
- package/types/service.d.ts +18 -0
- package/types/session.d.ts +51 -0
- package/types/store.d.ts +86 -0
- package/types/wal.d.ts +85 -0
package/dist/service.js
CHANGED
|
@@ -1,11 +1,452 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
|
+
var __require = import.meta.require;
|
|
18
|
+
|
|
19
|
+
// src/lanes/types.ts
|
|
20
|
+
import { statSync } from "fs";
|
|
21
|
+
function binaryOnPath(binary) {
|
|
22
|
+
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
23
|
+
for (const entry of pathEntries) {
|
|
24
|
+
try {
|
|
25
|
+
const stat = statSync(`${entry}/${binary}`);
|
|
26
|
+
if (stat.isFile())
|
|
27
|
+
return true;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function withTimeout(promise, timeoutMs, what) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
reject(new Error(`${what} exceeded the ${timeoutMs}ms budget`));
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
promise.then((value) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve(value);
|
|
40
|
+
}, (err) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function spawnCli(cmd, opts) {
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
49
|
+
let proc;
|
|
50
|
+
try {
|
|
51
|
+
proc = Bun.spawnSync({
|
|
52
|
+
cmd,
|
|
53
|
+
cwd: opts.cwd,
|
|
54
|
+
env: { ...process.env, ...opts.env ?? {} },
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "pipe",
|
|
57
|
+
timeout: timeoutMs
|
|
58
|
+
});
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const message = String(err instanceof Error ? err.message : err);
|
|
61
|
+
if (message.includes("ENOENT") || /not found/i.test(message)) {
|
|
62
|
+
throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
|
|
65
|
+
}
|
|
66
|
+
const durationMs = Date.now() - started;
|
|
67
|
+
const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
|
|
68
|
+
const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
|
|
69
|
+
const output = stderr ? `${stdout}
|
|
70
|
+
${stderr}`.trim() : stdout.trim();
|
|
71
|
+
const exitCode = proc.exitCode ?? 1;
|
|
72
|
+
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
73
|
+
}
|
|
74
|
+
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000;
|
|
75
|
+
var init_types = __esm(() => {
|
|
76
|
+
LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
|
|
77
|
+
LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
|
|
78
|
+
constructor(message) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "LaneDependencyMissingError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
LaneAdapterShapeError = class LaneAdapterShapeError extends Error {
|
|
84
|
+
constructor(message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "LaneAdapterShapeError";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// src/lanes/claude.ts
|
|
92
|
+
function createClaudeAdapter(deps = {}) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "claude",
|
|
95
|
+
async probe() {
|
|
96
|
+
const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
|
|
97
|
+
try {
|
|
98
|
+
await loader();
|
|
99
|
+
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
|
|
100
|
+
} catch {
|
|
101
|
+
if (binaryOnPath(deps.cliPath ?? "claude")) {
|
|
102
|
+
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
kind: "claude",
|
|
106
|
+
sdk: "@anthropic-ai/claude-agent-sdk",
|
|
107
|
+
wired: false,
|
|
108
|
+
reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async run(job) {
|
|
113
|
+
if (!job.prompt) {
|
|
114
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
|
|
115
|
+
}
|
|
116
|
+
const started = Date.now();
|
|
117
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
118
|
+
const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
|
|
119
|
+
let sdk;
|
|
120
|
+
try {
|
|
121
|
+
sdk = await loader();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
|
|
124
|
+
}
|
|
125
|
+
const query = sdk.query;
|
|
126
|
+
if (typeof query !== "function") {
|
|
127
|
+
throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
|
|
128
|
+
}
|
|
129
|
+
const prompt = job.prompt;
|
|
130
|
+
try {
|
|
131
|
+
const { output, isError } = await withTimeout((async () => {
|
|
132
|
+
const generator = await query({
|
|
133
|
+
prompt,
|
|
134
|
+
options: { model: job.model, maxTurns: 32, abortSignal: undefined }
|
|
135
|
+
});
|
|
136
|
+
let output2 = "";
|
|
137
|
+
let isError2 = false;
|
|
138
|
+
for await (const message of generator) {
|
|
139
|
+
if (message.type === "assistant") {
|
|
140
|
+
const content = message.message?.content;
|
|
141
|
+
if (Array.isArray(content)) {
|
|
142
|
+
for (const block of content) {
|
|
143
|
+
if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
|
144
|
+
output2 += block.text;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (message.type === "error")
|
|
150
|
+
isError2 = true;
|
|
151
|
+
}
|
|
152
|
+
return { output: output2, isError: isError2 };
|
|
153
|
+
})(), timeoutMs, `claude lane (${job.prompt.slice(0, 60)})`);
|
|
154
|
+
const durationMs = Date.now() - started;
|
|
155
|
+
return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const durationMs = Date.now() - started;
|
|
158
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function runClaudeCli(job, cliPath, started, timeoutMs) {
|
|
164
|
+
try {
|
|
165
|
+
const result = spawnCli([cliPath, "-p", job.prompt], {
|
|
166
|
+
binaryName: cliPath,
|
|
167
|
+
packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
|
|
168
|
+
timeoutMs,
|
|
169
|
+
env: job.env,
|
|
170
|
+
cwd: job.cwd
|
|
171
|
+
});
|
|
172
|
+
return { ...result, durationMs: Date.now() - started };
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
175
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
176
|
+
}
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
var init_claude = __esm(() => {
|
|
181
|
+
init_types();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// src/lanes/codex.ts
|
|
185
|
+
function createCodexAdapter(deps = {}) {
|
|
186
|
+
return {
|
|
187
|
+
kind: "codex",
|
|
188
|
+
async probe() {
|
|
189
|
+
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
190
|
+
try {
|
|
191
|
+
await loader();
|
|
192
|
+
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
|
|
193
|
+
} catch {
|
|
194
|
+
if (binaryOnPath(deps.cliPath ?? "codex")) {
|
|
195
|
+
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
kind: "codex",
|
|
199
|
+
sdk: "@openai/codex-sdk",
|
|
200
|
+
wired: false,
|
|
201
|
+
reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
async run(job) {
|
|
206
|
+
if (!job.prompt) {
|
|
207
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
|
|
208
|
+
}
|
|
209
|
+
const started = Date.now();
|
|
210
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
211
|
+
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
212
|
+
let sdk;
|
|
213
|
+
try {
|
|
214
|
+
sdk = await loader();
|
|
215
|
+
} catch {
|
|
216
|
+
return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
|
|
217
|
+
}
|
|
218
|
+
const CodexClass = sdk.Codex;
|
|
219
|
+
if (typeof CodexClass !== "function") {
|
|
220
|
+
throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const client = await withTimeout(Promise.resolve(new CodexClass({
|
|
224
|
+
config: { model: job.model },
|
|
225
|
+
env: job.env
|
|
226
|
+
})), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
|
|
227
|
+
const thread = client.startThread?.();
|
|
228
|
+
if (!thread || typeof thread.run !== "function") {
|
|
229
|
+
throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
|
|
230
|
+
}
|
|
231
|
+
const turn = await withTimeout(thread.run(job.prompt), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
|
|
232
|
+
const output = turn?.finalResponse ?? "";
|
|
233
|
+
const durationMs = Date.now() - started;
|
|
234
|
+
return { ok: true, exitCode: 0, output, durationMs };
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const durationMs = Date.now() - started;
|
|
237
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
243
|
+
try {
|
|
244
|
+
const result = spawnCli([cliPath, "exec", "--", job.prompt], {
|
|
245
|
+
binaryName: cliPath,
|
|
246
|
+
packageHint: "@openai/codex-sdk or the codex CLI",
|
|
247
|
+
timeoutMs,
|
|
248
|
+
env: job.env,
|
|
249
|
+
cwd: job.cwd
|
|
250
|
+
});
|
|
251
|
+
return { ...result, durationMs: Date.now() - started };
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
254
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
var init_codex = __esm(() => {
|
|
260
|
+
init_types();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// src/lanes/cursor.ts
|
|
264
|
+
function createCursorAdapter(deps = {}) {
|
|
265
|
+
return {
|
|
266
|
+
kind: "cursor",
|
|
267
|
+
async probe() {
|
|
268
|
+
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
269
|
+
try {
|
|
270
|
+
await loader();
|
|
271
|
+
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
|
|
272
|
+
} catch {
|
|
273
|
+
if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
|
|
274
|
+
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
kind: "cursor",
|
|
278
|
+
sdk: "@cursor/sdk",
|
|
279
|
+
wired: false,
|
|
280
|
+
reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
async run(job) {
|
|
285
|
+
if (!job.prompt) {
|
|
286
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
|
|
287
|
+
}
|
|
288
|
+
const started = Date.now();
|
|
289
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
290
|
+
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
291
|
+
let sdk;
|
|
292
|
+
try {
|
|
293
|
+
sdk = await loader();
|
|
294
|
+
} catch {
|
|
295
|
+
return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
|
|
299
|
+
const durationMs = Date.now() - started;
|
|
300
|
+
const status = run?.status ?? "finished";
|
|
301
|
+
const ok = status === "finished";
|
|
302
|
+
return { ok, exitCode: ok ? 0 : 1, output: run?.result ?? "", durationMs };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const durationMs = Date.now() - started;
|
|
305
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function runLocalAgent(sdk, job) {
|
|
311
|
+
const AgentClass = sdk.Agent;
|
|
312
|
+
const CursorClass = sdk.Cursor;
|
|
313
|
+
if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
|
|
314
|
+
throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
|
|
315
|
+
}
|
|
316
|
+
const agent = typeof AgentClass === "function" ? new AgentClass : undefined;
|
|
317
|
+
if (!agent || typeof agent.send !== "function") {
|
|
318
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
|
|
319
|
+
}
|
|
320
|
+
const run = await agent.send(job.prompt, { model: job.model });
|
|
321
|
+
if (!run || typeof run !== "object") {
|
|
322
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
|
|
323
|
+
}
|
|
324
|
+
return run;
|
|
325
|
+
}
|
|
326
|
+
function runCursorCli(job, cliPath, started, timeoutMs) {
|
|
327
|
+
try {
|
|
328
|
+
const result = spawnCli([cliPath, "run", job.prompt], {
|
|
329
|
+
binaryName: cliPath,
|
|
330
|
+
packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
|
|
331
|
+
timeoutMs,
|
|
332
|
+
env: job.env,
|
|
333
|
+
cwd: job.cwd
|
|
334
|
+
});
|
|
335
|
+
return { ...result, durationMs: Date.now() - started };
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
338
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
339
|
+
}
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
var init_cursor = __esm(() => {
|
|
344
|
+
init_types();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// src/lanes/grok.ts
|
|
348
|
+
function createGrokAdapter(deps = {}) {
|
|
349
|
+
return {
|
|
350
|
+
kind: "grok",
|
|
351
|
+
async probe() {
|
|
352
|
+
const cliPath = deps.cliPath ?? "grok";
|
|
353
|
+
if (binaryOnPath(cliPath)) {
|
|
354
|
+
return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
kind: "grok",
|
|
358
|
+
sdk: "no npm SDK (measured 2026-08-25)",
|
|
359
|
+
wired: false,
|
|
360
|
+
reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
async run(job) {
|
|
364
|
+
if (!job.prompt) {
|
|
365
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
|
|
366
|
+
}
|
|
367
|
+
const started = Date.now();
|
|
368
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
369
|
+
const cliPath = deps.cliPath ?? "grok";
|
|
370
|
+
try {
|
|
371
|
+
const result = spawnCli([cliPath, job.prompt], {
|
|
372
|
+
binaryName: cliPath,
|
|
373
|
+
packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
|
|
374
|
+
timeoutMs,
|
|
375
|
+
env: job.env,
|
|
376
|
+
cwd: job.cwd
|
|
377
|
+
});
|
|
378
|
+
return { ...result, durationMs: Date.now() - started };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
381
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
var init_grok = __esm(() => {
|
|
389
|
+
init_types();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// src/lanes/index.ts
|
|
393
|
+
var exports_lanes = {};
|
|
394
|
+
__export(exports_lanes, {
|
|
395
|
+
runLaneJob: () => runLaneJob,
|
|
396
|
+
resolveLane: () => resolveLane,
|
|
397
|
+
probeLane: () => probeLane,
|
|
398
|
+
laneInventory: () => laneInventory,
|
|
399
|
+
createGrokAdapter: () => createGrokAdapter,
|
|
400
|
+
createCursorAdapter: () => createCursorAdapter,
|
|
401
|
+
createCodexAdapter: () => createCodexAdapter,
|
|
402
|
+
createClaudeAdapter: () => createClaudeAdapter,
|
|
403
|
+
binaryOnPath: () => binaryOnPath,
|
|
404
|
+
LaneDependencyMissingError: () => LaneDependencyMissingError,
|
|
405
|
+
LaneAdapterShapeError: () => LaneAdapterShapeError,
|
|
406
|
+
LANE_KINDS: () => LANE_KINDS2
|
|
407
|
+
});
|
|
408
|
+
function resolveLane(kind) {
|
|
409
|
+
const adapter = adapters.get(kind);
|
|
410
|
+
if (!adapter)
|
|
411
|
+
throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
|
|
412
|
+
return adapter;
|
|
413
|
+
}
|
|
414
|
+
async function runLaneJob(job) {
|
|
415
|
+
return resolveLane(job.lane).run(job);
|
|
416
|
+
}
|
|
417
|
+
async function probeLane(kind) {
|
|
418
|
+
return resolveLane(kind).probe();
|
|
419
|
+
}
|
|
420
|
+
async function laneInventory() {
|
|
421
|
+
return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
|
|
422
|
+
}
|
|
423
|
+
var adapters;
|
|
424
|
+
var init_lanes = __esm(() => {
|
|
425
|
+
init_claude();
|
|
426
|
+
init_codex();
|
|
427
|
+
init_cursor();
|
|
428
|
+
init_grok();
|
|
429
|
+
init_types();
|
|
430
|
+
init_types();
|
|
431
|
+
init_claude();
|
|
432
|
+
init_codex();
|
|
433
|
+
init_cursor();
|
|
434
|
+
init_grok();
|
|
435
|
+
adapters = new Map([
|
|
436
|
+
["claude", createClaudeAdapter()],
|
|
437
|
+
["codex", createCodexAdapter()],
|
|
438
|
+
["cursor", createCursorAdapter()],
|
|
439
|
+
["grok", createGrokAdapter()]
|
|
440
|
+
]);
|
|
441
|
+
});
|
|
442
|
+
|
|
2
443
|
// src/service.ts
|
|
3
|
-
import { join } from "path";
|
|
444
|
+
import { join as join3 } from "path";
|
|
4
445
|
// package.json
|
|
5
446
|
var package_default = {
|
|
6
447
|
name: "@hasna/workflows",
|
|
7
|
-
version: "0.1.
|
|
8
|
-
description: "Universal graph workflow app \u2014
|
|
448
|
+
version: "0.1.2",
|
|
449
|
+
description: "Universal graph workflow app \u2014 graph language (start/step/decision/while/end), three-table store, session WAL, daemon with claims/leases, four lane adapters (claude/codex/cursor/grok), and a full CLI on four surfaces",
|
|
9
450
|
type: "module",
|
|
10
451
|
main: "./dist/index.js",
|
|
11
452
|
types: "./types/index.d.ts",
|
|
@@ -69,16 +510,18 @@ var package_default = {
|
|
|
69
510
|
url: "https://github.com/hasna/apps/issues"
|
|
70
511
|
},
|
|
71
512
|
engines: {
|
|
72
|
-
bun: ">=1.0.0"
|
|
73
|
-
node: ">=20.0.0"
|
|
513
|
+
bun: ">=1.0.0"
|
|
74
514
|
},
|
|
75
515
|
author: "Hasna",
|
|
76
516
|
license: "Apache-2.0",
|
|
77
517
|
dependencies: {
|
|
518
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.234",
|
|
519
|
+
"@cursor/sdk": "^1.0.28",
|
|
520
|
+
"@openai/codex-sdk": "^0.147.0",
|
|
78
521
|
commander: "^13.1.0"
|
|
79
522
|
},
|
|
80
523
|
devDependencies: {
|
|
81
|
-
"@hasna/contracts": "0.14.
|
|
524
|
+
"@hasna/contracts": "0.14.1",
|
|
82
525
|
"@types/bun": "latest",
|
|
83
526
|
"@types/pg": "^8.15.6",
|
|
84
527
|
pg: "^8.16.3",
|
|
@@ -86,6 +529,1428 @@ var package_default = {
|
|
|
86
529
|
}
|
|
87
530
|
};
|
|
88
531
|
|
|
532
|
+
// src/daemon.ts
|
|
533
|
+
import { createHash as createHash2 } from "crypto";
|
|
534
|
+
|
|
535
|
+
// src/expr.ts
|
|
536
|
+
class ExprSyntaxError extends Error {
|
|
537
|
+
constructor(message) {
|
|
538
|
+
super(message);
|
|
539
|
+
this.name = "ExprSyntaxError";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
class ExprEvalError extends Error {
|
|
544
|
+
constructor(message) {
|
|
545
|
+
super(message);
|
|
546
|
+
this.name = "ExprEvalError";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
var COMPARISON_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
550
|
+
function tokenize(input) {
|
|
551
|
+
const tokens = [];
|
|
552
|
+
let i = 0;
|
|
553
|
+
while (i < input.length) {
|
|
554
|
+
const ch = input[i];
|
|
555
|
+
if (ch === " " || ch === "\t" || ch === `
|
|
556
|
+
` || ch === "\r") {
|
|
557
|
+
i++;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (ch === "(") {
|
|
561
|
+
tokens.push({ kind: "lparen", value: "(" });
|
|
562
|
+
i++;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (ch === ")") {
|
|
566
|
+
tokens.push({ kind: "rparen", value: ")" });
|
|
567
|
+
i++;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (ch === '"' || ch === "'") {
|
|
571
|
+
const quote = ch;
|
|
572
|
+
let j = i + 1;
|
|
573
|
+
let out = "";
|
|
574
|
+
let closed = false;
|
|
575
|
+
while (j < input.length) {
|
|
576
|
+
if (input[j] === quote) {
|
|
577
|
+
closed = true;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
out += input[j];
|
|
581
|
+
j++;
|
|
582
|
+
}
|
|
583
|
+
if (!closed)
|
|
584
|
+
throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
|
|
585
|
+
tokens.push({ kind: "string", value: out });
|
|
586
|
+
i = j + 1;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const two = input.slice(i, i + 2);
|
|
590
|
+
if (COMPARISON_OPS.has(two)) {
|
|
591
|
+
tokens.push({ kind: "op", value: two });
|
|
592
|
+
i += 2;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (ch === "<" || ch === ">" || ch === "=" || ch === "!") {
|
|
596
|
+
tokens.push({ kind: "op", value: ch });
|
|
597
|
+
i++;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (/[0-9]/.test(ch)) {
|
|
601
|
+
let j = i;
|
|
602
|
+
while (j < input.length && /[0-9.]/.test(input[j]))
|
|
603
|
+
j++;
|
|
604
|
+
tokens.push({ kind: "number", value: input.slice(i, j) });
|
|
605
|
+
i = j;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
609
|
+
let j = i;
|
|
610
|
+
while (j < input.length && /[A-Za-z0-9_.]/.test(input[j]))
|
|
611
|
+
j++;
|
|
612
|
+
tokens.push({ kind: "ident", value: input.slice(i, j) });
|
|
613
|
+
i = j;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
throw new ExprSyntaxError(`unexpected character ${JSON.stringify(ch)} at offset ${i}`);
|
|
617
|
+
}
|
|
618
|
+
return tokens;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
class Parser {
|
|
622
|
+
tokens;
|
|
623
|
+
pos = 0;
|
|
624
|
+
constructor(tokens) {
|
|
625
|
+
this.tokens = tokens;
|
|
626
|
+
}
|
|
627
|
+
peek() {
|
|
628
|
+
return this.tokens[this.pos];
|
|
629
|
+
}
|
|
630
|
+
next() {
|
|
631
|
+
return this.tokens[this.pos++];
|
|
632
|
+
}
|
|
633
|
+
expectOp(value) {
|
|
634
|
+
const t = this.next();
|
|
635
|
+
if (!t || t.kind !== "op" || t.value !== value) {
|
|
636
|
+
throw new ExprSyntaxError(`expected ${JSON.stringify(value)}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
parse() {
|
|
640
|
+
if (this.tokens.length === 0)
|
|
641
|
+
throw new ExprSyntaxError("empty expression");
|
|
642
|
+
const node = this.parseOr();
|
|
643
|
+
if (this.pos < this.tokens.length) {
|
|
644
|
+
throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
|
|
645
|
+
}
|
|
646
|
+
return node;
|
|
647
|
+
}
|
|
648
|
+
parseOr() {
|
|
649
|
+
let left = this.parseAnd();
|
|
650
|
+
while (this.peek()?.kind === "ident" && this.peek().value === "or") {
|
|
651
|
+
this.next();
|
|
652
|
+
const right = this.parseAnd();
|
|
653
|
+
left = { kind: "or", left, right };
|
|
654
|
+
}
|
|
655
|
+
return left;
|
|
656
|
+
}
|
|
657
|
+
parseAnd() {
|
|
658
|
+
let left = this.parseNot();
|
|
659
|
+
while (this.peek()?.kind === "ident" && this.peek().value === "and") {
|
|
660
|
+
this.next();
|
|
661
|
+
const right = this.parseNot();
|
|
662
|
+
left = { kind: "and", left, right };
|
|
663
|
+
}
|
|
664
|
+
return left;
|
|
665
|
+
}
|
|
666
|
+
parseNot() {
|
|
667
|
+
if (this.peek()?.kind === "ident" && this.peek().value === "not") {
|
|
668
|
+
this.next();
|
|
669
|
+
return { kind: "not", operand: this.parseNot() };
|
|
670
|
+
}
|
|
671
|
+
return this.parseComparison();
|
|
672
|
+
}
|
|
673
|
+
parseComparison() {
|
|
674
|
+
const left = this.parsePrimary();
|
|
675
|
+
const t = this.peek();
|
|
676
|
+
if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
|
|
677
|
+
this.next();
|
|
678
|
+
const right = this.parsePrimary();
|
|
679
|
+
return { kind: "comparison", op: t.value, left, right };
|
|
680
|
+
}
|
|
681
|
+
return left;
|
|
682
|
+
}
|
|
683
|
+
parsePrimary() {
|
|
684
|
+
const t = this.next();
|
|
685
|
+
if (!t)
|
|
686
|
+
throw new ExprSyntaxError("unexpected end of expression");
|
|
687
|
+
if (t.kind === "lparen") {
|
|
688
|
+
const inner = this.parseOr();
|
|
689
|
+
this.expectCloseParen();
|
|
690
|
+
return inner;
|
|
691
|
+
}
|
|
692
|
+
if (t.kind === "number") {
|
|
693
|
+
const value = Number(t.value);
|
|
694
|
+
if (!Number.isFinite(value))
|
|
695
|
+
throw new ExprSyntaxError(`invalid number ${JSON.stringify(t.value)}`);
|
|
696
|
+
return { kind: "literal", value };
|
|
697
|
+
}
|
|
698
|
+
if (t.kind === "string")
|
|
699
|
+
return { kind: "literal", value: t.value };
|
|
700
|
+
if (t.kind === "ident") {
|
|
701
|
+
if (t.value === "true")
|
|
702
|
+
return { kind: "literal", value: true };
|
|
703
|
+
if (t.value === "false")
|
|
704
|
+
return { kind: "literal", value: false };
|
|
705
|
+
return { kind: "path", segments: t.value.split(".") };
|
|
706
|
+
}
|
|
707
|
+
throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
|
|
708
|
+
}
|
|
709
|
+
expectCloseParen() {
|
|
710
|
+
const t = this.next();
|
|
711
|
+
if (!t || t.kind !== "rparen")
|
|
712
|
+
throw new ExprSyntaxError("expected )");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
function parseExpr(input) {
|
|
716
|
+
return new Parser(tokenize(input)).parse();
|
|
717
|
+
}
|
|
718
|
+
function resolvePath(segments, context) {
|
|
719
|
+
let current = context;
|
|
720
|
+
for (const segment of segments) {
|
|
721
|
+
if (current === null || typeof current !== "object")
|
|
722
|
+
return;
|
|
723
|
+
const record = current;
|
|
724
|
+
if (!(segment in record))
|
|
725
|
+
return;
|
|
726
|
+
current = record[segment];
|
|
727
|
+
}
|
|
728
|
+
if (typeof current === "number" || typeof current === "string" || typeof current === "boolean") {
|
|
729
|
+
return current;
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
function evalNode(node, context) {
|
|
734
|
+
switch (node.kind) {
|
|
735
|
+
case "literal":
|
|
736
|
+
return node.value;
|
|
737
|
+
case "path":
|
|
738
|
+
return resolvePath(node.segments, context);
|
|
739
|
+
case "comparison": {
|
|
740
|
+
const left = evalNode(node.left, context);
|
|
741
|
+
const right = evalNode(node.right, context);
|
|
742
|
+
if (left === undefined || right === undefined) {
|
|
743
|
+
if (node.op === "==")
|
|
744
|
+
return left === undefined && right === undefined;
|
|
745
|
+
if (node.op === "!=")
|
|
746
|
+
return left !== right;
|
|
747
|
+
throw new ExprEvalError("cannot order-compare an unknown path");
|
|
748
|
+
}
|
|
749
|
+
if (typeof left !== typeof right) {
|
|
750
|
+
throw new ExprEvalError(`cannot compare ${JSON.stringify(left)} (${typeof left}) with ${JSON.stringify(right)} (${typeof right})`);
|
|
751
|
+
}
|
|
752
|
+
switch (node.op) {
|
|
753
|
+
case "==":
|
|
754
|
+
return left === right;
|
|
755
|
+
case "!=":
|
|
756
|
+
return left !== right;
|
|
757
|
+
case "<":
|
|
758
|
+
return left < right;
|
|
759
|
+
case "<=":
|
|
760
|
+
return left <= right;
|
|
761
|
+
case ">":
|
|
762
|
+
return left > right;
|
|
763
|
+
case ">=":
|
|
764
|
+
return left >= right;
|
|
765
|
+
default:
|
|
766
|
+
throw new ExprEvalError(`unknown operator ${node.op}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
case "and": {
|
|
770
|
+
const left = evalNode(node.left, context);
|
|
771
|
+
const right = evalNode(node.right, context);
|
|
772
|
+
if (typeof left !== "boolean" || typeof right !== "boolean") {
|
|
773
|
+
throw new ExprEvalError("and requires boolean operands");
|
|
774
|
+
}
|
|
775
|
+
return left && right;
|
|
776
|
+
}
|
|
777
|
+
case "or": {
|
|
778
|
+
const left = evalNode(node.left, context);
|
|
779
|
+
const right = evalNode(node.right, context);
|
|
780
|
+
if (typeof left !== "boolean" || typeof right !== "boolean") {
|
|
781
|
+
throw new ExprEvalError("or requires boolean operands");
|
|
782
|
+
}
|
|
783
|
+
return left || right;
|
|
784
|
+
}
|
|
785
|
+
case "not": {
|
|
786
|
+
const operand = evalNode(node.operand, context);
|
|
787
|
+
if (typeof operand !== "boolean")
|
|
788
|
+
throw new ExprEvalError("not requires a boolean operand");
|
|
789
|
+
return !operand;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function evaluateExpr(input, context) {
|
|
794
|
+
const ast = parseExpr(input);
|
|
795
|
+
const value = evalNode(ast, context);
|
|
796
|
+
if (typeof value !== "boolean") {
|
|
797
|
+
throw new ExprEvalError(`condition must evaluate to a boolean, got ${typeof value}`);
|
|
798
|
+
}
|
|
799
|
+
return value;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/graph.ts
|
|
803
|
+
var GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
|
|
804
|
+
var LANE_KINDS = ["claude", "codex", "cursor", "grok"];
|
|
805
|
+
var NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
806
|
+
function issue(issues, path, message) {
|
|
807
|
+
issues.push({ path, message });
|
|
808
|
+
}
|
|
809
|
+
function findNode(graph, id) {
|
|
810
|
+
return graph.nodes.find((n) => n.id === id);
|
|
811
|
+
}
|
|
812
|
+
function collectEdges(graph) {
|
|
813
|
+
const edges = [];
|
|
814
|
+
for (const node of graph.nodes) {
|
|
815
|
+
switch (node.type) {
|
|
816
|
+
case "start":
|
|
817
|
+
case "step":
|
|
818
|
+
if (node.next)
|
|
819
|
+
edges.push({ from: node.id, to: node.next });
|
|
820
|
+
break;
|
|
821
|
+
case "decision":
|
|
822
|
+
if (node.then)
|
|
823
|
+
edges.push({ from: node.id, to: node.then });
|
|
824
|
+
if (node.else)
|
|
825
|
+
edges.push({ from: node.id, to: node.else });
|
|
826
|
+
break;
|
|
827
|
+
case "while":
|
|
828
|
+
if (node.next)
|
|
829
|
+
edges.push({ from: node.id, to: node.next });
|
|
830
|
+
break;
|
|
831
|
+
case "end":
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return edges;
|
|
836
|
+
}
|
|
837
|
+
function reachableNodes(graph) {
|
|
838
|
+
const start = graph.nodes.find((n) => n.type === "start");
|
|
839
|
+
if (!start)
|
|
840
|
+
return [];
|
|
841
|
+
const seen = new Set;
|
|
842
|
+
const stack = [start.id];
|
|
843
|
+
while (stack.length > 0) {
|
|
844
|
+
const id = stack.pop();
|
|
845
|
+
if (seen.has(id))
|
|
846
|
+
continue;
|
|
847
|
+
seen.add(id);
|
|
848
|
+
const node = findNode(graph, id);
|
|
849
|
+
if (!node)
|
|
850
|
+
continue;
|
|
851
|
+
if (node.type === "start" || node.type === "step") {
|
|
852
|
+
if (node.next)
|
|
853
|
+
stack.push(node.next);
|
|
854
|
+
} else if (node.type === "decision") {
|
|
855
|
+
if (node.then)
|
|
856
|
+
stack.push(node.then);
|
|
857
|
+
if (node.else)
|
|
858
|
+
stack.push(node.else);
|
|
859
|
+
} else if (node.type === "while") {
|
|
860
|
+
for (const bodyId of node.body)
|
|
861
|
+
stack.push(bodyId);
|
|
862
|
+
if (node.next)
|
|
863
|
+
stack.push(node.next);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return [...seen];
|
|
867
|
+
}
|
|
868
|
+
function findCycles(graph) {
|
|
869
|
+
const edges = collectEdges(graph);
|
|
870
|
+
const adj = new Map;
|
|
871
|
+
for (const e of edges) {
|
|
872
|
+
const list = adj.get(e.from) ?? [];
|
|
873
|
+
list.push(e.to);
|
|
874
|
+
adj.set(e.from, list);
|
|
875
|
+
}
|
|
876
|
+
const color = new Map;
|
|
877
|
+
const stack = [];
|
|
878
|
+
const cycles = [];
|
|
879
|
+
function visit(id) {
|
|
880
|
+
color.set(id, "grey");
|
|
881
|
+
stack.push(id);
|
|
882
|
+
for (const to of adj.get(id) ?? []) {
|
|
883
|
+
const c = color.get(to);
|
|
884
|
+
if (c === "grey") {
|
|
885
|
+
const idx = stack.indexOf(to);
|
|
886
|
+
cycles.push([...stack.slice(idx), to]);
|
|
887
|
+
} else if (c === undefined) {
|
|
888
|
+
visit(to);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
stack.pop();
|
|
892
|
+
color.set(id, "black");
|
|
893
|
+
}
|
|
894
|
+
for (const node of graph.nodes) {
|
|
895
|
+
if (color.get(node.id) === undefined)
|
|
896
|
+
visit(node.id);
|
|
897
|
+
}
|
|
898
|
+
return cycles;
|
|
899
|
+
}
|
|
900
|
+
function validateGraph(graph) {
|
|
901
|
+
const issues = [];
|
|
902
|
+
if (typeof graph.name !== "string" || graph.name.trim() === "") {
|
|
903
|
+
issue(issues, "name", "graph requires a non-empty name");
|
|
904
|
+
}
|
|
905
|
+
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
906
|
+
issue(issues, "version", "graph requires a version string");
|
|
907
|
+
}
|
|
908
|
+
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
909
|
+
issue(issues, "nodes", "graph requires at least one node");
|
|
910
|
+
return { ok: false, issues };
|
|
911
|
+
}
|
|
912
|
+
const starts = graph.nodes.filter((n) => n.type === "start");
|
|
913
|
+
if (starts.length === 0)
|
|
914
|
+
issue(issues, "nodes", "graph requires exactly one start node (found none)");
|
|
915
|
+
if (starts.length > 1)
|
|
916
|
+
issue(issues, "nodes", "graph requires exactly one start node (found multiple)");
|
|
917
|
+
const ends = graph.nodes.filter((n) => n.type === "end");
|
|
918
|
+
if (ends.length === 0)
|
|
919
|
+
issue(issues, "nodes", "graph requires at least one end node");
|
|
920
|
+
const seenIds = new Map;
|
|
921
|
+
for (const node of graph.nodes) {
|
|
922
|
+
if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
|
|
923
|
+
issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (seenIds.has(node.id)) {
|
|
927
|
+
issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
seenIds.set(node.id, node);
|
|
931
|
+
if (!GRAPH_NODE_TYPES.includes(node.type)) {
|
|
932
|
+
issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
validateNode(graph, node, issues);
|
|
936
|
+
}
|
|
937
|
+
for (const node of graph.nodes) {
|
|
938
|
+
const edges = nodeEdges(node);
|
|
939
|
+
for (const { kind, target } of edges) {
|
|
940
|
+
if (!seenIds.has(target)) {
|
|
941
|
+
issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (starts.length === 1) {
|
|
946
|
+
const reachable = new Set(reachableNodes(graph));
|
|
947
|
+
for (const node of graph.nodes) {
|
|
948
|
+
if (!reachable.has(node.id)) {
|
|
949
|
+
issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const cycles = findCycles(graph);
|
|
954
|
+
for (const cycle of cycles) {
|
|
955
|
+
issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
|
|
956
|
+
}
|
|
957
|
+
return { ok: issues.length === 0, issues };
|
|
958
|
+
}
|
|
959
|
+
function nodeEdges(node) {
|
|
960
|
+
switch (node.type) {
|
|
961
|
+
case "start":
|
|
962
|
+
case "step":
|
|
963
|
+
return node.next ? [{ kind: "next", target: node.next }] : [];
|
|
964
|
+
case "decision":
|
|
965
|
+
return [
|
|
966
|
+
...node.then ? [{ kind: "then", target: node.then }] : [],
|
|
967
|
+
...node.else ? [{ kind: "else", target: node.else }] : []
|
|
968
|
+
];
|
|
969
|
+
case "while":
|
|
970
|
+
return node.next ? [{ kind: "next", target: node.next }] : [];
|
|
971
|
+
case "end":
|
|
972
|
+
return [];
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
function validateNode(graph, node, issues) {
|
|
976
|
+
switch (node.type) {
|
|
977
|
+
case "start":
|
|
978
|
+
if (!node.next)
|
|
979
|
+
issue(issues, `nodes.${node.id}`, "start node requires a next edge");
|
|
980
|
+
break;
|
|
981
|
+
case "step": {
|
|
982
|
+
if (!node.prompt && !node.command) {
|
|
983
|
+
issue(issues, `nodes.${node.id}`, "step node requires prompt or command");
|
|
984
|
+
}
|
|
985
|
+
if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
|
|
986
|
+
issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
|
|
987
|
+
}
|
|
988
|
+
if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
|
|
989
|
+
issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
|
|
990
|
+
}
|
|
991
|
+
if (node.timeoutMs !== undefined && (!Number.isInteger(node.timeoutMs) || node.timeoutMs < 1)) {
|
|
992
|
+
issue(issues, `nodes.${node.id}`, "timeoutMs must be a positive integer");
|
|
993
|
+
}
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
case "decision": {
|
|
997
|
+
if (typeof node.condition !== "string" || node.condition.trim() === "") {
|
|
998
|
+
issue(issues, `nodes.${node.id}`, "decision node requires a condition expression");
|
|
999
|
+
} else {
|
|
1000
|
+
try {
|
|
1001
|
+
parseExpr(node.condition);
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
if (err instanceof ExprSyntaxError) {
|
|
1004
|
+
issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
|
|
1005
|
+
} else {
|
|
1006
|
+
throw err;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (!node.then && !node.else) {
|
|
1011
|
+
issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
|
|
1012
|
+
}
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case "while": {
|
|
1016
|
+
if (typeof node.condition !== "string" || node.condition.trim() === "") {
|
|
1017
|
+
issue(issues, `nodes.${node.id}`, "while node requires a condition expression");
|
|
1018
|
+
} else {
|
|
1019
|
+
try {
|
|
1020
|
+
parseExpr(node.condition);
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
if (err instanceof ExprSyntaxError) {
|
|
1023
|
+
issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
|
|
1024
|
+
} else {
|
|
1025
|
+
throw err;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (node.maxIterations === undefined) {
|
|
1030
|
+
issue(issues, `nodes.${node.id}`, "while node requires maxIterations (finite loop bound)");
|
|
1031
|
+
} else if (!Number.isInteger(node.maxIterations) || node.maxIterations < 1) {
|
|
1032
|
+
issue(issues, `nodes.${node.id}`, "maxIterations must be a positive integer");
|
|
1033
|
+
}
|
|
1034
|
+
if (!Array.isArray(node.body) || node.body.length === 0) {
|
|
1035
|
+
issue(issues, `nodes.${node.id}`, "while node requires a non-empty body");
|
|
1036
|
+
} else {
|
|
1037
|
+
for (const bodyId of node.body) {
|
|
1038
|
+
const bodyNode = findNode(graph, bodyId);
|
|
1039
|
+
if (!bodyNode) {
|
|
1040
|
+
issue(issues, `nodes.${node.id}`, `while body references nonexistent node ${bodyId}`);
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (bodyNode.type === "start" || bodyNode.type === "end" || bodyNode.type === "while") {
|
|
1044
|
+
issue(issues, `nodes.${node.id}`, `while body may only contain step/decision nodes in v1 (found ${bodyNode.type})`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const bodySet = new Set(node.body);
|
|
1048
|
+
for (const bodyId of node.body) {
|
|
1049
|
+
const bodyNode = findNode(graph, bodyId);
|
|
1050
|
+
if (!bodyNode)
|
|
1051
|
+
continue;
|
|
1052
|
+
for (const { kind, target } of nodeEdges(bodyNode)) {
|
|
1053
|
+
if (!bodySet.has(target)) {
|
|
1054
|
+
issue(issues, `nodes.${node.id}`, `while body node ${bodyId} ${kind}-edge leaves the body (target ${target})`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
case "end":
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// src/secrets.ts
|
|
1067
|
+
class SecretsGateError extends Error {
|
|
1068
|
+
constructor(message) {
|
|
1069
|
+
super(message);
|
|
1070
|
+
this.name = "SecretsGateError";
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
var p = (...parts) => parts.join("");
|
|
1074
|
+
var VALUE_PATTERNS = [
|
|
1075
|
+
{ detector: "anthropic_api_key", re: new RegExp(p("sk-", "ant-") + "[A-Za-z0-9_-]{10,}") },
|
|
1076
|
+
{ detector: "openai_api_key", re: new RegExp(p("sk-", "proj-") + "[A-Za-z0-9_-]{10,}") },
|
|
1077
|
+
{ detector: "npm_token", re: /npm_[A-Za-z0-9]{20,}/ },
|
|
1078
|
+
{ detector: "github_token", re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
1079
|
+
{ detector: "aws_access_key", re: /AKIA[0-9A-Z]{16}/ },
|
|
1080
|
+
{ detector: "google_api_key", re: /AIza[0-9A-Za-z_-]{20,}/ },
|
|
1081
|
+
{ detector: "xai_api_key", re: new RegExp(p("xai-") + "[A-Za-z0-9]{20,}") },
|
|
1082
|
+
{ detector: "codex_token", re: new RegExp(p("ctx7", "sk-") + "[A-Za-z0-9]{10,}") },
|
|
1083
|
+
{ detector: "private_key_block", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
1084
|
+
{ detector: "bearer_token", re: /Bearer [A-Za-z0-9._~+/=-]{20,}/ }
|
|
1085
|
+
];
|
|
1086
|
+
var CREDENTIAL_KEY_RE = /^(token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|refresh[_-]?token|session[_-]?token|bot[_-]?token)$/i;
|
|
1087
|
+
function scanString(value, path, findings) {
|
|
1088
|
+
for (const { detector, re } of VALUE_PATTERNS) {
|
|
1089
|
+
if (re.test(value)) {
|
|
1090
|
+
findings.push({ path, detector });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function isTrivial(value) {
|
|
1096
|
+
const v = value.trim();
|
|
1097
|
+
if (v.length === 0)
|
|
1098
|
+
return true;
|
|
1099
|
+
const upper = v.toUpperCase();
|
|
1100
|
+
if (upper.startsWith("REPLACE") || upper.startsWith("CHANGE") || upper.startsWith("YOUR_") || upper.startsWith("EXAMPLE") || upper.startsWith("PLACEHOLDER"))
|
|
1101
|
+
return true;
|
|
1102
|
+
if (v.length < 8)
|
|
1103
|
+
return true;
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
function scanForSecrets(value, path = "$") {
|
|
1107
|
+
const findings = [];
|
|
1108
|
+
if (value === null || value === undefined)
|
|
1109
|
+
return findings;
|
|
1110
|
+
if (typeof value === "string") {
|
|
1111
|
+
scanString(value, path, findings);
|
|
1112
|
+
return findings;
|
|
1113
|
+
}
|
|
1114
|
+
if (Array.isArray(value)) {
|
|
1115
|
+
value.forEach((item, index) => {
|
|
1116
|
+
findings.push(...scanForSecrets(item, `${path}[${index}]`));
|
|
1117
|
+
});
|
|
1118
|
+
return findings;
|
|
1119
|
+
}
|
|
1120
|
+
if (typeof value === "object") {
|
|
1121
|
+
const record = value;
|
|
1122
|
+
for (const [key, child] of Object.entries(record)) {
|
|
1123
|
+
if (CREDENTIAL_KEY_RE.test(key) && typeof child === "string" && !isTrivial(child)) {
|
|
1124
|
+
findings.push({ path: `${path}.${key}`, detector: "credential_key_field" });
|
|
1125
|
+
} else {
|
|
1126
|
+
findings.push(...scanForSecrets(child, `${path}.${key}`));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return findings;
|
|
1131
|
+
}
|
|
1132
|
+
function assertNoSecrets(value, what) {
|
|
1133
|
+
const findings = scanForSecrets(value);
|
|
1134
|
+
if (findings.length === 0)
|
|
1135
|
+
return;
|
|
1136
|
+
const detail = findings.map((f) => `${f.path} (${f.detector})`).join(", ");
|
|
1137
|
+
throw new SecretsGateError(`secrets write-gate refused to persist ${what}: credential-shaped value at ${detail}`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/session.ts
|
|
1141
|
+
import { createHash } from "crypto";
|
|
1142
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1143
|
+
function repairTornRuns(store, wal, opts = {}) {
|
|
1144
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
1145
|
+
const replay = wal.replay();
|
|
1146
|
+
const liveClaims = replay.liveClaims(opts.now);
|
|
1147
|
+
const report = { interrupted: 0, requeued: 0, failed: 0 };
|
|
1148
|
+
const running = store.listRuns({ status: "running", limit: 1000 });
|
|
1149
|
+
for (const run of running) {
|
|
1150
|
+
if (liveClaims.has(run.id))
|
|
1151
|
+
continue;
|
|
1152
|
+
report.interrupted++;
|
|
1153
|
+
const nodes = store.listRunNodes(run.id);
|
|
1154
|
+
if (run.attempts < maxAttempts) {
|
|
1155
|
+
store.bumpAttempts(run.id);
|
|
1156
|
+
store.setRunStatus(run.id, "interrupted", {
|
|
1157
|
+
error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
|
|
1158
|
+
});
|
|
1159
|
+
for (const node of nodes) {
|
|
1160
|
+
if (node.status === "running") {
|
|
1161
|
+
store.setRunNodeStatus(node.id, "pending", { error: undefined });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
report.requeued++;
|
|
1165
|
+
} else {
|
|
1166
|
+
store.setRunStatus(run.id, "failed", {
|
|
1167
|
+
error: `torn run failed after ${maxAttempts} interrupted attempts`
|
|
1168
|
+
});
|
|
1169
|
+
for (const node of nodes) {
|
|
1170
|
+
if (node.status === "running") {
|
|
1171
|
+
store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
report.failed++;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return report;
|
|
1178
|
+
}
|
|
1179
|
+
function inputHash(payload) {
|
|
1180
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
1181
|
+
}
|
|
1182
|
+
function memoKey(graphName, nodeId, input) {
|
|
1183
|
+
return `${graphName}:${nodeId}:${inputHash(input)}`;
|
|
1184
|
+
}
|
|
1185
|
+
function tryMemoHit(store, graphName, nodeId, input) {
|
|
1186
|
+
const key = memoKey(graphName, nodeId, input);
|
|
1187
|
+
const memo = store.memoGet(key);
|
|
1188
|
+
if (!memo)
|
|
1189
|
+
return;
|
|
1190
|
+
store.memoHit(key);
|
|
1191
|
+
return memo;
|
|
1192
|
+
}
|
|
1193
|
+
function recordMemo(store, graphName, nodeId, input, output) {
|
|
1194
|
+
const key = memoKey(graphName, nodeId, input);
|
|
1195
|
+
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1196
|
+
}
|
|
1197
|
+
function findRunByIdempotencyKey(store, key) {
|
|
1198
|
+
const runs = store.listRuns({ limit: 1000 });
|
|
1199
|
+
for (const run of runs) {
|
|
1200
|
+
try {
|
|
1201
|
+
const context = JSON.parse(run.contextJson);
|
|
1202
|
+
if (context?.__wf?.idempotencyKey === key)
|
|
1203
|
+
return run;
|
|
1204
|
+
} catch {}
|
|
1205
|
+
}
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/daemon.ts
|
|
1210
|
+
init_types();
|
|
1211
|
+
var DEFAULT_LEASE_TTL_MS = 120000;
|
|
1212
|
+
var DEFAULT_MAX_ATTEMPTS2 = 3;
|
|
1213
|
+
var DEFAULT_MAX_DISPATCH = 4;
|
|
1214
|
+
var DEFAULT_MAX_STEPS = 25;
|
|
1215
|
+
var DEFAULT_TIMEOUT_MS = 120000;
|
|
1216
|
+
function nowIso() {
|
|
1217
|
+
return new Date().toISOString();
|
|
1218
|
+
}
|
|
1219
|
+
function stripRunState(context) {
|
|
1220
|
+
const { __wf: _wf, steps: _steps, ...user } = context;
|
|
1221
|
+
return user;
|
|
1222
|
+
}
|
|
1223
|
+
function runCommand(job) {
|
|
1224
|
+
if (!job.command) {
|
|
1225
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
|
|
1226
|
+
}
|
|
1227
|
+
const started = Date.now();
|
|
1228
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1229
|
+
const proc = Bun.spawnSync({
|
|
1230
|
+
cmd: ["/bin/sh", "-c", job.command],
|
|
1231
|
+
cwd: job.cwd,
|
|
1232
|
+
env: { ...process.env, ...job.env ?? {} },
|
|
1233
|
+
stdout: "pipe",
|
|
1234
|
+
stderr: "pipe",
|
|
1235
|
+
timeout: timeoutMs
|
|
1236
|
+
});
|
|
1237
|
+
const durationMs = Date.now() - started;
|
|
1238
|
+
const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
|
|
1239
|
+
const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
|
|
1240
|
+
const output = stderr ? `${stdout}
|
|
1241
|
+
${stderr}`.trim() : stdout.trim();
|
|
1242
|
+
const exitCode = proc.exitCode ?? (proc.signalCode ? 1 : 1);
|
|
1243
|
+
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
class WorkflowsDaemon {
|
|
1247
|
+
store;
|
|
1248
|
+
wal;
|
|
1249
|
+
worker;
|
|
1250
|
+
leaseTtlMs;
|
|
1251
|
+
maxAttempts;
|
|
1252
|
+
maxDispatchPerCycle;
|
|
1253
|
+
maxStepsPerCycle;
|
|
1254
|
+
time;
|
|
1255
|
+
laneRunner;
|
|
1256
|
+
env;
|
|
1257
|
+
leases = new Map;
|
|
1258
|
+
fencingCounter = new Map;
|
|
1259
|
+
graphCache = new Map;
|
|
1260
|
+
isolatedRunId = null;
|
|
1261
|
+
constructor(store, wal, options = {}) {
|
|
1262
|
+
this.store = store;
|
|
1263
|
+
this.wal = wal;
|
|
1264
|
+
this.worker = options.worker ?? "local";
|
|
1265
|
+
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
1266
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
|
|
1267
|
+
this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
|
|
1268
|
+
this.maxStepsPerCycle = options.maxStepsPerCycle ?? DEFAULT_MAX_STEPS;
|
|
1269
|
+
this.time = options.time ?? Date.now;
|
|
1270
|
+
this.env = options.env ?? {};
|
|
1271
|
+
this.laneRunner = options.laneRunner ?? defaultLaneRunner;
|
|
1272
|
+
this.isolatedRunId = options.isolatedRunId ?? null;
|
|
1273
|
+
this.replayRegisteredGraphs();
|
|
1274
|
+
}
|
|
1275
|
+
isolateRun(runId) {
|
|
1276
|
+
this.isolatedRunId = runId;
|
|
1277
|
+
}
|
|
1278
|
+
replayRegisteredGraphs() {
|
|
1279
|
+
const entries = this.wal.replay().entries;
|
|
1280
|
+
for (const entry of entries) {
|
|
1281
|
+
const op = entry.op;
|
|
1282
|
+
if (op.op === "graph_registered") {
|
|
1283
|
+
this.graphCache.set(op.name, op.graph);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
liveLeases() {
|
|
1288
|
+
const now = this.time();
|
|
1289
|
+
for (const [runId, lease] of this.leases) {
|
|
1290
|
+
if (lease.expiresAtMs <= now)
|
|
1291
|
+
this.leases.delete(runId);
|
|
1292
|
+
}
|
|
1293
|
+
return this.leases;
|
|
1294
|
+
}
|
|
1295
|
+
claim(runId) {
|
|
1296
|
+
const now = this.time();
|
|
1297
|
+
const existing = this.leases.get(runId);
|
|
1298
|
+
if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
const liveClaims = this.wal.replay().liveClaims(now);
|
|
1302
|
+
const foreign = liveClaims.get(runId);
|
|
1303
|
+
if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
const fencing = (this.fencingCounter.get(runId) ?? 0) + 1;
|
|
1307
|
+
this.fencingCounter.set(runId, fencing);
|
|
1308
|
+
const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
|
|
1309
|
+
this.leases.set(runId, lease);
|
|
1310
|
+
this.wal.append({
|
|
1311
|
+
op: "claim_acquired",
|
|
1312
|
+
runId,
|
|
1313
|
+
worker: this.worker,
|
|
1314
|
+
expiresAt: new Date(lease.expiresAtMs).toISOString(),
|
|
1315
|
+
fencing,
|
|
1316
|
+
at: nowIso()
|
|
1317
|
+
});
|
|
1318
|
+
return lease;
|
|
1319
|
+
}
|
|
1320
|
+
heartbeat(runId, fencing) {
|
|
1321
|
+
const lease = this.leases.get(runId);
|
|
1322
|
+
if (!lease || lease.fencing !== fencing)
|
|
1323
|
+
return false;
|
|
1324
|
+
lease.expiresAtMs = this.time() + this.leaseTtlMs;
|
|
1325
|
+
return true;
|
|
1326
|
+
}
|
|
1327
|
+
release(runId, fencing) {
|
|
1328
|
+
const lease = this.leases.get(runId);
|
|
1329
|
+
if (!lease || lease.fencing !== fencing)
|
|
1330
|
+
return false;
|
|
1331
|
+
this.leases.delete(runId);
|
|
1332
|
+
this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
async reap() {
|
|
1336
|
+
const report = { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
|
|
1337
|
+
const now = this.time();
|
|
1338
|
+
for (const [runId, lease] of this.leases) {
|
|
1339
|
+
if (lease.expiresAtMs <= now) {
|
|
1340
|
+
this.leases.delete(runId);
|
|
1341
|
+
report.expired++;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (this.isolatedRunId === null) {
|
|
1345
|
+
const repair = repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
|
|
1346
|
+
report.interrupted += repair.interrupted;
|
|
1347
|
+
report.requeued += repair.requeued;
|
|
1348
|
+
report.failed += repair.failed;
|
|
1349
|
+
}
|
|
1350
|
+
const pending = this.store.listRuns({ status: "pending", limit: this.maxDispatchPerCycle }).concat(this.store.listRuns({ status: "interrupted", limit: this.maxDispatchPerCycle })).sort((a, b) => a.createdAt === b.createdAt ? a.id < b.id ? 1 : -1 : a.createdAt < b.createdAt ? 1 : -1).slice(0, this.maxDispatchPerCycle);
|
|
1351
|
+
for (const run of pending) {
|
|
1352
|
+
if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
|
|
1353
|
+
continue;
|
|
1354
|
+
const lease = this.claim(run.id);
|
|
1355
|
+
if (!lease)
|
|
1356
|
+
continue;
|
|
1357
|
+
this.store.setRunStatus(run.id, "running");
|
|
1358
|
+
this.wal.append({ op: "run_started", runId: run.id, at: nowIso() });
|
|
1359
|
+
report.dispatched++;
|
|
1360
|
+
}
|
|
1361
|
+
const running = this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
|
|
1362
|
+
let steps = 0;
|
|
1363
|
+
for (const run of running) {
|
|
1364
|
+
if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
|
|
1365
|
+
continue;
|
|
1366
|
+
if (steps >= this.maxStepsPerCycle)
|
|
1367
|
+
break;
|
|
1368
|
+
const outcome = await this.advanceRun(run.id);
|
|
1369
|
+
steps++;
|
|
1370
|
+
if (outcome.kind === "completed")
|
|
1371
|
+
report.completed++;
|
|
1372
|
+
if (outcome.kind === "failed")
|
|
1373
|
+
report.failed++;
|
|
1374
|
+
if (outcome.kind === "advanced")
|
|
1375
|
+
report.advanced++;
|
|
1376
|
+
}
|
|
1377
|
+
return report;
|
|
1378
|
+
}
|
|
1379
|
+
startRun(graph, context) {
|
|
1380
|
+
const validation = validateGraph(graph);
|
|
1381
|
+
if (!validation.ok) {
|
|
1382
|
+
throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
1383
|
+
}
|
|
1384
|
+
assertNoSecrets(context, "run context");
|
|
1385
|
+
assertNoSecrets(graph, "graph definition");
|
|
1386
|
+
const existing = this.graphCache.get(graph.name);
|
|
1387
|
+
const serialized = JSON.stringify(graph);
|
|
1388
|
+
if (!existing || JSON.stringify(existing) !== serialized) {
|
|
1389
|
+
this.graphCache.set(graph.name, graph);
|
|
1390
|
+
this.wal.append({ op: "graph_registered", name: graph.name, version: graph.version, graph, at: nowIso() });
|
|
1391
|
+
} else {
|
|
1392
|
+
this.graphCache.set(graph.name, existing);
|
|
1393
|
+
}
|
|
1394
|
+
return this.store.createRun({ graphName: graph.name, graphVersion: graph.version, context: context ?? {} });
|
|
1395
|
+
}
|
|
1396
|
+
async advanceRun(runId) {
|
|
1397
|
+
const run = this.store.getRun(runId);
|
|
1398
|
+
if (!run || run.status !== "running")
|
|
1399
|
+
return { kind: "noop" };
|
|
1400
|
+
const lease = this.leases.get(runId);
|
|
1401
|
+
if (!lease || lease.expiresAtMs <= this.time())
|
|
1402
|
+
return { kind: "blocked" };
|
|
1403
|
+
const graph = this.loadGraph(run.graphName);
|
|
1404
|
+
if (!graph) {
|
|
1405
|
+
this.failRun(run, `graph ${run.graphName} not found in daemon graph cache`);
|
|
1406
|
+
return { kind: "failed", error: "graph not found" };
|
|
1407
|
+
}
|
|
1408
|
+
const context = JSON.parse(run.contextJson);
|
|
1409
|
+
const rawWf = context.__wf ?? {};
|
|
1410
|
+
const wf = {
|
|
1411
|
+
...rawWf,
|
|
1412
|
+
cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
|
|
1413
|
+
loops: rawWf.loops ?? {},
|
|
1414
|
+
completedLoops: rawWf.completedLoops ?? {}
|
|
1415
|
+
};
|
|
1416
|
+
try {
|
|
1417
|
+
const cursor = wf.cursor;
|
|
1418
|
+
const node = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
|
|
1419
|
+
if (!node) {
|
|
1420
|
+
this.failRun(run, `cursor ${cursor ?? "<start>"} resolves to no node`);
|
|
1421
|
+
return { kind: "failed", error: "no node at cursor" };
|
|
1422
|
+
}
|
|
1423
|
+
switch (node.type) {
|
|
1424
|
+
case "start": {
|
|
1425
|
+
wf.cursor = node.next;
|
|
1426
|
+
this.persistContext(run, context, wf);
|
|
1427
|
+
return { kind: "advanced", nodeId: node.id };
|
|
1428
|
+
}
|
|
1429
|
+
case "step": {
|
|
1430
|
+
return await this.executeStep(run, graph, context, wf, node);
|
|
1431
|
+
}
|
|
1432
|
+
case "decision": {
|
|
1433
|
+
const value = evaluateExpr(node.condition, this.conditionScope(context));
|
|
1434
|
+
const next = value ? node.then : node.else;
|
|
1435
|
+
if (!next) {
|
|
1436
|
+
this.failRun(run, `decision ${node.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
|
|
1437
|
+
return { kind: "failed", error: "missing decision edge" };
|
|
1438
|
+
}
|
|
1439
|
+
wf.cursor = next;
|
|
1440
|
+
this.persistContext(run, context, wf);
|
|
1441
|
+
return { kind: "advanced", nodeId: node.id };
|
|
1442
|
+
}
|
|
1443
|
+
case "while": {
|
|
1444
|
+
return this.executeWhile(run, graph, context, wf, node);
|
|
1445
|
+
}
|
|
1446
|
+
case "end": {
|
|
1447
|
+
this.completeRun(run, context);
|
|
1448
|
+
return { kind: "completed" };
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
|
|
1453
|
+
this.failRun(run, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
|
|
1454
|
+
return { kind: "failed", error: err.message };
|
|
1455
|
+
}
|
|
1456
|
+
throw err;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
loadGraph(name) {
|
|
1460
|
+
return this.graphCache.get(name);
|
|
1461
|
+
}
|
|
1462
|
+
conditionScope(context) {
|
|
1463
|
+
return { ...context, steps: context.steps ?? {} };
|
|
1464
|
+
}
|
|
1465
|
+
persistContext(run, context, wf) {
|
|
1466
|
+
context.__wf = wf;
|
|
1467
|
+
this.store.setRunContext(run.id, context);
|
|
1468
|
+
}
|
|
1469
|
+
async executeStep(run, graph, context, wf, node) {
|
|
1470
|
+
const nodeId = node.id;
|
|
1471
|
+
const existing = this.store.listRunNodes(run.id).find((n) => n.nodeId === nodeId);
|
|
1472
|
+
const nodeRow = existing ?? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" });
|
|
1473
|
+
if (node.memo) {
|
|
1474
|
+
const memoInput = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
|
|
1475
|
+
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1476
|
+
if (hit) {
|
|
1477
|
+
const cached = JSON.parse(hit.outputJson);
|
|
1478
|
+
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } });
|
|
1479
|
+
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1480
|
+
this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
|
|
1481
|
+
return this.afterNode(run, context, wf, node.next);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (nodeRow.status !== "running") {
|
|
1485
|
+
this.store.setRunNodeStatus(nodeRow.id, "running");
|
|
1486
|
+
this.wal.append({ op: "node_started", runId: run.id, nodeId, at: nowIso() });
|
|
1487
|
+
}
|
|
1488
|
+
const job = {
|
|
1489
|
+
lane: node.lane ?? "claude",
|
|
1490
|
+
prompt: node.prompt,
|
|
1491
|
+
command: node.command,
|
|
1492
|
+
timeoutMs: node.timeoutMs,
|
|
1493
|
+
env: this.env
|
|
1494
|
+
};
|
|
1495
|
+
let result;
|
|
1496
|
+
try {
|
|
1497
|
+
result = await this.laneRunner(job);
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
result = { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
|
|
1500
|
+
}
|
|
1501
|
+
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1502
|
+
if (!result.ok) {
|
|
1503
|
+
const retriesLeft = (node.maxRetries ?? 0) + 1 - nodeRow.attempts;
|
|
1504
|
+
if (retriesLeft > 0) {
|
|
1505
|
+
this.store.bumpAttemptsNode(nodeRow.id);
|
|
1506
|
+
this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
|
|
1507
|
+
return { kind: "advanced", nodeId };
|
|
1508
|
+
}
|
|
1509
|
+
const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
|
|
1510
|
+
this.store.setRunNodeStatus(nodeRow.id, "failed", { exitCode: result.exitCode, error });
|
|
1511
|
+
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
|
|
1512
|
+
this.failRun(run, error);
|
|
1513
|
+
return { kind: "failed", error };
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
assertNoSecrets(output, `node ${nodeId} output`);
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1519
|
+
this.store.setRunNodeStatus(nodeRow.id, "failed", { error: message });
|
|
1520
|
+
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
|
|
1521
|
+
this.failRun(run, message);
|
|
1522
|
+
return { kind: "failed", error: message };
|
|
1523
|
+
}
|
|
1524
|
+
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1525
|
+
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1526
|
+
if (node.memo) {
|
|
1527
|
+
recordMemo(this.store, run.graphName, nodeId, { command: node.command, prompt: node.prompt, context: stripRunState(context) }, output);
|
|
1528
|
+
this.wal.append({ op: "memo_set", key: `${run.graphName}:${nodeId}:${createHash2("sha256").update(JSON.stringify({ command: node.command, prompt: node.prompt, context: stripRunState(context) })).digest("hex")}`, at: nowIso() });
|
|
1529
|
+
}
|
|
1530
|
+
this.recordStepResult(context, nodeId, output);
|
|
1531
|
+
return this.afterNode(run, context, wf, node.next);
|
|
1532
|
+
}
|
|
1533
|
+
afterNode(run, context, wf, next) {
|
|
1534
|
+
if (next) {
|
|
1535
|
+
wf.cursor = next;
|
|
1536
|
+
this.persistContext(run, context, wf);
|
|
1537
|
+
return { kind: "advanced", nodeId: wf.cursor };
|
|
1538
|
+
}
|
|
1539
|
+
const cursor = wf.cursor;
|
|
1540
|
+
const graph = this.loadGraph(run.graphName);
|
|
1541
|
+
const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
|
|
1542
|
+
if (whileNode) {
|
|
1543
|
+
const index = whileNode.body.indexOf(cursor ?? "");
|
|
1544
|
+
if (index >= 0 && index < whileNode.body.length - 1) {
|
|
1545
|
+
wf.cursor = whileNode.body[index + 1];
|
|
1546
|
+
this.persistContext(run, context, wf);
|
|
1547
|
+
return { kind: "advanced", nodeId: wf.cursor };
|
|
1548
|
+
}
|
|
1549
|
+
wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
|
|
1550
|
+
wf.cursor = whileNode.id;
|
|
1551
|
+
this.persistContext(run, context, wf);
|
|
1552
|
+
return { kind: "advanced", nodeId: whileNode.id };
|
|
1553
|
+
}
|
|
1554
|
+
this.completeRun(run, context);
|
|
1555
|
+
return { kind: "completed" };
|
|
1556
|
+
}
|
|
1557
|
+
executeWhile(run, graph, context, wf, node) {
|
|
1558
|
+
const iteration = wf.loops[node.id] ?? 0;
|
|
1559
|
+
if (iteration >= node.maxIterations) {
|
|
1560
|
+
const error = `while node ${node.id} exceeded maxIterations (${node.maxIterations})`;
|
|
1561
|
+
this.failRun(run, error);
|
|
1562
|
+
return { kind: "failed", error };
|
|
1563
|
+
}
|
|
1564
|
+
const scope = { ...this.conditionScope(context), i: iteration };
|
|
1565
|
+
const keepGoing = evaluateExpr(node.condition, scope);
|
|
1566
|
+
if (!keepGoing) {
|
|
1567
|
+
wf.completedLoops[node.id] = iteration;
|
|
1568
|
+
delete wf.loops[node.id];
|
|
1569
|
+
wf.cursor = node.next;
|
|
1570
|
+
this.persistContext(run, context, wf);
|
|
1571
|
+
return { kind: "advanced", nodeId: node.id };
|
|
1572
|
+
}
|
|
1573
|
+
wf.cursor = node.body[0];
|
|
1574
|
+
this.persistContext(run, context, wf);
|
|
1575
|
+
return { kind: "advanced", nodeId: node.id };
|
|
1576
|
+
}
|
|
1577
|
+
recordStepResult(context, nodeId, output) {
|
|
1578
|
+
const steps = context.steps ?? {};
|
|
1579
|
+
steps[nodeId] = output;
|
|
1580
|
+
context.steps = steps;
|
|
1581
|
+
}
|
|
1582
|
+
completeRun(run, context) {
|
|
1583
|
+
const result = {
|
|
1584
|
+
steps: context.steps ?? {},
|
|
1585
|
+
iterations: context.__wf?.completedLoops ?? {}
|
|
1586
|
+
};
|
|
1587
|
+
try {
|
|
1588
|
+
assertNoSecrets(result, "run result");
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1591
|
+
this.failRun(run, message);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
this.store.setRunStatus(run.id, "completed", { result, finishedAt: nowIso(), error: null });
|
|
1595
|
+
this.wal.append({ op: "run_finished", runId: run.id, status: "completed", at: nowIso() });
|
|
1596
|
+
const lease = this.leases.get(run.id);
|
|
1597
|
+
if (lease)
|
|
1598
|
+
this.release(run.id, lease.fencing);
|
|
1599
|
+
}
|
|
1600
|
+
failRun(run, error) {
|
|
1601
|
+
this.store.setRunStatus(run.id, "failed", { error, finishedAt: nowIso() });
|
|
1602
|
+
this.wal.append({ op: "run_finished", runId: run.id, status: "failed", at: nowIso() });
|
|
1603
|
+
const lease = this.leases.get(run.id);
|
|
1604
|
+
if (lease)
|
|
1605
|
+
this.release(run.id, lease.fencing);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
var defaultLaneRunner = async (job) => {
|
|
1609
|
+
if (job.command)
|
|
1610
|
+
return runCommand(job);
|
|
1611
|
+
const { runLaneJob: runLaneJob2 } = await Promise.resolve().then(() => (init_lanes(), exports_lanes));
|
|
1612
|
+
return runLaneJob2(job);
|
|
1613
|
+
};
|
|
1614
|
+
async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
1615
|
+
const daemon = new WorkflowsDaemon(store, wal, {
|
|
1616
|
+
worker: "local",
|
|
1617
|
+
laneRunner: options.laneRunner,
|
|
1618
|
+
time: options.time,
|
|
1619
|
+
env: options.env,
|
|
1620
|
+
isolatedRunId: options.isolatedRunId
|
|
1621
|
+
});
|
|
1622
|
+
const run = daemon.startRun(graph, context);
|
|
1623
|
+
daemon.isolateRun(run.id);
|
|
1624
|
+
const maxCycles = options.maxCycles ?? 500;
|
|
1625
|
+
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
1626
|
+
const report = await daemon.reap();
|
|
1627
|
+
const current = store.getRun(run.id);
|
|
1628
|
+
if (current.status !== "running")
|
|
1629
|
+
return current;
|
|
1630
|
+
if (report.advanced === 0 && report.dispatched === 0) {
|
|
1631
|
+
return store.getRun(run.id);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return store.getRun(run.id);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/store.ts
|
|
1638
|
+
import { Database } from "bun:sqlite";
|
|
1639
|
+
import { mkdirSync } from "fs";
|
|
1640
|
+
import { join } from "path";
|
|
1641
|
+
import { randomUUID } from "crypto";
|
|
1642
|
+
function nowIso2() {
|
|
1643
|
+
return new Date().toISOString();
|
|
1644
|
+
}
|
|
1645
|
+
function openStore(dataDir) {
|
|
1646
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1647
|
+
const db = new Database(join(dataDir, "workflows.db"));
|
|
1648
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1649
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
1650
|
+
db.exec(`
|
|
1651
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
1652
|
+
id TEXT PRIMARY KEY,
|
|
1653
|
+
graph_name TEXT NOT NULL,
|
|
1654
|
+
graph_version TEXT NOT NULL,
|
|
1655
|
+
status TEXT NOT NULL,
|
|
1656
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1657
|
+
context_json TEXT NOT NULL,
|
|
1658
|
+
result_json TEXT,
|
|
1659
|
+
error TEXT,
|
|
1660
|
+
started_at TEXT,
|
|
1661
|
+
finished_at TEXT,
|
|
1662
|
+
created_at TEXT NOT NULL,
|
|
1663
|
+
updated_at TEXT NOT NULL
|
|
1664
|
+
);
|
|
1665
|
+
CREATE TABLE IF NOT EXISTS run_nodes (
|
|
1666
|
+
id TEXT PRIMARY KEY,
|
|
1667
|
+
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1668
|
+
node_id TEXT NOT NULL,
|
|
1669
|
+
status TEXT NOT NULL,
|
|
1670
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1671
|
+
lane TEXT,
|
|
1672
|
+
exit_code INTEGER,
|
|
1673
|
+
output_json TEXT,
|
|
1674
|
+
error TEXT,
|
|
1675
|
+
started_at TEXT,
|
|
1676
|
+
finished_at TEXT
|
|
1677
|
+
);
|
|
1678
|
+
CREATE TABLE IF NOT EXISTS memos (
|
|
1679
|
+
key TEXT PRIMARY KEY,
|
|
1680
|
+
graph_name TEXT NOT NULL,
|
|
1681
|
+
node_id TEXT NOT NULL,
|
|
1682
|
+
input_hash TEXT NOT NULL,
|
|
1683
|
+
output_json TEXT NOT NULL,
|
|
1684
|
+
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
1685
|
+
created_at TEXT NOT NULL
|
|
1686
|
+
);
|
|
1687
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1688
|
+
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1689
|
+
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1690
|
+
`);
|
|
1691
|
+
const rowToRun = (r) => ({
|
|
1692
|
+
id: String(r.id),
|
|
1693
|
+
graphName: String(r.graph_name),
|
|
1694
|
+
graphVersion: String(r.graph_version),
|
|
1695
|
+
status: r.status,
|
|
1696
|
+
attempts: Number(r.attempts),
|
|
1697
|
+
contextJson: String(r.context_json),
|
|
1698
|
+
resultJson: r.result_json === null ? null : String(r.result_json),
|
|
1699
|
+
error: r.error === null ? null : String(r.error),
|
|
1700
|
+
startedAt: r.started_at === null ? null : String(r.started_at),
|
|
1701
|
+
finishedAt: r.finished_at === null ? null : String(r.finished_at),
|
|
1702
|
+
createdAt: String(r.created_at),
|
|
1703
|
+
updatedAt: String(r.updated_at)
|
|
1704
|
+
});
|
|
1705
|
+
const rowToNode = (r) => ({
|
|
1706
|
+
id: String(r.id),
|
|
1707
|
+
runId: String(r.run_id),
|
|
1708
|
+
nodeId: String(r.node_id),
|
|
1709
|
+
status: r.status,
|
|
1710
|
+
attempts: Number(r.attempts),
|
|
1711
|
+
lane: r.lane === null ? null : String(r.lane),
|
|
1712
|
+
exitCode: r.exit_code === null ? null : Number(r.exit_code),
|
|
1713
|
+
outputJson: r.output_json === null ? null : String(r.output_json),
|
|
1714
|
+
error: r.error === null ? null : String(r.error),
|
|
1715
|
+
startedAt: r.started_at === null ? null : String(r.started_at),
|
|
1716
|
+
finishedAt: r.finished_at === null ? null : String(r.finished_at)
|
|
1717
|
+
});
|
|
1718
|
+
const rowToMemo = (r) => ({
|
|
1719
|
+
key: String(r.key),
|
|
1720
|
+
graphName: String(r.graph_name),
|
|
1721
|
+
nodeId: String(r.node_id),
|
|
1722
|
+
inputHash: String(r.input_hash),
|
|
1723
|
+
outputJson: String(r.output_json),
|
|
1724
|
+
hitCount: Number(r.hit_count),
|
|
1725
|
+
createdAt: String(r.created_at)
|
|
1726
|
+
});
|
|
1727
|
+
return {
|
|
1728
|
+
close() {
|
|
1729
|
+
db.close();
|
|
1730
|
+
},
|
|
1731
|
+
listTables() {
|
|
1732
|
+
const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
1733
|
+
return rows.map((r) => r.name);
|
|
1734
|
+
},
|
|
1735
|
+
createRun(input) {
|
|
1736
|
+
const id = randomUUID();
|
|
1737
|
+
const now = nowIso2();
|
|
1738
|
+
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1739
|
+
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now);
|
|
1740
|
+
return this.getRun(id);
|
|
1741
|
+
},
|
|
1742
|
+
getRun(id) {
|
|
1743
|
+
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1744
|
+
return row ? rowToRun(row) : undefined;
|
|
1745
|
+
},
|
|
1746
|
+
listRuns(opts = {}) {
|
|
1747
|
+
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1748
|
+
if (opts.status) {
|
|
1749
|
+
const rows2 = db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(opts.status, limit);
|
|
1750
|
+
return rows2.map((r) => rowToRun(r));
|
|
1751
|
+
}
|
|
1752
|
+
const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
|
|
1753
|
+
return rows.map((r) => rowToRun(r));
|
|
1754
|
+
},
|
|
1755
|
+
setRunStatus(id, status, patch = {}) {
|
|
1756
|
+
const now = nowIso2();
|
|
1757
|
+
const existing = this.getRun(id);
|
|
1758
|
+
const resultJson = patch.result !== undefined ? JSON.stringify(patch.result) : existing?.resultJson ?? null;
|
|
1759
|
+
const error = patch.error !== undefined ? patch.error : existing?.error ?? null;
|
|
1760
|
+
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1761
|
+
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1762
|
+
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1763
|
+
db.query(`UPDATE runs SET status = ?, result_json = ?, error = ?, started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`).run(status, resultJson, error, startedAt, finishedAt, now, id);
|
|
1764
|
+
},
|
|
1765
|
+
bumpAttempts(id) {
|
|
1766
|
+
db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
|
|
1767
|
+
},
|
|
1768
|
+
setRunContext(id, context) {
|
|
1769
|
+
db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
|
|
1770
|
+
},
|
|
1771
|
+
createRunNode(input) {
|
|
1772
|
+
const id = randomUUID();
|
|
1773
|
+
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1774
|
+
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null);
|
|
1775
|
+
return this.getRunNode(id);
|
|
1776
|
+
},
|
|
1777
|
+
getRunNode(id) {
|
|
1778
|
+
const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
|
|
1779
|
+
return row ? rowToNode(row) : undefined;
|
|
1780
|
+
},
|
|
1781
|
+
listRunNodes(runId) {
|
|
1782
|
+
const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
|
|
1783
|
+
return rows.map((r) => rowToNode(r));
|
|
1784
|
+
},
|
|
1785
|
+
setRunNodeStatus(id, status, patch = {}) {
|
|
1786
|
+
const now = nowIso2();
|
|
1787
|
+
const existing = this.getRunNode(id);
|
|
1788
|
+
if (!existing)
|
|
1789
|
+
return;
|
|
1790
|
+
const exitCode = patch.exitCode !== undefined ? patch.exitCode : existing.exitCode;
|
|
1791
|
+
const outputJson = patch.output !== undefined ? JSON.stringify(patch.output) : existing.outputJson;
|
|
1792
|
+
const error = patch.error !== undefined ? patch.error : existing.error;
|
|
1793
|
+
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1794
|
+
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1795
|
+
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1796
|
+
db.query(`UPDATE run_nodes SET status = ?, exit_code = ?, output_json = ?, error = ?, started_at = ?, finished_at = ? WHERE id = ?`).run(status, exitCode, outputJson, error, startedAt, finishedAt, id);
|
|
1797
|
+
},
|
|
1798
|
+
bumpAttemptsNode(id) {
|
|
1799
|
+
db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
|
|
1800
|
+
},
|
|
1801
|
+
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1802
|
+
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1803
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
1804
|
+
ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2());
|
|
1805
|
+
},
|
|
1806
|
+
memoGet(key) {
|
|
1807
|
+
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1808
|
+
return row ? rowToMemo(row) : undefined;
|
|
1809
|
+
},
|
|
1810
|
+
memoHit(key) {
|
|
1811
|
+
db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
|
|
1812
|
+
},
|
|
1813
|
+
memoList() {
|
|
1814
|
+
const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
|
|
1815
|
+
return rows.map((r) => rowToMemo(r));
|
|
1816
|
+
},
|
|
1817
|
+
memoClear() {
|
|
1818
|
+
db.exec(`DELETE FROM memos`);
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// src/wal.ts
|
|
1824
|
+
import { createHash as createHash3 } from "crypto";
|
|
1825
|
+
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1826
|
+
import { join as join2 } from "path";
|
|
1827
|
+
var WAL_FILE_NAME = "session.wal";
|
|
1828
|
+
var SESSIONS_DIR_NAME = "sessions";
|
|
1829
|
+
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1830
|
+
function checksumOf(op) {
|
|
1831
|
+
if (op === undefined || op === null)
|
|
1832
|
+
return "";
|
|
1833
|
+
return createHash3("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
class SessionWAL {
|
|
1837
|
+
filePath;
|
|
1838
|
+
constructor(filePath) {
|
|
1839
|
+
this.filePath = filePath;
|
|
1840
|
+
}
|
|
1841
|
+
static open(dataDir) {
|
|
1842
|
+
mkdirSync2(join2(dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
1843
|
+
const canonical = join2(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
1844
|
+
const legacy = join2(dataDir, WAL_FILE_NAME);
|
|
1845
|
+
if (existsSync(legacy) && !existsSync(canonical)) {
|
|
1846
|
+
try {
|
|
1847
|
+
renameSync(legacy, canonical);
|
|
1848
|
+
} catch {
|
|
1849
|
+
return new SessionWAL(legacy);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return new SessionWAL(canonical);
|
|
1853
|
+
}
|
|
1854
|
+
static openAt(filePath) {
|
|
1855
|
+
return new SessionWAL(filePath);
|
|
1856
|
+
}
|
|
1857
|
+
append(op) {
|
|
1858
|
+
const at = op.at ?? new Date().toISOString();
|
|
1859
|
+
const full = { ...op, at };
|
|
1860
|
+
const nextSeq = this.nextSeq();
|
|
1861
|
+
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
1862
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + `
|
|
1863
|
+
`, "utf8");
|
|
1864
|
+
return entry;
|
|
1865
|
+
}
|
|
1866
|
+
nextSeq() {
|
|
1867
|
+
const replay = this.replay();
|
|
1868
|
+
return replay.entries.length + 1;
|
|
1869
|
+
}
|
|
1870
|
+
replay() {
|
|
1871
|
+
const entries = [];
|
|
1872
|
+
let torn = false;
|
|
1873
|
+
let repaired = false;
|
|
1874
|
+
let content;
|
|
1875
|
+
try {
|
|
1876
|
+
content = readFileSync(this.filePath, "utf8");
|
|
1877
|
+
} catch {
|
|
1878
|
+
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1879
|
+
}
|
|
1880
|
+
if (content.length > MAX_WAL_BYTES) {
|
|
1881
|
+
throw new Error(`session WAL exceeds ${MAX_WAL_BYTES} bytes; refusing to replay a truncated journal`);
|
|
1882
|
+
}
|
|
1883
|
+
if (content === "") {
|
|
1884
|
+
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1885
|
+
}
|
|
1886
|
+
const lines = content.split(`
|
|
1887
|
+
`);
|
|
1888
|
+
let badOffset = null;
|
|
1889
|
+
let expectedSeq = 1;
|
|
1890
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1891
|
+
const line = lines[i];
|
|
1892
|
+
if (line === "")
|
|
1893
|
+
continue;
|
|
1894
|
+
if (badOffset !== null)
|
|
1895
|
+
break;
|
|
1896
|
+
const lineOffset = cumulativeOffset(lines, i);
|
|
1897
|
+
let entry;
|
|
1898
|
+
try {
|
|
1899
|
+
entry = JSON.parse(line);
|
|
1900
|
+
} catch {
|
|
1901
|
+
badOffset = lineOffset;
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
if (typeof entry?.seq !== "number" || entry.seq !== expectedSeq || entry.checksum !== checksumOf(entry.op)) {
|
|
1905
|
+
badOffset = lineOffset;
|
|
1906
|
+
break;
|
|
1907
|
+
}
|
|
1908
|
+
entries.push(entry);
|
|
1909
|
+
expectedSeq++;
|
|
1910
|
+
}
|
|
1911
|
+
if (badOffset !== null) {
|
|
1912
|
+
torn = true;
|
|
1913
|
+
try {
|
|
1914
|
+
truncateSync(this.filePath, badOffset);
|
|
1915
|
+
repaired = true;
|
|
1916
|
+
} catch {}
|
|
1917
|
+
}
|
|
1918
|
+
return {
|
|
1919
|
+
entries,
|
|
1920
|
+
torn,
|
|
1921
|
+
repaired,
|
|
1922
|
+
liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs)
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
close() {}
|
|
1926
|
+
}
|
|
1927
|
+
function cumulativeOffset(lines, index) {
|
|
1928
|
+
let offset = 0;
|
|
1929
|
+
for (let i = 0;i < index; i++) {
|
|
1930
|
+
offset += Buffer.byteLength(lines[i]) + 1;
|
|
1931
|
+
}
|
|
1932
|
+
return offset;
|
|
1933
|
+
}
|
|
1934
|
+
function reconstructLiveClaims(entries, nowMs = Date.now()) {
|
|
1935
|
+
const claims = new Map;
|
|
1936
|
+
for (const entry of entries) {
|
|
1937
|
+
const op = entry.op;
|
|
1938
|
+
if (op.op === "claim_acquired") {
|
|
1939
|
+
claims.set(op.runId, { worker: op.worker, fencing: op.fencing, expiresAtMs: Date.parse(op.expiresAt) });
|
|
1940
|
+
} else if (op.op === "claim_released") {
|
|
1941
|
+
const existing = claims.get(op.runId);
|
|
1942
|
+
if (existing && existing.fencing === op.fencing) {
|
|
1943
|
+
claims.delete(op.runId);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
for (const [runId, claim] of claims) {
|
|
1948
|
+
if (claim.expiresAtMs <= nowMs)
|
|
1949
|
+
claims.delete(runId);
|
|
1950
|
+
}
|
|
1951
|
+
return claims;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
89
1954
|
// src/service.ts
|
|
90
1955
|
var DEFAULT_PORT = 8790;
|
|
91
1956
|
var DEFAULT_HOST = "127.0.0.1";
|
|
@@ -112,7 +1977,7 @@ function resolveWorkflowsConfig(overrides = {}) {
|
|
|
112
1977
|
return {
|
|
113
1978
|
port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
|
|
114
1979
|
host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
|
|
115
|
-
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ??
|
|
1980
|
+
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join3(home, ".hasna", "workflows"),
|
|
116
1981
|
apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
|
|
117
1982
|
apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
|
|
118
1983
|
};
|
|
@@ -144,6 +2009,45 @@ class WorkflowsService {
|
|
|
144
2009
|
checks: { version: "ok" }
|
|
145
2010
|
};
|
|
146
2011
|
}
|
|
2012
|
+
async triggerRun(graph, context = {}, opts = {}) {
|
|
2013
|
+
const store = openStore(this.config.dataDir);
|
|
2014
|
+
const wal = SessionWAL.open(this.config.dataDir);
|
|
2015
|
+
try {
|
|
2016
|
+
if (opts.idempotencyKey) {
|
|
2017
|
+
const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
|
|
2018
|
+
if (existing) {
|
|
2019
|
+
return {
|
|
2020
|
+
runId: existing.id,
|
|
2021
|
+
status: existing.status,
|
|
2022
|
+
error: existing.error ?? null,
|
|
2023
|
+
result: existing.resultJson ? JSON.parse(existing.resultJson) : null,
|
|
2024
|
+
reused: true
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
const contextWithKey = opts.idempotencyKey ? {
|
|
2029
|
+
...context,
|
|
2030
|
+
__wf: {
|
|
2031
|
+
cursor: undefined,
|
|
2032
|
+
loops: {},
|
|
2033
|
+
completedLoops: {},
|
|
2034
|
+
...context?.__wf ?? {},
|
|
2035
|
+
idempotencyKey: opts.idempotencyKey
|
|
2036
|
+
}
|
|
2037
|
+
} : context;
|
|
2038
|
+
const maxCycles = Math.min(Math.max(opts.maxCycles ?? 500, 1), 2000);
|
|
2039
|
+
const final = await runGraphToCompletion(store, wal, graph, contextWithKey, { maxCycles });
|
|
2040
|
+
return {
|
|
2041
|
+
runId: final.id,
|
|
2042
|
+
status: final.status,
|
|
2043
|
+
error: final.error ?? null,
|
|
2044
|
+
result: final.resultJson ? JSON.parse(final.resultJson) : null,
|
|
2045
|
+
reused: false
|
|
2046
|
+
};
|
|
2047
|
+
} finally {
|
|
2048
|
+
store.close();
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
147
2051
|
}
|
|
148
2052
|
function createWorkflowsService(config = {}) {
|
|
149
2053
|
return new WorkflowsService(config);
|