@hasna/workflows 0.1.4 → 0.2.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/README.md +152 -98
- package/dist/agent-tools.js +130 -1035
- package/dist/cli/index.js +4108 -2473
- package/dist/handlers.js +3287 -55
- package/dist/index.js +1403 -337
- package/dist/mcp/index.js +1725 -1742
- package/dist/sdk.js +1620 -0
- package/dist/serve/admin.js +4248 -0
- package/dist/serve/index.js +3349 -1381
- package/dist/service.js +1168 -250
- package/hasna.contract.json +17 -17
- package/package.json +15 -12
- package/types/agent-tools.d.ts +3 -17
- package/types/client-error.d.ts +4 -0
- package/types/client-tools.d.ts +4 -0
- package/types/client.d.ts +60 -0
- package/types/daemon.d.ts +15 -9
- package/types/db/postgres-store.d.ts +54 -0
- package/types/handlers.d.ts +2 -1
- package/types/identity.d.ts +7 -0
- package/types/lanes/types.d.ts +12 -3
- package/types/openapi.d.ts +7 -0
- package/types/paths.d.ts +10 -1
- package/types/sdk/generated-api.d.ts +159 -0
- package/types/sdk.d.ts +6 -0
- package/types/serve/admin.d.ts +2 -0
- package/types/service.d.ts +75 -4
- package/types/session.d.ts +7 -8
- package/types/setup.d.ts +71 -0
- package/types/store.d.ts +30 -0
- package/types/tool-definitions.d.ts +18 -0
- package/types/types.d.ts +8 -0
- package/types/version.d.ts +1 -0
- package/types/wal.d.ts +9 -1
package/dist/agent-tools.js
CHANGED
|
@@ -17,22 +17,42 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
|
17
17
|
var __require = import.meta.require;
|
|
18
18
|
|
|
19
19
|
// src/lanes/types.ts
|
|
20
|
-
import { statSync
|
|
20
|
+
import { statSync } from "fs";
|
|
21
21
|
function binaryOnPath(binary) {
|
|
22
22
|
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
23
23
|
for (const entry of pathEntries) {
|
|
24
24
|
try {
|
|
25
|
-
const stat =
|
|
25
|
+
const stat = statSync(`${entry}/${binary}`);
|
|
26
26
|
if (stat.isFile())
|
|
27
27
|
return true;
|
|
28
28
|
} catch {}
|
|
29
29
|
}
|
|
30
30
|
return false;
|
|
31
31
|
}
|
|
32
|
-
function
|
|
32
|
+
function subscriptionEnvironment(overrides = {}) {
|
|
33
|
+
const paid = /^(OPENAI_API_KEY|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CODEX_API_KEY|CURSOR_API_KEY)$/;
|
|
34
|
+
const sensitive = /(?:API[_-]?KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL|CREDENTIAL)/i;
|
|
35
|
+
const env = {};
|
|
36
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
37
|
+
if (value !== undefined && !paid.test(name) && (!sensitive.test(name) || name === "CLAUDE_CODE_OAUTH_TOKEN"))
|
|
38
|
+
env[name] = value;
|
|
39
|
+
}
|
|
40
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
41
|
+
if (paid.test(name) || sensitive.test(name))
|
|
42
|
+
throw new Error(`lane environment field ${name} requires a supported scoped credential consumer`);
|
|
43
|
+
env[name] = value;
|
|
44
|
+
}
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
function subscriptionSdkEnvironment(overrides = {}) {
|
|
48
|
+
const safe = subscriptionEnvironment(overrides);
|
|
49
|
+
return { ...Object.fromEntries(Object.keys(process.env).map((name) => [name, undefined])), ...safe };
|
|
50
|
+
}
|
|
51
|
+
function withTimeout(promise, timeoutMs, what, cancel) {
|
|
33
52
|
return new Promise((resolve, reject) => {
|
|
34
53
|
const timer = setTimeout(() => {
|
|
35
|
-
|
|
54
|
+
cancel?.();
|
|
55
|
+
reject(new LaneTimeoutError(what, timeoutMs));
|
|
36
56
|
}, timeoutMs);
|
|
37
57
|
promise.then((value) => {
|
|
38
58
|
clearTimeout(timer);
|
|
@@ -43,15 +63,16 @@ function withTimeout(promise, timeoutMs, what) {
|
|
|
43
63
|
});
|
|
44
64
|
});
|
|
45
65
|
}
|
|
46
|
-
function spawnCli(cmd, opts) {
|
|
66
|
+
async function spawnCli(cmd, opts) {
|
|
47
67
|
const started = Date.now();
|
|
48
68
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
49
69
|
let proc;
|
|
50
70
|
try {
|
|
51
|
-
proc = Bun.
|
|
71
|
+
proc = Bun.spawn({
|
|
52
72
|
cmd,
|
|
53
73
|
cwd: opts.cwd,
|
|
54
|
-
env:
|
|
74
|
+
env: subscriptionEnvironment(opts.env),
|
|
75
|
+
stdin: "ignore",
|
|
55
76
|
stdout: "pipe",
|
|
56
77
|
stderr: "pipe",
|
|
57
78
|
timeout: timeoutMs
|
|
@@ -63,15 +84,16 @@ function spawnCli(cmd, opts) {
|
|
|
63
84
|
}
|
|
64
85
|
return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
|
|
65
86
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
88
|
+
new Response(proc.stdout).text(),
|
|
89
|
+
new Response(proc.stderr).text(),
|
|
90
|
+
proc.exited
|
|
91
|
+
]);
|
|
69
92
|
const output = stderr ? `${stdout}
|
|
70
93
|
${stderr}`.trim() : stdout.trim();
|
|
71
|
-
|
|
72
|
-
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
94
|
+
return { ok: exitCode === 0, exitCode, output, durationMs: Date.now() - started };
|
|
73
95
|
}
|
|
74
|
-
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000;
|
|
96
|
+
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000, LaneTimeoutError;
|
|
75
97
|
var init_types = __esm(() => {
|
|
76
98
|
LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
|
|
77
99
|
LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
|
|
@@ -86,6 +108,12 @@ var init_types = __esm(() => {
|
|
|
86
108
|
this.name = "LaneAdapterShapeError";
|
|
87
109
|
}
|
|
88
110
|
};
|
|
111
|
+
LaneTimeoutError = class LaneTimeoutError extends Error {
|
|
112
|
+
constructor(what, timeoutMs) {
|
|
113
|
+
super(`${what} exceeded the ${timeoutMs}ms budget`);
|
|
114
|
+
this.name = "LaneTimeoutError";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
89
117
|
});
|
|
90
118
|
|
|
91
119
|
// src/lanes/claude.ts
|
|
@@ -127,11 +155,12 @@ function createClaudeAdapter(deps = {}) {
|
|
|
127
155
|
throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
|
|
128
156
|
}
|
|
129
157
|
const prompt = job.prompt;
|
|
158
|
+
const abortController = new AbortController;
|
|
130
159
|
try {
|
|
131
160
|
const { output, isError } = await withTimeout((async () => {
|
|
132
161
|
const generator = await query({
|
|
133
162
|
prompt,
|
|
134
|
-
options: { model: job.model, maxTurns: 32,
|
|
163
|
+
options: { model: job.model, cwd: job.cwd, maxTurns: 32, abortController, env: subscriptionSdkEnvironment(job.env) }
|
|
135
164
|
});
|
|
136
165
|
let output2 = "";
|
|
137
166
|
let isError2 = false;
|
|
@@ -150,19 +179,19 @@ function createClaudeAdapter(deps = {}) {
|
|
|
150
179
|
isError2 = true;
|
|
151
180
|
}
|
|
152
181
|
return { output: output2, isError: isError2 };
|
|
153
|
-
})(), timeoutMs,
|
|
182
|
+
})(), timeoutMs, "claude lane", () => abortController.abort());
|
|
154
183
|
const durationMs = Date.now() - started;
|
|
155
184
|
return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
|
|
156
185
|
} catch (err) {
|
|
157
186
|
const durationMs = Date.now() - started;
|
|
158
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
187
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
190
|
};
|
|
162
191
|
}
|
|
163
|
-
function runClaudeCli(job, cliPath, started, timeoutMs) {
|
|
192
|
+
async function runClaudeCli(job, cliPath, started, timeoutMs) {
|
|
164
193
|
try {
|
|
165
|
-
const result = spawnCli([cliPath, "-p", job.prompt], {
|
|
194
|
+
const result = await spawnCli([cliPath, "-p", job.prompt], {
|
|
166
195
|
binaryName: cliPath,
|
|
167
196
|
packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
|
|
168
197
|
timeoutMs,
|
|
@@ -219,29 +248,30 @@ function createCodexAdapter(deps = {}) {
|
|
|
219
248
|
if (typeof CodexClass !== "function") {
|
|
220
249
|
throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
|
|
221
250
|
}
|
|
251
|
+
const abortController = new AbortController;
|
|
222
252
|
try {
|
|
223
253
|
const client = await withTimeout(Promise.resolve(new CodexClass({
|
|
224
254
|
config: { model: job.model },
|
|
225
|
-
env: job.env
|
|
226
|
-
})), timeoutMs,
|
|
227
|
-
const thread = client.startThread?.();
|
|
255
|
+
env: subscriptionEnvironment(job.env)
|
|
256
|
+
})), timeoutMs, "codex lane");
|
|
257
|
+
const thread = client.startThread?.({ model: job.model, workingDirectory: job.cwd });
|
|
228
258
|
if (!thread || typeof thread.run !== "function") {
|
|
229
259
|
throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
|
|
230
260
|
}
|
|
231
|
-
const turn = await withTimeout(thread.run(job.prompt), timeoutMs,
|
|
261
|
+
const turn = await withTimeout(thread.run(job.prompt, { signal: abortController.signal }), timeoutMs, "codex lane", () => abortController.abort());
|
|
232
262
|
const output = turn?.finalResponse ?? "";
|
|
233
263
|
const durationMs = Date.now() - started;
|
|
234
264
|
return { ok: true, exitCode: 0, output, durationMs };
|
|
235
265
|
} catch (err) {
|
|
236
266
|
const durationMs = Date.now() - started;
|
|
237
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
267
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
238
268
|
}
|
|
239
269
|
}
|
|
240
270
|
};
|
|
241
271
|
}
|
|
242
|
-
function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
272
|
+
async function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
243
273
|
try {
|
|
244
|
-
const result = spawnCli([cliPath, "exec", "--", job.prompt], {
|
|
274
|
+
const result = await spawnCli([cliPath, "exec", "--", job.prompt], {
|
|
245
275
|
binaryName: cliPath,
|
|
246
276
|
packageHint: "@openai/codex-sdk or the codex CLI",
|
|
247
277
|
timeoutMs,
|
|
@@ -285,6 +315,8 @@ function createCursorAdapter(deps = {}) {
|
|
|
285
315
|
if (!job.prompt) {
|
|
286
316
|
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
|
|
287
317
|
}
|
|
318
|
+
if (!deps.sdkLoader && binaryOnPath(deps.cliPath ?? "cursor-agent"))
|
|
319
|
+
return runCursorCli(job, deps.cliPath ?? "cursor-agent", Date.now(), job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS);
|
|
288
320
|
const started = Date.now();
|
|
289
321
|
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
290
322
|
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
@@ -294,8 +326,14 @@ function createCursorAdapter(deps = {}) {
|
|
|
294
326
|
} catch {
|
|
295
327
|
return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
|
|
296
328
|
}
|
|
329
|
+
let cancel;
|
|
297
330
|
try {
|
|
298
|
-
|
|
331
|
+
subscriptionEnvironment(job.env);
|
|
332
|
+
if (!deps.sdkLoader && process.env.CURSOR_API_KEY)
|
|
333
|
+
throw new Error("cursor lane refuses ambient API billing; use the native subscription CLI");
|
|
334
|
+
const run = await withTimeout(runLocalAgent(sdk, job, (callback) => {
|
|
335
|
+
cancel = callback;
|
|
336
|
+
}), timeoutMs, "cursor lane", () => cancel?.());
|
|
299
337
|
const durationMs = Date.now() - started;
|
|
300
338
|
const status = run.status;
|
|
301
339
|
const ok = status === "finished";
|
|
@@ -304,22 +342,32 @@ function createCursorAdapter(deps = {}) {
|
|
|
304
342
|
if (err instanceof LaneAdapterShapeError)
|
|
305
343
|
throw err;
|
|
306
344
|
const durationMs = Date.now() - started;
|
|
307
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
345
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
308
346
|
}
|
|
309
347
|
}
|
|
310
348
|
};
|
|
311
349
|
}
|
|
312
|
-
async function runLocalAgent(sdk, job) {
|
|
350
|
+
async function runLocalAgent(sdk, job, onCancel) {
|
|
313
351
|
const AgentClass = sdk.Agent;
|
|
314
352
|
const CursorClass = sdk.Cursor;
|
|
315
353
|
if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
|
|
316
354
|
throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
|
|
317
355
|
}
|
|
318
|
-
const
|
|
356
|
+
const create = AgentClass.create;
|
|
357
|
+
const agent = typeof create === "function" ? await create.call(AgentClass, { local: { cwd: job.cwd, enableAgentRetries: false } }) : typeof AgentClass === "function" ? new AgentClass : undefined;
|
|
319
358
|
if (!agent || typeof agent.send !== "function") {
|
|
320
359
|
throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
|
|
321
360
|
}
|
|
322
|
-
|
|
361
|
+
let run = await agent.send(job.prompt, { model: job.model });
|
|
362
|
+
if (run && typeof run === "object") {
|
|
363
|
+
const handle = run;
|
|
364
|
+
if (typeof handle.cancel === "function")
|
|
365
|
+
onCancel(() => {
|
|
366
|
+
handle.cancel().catch(() => {});
|
|
367
|
+
});
|
|
368
|
+
if (typeof handle.wait === "function")
|
|
369
|
+
run = await handle.wait();
|
|
370
|
+
}
|
|
323
371
|
if (!run || typeof run !== "object") {
|
|
324
372
|
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
|
|
325
373
|
}
|
|
@@ -332,9 +380,9 @@ async function runLocalAgent(sdk, job) {
|
|
|
332
380
|
}
|
|
333
381
|
return { status: candidate.status, result: candidate.result };
|
|
334
382
|
}
|
|
335
|
-
function runCursorCli(job, cliPath, started, timeoutMs) {
|
|
383
|
+
async function runCursorCli(job, cliPath, started, timeoutMs) {
|
|
336
384
|
try {
|
|
337
|
-
const result = spawnCli([cliPath, "
|
|
385
|
+
const result = await spawnCli([cliPath, "--print", job.prompt, ...job.model ? ["--model", job.model] : []], {
|
|
338
386
|
binaryName: cliPath,
|
|
339
387
|
packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
|
|
340
388
|
timeoutMs,
|
|
@@ -377,7 +425,7 @@ function createGrokAdapter(deps = {}) {
|
|
|
377
425
|
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
378
426
|
const cliPath = deps.cliPath ?? "grok";
|
|
379
427
|
try {
|
|
380
|
-
const result = spawnCli([cliPath, job.prompt], {
|
|
428
|
+
const result = await spawnCli([cliPath, job.prompt], {
|
|
381
429
|
binaryName: cliPath,
|
|
382
430
|
packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
|
|
383
431
|
timeoutMs,
|
|
@@ -816,6 +864,9 @@ function findCycles(graph) {
|
|
|
816
864
|
}
|
|
817
865
|
function validateGraph(graph) {
|
|
818
866
|
const issues = [];
|
|
867
|
+
if (graph === null || typeof graph !== "object" || Array.isArray(graph)) {
|
|
868
|
+
return { ok: false, issues: [{ path: "graph", message: "graph must be an object" }] };
|
|
869
|
+
}
|
|
819
870
|
if (typeof graph.name !== "string" || graph.name.trim() === "") {
|
|
820
871
|
issue(issues, "name", "graph requires a non-empty name");
|
|
821
872
|
}
|
|
@@ -838,6 +889,24 @@ function validateGraph(graph) {
|
|
|
838
889
|
issue(issues, "nodes", "graph requires at least one node");
|
|
839
890
|
return { ok: false, issues };
|
|
840
891
|
}
|
|
892
|
+
for (let index = 0;index < graph.nodes.length; index++) {
|
|
893
|
+
const node = graph.nodes[index];
|
|
894
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
895
|
+
issue(issues, `nodes.${index}`, "node must be an object");
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!GRAPH_NODE_TYPES.includes(node.type))
|
|
899
|
+
issue(issues, `nodes.${index}.type`, "type must be start, step, decision, while, or end");
|
|
900
|
+
for (const key of ["next", "then", "else", "prompt", "command"]) {
|
|
901
|
+
const value = node[key];
|
|
902
|
+
if (value !== undefined && (typeof value !== "string" || value.trim() === ""))
|
|
903
|
+
issue(issues, `nodes.${index}.${key}`, `${key} must be a non-empty string`);
|
|
904
|
+
}
|
|
905
|
+
if (node.type === "while" && (!Array.isArray(node.body) || node.body.some((id) => typeof id !== "string")))
|
|
906
|
+
issue(issues, `nodes.${index}.body`, "body must be an array of node ids");
|
|
907
|
+
}
|
|
908
|
+
if (issues.length)
|
|
909
|
+
return { ok: false, issues };
|
|
841
910
|
const starts = graph.nodes.filter((n) => n.type === "start");
|
|
842
911
|
if (starts.length === 0)
|
|
843
912
|
issue(issues, "nodes", "graph requires exactly one start node (found none)");
|
|
@@ -899,6 +968,8 @@ function nodeEdges(node) {
|
|
|
899
968
|
return node.next ? [{ kind: "next", target: node.next }] : [];
|
|
900
969
|
case "end":
|
|
901
970
|
return [];
|
|
971
|
+
default:
|
|
972
|
+
return [];
|
|
902
973
|
}
|
|
903
974
|
}
|
|
904
975
|
function validateNode(graph, node, issues) {
|
|
@@ -992,983 +1063,15 @@ function validateNode(graph, node, issues) {
|
|
|
992
1063
|
}
|
|
993
1064
|
}
|
|
994
1065
|
|
|
995
|
-
// src/secrets.ts
|
|
996
|
-
class SecretsGateError extends Error {
|
|
997
|
-
constructor(message) {
|
|
998
|
-
super(message);
|
|
999
|
-
this.name = "SecretsGateError";
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
var p = (...parts) => parts.join("");
|
|
1003
|
-
var VALUE_PATTERNS = [
|
|
1004
|
-
{ detector: "anthropic_api_key", re: new RegExp(p("sk-", "ant-") + "[A-Za-z0-9_-]{10,}") },
|
|
1005
|
-
{ detector: "openai_api_key", re: new RegExp(p("sk-", "proj-") + "[A-Za-z0-9_-]{10,}") },
|
|
1006
|
-
{ detector: "npm_token", re: /npm_[A-Za-z0-9]{20,}/ },
|
|
1007
|
-
{ detector: "github_token", re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
1008
|
-
{ detector: "aws_access_key", re: /AKIA[0-9A-Z]{16}/ },
|
|
1009
|
-
{ detector: "google_api_key", re: /AIza[0-9A-Za-z_-]{20,}/ },
|
|
1010
|
-
{ detector: "xai_api_key", re: new RegExp(p("xai-") + "[A-Za-z0-9]{20,}") },
|
|
1011
|
-
{ detector: "codex_token", re: new RegExp(p("ctx7", "sk-") + "[A-Za-z0-9]{10,}") },
|
|
1012
|
-
{ detector: "private_key_block", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
1013
|
-
{ detector: "bearer_token", re: /Bearer [A-Za-z0-9._~+/=-]{20,}/ }
|
|
1014
|
-
];
|
|
1015
|
-
var CREDENTIAL_KEY_RE = /^(token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|refresh[_-]?token|session[_-]?token|bot[_-]?token)$/i;
|
|
1016
|
-
function scanString(value, path, findings) {
|
|
1017
|
-
for (const { detector, re } of VALUE_PATTERNS) {
|
|
1018
|
-
if (re.test(value)) {
|
|
1019
|
-
findings.push({ path, detector });
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
function isTrivial(value) {
|
|
1025
|
-
const v = value.trim();
|
|
1026
|
-
if (v.length === 0)
|
|
1027
|
-
return true;
|
|
1028
|
-
const upper = v.toUpperCase();
|
|
1029
|
-
if (upper.startsWith("REPLACE") || upper.startsWith("CHANGE") || upper.startsWith("YOUR_") || upper.startsWith("EXAMPLE") || upper.startsWith("PLACEHOLDER"))
|
|
1030
|
-
return true;
|
|
1031
|
-
if (v.length < 8)
|
|
1032
|
-
return true;
|
|
1033
|
-
return false;
|
|
1034
|
-
}
|
|
1035
|
-
function scanForSecrets(value, path = "$") {
|
|
1036
|
-
const findings = [];
|
|
1037
|
-
if (value === null || value === undefined)
|
|
1038
|
-
return findings;
|
|
1039
|
-
if (typeof value === "string") {
|
|
1040
|
-
scanString(value, path, findings);
|
|
1041
|
-
return findings;
|
|
1042
|
-
}
|
|
1043
|
-
if (Array.isArray(value)) {
|
|
1044
|
-
value.forEach((item, index) => {
|
|
1045
|
-
findings.push(...scanForSecrets(item, `${path}[${index}]`));
|
|
1046
|
-
});
|
|
1047
|
-
return findings;
|
|
1048
|
-
}
|
|
1049
|
-
if (typeof value === "object") {
|
|
1050
|
-
const record = value;
|
|
1051
|
-
for (const [key, child] of Object.entries(record)) {
|
|
1052
|
-
if (CREDENTIAL_KEY_RE.test(key) && typeof child === "string" && !isTrivial(child)) {
|
|
1053
|
-
findings.push({ path: `${path}.${key}`, detector: "credential_key_field" });
|
|
1054
|
-
} else {
|
|
1055
|
-
findings.push(...scanForSecrets(child, `${path}.${key}`));
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
return findings;
|
|
1060
|
-
}
|
|
1061
|
-
function assertNoSecrets(value, what) {
|
|
1062
|
-
const findings = scanForSecrets(value);
|
|
1063
|
-
if (findings.length === 0)
|
|
1064
|
-
return;
|
|
1065
|
-
const detail = findings.map((f) => `${f.path} (${f.detector})`).join(", ");
|
|
1066
|
-
throw new SecretsGateError(`secrets write-gate refused to persist ${what}: credential-shaped value at ${detail}`);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// src/session.ts
|
|
1070
|
-
import { createHash } from "crypto";
|
|
1071
|
-
import { readFileSync, statSync } from "fs";
|
|
1072
|
-
import { isAbsolute, join } from "path";
|
|
1073
|
-
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1074
|
-
function repairTornRuns(store, wal, opts = {}) {
|
|
1075
|
-
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
1076
|
-
const replay = wal.replay();
|
|
1077
|
-
const liveClaims = replay.liveClaims(opts.now);
|
|
1078
|
-
const report = { interrupted: 0, requeued: 0, failed: 0 };
|
|
1079
|
-
const running = store.listRuns({ status: "running", limit: 1000 });
|
|
1080
|
-
for (const run of running) {
|
|
1081
|
-
if (liveClaims.has(run.id))
|
|
1082
|
-
continue;
|
|
1083
|
-
report.interrupted++;
|
|
1084
|
-
const nodes = store.listRunNodes(run.id);
|
|
1085
|
-
if (run.attempts < maxAttempts) {
|
|
1086
|
-
store.bumpAttempts(run.id);
|
|
1087
|
-
store.setRunStatus(run.id, "interrupted", {
|
|
1088
|
-
error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
|
|
1089
|
-
});
|
|
1090
|
-
for (const node of nodes) {
|
|
1091
|
-
if (node.status === "running") {
|
|
1092
|
-
store.setRunNodeStatus(node.id, "pending", { error: undefined });
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
report.requeued++;
|
|
1096
|
-
} else {
|
|
1097
|
-
store.setRunStatus(run.id, "failed", {
|
|
1098
|
-
error: `torn run failed after ${maxAttempts} interrupted attempts`
|
|
1099
|
-
});
|
|
1100
|
-
for (const node of nodes) {
|
|
1101
|
-
if (node.status === "running") {
|
|
1102
|
-
store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
report.failed++;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
return report;
|
|
1109
|
-
}
|
|
1110
|
-
function inputHash(payload) {
|
|
1111
|
-
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
1112
|
-
}
|
|
1113
|
-
function memoKey(graphName, nodeId, input) {
|
|
1114
|
-
return `${graphName}:${nodeId}:${inputHash(input)}`;
|
|
1115
|
-
}
|
|
1116
|
-
function tryMemoHit(store, graphName, nodeId, input) {
|
|
1117
|
-
const key = memoKey(graphName, nodeId, input);
|
|
1118
|
-
const memo = store.memoGet(key);
|
|
1119
|
-
if (!memo)
|
|
1120
|
-
return;
|
|
1121
|
-
store.memoHit(key);
|
|
1122
|
-
return memo;
|
|
1123
|
-
}
|
|
1124
|
-
function recordMemo(store, graphName, nodeId, input, output) {
|
|
1125
|
-
const key = memoKey(graphName, nodeId, input);
|
|
1126
|
-
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1127
|
-
}
|
|
1128
|
-
function memoWatchFingerprint(graph, dataDir) {
|
|
1129
|
-
if (!graph.memoWatch || graph.memoWatch.length === 0)
|
|
1130
|
-
return;
|
|
1131
|
-
const entries = [];
|
|
1132
|
-
for (const pattern of graph.memoWatch) {
|
|
1133
|
-
if (hasGlobMagic(pattern)) {
|
|
1134
|
-
const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
|
|
1135
|
-
for (const match of matches) {
|
|
1136
|
-
const path2 = isAbsolute(match) ? match : join(dataDir, match);
|
|
1137
|
-
entries.push(fingerprintFile(pattern, path2));
|
|
1138
|
-
}
|
|
1139
|
-
continue;
|
|
1140
|
-
}
|
|
1141
|
-
const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
|
|
1142
|
-
entries.push(fingerprintFile(pattern, path));
|
|
1143
|
-
}
|
|
1144
|
-
return entries;
|
|
1145
|
-
}
|
|
1146
|
-
function hasGlobMagic(pattern) {
|
|
1147
|
-
return /[*?[\]{}]/.test(pattern);
|
|
1148
|
-
}
|
|
1149
|
-
function fingerprintFile(pattern, path) {
|
|
1150
|
-
try {
|
|
1151
|
-
const stat = statSync(path);
|
|
1152
|
-
if (!stat.isFile()) {
|
|
1153
|
-
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1154
|
-
}
|
|
1155
|
-
const content = readFileSync(path);
|
|
1156
|
-
return {
|
|
1157
|
-
pattern,
|
|
1158
|
-
path,
|
|
1159
|
-
missing: false,
|
|
1160
|
-
mtimeMs: stat.mtimeMs,
|
|
1161
|
-
size: content.byteLength,
|
|
1162
|
-
sha256: createHash("sha256").update(content).digest("hex")
|
|
1163
|
-
};
|
|
1164
|
-
} catch {
|
|
1165
|
-
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
function findRunByIdempotencyKey(store, key) {
|
|
1169
|
-
const runs = store.listRuns({ limit: 1000 });
|
|
1170
|
-
for (const run of runs) {
|
|
1171
|
-
try {
|
|
1172
|
-
const context = JSON.parse(run.contextJson);
|
|
1173
|
-
if (context?.__wf?.idempotencyKey === key)
|
|
1174
|
-
return run;
|
|
1175
|
-
} catch {}
|
|
1176
|
-
}
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// src/daemon.ts
|
|
1181
|
-
init_types();
|
|
1182
|
-
var DEFAULT_LEASE_TTL_MS = 120000;
|
|
1183
|
-
var DEFAULT_MAX_ATTEMPTS2 = 3;
|
|
1184
|
-
var DEFAULT_MAX_DISPATCH = 4;
|
|
1185
|
-
var DEFAULT_MAX_STEPS = 25;
|
|
1186
|
-
var DEFAULT_TIMEOUT_MS = 120000;
|
|
1187
|
-
function nowIso() {
|
|
1188
|
-
return new Date().toISOString();
|
|
1189
|
-
}
|
|
1190
|
-
function stripRunState(context) {
|
|
1191
|
-
const { __wf: _wf, steps: _steps, ...user } = context;
|
|
1192
|
-
return user;
|
|
1193
|
-
}
|
|
1194
|
-
function runCommand(job) {
|
|
1195
|
-
if (!job.command) {
|
|
1196
|
-
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
|
|
1197
|
-
}
|
|
1198
|
-
const started = Date.now();
|
|
1199
|
-
const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1200
|
-
const proc = Bun.spawnSync({
|
|
1201
|
-
cmd: ["/bin/sh", "-c", job.command],
|
|
1202
|
-
cwd: job.cwd,
|
|
1203
|
-
env: { ...process.env, ...job.env ?? {} },
|
|
1204
|
-
stdout: "pipe",
|
|
1205
|
-
stderr: "pipe",
|
|
1206
|
-
timeout: timeoutMs
|
|
1207
|
-
});
|
|
1208
|
-
const durationMs = Date.now() - started;
|
|
1209
|
-
const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
|
|
1210
|
-
const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
|
|
1211
|
-
const output = stderr ? `${stdout}
|
|
1212
|
-
${stderr}`.trim() : stdout.trim();
|
|
1213
|
-
const exitCode = proc.exitCode ?? (proc.signalCode ? 1 : 1);
|
|
1214
|
-
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
class WorkflowsDaemon {
|
|
1218
|
-
store;
|
|
1219
|
-
wal;
|
|
1220
|
-
worker;
|
|
1221
|
-
leaseTtlMs;
|
|
1222
|
-
maxAttempts;
|
|
1223
|
-
maxDispatchPerCycle;
|
|
1224
|
-
maxStepsPerCycle;
|
|
1225
|
-
time;
|
|
1226
|
-
laneRunner;
|
|
1227
|
-
env;
|
|
1228
|
-
leases = new Map;
|
|
1229
|
-
fencingCounter = new Map;
|
|
1230
|
-
graphCache = new Map;
|
|
1231
|
-
isolatedRunId = null;
|
|
1232
|
-
constructor(store, wal, options = {}) {
|
|
1233
|
-
this.store = store;
|
|
1234
|
-
this.wal = wal;
|
|
1235
|
-
this.worker = options.worker ?? "local";
|
|
1236
|
-
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
1237
|
-
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
|
|
1238
|
-
this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
|
|
1239
|
-
this.maxStepsPerCycle = options.maxStepsPerCycle ?? DEFAULT_MAX_STEPS;
|
|
1240
|
-
this.time = options.time ?? Date.now;
|
|
1241
|
-
this.env = options.env ?? {};
|
|
1242
|
-
this.laneRunner = options.laneRunner ?? defaultLaneRunner;
|
|
1243
|
-
this.isolatedRunId = options.isolatedRunId ?? null;
|
|
1244
|
-
this.replayRegisteredGraphs();
|
|
1245
|
-
}
|
|
1246
|
-
isolateRun(runId) {
|
|
1247
|
-
this.isolatedRunId = runId;
|
|
1248
|
-
}
|
|
1249
|
-
replayRegisteredGraphs() {
|
|
1250
|
-
const entries = this.wal.replay().entries;
|
|
1251
|
-
for (const entry of entries) {
|
|
1252
|
-
const op = entry.op;
|
|
1253
|
-
if (op.op === "graph_registered") {
|
|
1254
|
-
this.graphCache.set(op.name, op.graph);
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
liveLeases() {
|
|
1259
|
-
const now = this.time();
|
|
1260
|
-
for (const [runId, lease] of this.leases) {
|
|
1261
|
-
if (lease.expiresAtMs <= now)
|
|
1262
|
-
this.leases.delete(runId);
|
|
1263
|
-
}
|
|
1264
|
-
return this.leases;
|
|
1265
|
-
}
|
|
1266
|
-
claim(runId) {
|
|
1267
|
-
const now = this.time();
|
|
1268
|
-
const existing = this.leases.get(runId);
|
|
1269
|
-
if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
|
|
1270
|
-
return null;
|
|
1271
|
-
}
|
|
1272
|
-
const liveClaims = this.wal.replay().liveClaims(now);
|
|
1273
|
-
const foreign = liveClaims.get(runId);
|
|
1274
|
-
if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
|
|
1275
|
-
return null;
|
|
1276
|
-
}
|
|
1277
|
-
const fencing = (this.fencingCounter.get(runId) ?? 0) + 1;
|
|
1278
|
-
this.fencingCounter.set(runId, fencing);
|
|
1279
|
-
const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
|
|
1280
|
-
this.leases.set(runId, lease);
|
|
1281
|
-
this.wal.append({
|
|
1282
|
-
op: "claim_acquired",
|
|
1283
|
-
runId,
|
|
1284
|
-
worker: this.worker,
|
|
1285
|
-
expiresAt: new Date(lease.expiresAtMs).toISOString(),
|
|
1286
|
-
fencing,
|
|
1287
|
-
at: nowIso()
|
|
1288
|
-
});
|
|
1289
|
-
return lease;
|
|
1290
|
-
}
|
|
1291
|
-
heartbeat(runId, fencing) {
|
|
1292
|
-
const lease = this.leases.get(runId);
|
|
1293
|
-
if (!lease || lease.fencing !== fencing)
|
|
1294
|
-
return false;
|
|
1295
|
-
lease.expiresAtMs = this.time() + this.leaseTtlMs;
|
|
1296
|
-
return true;
|
|
1297
|
-
}
|
|
1298
|
-
release(runId, fencing) {
|
|
1299
|
-
const lease = this.leases.get(runId);
|
|
1300
|
-
if (!lease || lease.fencing !== fencing)
|
|
1301
|
-
return false;
|
|
1302
|
-
this.leases.delete(runId);
|
|
1303
|
-
this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
|
|
1304
|
-
return true;
|
|
1305
|
-
}
|
|
1306
|
-
async reap() {
|
|
1307
|
-
const report = { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
|
|
1308
|
-
const now = this.time();
|
|
1309
|
-
for (const [runId, lease] of this.leases) {
|
|
1310
|
-
if (lease.expiresAtMs <= now) {
|
|
1311
|
-
this.leases.delete(runId);
|
|
1312
|
-
report.expired++;
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
if (this.isolatedRunId === null) {
|
|
1316
|
-
const repair = repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
|
|
1317
|
-
report.interrupted += repair.interrupted;
|
|
1318
|
-
report.requeued += repair.requeued;
|
|
1319
|
-
report.failed += repair.failed;
|
|
1320
|
-
}
|
|
1321
|
-
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);
|
|
1322
|
-
for (const run of pending) {
|
|
1323
|
-
if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
|
|
1324
|
-
continue;
|
|
1325
|
-
const lease = this.claim(run.id);
|
|
1326
|
-
if (!lease)
|
|
1327
|
-
continue;
|
|
1328
|
-
this.store.setRunStatus(run.id, "running");
|
|
1329
|
-
this.wal.append({ op: "run_started", runId: run.id, at: nowIso() });
|
|
1330
|
-
report.dispatched++;
|
|
1331
|
-
}
|
|
1332
|
-
const running = this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
|
|
1333
|
-
let steps = 0;
|
|
1334
|
-
for (const run of running) {
|
|
1335
|
-
if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
|
|
1336
|
-
continue;
|
|
1337
|
-
if (steps >= this.maxStepsPerCycle)
|
|
1338
|
-
break;
|
|
1339
|
-
const outcome = await this.advanceRun(run.id);
|
|
1340
|
-
steps++;
|
|
1341
|
-
if (outcome.kind === "completed")
|
|
1342
|
-
report.completed++;
|
|
1343
|
-
if (outcome.kind === "failed")
|
|
1344
|
-
report.failed++;
|
|
1345
|
-
if (outcome.kind === "advanced")
|
|
1346
|
-
report.advanced++;
|
|
1347
|
-
}
|
|
1348
|
-
return report;
|
|
1349
|
-
}
|
|
1350
|
-
startRun(graph, context) {
|
|
1351
|
-
const validation = validateGraph(graph);
|
|
1352
|
-
if (!validation.ok) {
|
|
1353
|
-
throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
1354
|
-
}
|
|
1355
|
-
assertNoSecrets(context, "run context");
|
|
1356
|
-
assertNoSecrets(graph, "graph definition");
|
|
1357
|
-
const existing = this.graphCache.get(graph.name);
|
|
1358
|
-
const serialized = JSON.stringify(graph);
|
|
1359
|
-
if (!existing || JSON.stringify(existing) !== serialized) {
|
|
1360
|
-
this.graphCache.set(graph.name, graph);
|
|
1361
|
-
this.wal.append({ op: "graph_registered", name: graph.name, version: graph.version, graph, at: nowIso() });
|
|
1362
|
-
} else {
|
|
1363
|
-
this.graphCache.set(graph.name, existing);
|
|
1364
|
-
}
|
|
1365
|
-
return this.store.createRun({ graphName: graph.name, graphVersion: graph.version, context: context ?? {} });
|
|
1366
|
-
}
|
|
1367
|
-
async advanceRun(runId) {
|
|
1368
|
-
const run = this.store.getRun(runId);
|
|
1369
|
-
if (!run || run.status !== "running")
|
|
1370
|
-
return { kind: "noop" };
|
|
1371
|
-
const lease = this.leases.get(runId);
|
|
1372
|
-
if (!lease || lease.expiresAtMs <= this.time())
|
|
1373
|
-
return { kind: "blocked" };
|
|
1374
|
-
const graph = this.loadGraph(run.graphName);
|
|
1375
|
-
if (!graph) {
|
|
1376
|
-
this.failRun(run, `graph ${run.graphName} not found in daemon graph cache`);
|
|
1377
|
-
return { kind: "failed", error: "graph not found" };
|
|
1378
|
-
}
|
|
1379
|
-
const context = JSON.parse(run.contextJson);
|
|
1380
|
-
const rawWf = context.__wf ?? {};
|
|
1381
|
-
const wf = {
|
|
1382
|
-
...rawWf,
|
|
1383
|
-
cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
|
|
1384
|
-
loops: rawWf.loops ?? {},
|
|
1385
|
-
completedLoops: rawWf.completedLoops ?? {}
|
|
1386
|
-
};
|
|
1387
|
-
try {
|
|
1388
|
-
const cursor = wf.cursor;
|
|
1389
|
-
const node = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
|
|
1390
|
-
if (!node) {
|
|
1391
|
-
this.failRun(run, `cursor ${cursor ?? "<start>"} resolves to no node`);
|
|
1392
|
-
return { kind: "failed", error: "no node at cursor" };
|
|
1393
|
-
}
|
|
1394
|
-
switch (node.type) {
|
|
1395
|
-
case "start": {
|
|
1396
|
-
wf.cursor = node.next;
|
|
1397
|
-
this.persistContext(run, context, wf);
|
|
1398
|
-
return { kind: "advanced", nodeId: node.id };
|
|
1399
|
-
}
|
|
1400
|
-
case "step": {
|
|
1401
|
-
return await this.executeStep(run, graph, context, wf, node);
|
|
1402
|
-
}
|
|
1403
|
-
case "decision": {
|
|
1404
|
-
const value = evaluateExpr(node.condition, this.conditionScope(context));
|
|
1405
|
-
const next = value ? node.then : node.else;
|
|
1406
|
-
if (!next) {
|
|
1407
|
-
this.failRun(run, `decision ${node.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
|
|
1408
|
-
return { kind: "failed", error: "missing decision edge" };
|
|
1409
|
-
}
|
|
1410
|
-
wf.cursor = next;
|
|
1411
|
-
this.persistContext(run, context, wf);
|
|
1412
|
-
return { kind: "advanced", nodeId: node.id };
|
|
1413
|
-
}
|
|
1414
|
-
case "while": {
|
|
1415
|
-
return this.executeWhile(run, graph, context, wf, node);
|
|
1416
|
-
}
|
|
1417
|
-
case "end": {
|
|
1418
|
-
this.completeRun(run, context);
|
|
1419
|
-
return { kind: "completed" };
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
} catch (err) {
|
|
1423
|
-
if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
|
|
1424
|
-
this.failRun(run, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
|
|
1425
|
-
return { kind: "failed", error: err.message };
|
|
1426
|
-
}
|
|
1427
|
-
throw err;
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
loadGraph(name) {
|
|
1431
|
-
return this.graphCache.get(name);
|
|
1432
|
-
}
|
|
1433
|
-
conditionScope(context) {
|
|
1434
|
-
return { ...context, steps: context.steps ?? {} };
|
|
1435
|
-
}
|
|
1436
|
-
persistContext(run, context, wf) {
|
|
1437
|
-
context.__wf = wf;
|
|
1438
|
-
this.store.setRunContext(run.id, context);
|
|
1439
|
-
}
|
|
1440
|
-
async executeStep(run, graph, context, wf, node) {
|
|
1441
|
-
const nodeId = node.id;
|
|
1442
|
-
const latest = this.store.getLatestRunNode(run.id, nodeId);
|
|
1443
|
-
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
1444
|
-
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" }) : latest;
|
|
1445
|
-
const memoWatch = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1446
|
-
const memoSource = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
|
|
1447
|
-
const memoInput = node.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
1448
|
-
if (memoInput !== undefined) {
|
|
1449
|
-
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1450
|
-
if (hit) {
|
|
1451
|
-
const cached = JSON.parse(hit.outputJson);
|
|
1452
|
-
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } });
|
|
1453
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1454
|
-
this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
|
|
1455
|
-
return this.afterNode(run, context, wf, node.next);
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
if (nodeRow.status !== "running") {
|
|
1459
|
-
this.store.setRunNodeStatus(nodeRow.id, "running");
|
|
1460
|
-
this.wal.append({ op: "node_started", runId: run.id, nodeId, at: nowIso() });
|
|
1461
|
-
}
|
|
1462
|
-
const job = {
|
|
1463
|
-
lane: node.lane ?? "claude",
|
|
1464
|
-
prompt: node.prompt,
|
|
1465
|
-
command: node.command,
|
|
1466
|
-
timeoutMs: node.timeoutMs,
|
|
1467
|
-
env: this.env
|
|
1468
|
-
};
|
|
1469
|
-
let result;
|
|
1470
|
-
try {
|
|
1471
|
-
result = await this.laneRunner(job);
|
|
1472
|
-
} catch (err) {
|
|
1473
|
-
result = { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
|
|
1474
|
-
}
|
|
1475
|
-
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1476
|
-
if (!result.ok) {
|
|
1477
|
-
const shouldRetry = nodeRow.attempts < (node.maxRetries ?? 0);
|
|
1478
|
-
this.store.bumpAttemptsNode(nodeRow.id);
|
|
1479
|
-
if (shouldRetry) {
|
|
1480
|
-
this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
|
|
1481
|
-
return { kind: "advanced", nodeId };
|
|
1482
|
-
}
|
|
1483
|
-
const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
|
|
1484
|
-
this.store.setRunNodeStatus(nodeRow.id, "failed", { exitCode: result.exitCode, error });
|
|
1485
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
|
|
1486
|
-
this.failRun(run, error);
|
|
1487
|
-
return { kind: "failed", error };
|
|
1488
|
-
}
|
|
1489
|
-
try {
|
|
1490
|
-
assertNoSecrets(output, `node ${nodeId} output`);
|
|
1491
|
-
} catch (err) {
|
|
1492
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1493
|
-
this.store.setRunNodeStatus(nodeRow.id, "failed", { error: message });
|
|
1494
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
|
|
1495
|
-
this.failRun(run, message);
|
|
1496
|
-
return { kind: "failed", error: message };
|
|
1497
|
-
}
|
|
1498
|
-
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1499
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1500
|
-
if (memoInput !== undefined) {
|
|
1501
|
-
const memoInputAfter = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1502
|
-
const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
|
|
1503
|
-
if (stable) {
|
|
1504
|
-
recordMemo(this.store, run.graphName, nodeId, memoInput, output);
|
|
1505
|
-
this.wal.append({ op: "memo_set", key: memoKey(run.graphName, nodeId, memoInput), at: nowIso() });
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
this.recordStepResult(context, nodeId, output);
|
|
1509
|
-
return this.afterNode(run, context, wf, node.next);
|
|
1510
|
-
}
|
|
1511
|
-
afterNode(run, context, wf, next) {
|
|
1512
|
-
if (next) {
|
|
1513
|
-
wf.cursor = next;
|
|
1514
|
-
this.persistContext(run, context, wf);
|
|
1515
|
-
return { kind: "advanced", nodeId: wf.cursor };
|
|
1516
|
-
}
|
|
1517
|
-
const cursor = wf.cursor;
|
|
1518
|
-
const graph = this.loadGraph(run.graphName);
|
|
1519
|
-
const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
|
|
1520
|
-
if (whileNode) {
|
|
1521
|
-
const index = whileNode.body.indexOf(cursor ?? "");
|
|
1522
|
-
if (index >= 0 && index < whileNode.body.length - 1) {
|
|
1523
|
-
wf.cursor = whileNode.body[index + 1];
|
|
1524
|
-
this.persistContext(run, context, wf);
|
|
1525
|
-
return { kind: "advanced", nodeId: wf.cursor };
|
|
1526
|
-
}
|
|
1527
|
-
wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
|
|
1528
|
-
wf.cursor = whileNode.id;
|
|
1529
|
-
this.persistContext(run, context, wf);
|
|
1530
|
-
return { kind: "advanced", nodeId: whileNode.id };
|
|
1531
|
-
}
|
|
1532
|
-
this.completeRun(run, context);
|
|
1533
|
-
return { kind: "completed" };
|
|
1534
|
-
}
|
|
1535
|
-
executeWhile(run, graph, context, wf, node) {
|
|
1536
|
-
const iteration = wf.loops[node.id] ?? 0;
|
|
1537
|
-
if (iteration >= node.maxIterations) {
|
|
1538
|
-
const error = `while node ${node.id} exceeded maxIterations (${node.maxIterations})`;
|
|
1539
|
-
this.failRun(run, error);
|
|
1540
|
-
return { kind: "failed", error };
|
|
1541
|
-
}
|
|
1542
|
-
const scope = { ...this.conditionScope(context), i: iteration };
|
|
1543
|
-
const keepGoing = evaluateExpr(node.condition, scope);
|
|
1544
|
-
if (!keepGoing) {
|
|
1545
|
-
wf.completedLoops[node.id] = iteration;
|
|
1546
|
-
delete wf.loops[node.id];
|
|
1547
|
-
wf.cursor = node.next;
|
|
1548
|
-
this.persistContext(run, context, wf);
|
|
1549
|
-
return { kind: "advanced", nodeId: node.id };
|
|
1550
|
-
}
|
|
1551
|
-
wf.cursor = node.body[0];
|
|
1552
|
-
this.persistContext(run, context, wf);
|
|
1553
|
-
return { kind: "advanced", nodeId: node.id };
|
|
1554
|
-
}
|
|
1555
|
-
recordStepResult(context, nodeId, output) {
|
|
1556
|
-
const steps = context.steps ?? {};
|
|
1557
|
-
steps[nodeId] = output;
|
|
1558
|
-
context.steps = steps;
|
|
1559
|
-
}
|
|
1560
|
-
completeRun(run, context) {
|
|
1561
|
-
const result = {
|
|
1562
|
-
steps: context.steps ?? {},
|
|
1563
|
-
iterations: context.__wf?.completedLoops ?? {}
|
|
1564
|
-
};
|
|
1565
|
-
try {
|
|
1566
|
-
assertNoSecrets(result, "run result");
|
|
1567
|
-
} catch (err) {
|
|
1568
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1569
|
-
this.failRun(run, message);
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
this.store.setRunStatus(run.id, "completed", { result, finishedAt: nowIso(), error: null });
|
|
1573
|
-
this.wal.append({ op: "run_finished", runId: run.id, status: "completed", at: nowIso() });
|
|
1574
|
-
const lease = this.leases.get(run.id);
|
|
1575
|
-
if (lease)
|
|
1576
|
-
this.release(run.id, lease.fencing);
|
|
1577
|
-
}
|
|
1578
|
-
failRun(run, error) {
|
|
1579
|
-
this.store.setRunStatus(run.id, "failed", { error, finishedAt: nowIso() });
|
|
1580
|
-
this.wal.append({ op: "run_finished", runId: run.id, status: "failed", at: nowIso() });
|
|
1581
|
-
const lease = this.leases.get(run.id);
|
|
1582
|
-
if (lease)
|
|
1583
|
-
this.release(run.id, lease.fencing);
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
var defaultLaneRunner = async (job) => {
|
|
1587
|
-
if (job.command)
|
|
1588
|
-
return runCommand(job);
|
|
1589
|
-
const { runLaneJob: runLaneJob2 } = await Promise.resolve().then(() => (init_lanes(), exports_lanes));
|
|
1590
|
-
return runLaneJob2(job);
|
|
1591
|
-
};
|
|
1592
|
-
async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
1593
|
-
const daemon = new WorkflowsDaemon(store, wal, {
|
|
1594
|
-
worker: "local",
|
|
1595
|
-
laneRunner: options.laneRunner,
|
|
1596
|
-
time: options.time,
|
|
1597
|
-
env: options.env,
|
|
1598
|
-
isolatedRunId: options.isolatedRunId
|
|
1599
|
-
});
|
|
1600
|
-
const run = daemon.startRun(graph, context);
|
|
1601
|
-
options.onRunCreated?.(run);
|
|
1602
|
-
daemon.isolateRun(run.id);
|
|
1603
|
-
const maxCycles = options.maxCycles ?? 500;
|
|
1604
|
-
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
1605
|
-
const report = await daemon.reap();
|
|
1606
|
-
const current = store.getRun(run.id);
|
|
1607
|
-
if (current.status !== "running")
|
|
1608
|
-
return current;
|
|
1609
|
-
if (report.advanced === 0 && report.dispatched === 0) {
|
|
1610
|
-
return store.getRun(run.id);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
return store.getRun(run.id);
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
1066
|
// src/agent-tools.ts
|
|
1617
1067
|
init_lanes();
|
|
1618
1068
|
|
|
1619
|
-
// src/
|
|
1620
|
-
import { Database } from "bun:sqlite";
|
|
1621
|
-
import { mkdirSync } from "fs";
|
|
1622
|
-
import { join as join2 } from "path";
|
|
1623
|
-
import { randomUUID } from "crypto";
|
|
1624
|
-
function nowIso2() {
|
|
1625
|
-
return new Date().toISOString();
|
|
1626
|
-
}
|
|
1627
|
-
var BUSY_TIMEOUT_MS = 3000;
|
|
1628
|
-
var BUSY_RETRIES = 2;
|
|
1629
|
-
var BUSY_RETRY_SLEEP_MS = 50;
|
|
1630
|
-
function isBusyError(err) {
|
|
1631
|
-
if (!(err instanceof Error))
|
|
1632
|
-
return false;
|
|
1633
|
-
const code = String(err.code ?? "");
|
|
1634
|
-
return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
|
|
1635
|
-
}
|
|
1636
|
-
function retryBusy(fn) {
|
|
1637
|
-
let last;
|
|
1638
|
-
for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
|
|
1639
|
-
try {
|
|
1640
|
-
return fn();
|
|
1641
|
-
} catch (err) {
|
|
1642
|
-
if (!isBusyError(err))
|
|
1643
|
-
throw err;
|
|
1644
|
-
last = err;
|
|
1645
|
-
Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
throw last;
|
|
1649
|
-
}
|
|
1650
|
-
function openStore(dataDir) {
|
|
1651
|
-
mkdirSync(dataDir, { recursive: true });
|
|
1652
|
-
const db = new Database(join2(dataDir, "workflows.db"));
|
|
1653
|
-
retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
|
|
1654
|
-
retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
|
|
1655
|
-
retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
|
|
1656
|
-
retryBusy(() => db.exec(`
|
|
1657
|
-
CREATE TABLE IF NOT EXISTS runs (
|
|
1658
|
-
id TEXT PRIMARY KEY,
|
|
1659
|
-
graph_name TEXT NOT NULL,
|
|
1660
|
-
graph_version TEXT NOT NULL,
|
|
1661
|
-
status TEXT NOT NULL,
|
|
1662
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1663
|
-
context_json TEXT NOT NULL,
|
|
1664
|
-
result_json TEXT,
|
|
1665
|
-
error TEXT,
|
|
1666
|
-
started_at TEXT,
|
|
1667
|
-
finished_at TEXT,
|
|
1668
|
-
created_at TEXT NOT NULL,
|
|
1669
|
-
updated_at TEXT NOT NULL
|
|
1670
|
-
);
|
|
1671
|
-
CREATE TABLE IF NOT EXISTS run_nodes (
|
|
1672
|
-
id TEXT PRIMARY KEY,
|
|
1673
|
-
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1674
|
-
node_id TEXT NOT NULL,
|
|
1675
|
-
status TEXT NOT NULL,
|
|
1676
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1677
|
-
lane TEXT,
|
|
1678
|
-
exit_code INTEGER,
|
|
1679
|
-
output_json TEXT,
|
|
1680
|
-
error TEXT,
|
|
1681
|
-
started_at TEXT,
|
|
1682
|
-
finished_at TEXT
|
|
1683
|
-
);
|
|
1684
|
-
CREATE TABLE IF NOT EXISTS memos (
|
|
1685
|
-
key TEXT PRIMARY KEY,
|
|
1686
|
-
graph_name TEXT NOT NULL,
|
|
1687
|
-
node_id TEXT NOT NULL,
|
|
1688
|
-
input_hash TEXT NOT NULL,
|
|
1689
|
-
output_json TEXT NOT NULL,
|
|
1690
|
-
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
1691
|
-
created_at TEXT NOT NULL
|
|
1692
|
-
);
|
|
1693
|
-
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1694
|
-
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1695
|
-
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1696
|
-
`));
|
|
1697
|
-
const rowToRun = (r) => ({
|
|
1698
|
-
id: String(r.id),
|
|
1699
|
-
graphName: String(r.graph_name),
|
|
1700
|
-
graphVersion: String(r.graph_version),
|
|
1701
|
-
status: r.status,
|
|
1702
|
-
attempts: Number(r.attempts),
|
|
1703
|
-
contextJson: String(r.context_json),
|
|
1704
|
-
resultJson: r.result_json === null ? null : String(r.result_json),
|
|
1705
|
-
error: r.error === null ? null : String(r.error),
|
|
1706
|
-
startedAt: r.started_at === null ? null : String(r.started_at),
|
|
1707
|
-
finishedAt: r.finished_at === null ? null : String(r.finished_at),
|
|
1708
|
-
createdAt: String(r.created_at),
|
|
1709
|
-
updatedAt: String(r.updated_at)
|
|
1710
|
-
});
|
|
1711
|
-
const rowToNode = (r) => ({
|
|
1712
|
-
id: String(r.id),
|
|
1713
|
-
runId: String(r.run_id),
|
|
1714
|
-
nodeId: String(r.node_id),
|
|
1715
|
-
status: r.status,
|
|
1716
|
-
attempts: Number(r.attempts),
|
|
1717
|
-
lane: r.lane === null ? null : String(r.lane),
|
|
1718
|
-
exitCode: r.exit_code === null ? null : Number(r.exit_code),
|
|
1719
|
-
outputJson: r.output_json === null ? null : String(r.output_json),
|
|
1720
|
-
error: r.error === null ? null : String(r.error),
|
|
1721
|
-
startedAt: r.started_at === null ? null : String(r.started_at),
|
|
1722
|
-
finishedAt: r.finished_at === null ? null : String(r.finished_at)
|
|
1723
|
-
});
|
|
1724
|
-
const rowToMemo = (r) => ({
|
|
1725
|
-
key: String(r.key),
|
|
1726
|
-
graphName: String(r.graph_name),
|
|
1727
|
-
nodeId: String(r.node_id),
|
|
1728
|
-
inputHash: String(r.input_hash),
|
|
1729
|
-
outputJson: String(r.output_json),
|
|
1730
|
-
hitCount: Number(r.hit_count),
|
|
1731
|
-
createdAt: String(r.created_at)
|
|
1732
|
-
});
|
|
1733
|
-
return {
|
|
1734
|
-
dataDir,
|
|
1735
|
-
close() {
|
|
1736
|
-
db.close();
|
|
1737
|
-
},
|
|
1738
|
-
listTables() {
|
|
1739
|
-
const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
|
|
1740
|
-
return rows.map((r) => r.name);
|
|
1741
|
-
},
|
|
1742
|
-
createRun(input) {
|
|
1743
|
-
const id = randomUUID();
|
|
1744
|
-
const now = nowIso2();
|
|
1745
|
-
retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1746
|
-
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
|
|
1747
|
-
return this.getRun(id);
|
|
1748
|
-
},
|
|
1749
|
-
getRun(id) {
|
|
1750
|
-
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1751
|
-
return row ? rowToRun(row) : undefined;
|
|
1752
|
-
},
|
|
1753
|
-
listRuns(opts = {}) {
|
|
1754
|
-
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1755
|
-
if (opts.status) {
|
|
1756
|
-
const status = opts.status;
|
|
1757
|
-
const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
|
|
1758
|
-
return rows2.map((r) => rowToRun(r));
|
|
1759
|
-
}
|
|
1760
|
-
const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
|
|
1761
|
-
return rows.map((r) => rowToRun(r));
|
|
1762
|
-
},
|
|
1763
|
-
setRunStatus(id, status, patch = {}) {
|
|
1764
|
-
const now = nowIso2();
|
|
1765
|
-
const existing = this.getRun(id);
|
|
1766
|
-
const resultJson = patch.result !== undefined ? JSON.stringify(patch.result) : existing?.resultJson ?? null;
|
|
1767
|
-
const error = patch.error !== undefined ? patch.error : existing?.error ?? null;
|
|
1768
|
-
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1769
|
-
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1770
|
-
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1771
|
-
retryBusy(() => 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));
|
|
1772
|
-
},
|
|
1773
|
-
bumpAttempts(id) {
|
|
1774
|
-
retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
|
|
1775
|
-
},
|
|
1776
|
-
setRunContext(id, context) {
|
|
1777
|
-
retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
|
|
1778
|
-
},
|
|
1779
|
-
createRunNode(input) {
|
|
1780
|
-
const id = randomUUID();
|
|
1781
|
-
retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1782
|
-
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
|
|
1783
|
-
return this.getRunNode(id);
|
|
1784
|
-
},
|
|
1785
|
-
getRunNode(id) {
|
|
1786
|
-
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
|
|
1787
|
-
return row ? rowToNode(row) : undefined;
|
|
1788
|
-
},
|
|
1789
|
-
listRunNodes(runId) {
|
|
1790
|
-
const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
|
|
1791
|
-
return rows.map((r) => rowToNode(r));
|
|
1792
|
-
},
|
|
1793
|
-
getLatestRunNode(runId, nodeId) {
|
|
1794
|
-
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
|
|
1795
|
-
return row ? rowToNode(row) : undefined;
|
|
1796
|
-
},
|
|
1797
|
-
setRunNodeStatus(id, status, patch = {}) {
|
|
1798
|
-
const now = nowIso2();
|
|
1799
|
-
const existing = this.getRunNode(id);
|
|
1800
|
-
if (!existing)
|
|
1801
|
-
return;
|
|
1802
|
-
const exitCode = patch.exitCode !== undefined ? patch.exitCode : existing.exitCode;
|
|
1803
|
-
const outputJson = patch.output !== undefined ? JSON.stringify(patch.output) : existing.outputJson;
|
|
1804
|
-
const error = patch.error !== undefined ? patch.error : existing.error;
|
|
1805
|
-
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1806
|
-
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1807
|
-
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1808
|
-
retryBusy(() => 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));
|
|
1809
|
-
},
|
|
1810
|
-
bumpAttemptsNode(id) {
|
|
1811
|
-
retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
|
|
1812
|
-
},
|
|
1813
|
-
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1814
|
-
retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1815
|
-
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
1816
|
-
ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
|
|
1817
|
-
},
|
|
1818
|
-
memoGet(key) {
|
|
1819
|
-
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1820
|
-
return row ? rowToMemo(row) : undefined;
|
|
1821
|
-
},
|
|
1822
|
-
memoHit(key) {
|
|
1823
|
-
retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
|
|
1824
|
-
},
|
|
1825
|
-
memoList() {
|
|
1826
|
-
const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
|
|
1827
|
-
return rows.map((r) => rowToMemo(r));
|
|
1828
|
-
},
|
|
1829
|
-
memoClear() {
|
|
1830
|
-
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1831
|
-
}
|
|
1832
|
-
};
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
// src/wal.ts
|
|
1836
|
-
import { createHash as createHash2 } from "crypto";
|
|
1837
|
-
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1838
|
-
import { join as join3 } from "path";
|
|
1839
|
-
var WAL_FILE_NAME = "session.wal";
|
|
1840
|
-
var SESSIONS_DIR_NAME = "sessions";
|
|
1841
|
-
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1842
|
-
function checksumOf(op) {
|
|
1843
|
-
if (op === undefined || op === null)
|
|
1844
|
-
return "";
|
|
1845
|
-
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
class SessionWAL {
|
|
1849
|
-
filePath;
|
|
1850
|
-
constructor(filePath) {
|
|
1851
|
-
this.filePath = filePath;
|
|
1852
|
-
}
|
|
1853
|
-
static open(dataDir) {
|
|
1854
|
-
mkdirSync2(join3(dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
1855
|
-
const canonical = join3(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
1856
|
-
const legacy = join3(dataDir, WAL_FILE_NAME);
|
|
1857
|
-
if (existsSync(legacy) && !existsSync(canonical)) {
|
|
1858
|
-
try {
|
|
1859
|
-
renameSync(legacy, canonical);
|
|
1860
|
-
} catch {
|
|
1861
|
-
return new SessionWAL(legacy);
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
return new SessionWAL(canonical);
|
|
1865
|
-
}
|
|
1866
|
-
static openAt(filePath) {
|
|
1867
|
-
return new SessionWAL(filePath);
|
|
1868
|
-
}
|
|
1869
|
-
append(op) {
|
|
1870
|
-
const at = op.at ?? new Date().toISOString();
|
|
1871
|
-
const full = { ...op, at };
|
|
1872
|
-
const nextSeq = this.nextSeq();
|
|
1873
|
-
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
1874
|
-
appendFileSync(this.filePath, JSON.stringify(entry) + `
|
|
1875
|
-
`, "utf8");
|
|
1876
|
-
return entry;
|
|
1877
|
-
}
|
|
1878
|
-
nextSeq() {
|
|
1879
|
-
const replay = this.replay();
|
|
1880
|
-
return replay.entries.length + 1;
|
|
1881
|
-
}
|
|
1882
|
-
replay() {
|
|
1883
|
-
const entries = [];
|
|
1884
|
-
let torn = false;
|
|
1885
|
-
let repaired = false;
|
|
1886
|
-
let content;
|
|
1887
|
-
try {
|
|
1888
|
-
content = readFileSync2(this.filePath, "utf8");
|
|
1889
|
-
} catch {
|
|
1890
|
-
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1891
|
-
}
|
|
1892
|
-
if (content.length > MAX_WAL_BYTES) {
|
|
1893
|
-
throw new Error(`session WAL exceeds ${MAX_WAL_BYTES} bytes; refusing to replay a truncated journal`);
|
|
1894
|
-
}
|
|
1895
|
-
if (content === "") {
|
|
1896
|
-
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1897
|
-
}
|
|
1898
|
-
const lines = content.split(`
|
|
1899
|
-
`);
|
|
1900
|
-
let badOffset = null;
|
|
1901
|
-
let expectedSeq = 1;
|
|
1902
|
-
for (let i = 0;i < lines.length; i++) {
|
|
1903
|
-
const line = lines[i];
|
|
1904
|
-
if (line === "")
|
|
1905
|
-
continue;
|
|
1906
|
-
if (badOffset !== null)
|
|
1907
|
-
break;
|
|
1908
|
-
const lineOffset = cumulativeOffset(lines, i);
|
|
1909
|
-
let entry;
|
|
1910
|
-
try {
|
|
1911
|
-
entry = JSON.parse(line);
|
|
1912
|
-
} catch {
|
|
1913
|
-
badOffset = lineOffset;
|
|
1914
|
-
break;
|
|
1915
|
-
}
|
|
1916
|
-
if (typeof entry?.seq !== "number" || entry.seq !== expectedSeq || entry.checksum !== checksumOf(entry.op)) {
|
|
1917
|
-
badOffset = lineOffset;
|
|
1918
|
-
break;
|
|
1919
|
-
}
|
|
1920
|
-
entries.push(entry);
|
|
1921
|
-
expectedSeq++;
|
|
1922
|
-
}
|
|
1923
|
-
if (badOffset !== null) {
|
|
1924
|
-
torn = true;
|
|
1925
|
-
try {
|
|
1926
|
-
truncateSync(this.filePath, badOffset);
|
|
1927
|
-
repaired = true;
|
|
1928
|
-
} catch {}
|
|
1929
|
-
}
|
|
1930
|
-
return {
|
|
1931
|
-
entries,
|
|
1932
|
-
torn,
|
|
1933
|
-
repaired,
|
|
1934
|
-
liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs)
|
|
1935
|
-
};
|
|
1936
|
-
}
|
|
1937
|
-
close() {}
|
|
1938
|
-
}
|
|
1939
|
-
function cumulativeOffset(lines, index) {
|
|
1940
|
-
let offset = 0;
|
|
1941
|
-
for (let i = 0;i < index; i++) {
|
|
1942
|
-
offset += Buffer.byteLength(lines[i]) + 1;
|
|
1943
|
-
}
|
|
1944
|
-
return offset;
|
|
1945
|
-
}
|
|
1946
|
-
function reconstructLiveClaims(entries, nowMs = Date.now()) {
|
|
1947
|
-
const claims = new Map;
|
|
1948
|
-
for (const entry of entries) {
|
|
1949
|
-
const op = entry.op;
|
|
1950
|
-
if (op.op === "claim_acquired") {
|
|
1951
|
-
claims.set(op.runId, { worker: op.worker, fencing: op.fencing, expiresAtMs: Date.parse(op.expiresAt) });
|
|
1952
|
-
} else if (op.op === "claim_released") {
|
|
1953
|
-
const existing = claims.get(op.runId);
|
|
1954
|
-
if (existing && existing.fencing === op.fencing) {
|
|
1955
|
-
claims.delete(op.runId);
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
for (const [runId, claim] of claims) {
|
|
1960
|
-
if (claim.expiresAtMs <= nowMs)
|
|
1961
|
-
claims.delete(runId);
|
|
1962
|
-
}
|
|
1963
|
-
return claims;
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// src/agent-tools.ts
|
|
1069
|
+
// src/tool-definitions.ts
|
|
1967
1070
|
function textResult(payload) {
|
|
1968
1071
|
return { content: [{ type: "text", text: JSON.stringify(payload) }], isError: false };
|
|
1969
1072
|
}
|
|
1970
1073
|
function workflowsTools(_service) {
|
|
1971
|
-
|
|
1074
|
+
const tools = [
|
|
1972
1075
|
{
|
|
1973
1076
|
name: "workflows_version",
|
|
1974
1077
|
description: "Report the installed @hasna/workflows version.",
|
|
@@ -1981,7 +1084,7 @@ function workflowsTools(_service) {
|
|
|
1981
1084
|
},
|
|
1982
1085
|
{
|
|
1983
1086
|
name: "workflows_ready",
|
|
1984
|
-
description: "Report the workflows service readiness
|
|
1087
|
+
description: "Report the hosted workflows service readiness.",
|
|
1985
1088
|
inputSchema: { type: "object", properties: {} }
|
|
1986
1089
|
},
|
|
1987
1090
|
{
|
|
@@ -2000,14 +1103,16 @@ function workflowsTools(_service) {
|
|
|
2000
1103
|
type: "object",
|
|
2001
1104
|
properties: {
|
|
2002
1105
|
graph: { type: "object", description: "the workflow graph" },
|
|
2003
|
-
context: { type: "object", description: "optional run context" }
|
|
1106
|
+
context: { type: "object", description: "optional run context" },
|
|
1107
|
+
idempotencyKey: { type: "string", description: "stable request key" },
|
|
1108
|
+
maxCycles: { type: "integer", minimum: 1, maximum: 2000 }
|
|
2004
1109
|
},
|
|
2005
|
-
required: ["graph"]
|
|
1110
|
+
required: ["graph", "idempotencyKey"]
|
|
2006
1111
|
}
|
|
2007
1112
|
},
|
|
2008
1113
|
{
|
|
2009
1114
|
name: "workflows_runs_list",
|
|
2010
|
-
description: "List recent runs from the
|
|
1115
|
+
description: "List recent runs from the configured hosted service.",
|
|
2011
1116
|
inputSchema: {
|
|
2012
1117
|
type: "object",
|
|
2013
1118
|
properties: {
|
|
@@ -2022,7 +1127,13 @@ function workflowsTools(_service) {
|
|
|
2022
1127
|
inputSchema: { type: "object", properties: {} }
|
|
2023
1128
|
}
|
|
2024
1129
|
];
|
|
1130
|
+
return tools.map((tool) => ({ ...tool, inputSchema: { ...tool.inputSchema, properties: {
|
|
1131
|
+
...tool.inputSchema.properties,
|
|
1132
|
+
agent: { type: "string", description: "calling agent identity" }
|
|
1133
|
+
} } }));
|
|
2025
1134
|
}
|
|
1135
|
+
|
|
1136
|
+
// src/agent-tools.ts
|
|
2026
1137
|
async function callWorkflowTool(service, name, args) {
|
|
2027
1138
|
switch (name) {
|
|
2028
1139
|
case "workflows_version":
|
|
@@ -2030,42 +1141,26 @@ async function callWorkflowTool(service, name, args) {
|
|
|
2030
1141
|
case "workflows_health":
|
|
2031
1142
|
return textResult(service.health());
|
|
2032
1143
|
case "workflows_ready":
|
|
2033
|
-
return textResult(service.ready());
|
|
1144
|
+
return textResult(await service.ready());
|
|
2034
1145
|
case "workflows_validate": {
|
|
2035
1146
|
const graph = args.graph;
|
|
2036
1147
|
const result = validateGraph(graph);
|
|
2037
1148
|
return textResult(result);
|
|
2038
1149
|
}
|
|
2039
|
-
case "workflows_run":
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
result: final.resultJson ? JSON.parse(final.resultJson) : null
|
|
2050
|
-
});
|
|
2051
|
-
} finally {
|
|
2052
|
-
store.close();
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
case "workflows_runs_list": {
|
|
2056
|
-
const store = openStore(service.config.dataDir);
|
|
2057
|
-
try {
|
|
2058
|
-
const status = typeof args.status === "string" ? args.status : undefined;
|
|
2059
|
-
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
2060
|
-
return textResult(store.listRuns({ status, limit }));
|
|
2061
|
-
} finally {
|
|
2062
|
-
store.close();
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
1150
|
+
case "workflows_run":
|
|
1151
|
+
return textResult(await service.triggerRun(args.graph, args.context ?? {}, {
|
|
1152
|
+
idempotencyKey: typeof args.idempotencyKey === "string" ? args.idempotencyKey : undefined,
|
|
1153
|
+
maxCycles: typeof args.maxCycles === "number" ? args.maxCycles : undefined
|
|
1154
|
+
}));
|
|
1155
|
+
case "workflows_runs_list":
|
|
1156
|
+
return textResult(await service.listRuns({
|
|
1157
|
+
status: typeof args.status === "string" ? args.status : undefined,
|
|
1158
|
+
limit: typeof args.limit === "number" ? args.limit : 100
|
|
1159
|
+
}));
|
|
2065
1160
|
case "workflows_lanes_list":
|
|
2066
1161
|
return textResult(await laneInventory());
|
|
2067
1162
|
default:
|
|
2068
|
-
throw new Error(
|
|
1163
|
+
throw new Error("Unknown workflows tool");
|
|
2069
1164
|
}
|
|
2070
1165
|
}
|
|
2071
1166
|
export {
|