@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.
@@ -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 as statSync2 } from "fs";
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 = statSync2(`${entry}/${binary}`);
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 withTimeout(promise, timeoutMs, what) {
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
- reject(new Error(`${what} exceeded the ${timeoutMs}ms budget`));
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.spawnSync({
71
+ proc = Bun.spawn({
52
72
  cmd,
53
73
  cwd: opts.cwd,
54
- env: { ...process.env, ...opts.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 durationMs = Date.now() - started;
67
- const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
68
- const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
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
- const exitCode = proc.exitCode ?? 1;
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, abortSignal: undefined }
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, `claude lane (${job.prompt.slice(0, 60)})`);
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, `codex lane (${job.prompt.slice(0, 60)})`);
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, `codex lane (${job.prompt.slice(0, 60)})`);
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
- const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
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 agent = typeof AgentClass === "function" ? new AgentClass : undefined;
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
- const run = await agent.send(job.prompt, { model: job.model });
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, "run", job.prompt], {
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/store.ts
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
- return [
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 (version check).",
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 local store.",
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
- const graph = args.graph;
2041
- const store = openStore(service.config.dataDir);
2042
- const wal = SessionWAL.open(service.config.dataDir);
2043
- try {
2044
- const final = await runGraphToCompletion(store, wal, graph, args.context ?? {});
2045
- return textResult({
2046
- runId: final.id,
2047
- status: final.status,
2048
- error: final.error ?? null,
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(`Unknown tool: ${name}`);
1163
+ throw new Error("Unknown workflows tool");
2069
1164
  }
2070
1165
  }
2071
1166
  export {