@hasna/workflows 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -98
- package/dist/agent-tools.js +554 -1418
- package/dist/cli/index.js +4120 -2474
- package/dist/handlers.js +3339 -55
- package/dist/index.js +2372 -1253
- package/dist/mcp/index.js +1738 -1744
- package/dist/sdk.js +1620 -0
- package/dist/serve/admin.js +4291 -0
- package/dist/serve/index.js +3392 -1381
- package/dist/service.js +2134 -1165
- package/hasna.contract.json +17 -17
- package/package.json +16 -13
- 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/graph.d.ts +6 -0
- package/types/handlers.d.ts +2 -1
- package/types/identity.d.ts +7 -0
- package/types/lanes/codex.d.ts +0 -6
- package/types/lanes/types.d.ts +16 -3
- package/types/openapi.d.ts +7 -0
- package/types/paths.d.ts +10 -1
- package/types/sdk/generated-api.d.ts +161 -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/index.js
CHANGED
|
@@ -16,1069 +16,1196 @@ var __export = (target, all) => {
|
|
|
16
16
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
17
|
var __require = import.meta.require;
|
|
18
18
|
|
|
19
|
-
// src/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
function withTimeout(promise, timeoutMs, what) {
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const timer = setTimeout(() => {
|
|
35
|
-
reject(new Error(`${what} exceeded the ${timeoutMs}ms budget`));
|
|
36
|
-
}, timeoutMs);
|
|
37
|
-
promise.then((value) => {
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
resolve(value);
|
|
40
|
-
}, (err) => {
|
|
41
|
-
clearTimeout(timer);
|
|
42
|
-
reject(err);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
function spawnCli(cmd, opts) {
|
|
47
|
-
const started = Date.now();
|
|
48
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
49
|
-
let proc;
|
|
50
|
-
try {
|
|
51
|
-
proc = Bun.spawnSync({
|
|
52
|
-
cmd,
|
|
53
|
-
cwd: opts.cwd,
|
|
54
|
-
env: { ...process.env, ...opts.env ?? {} },
|
|
55
|
-
stdout: "pipe",
|
|
56
|
-
stderr: "pipe",
|
|
57
|
-
timeout: timeoutMs
|
|
58
|
-
});
|
|
59
|
-
} catch (err) {
|
|
60
|
-
const message = String(err instanceof Error ? err.message : err);
|
|
61
|
-
if (message.includes("ENOENT") || /not found/i.test(message)) {
|
|
62
|
-
throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
|
|
19
|
+
// src/expr.ts
|
|
20
|
+
function tokenize(input) {
|
|
21
|
+
const tokens = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < input.length) {
|
|
24
|
+
const ch = input[i];
|
|
25
|
+
if (ch === " " || ch === "\t" || ch === `
|
|
26
|
+
` || ch === "\r") {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
63
29
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
|
|
69
|
-
const output = stderr ? `${stdout}
|
|
70
|
-
${stderr}`.trim() : stdout.trim();
|
|
71
|
-
const exitCode = proc.exitCode ?? 1;
|
|
72
|
-
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
73
|
-
}
|
|
74
|
-
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000;
|
|
75
|
-
var init_types = __esm(() => {
|
|
76
|
-
LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
|
|
77
|
-
LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
|
|
78
|
-
constructor(message) {
|
|
79
|
-
super(message);
|
|
80
|
-
this.name = "LaneDependencyMissingError";
|
|
30
|
+
if (ch === "(") {
|
|
31
|
+
tokens.push({ kind: "lparen", value: "(" });
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
81
34
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.name = "LaneAdapterShapeError";
|
|
35
|
+
if (ch === ")") {
|
|
36
|
+
tokens.push({ kind: "rparen", value: ")" });
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
87
39
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
await loader();
|
|
99
|
-
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
|
|
100
|
-
} catch {
|
|
101
|
-
if (binaryOnPath(deps.cliPath ?? "claude")) {
|
|
102
|
-
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
|
|
40
|
+
if (ch === '"' || ch === "'") {
|
|
41
|
+
const quote = ch;
|
|
42
|
+
let j = i + 1;
|
|
43
|
+
let out = "";
|
|
44
|
+
let closed = false;
|
|
45
|
+
while (j < input.length) {
|
|
46
|
+
if (input[j] === quote) {
|
|
47
|
+
closed = true;
|
|
48
|
+
break;
|
|
103
49
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
sdk: "@anthropic-ai/claude-agent-sdk",
|
|
107
|
-
wired: false,
|
|
108
|
-
reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
async run(job) {
|
|
113
|
-
if (!job.prompt) {
|
|
114
|
-
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
|
|
115
|
-
}
|
|
116
|
-
const started = Date.now();
|
|
117
|
-
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
118
|
-
const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
|
|
119
|
-
let sdk;
|
|
120
|
-
try {
|
|
121
|
-
sdk = await loader();
|
|
122
|
-
} catch (err) {
|
|
123
|
-
return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
|
|
124
|
-
}
|
|
125
|
-
const query = sdk.query;
|
|
126
|
-
if (typeof query !== "function") {
|
|
127
|
-
throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
|
|
128
|
-
}
|
|
129
|
-
const prompt = job.prompt;
|
|
130
|
-
try {
|
|
131
|
-
const { output, isError } = await withTimeout((async () => {
|
|
132
|
-
const generator = await query({
|
|
133
|
-
prompt,
|
|
134
|
-
options: { model: job.model, maxTurns: 32, abortSignal: undefined }
|
|
135
|
-
});
|
|
136
|
-
let output2 = "";
|
|
137
|
-
let isError2 = false;
|
|
138
|
-
for await (const message of generator) {
|
|
139
|
-
if (message.type === "assistant") {
|
|
140
|
-
const content = message.message?.content;
|
|
141
|
-
if (Array.isArray(content)) {
|
|
142
|
-
for (const block of content) {
|
|
143
|
-
if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
|
144
|
-
output2 += block.text;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (message.type === "error")
|
|
150
|
-
isError2 = true;
|
|
151
|
-
}
|
|
152
|
-
return { output: output2, isError: isError2 };
|
|
153
|
-
})(), timeoutMs, `claude lane (${job.prompt.slice(0, 60)})`);
|
|
154
|
-
const durationMs = Date.now() - started;
|
|
155
|
-
return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
|
|
156
|
-
} catch (err) {
|
|
157
|
-
const durationMs = Date.now() - started;
|
|
158
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
50
|
+
out += input[j];
|
|
51
|
+
j++;
|
|
159
52
|
}
|
|
53
|
+
if (!closed)
|
|
54
|
+
throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
|
|
55
|
+
tokens.push({ kind: "string", value: out });
|
|
56
|
+
i = j + 1;
|
|
57
|
+
continue;
|
|
160
58
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
binaryName: cliPath,
|
|
167
|
-
packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
|
|
168
|
-
timeoutMs,
|
|
169
|
-
env: job.env,
|
|
170
|
-
cwd: job.cwd
|
|
171
|
-
});
|
|
172
|
-
return { ...result, durationMs: Date.now() - started };
|
|
173
|
-
} catch (err) {
|
|
174
|
-
if (err instanceof LaneDependencyMissingError) {
|
|
175
|
-
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
59
|
+
const two = input.slice(i, i + 2);
|
|
60
|
+
if (COMPARISON_OPS.has(two)) {
|
|
61
|
+
tokens.push({ kind: "op", value: two });
|
|
62
|
+
i += 2;
|
|
63
|
+
continue;
|
|
176
64
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
init_types();
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// src/lanes/codex.ts
|
|
185
|
-
function createCodexAdapter(deps = {}) {
|
|
186
|
-
return {
|
|
187
|
-
kind: "codex",
|
|
188
|
-
async probe() {
|
|
189
|
-
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
190
|
-
try {
|
|
191
|
-
await loader();
|
|
192
|
-
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
|
|
193
|
-
} catch {
|
|
194
|
-
if (binaryOnPath(deps.cliPath ?? "codex")) {
|
|
195
|
-
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
|
|
196
|
-
}
|
|
197
|
-
return {
|
|
198
|
-
kind: "codex",
|
|
199
|
-
sdk: "@openai/codex-sdk",
|
|
200
|
-
wired: false,
|
|
201
|
-
reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
},
|
|
205
|
-
async run(job) {
|
|
206
|
-
if (!job.prompt) {
|
|
207
|
-
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
|
|
208
|
-
}
|
|
209
|
-
const started = Date.now();
|
|
210
|
-
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
211
|
-
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
212
|
-
let sdk;
|
|
213
|
-
try {
|
|
214
|
-
sdk = await loader();
|
|
215
|
-
} catch {
|
|
216
|
-
return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
|
|
217
|
-
}
|
|
218
|
-
const CodexClass = sdk.Codex;
|
|
219
|
-
if (typeof CodexClass !== "function") {
|
|
220
|
-
throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
|
|
221
|
-
}
|
|
222
|
-
try {
|
|
223
|
-
const client = await withTimeout(Promise.resolve(new CodexClass({
|
|
224
|
-
config: { model: job.model },
|
|
225
|
-
env: job.env
|
|
226
|
-
})), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
|
|
227
|
-
const thread = client.startThread?.();
|
|
228
|
-
if (!thread || typeof thread.run !== "function") {
|
|
229
|
-
throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
|
|
230
|
-
}
|
|
231
|
-
const turn = await withTimeout(thread.run(job.prompt), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
|
|
232
|
-
const output = turn?.finalResponse ?? "";
|
|
233
|
-
const durationMs = Date.now() - started;
|
|
234
|
-
return { ok: true, exitCode: 0, output, durationMs };
|
|
235
|
-
} catch (err) {
|
|
236
|
-
const durationMs = Date.now() - started;
|
|
237
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
238
|
-
}
|
|
65
|
+
if (ch === "<" || ch === ">" || ch === "=" || ch === "!") {
|
|
66
|
+
tokens.push({ kind: "op", value: ch });
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
239
69
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
timeoutMs,
|
|
248
|
-
env: job.env,
|
|
249
|
-
cwd: job.cwd
|
|
250
|
-
});
|
|
251
|
-
return { ...result, durationMs: Date.now() - started };
|
|
252
|
-
} catch (err) {
|
|
253
|
-
if (err instanceof LaneDependencyMissingError) {
|
|
254
|
-
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
70
|
+
if (/[0-9]/.test(ch)) {
|
|
71
|
+
let j = i;
|
|
72
|
+
while (j < input.length && /[0-9.]/.test(input[j]))
|
|
73
|
+
j++;
|
|
74
|
+
tokens.push({ kind: "number", value: input.slice(i, j) });
|
|
75
|
+
i = j;
|
|
76
|
+
continue;
|
|
255
77
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// src/lanes/cursor.ts
|
|
264
|
-
function createCursorAdapter(deps = {}) {
|
|
265
|
-
return {
|
|
266
|
-
kind: "cursor",
|
|
267
|
-
async probe() {
|
|
268
|
-
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
269
|
-
try {
|
|
270
|
-
await loader();
|
|
271
|
-
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
|
|
272
|
-
} catch {
|
|
273
|
-
if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
|
|
274
|
-
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
kind: "cursor",
|
|
278
|
-
sdk: "@cursor/sdk",
|
|
279
|
-
wired: false,
|
|
280
|
-
reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
async run(job) {
|
|
285
|
-
if (!job.prompt) {
|
|
286
|
-
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
|
|
287
|
-
}
|
|
288
|
-
const started = Date.now();
|
|
289
|
-
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
290
|
-
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
291
|
-
let sdk;
|
|
292
|
-
try {
|
|
293
|
-
sdk = await loader();
|
|
294
|
-
} catch {
|
|
295
|
-
return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
|
|
296
|
-
}
|
|
297
|
-
try {
|
|
298
|
-
const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
|
|
299
|
-
const durationMs = Date.now() - started;
|
|
300
|
-
const status = run.status;
|
|
301
|
-
const ok = status === "finished";
|
|
302
|
-
return { ok, exitCode: ok ? 0 : 1, output: run.result, durationMs };
|
|
303
|
-
} catch (err) {
|
|
304
|
-
if (err instanceof LaneAdapterShapeError)
|
|
305
|
-
throw err;
|
|
306
|
-
const durationMs = Date.now() - started;
|
|
307
|
-
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
308
|
-
}
|
|
78
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
79
|
+
let j = i;
|
|
80
|
+
while (j < input.length && /[A-Za-z0-9_.]/.test(input[j]))
|
|
81
|
+
j++;
|
|
82
|
+
tokens.push({ kind: "ident", value: input.slice(i, j) });
|
|
83
|
+
i = j;
|
|
84
|
+
continue;
|
|
309
85
|
}
|
|
310
|
-
|
|
86
|
+
throw new ExprSyntaxError(`unexpected character ${JSON.stringify(ch)} at offset ${i}`);
|
|
87
|
+
}
|
|
88
|
+
return tokens;
|
|
311
89
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
90
|
+
|
|
91
|
+
class Parser {
|
|
92
|
+
tokens;
|
|
93
|
+
pos = 0;
|
|
94
|
+
constructor(tokens) {
|
|
95
|
+
this.tokens = tokens;
|
|
317
96
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
|
|
97
|
+
peek() {
|
|
98
|
+
return this.tokens[this.pos];
|
|
321
99
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
|
|
100
|
+
next() {
|
|
101
|
+
return this.tokens[this.pos++];
|
|
325
102
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
103
|
+
expectOp(value) {
|
|
104
|
+
const t = this.next();
|
|
105
|
+
if (!t || t.kind !== "op" || t.value !== value) {
|
|
106
|
+
throw new ExprSyntaxError(`expected ${JSON.stringify(value)}`);
|
|
107
|
+
}
|
|
329
108
|
}
|
|
330
|
-
|
|
331
|
-
|
|
109
|
+
parse() {
|
|
110
|
+
if (this.tokens.length === 0)
|
|
111
|
+
throw new ExprSyntaxError("empty expression");
|
|
112
|
+
const node = this.parseOr();
|
|
113
|
+
if (this.pos < this.tokens.length) {
|
|
114
|
+
throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
|
|
115
|
+
}
|
|
116
|
+
return node;
|
|
332
117
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
|
|
340
|
-
timeoutMs,
|
|
341
|
-
env: job.env,
|
|
342
|
-
cwd: job.cwd
|
|
343
|
-
});
|
|
344
|
-
return { ...result, durationMs: Date.now() - started };
|
|
345
|
-
} catch (err) {
|
|
346
|
-
if (err instanceof LaneDependencyMissingError) {
|
|
347
|
-
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
118
|
+
parseOr() {
|
|
119
|
+
let left = this.parseAnd();
|
|
120
|
+
while (this.peek()?.kind === "ident" && this.peek().value === "or") {
|
|
121
|
+
this.next();
|
|
122
|
+
const right = this.parseAnd();
|
|
123
|
+
left = { kind: "or", left, right };
|
|
348
124
|
}
|
|
349
|
-
|
|
125
|
+
return left;
|
|
350
126
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
function createGrokAdapter(deps = {}) {
|
|
358
|
-
return {
|
|
359
|
-
kind: "grok",
|
|
360
|
-
async probe() {
|
|
361
|
-
const cliPath = deps.cliPath ?? "grok";
|
|
362
|
-
if (binaryOnPath(cliPath)) {
|
|
363
|
-
return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
|
|
364
|
-
}
|
|
365
|
-
return {
|
|
366
|
-
kind: "grok",
|
|
367
|
-
sdk: "no npm SDK (measured 2026-08-25)",
|
|
368
|
-
wired: false,
|
|
369
|
-
reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
|
|
370
|
-
};
|
|
371
|
-
},
|
|
372
|
-
async run(job) {
|
|
373
|
-
if (!job.prompt) {
|
|
374
|
-
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
|
|
375
|
-
}
|
|
376
|
-
const started = Date.now();
|
|
377
|
-
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
378
|
-
const cliPath = deps.cliPath ?? "grok";
|
|
379
|
-
try {
|
|
380
|
-
const result = spawnCli([cliPath, job.prompt], {
|
|
381
|
-
binaryName: cliPath,
|
|
382
|
-
packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
|
|
383
|
-
timeoutMs,
|
|
384
|
-
env: job.env,
|
|
385
|
-
cwd: job.cwd
|
|
386
|
-
});
|
|
387
|
-
return { ...result, durationMs: Date.now() - started };
|
|
388
|
-
} catch (err) {
|
|
389
|
-
if (err instanceof LaneDependencyMissingError) {
|
|
390
|
-
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
391
|
-
}
|
|
392
|
-
throw err;
|
|
393
|
-
}
|
|
127
|
+
parseAnd() {
|
|
128
|
+
let left = this.parseNot();
|
|
129
|
+
while (this.peek()?.kind === "ident" && this.peek().value === "and") {
|
|
130
|
+
this.next();
|
|
131
|
+
const right = this.parseNot();
|
|
132
|
+
left = { kind: "and", left, right };
|
|
394
133
|
}
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// src/lanes/index.ts
|
|
402
|
-
var exports_lanes = {};
|
|
403
|
-
__export(exports_lanes, {
|
|
404
|
-
runLaneJob: () => runLaneJob,
|
|
405
|
-
resolveLane: () => resolveLane,
|
|
406
|
-
probeLane: () => probeLane,
|
|
407
|
-
laneInventory: () => laneInventory,
|
|
408
|
-
createGrokAdapter: () => createGrokAdapter,
|
|
409
|
-
createCursorAdapter: () => createCursorAdapter,
|
|
410
|
-
createCodexAdapter: () => createCodexAdapter,
|
|
411
|
-
createClaudeAdapter: () => createClaudeAdapter,
|
|
412
|
-
binaryOnPath: () => binaryOnPath,
|
|
413
|
-
LaneDependencyMissingError: () => LaneDependencyMissingError,
|
|
414
|
-
LaneAdapterShapeError: () => LaneAdapterShapeError,
|
|
415
|
-
LANE_KINDS: () => LANE_KINDS2
|
|
416
|
-
});
|
|
417
|
-
function resolveLane(kind) {
|
|
418
|
-
const adapter = adapters.get(kind);
|
|
419
|
-
if (!adapter)
|
|
420
|
-
throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
|
|
421
|
-
return adapter;
|
|
422
|
-
}
|
|
423
|
-
async function runLaneJob(job) {
|
|
424
|
-
return resolveLane(job.lane).run(job);
|
|
425
|
-
}
|
|
426
|
-
async function probeLane(kind) {
|
|
427
|
-
return resolveLane(kind).probe();
|
|
428
|
-
}
|
|
429
|
-
async function laneInventory() {
|
|
430
|
-
return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
|
|
431
|
-
}
|
|
432
|
-
var adapters;
|
|
433
|
-
var init_lanes = __esm(() => {
|
|
434
|
-
init_claude();
|
|
435
|
-
init_codex();
|
|
436
|
-
init_cursor();
|
|
437
|
-
init_grok();
|
|
438
|
-
init_types();
|
|
439
|
-
init_types();
|
|
440
|
-
init_claude();
|
|
441
|
-
init_codex();
|
|
442
|
-
init_cursor();
|
|
443
|
-
init_grok();
|
|
444
|
-
adapters = new Map([
|
|
445
|
-
["claude", createClaudeAdapter()],
|
|
446
|
-
["codex", createCodexAdapter()],
|
|
447
|
-
["cursor", createCursorAdapter()],
|
|
448
|
-
["grok", createGrokAdapter()]
|
|
449
|
-
]);
|
|
450
|
-
});
|
|
451
|
-
// package.json
|
|
452
|
-
var package_default = {
|
|
453
|
-
name: "@hasna/workflows",
|
|
454
|
-
version: "0.1.4",
|
|
455
|
-
description: "Universal graph workflow app \u2014 graph language (start/step/decision/while/end), three-table store, session WAL, daemon with claims/leases, four lane adapters (claude/codex/cursor/grok), and a full CLI on four surfaces",
|
|
456
|
-
type: "module",
|
|
457
|
-
main: "./dist/index.js",
|
|
458
|
-
types: "./types/index.d.ts",
|
|
459
|
-
bin: {
|
|
460
|
-
workflows: "dist/cli/index.js",
|
|
461
|
-
"workflows-mcp": "dist/mcp/index.js",
|
|
462
|
-
"workflows-serve": "dist/serve/index.js"
|
|
463
|
-
},
|
|
464
|
-
exports: {
|
|
465
|
-
".": {
|
|
466
|
-
types: "./types/index.d.ts",
|
|
467
|
-
import: "./dist/index.js"
|
|
468
|
-
},
|
|
469
|
-
"./sdk": {
|
|
470
|
-
types: "./types/index.d.ts",
|
|
471
|
-
import: "./dist/index.js"
|
|
472
|
-
},
|
|
473
|
-
"./cli": {
|
|
474
|
-
types: "./types/cli/index.d.ts",
|
|
475
|
-
import: "./dist/cli/index.js"
|
|
134
|
+
return left;
|
|
135
|
+
}
|
|
136
|
+
parseNot() {
|
|
137
|
+
if (this.peek()?.kind === "ident" && this.peek().value === "not") {
|
|
138
|
+
this.next();
|
|
139
|
+
return { kind: "not", operand: this.parseNot() };
|
|
476
140
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
"
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
"
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
"
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
homepage: "https://github.com/hasna/apps/tree/main/apps/workflows",
|
|
515
|
-
bugs: {
|
|
516
|
-
url: "https://github.com/hasna/apps/issues"
|
|
517
|
-
},
|
|
518
|
-
engines: {
|
|
519
|
-
bun: ">=1.0.0"
|
|
520
|
-
},
|
|
521
|
-
author: "Hasna",
|
|
522
|
-
license: "Apache-2.0",
|
|
523
|
-
dependencies: {
|
|
524
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
525
|
-
"@cursor/sdk": "^1.0.28",
|
|
526
|
-
"@hasna/paths": "0.1.0",
|
|
527
|
-
"@openai/codex-sdk": "0.147.0",
|
|
528
|
-
commander: "^13.1.0"
|
|
529
|
-
},
|
|
530
|
-
devDependencies: {
|
|
531
|
-
"@hasna/contracts": "0.14.2",
|
|
532
|
-
"@types/bun": "1.3.14",
|
|
533
|
-
"@types/pg": "^8.15.6",
|
|
534
|
-
pg: "^8.16.3",
|
|
535
|
-
typescript: "^5.7.3"
|
|
141
|
+
return this.parseComparison();
|
|
142
|
+
}
|
|
143
|
+
parseComparison() {
|
|
144
|
+
const left = this.parsePrimary();
|
|
145
|
+
const t = this.peek();
|
|
146
|
+
if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
|
|
147
|
+
this.next();
|
|
148
|
+
const right = this.parsePrimary();
|
|
149
|
+
return { kind: "comparison", op: t.value, left, right };
|
|
150
|
+
}
|
|
151
|
+
return left;
|
|
152
|
+
}
|
|
153
|
+
parsePrimary() {
|
|
154
|
+
const t = this.next();
|
|
155
|
+
if (!t)
|
|
156
|
+
throw new ExprSyntaxError("unexpected end of expression");
|
|
157
|
+
if (t.kind === "lparen") {
|
|
158
|
+
const inner = this.parseOr();
|
|
159
|
+
this.expectCloseParen();
|
|
160
|
+
return inner;
|
|
161
|
+
}
|
|
162
|
+
if (t.kind === "number") {
|
|
163
|
+
const value = Number(t.value);
|
|
164
|
+
if (!Number.isFinite(value))
|
|
165
|
+
throw new ExprSyntaxError(`invalid number ${JSON.stringify(t.value)}`);
|
|
166
|
+
return { kind: "literal", value };
|
|
167
|
+
}
|
|
168
|
+
if (t.kind === "string")
|
|
169
|
+
return { kind: "literal", value: t.value };
|
|
170
|
+
if (t.kind === "ident") {
|
|
171
|
+
if (t.value === "true")
|
|
172
|
+
return { kind: "literal", value: true };
|
|
173
|
+
if (t.value === "false")
|
|
174
|
+
return { kind: "literal", value: false };
|
|
175
|
+
return { kind: "path", segments: t.value.split(".") };
|
|
176
|
+
}
|
|
177
|
+
throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
|
|
536
178
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
constructor(message) {
|
|
542
|
-
super(message);
|
|
543
|
-
this.name = "ExprSyntaxError";
|
|
179
|
+
expectCloseParen() {
|
|
180
|
+
const t = this.next();
|
|
181
|
+
if (!t || t.kind !== "rparen")
|
|
182
|
+
throw new ExprSyntaxError("expected )");
|
|
544
183
|
}
|
|
545
184
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
185
|
+
function parseExpr(input) {
|
|
186
|
+
return new Parser(tokenize(input)).parse();
|
|
187
|
+
}
|
|
188
|
+
function resolvePath(segments, context) {
|
|
189
|
+
let current = context;
|
|
190
|
+
for (const segment of segments) {
|
|
191
|
+
if (current === null || typeof current !== "object")
|
|
192
|
+
return;
|
|
193
|
+
const record = current;
|
|
194
|
+
if (!(segment in record))
|
|
195
|
+
return;
|
|
196
|
+
current = record[segment];
|
|
197
|
+
}
|
|
198
|
+
if (typeof current === "number" || typeof current === "string" || typeof current === "boolean") {
|
|
199
|
+
return current;
|
|
551
200
|
}
|
|
201
|
+
return;
|
|
552
202
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
203
|
+
function evalNode(node, context) {
|
|
204
|
+
switch (node.kind) {
|
|
205
|
+
case "literal":
|
|
206
|
+
return node.value;
|
|
207
|
+
case "path":
|
|
208
|
+
return resolvePath(node.segments, context);
|
|
209
|
+
case "comparison": {
|
|
210
|
+
const left = evalNode(node.left, context);
|
|
211
|
+
const right = evalNode(node.right, context);
|
|
212
|
+
if (left === undefined || right === undefined) {
|
|
213
|
+
if (node.op === "==")
|
|
214
|
+
return left === undefined && right === undefined;
|
|
215
|
+
if (node.op === "!=")
|
|
216
|
+
return left !== right;
|
|
217
|
+
throw new ExprEvalError("cannot order-compare an unknown path");
|
|
218
|
+
}
|
|
219
|
+
if (typeof left !== typeof right) {
|
|
220
|
+
throw new ExprEvalError(`cannot compare ${JSON.stringify(left)} (${typeof left}) with ${JSON.stringify(right)} (${typeof right})`);
|
|
221
|
+
}
|
|
222
|
+
switch (node.op) {
|
|
223
|
+
case "==":
|
|
224
|
+
return left === right;
|
|
225
|
+
case "!=":
|
|
226
|
+
return left !== right;
|
|
227
|
+
case "<":
|
|
228
|
+
return left < right;
|
|
229
|
+
case "<=":
|
|
230
|
+
return left <= right;
|
|
231
|
+
case ">":
|
|
232
|
+
return left > right;
|
|
233
|
+
case ">=":
|
|
234
|
+
return left >= right;
|
|
235
|
+
default:
|
|
236
|
+
throw new ExprEvalError(`unknown operator ${node.op}`);
|
|
237
|
+
}
|
|
568
238
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
239
|
+
case "and": {
|
|
240
|
+
const left = evalNode(node.left, context);
|
|
241
|
+
const right = evalNode(node.right, context);
|
|
242
|
+
if (typeof left !== "boolean" || typeof right !== "boolean") {
|
|
243
|
+
throw new ExprEvalError("and requires boolean operands");
|
|
244
|
+
}
|
|
245
|
+
return left && right;
|
|
573
246
|
}
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
while (j < input.length) {
|
|
580
|
-
if (input[j] === quote) {
|
|
581
|
-
closed = true;
|
|
582
|
-
break;
|
|
583
|
-
}
|
|
584
|
-
out += input[j];
|
|
585
|
-
j++;
|
|
247
|
+
case "or": {
|
|
248
|
+
const left = evalNode(node.left, context);
|
|
249
|
+
const right = evalNode(node.right, context);
|
|
250
|
+
if (typeof left !== "boolean" || typeof right !== "boolean") {
|
|
251
|
+
throw new ExprEvalError("or requires boolean operands");
|
|
586
252
|
}
|
|
587
|
-
|
|
588
|
-
throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
|
|
589
|
-
tokens.push({ kind: "string", value: out });
|
|
590
|
-
i = j + 1;
|
|
591
|
-
continue;
|
|
253
|
+
return left || right;
|
|
592
254
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
255
|
+
case "not": {
|
|
256
|
+
const operand = evalNode(node.operand, context);
|
|
257
|
+
if (typeof operand !== "boolean")
|
|
258
|
+
throw new ExprEvalError("not requires a boolean operand");
|
|
259
|
+
return !operand;
|
|
598
260
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function evaluateExpr(input, context) {
|
|
264
|
+
const ast = parseExpr(input);
|
|
265
|
+
const value = evalNode(ast, context);
|
|
266
|
+
if (typeof value !== "boolean") {
|
|
267
|
+
throw new ExprEvalError(`condition must evaluate to a boolean, got ${typeof value}`);
|
|
268
|
+
}
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
var ExprSyntaxError, ExprEvalError, COMPARISON_OPS;
|
|
272
|
+
var init_expr = __esm(() => {
|
|
273
|
+
ExprSyntaxError = class ExprSyntaxError extends Error {
|
|
274
|
+
constructor(message) {
|
|
275
|
+
super(message);
|
|
276
|
+
this.name = "ExprSyntaxError";
|
|
603
277
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
i = j;
|
|
610
|
-
continue;
|
|
278
|
+
};
|
|
279
|
+
ExprEvalError = class ExprEvalError extends Error {
|
|
280
|
+
constructor(message) {
|
|
281
|
+
super(message);
|
|
282
|
+
this.name = "ExprEvalError";
|
|
611
283
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
284
|
+
};
|
|
285
|
+
COMPARISON_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// src/graph.ts
|
|
289
|
+
function issue(issues, path, message) {
|
|
290
|
+
issues.push({ path, message });
|
|
291
|
+
}
|
|
292
|
+
function findNode(graph, id) {
|
|
293
|
+
return graph.nodes.find((n) => n.id === id);
|
|
294
|
+
}
|
|
295
|
+
function collectEdges(graph) {
|
|
296
|
+
const edges = [];
|
|
297
|
+
for (const node of graph.nodes) {
|
|
298
|
+
switch (node.type) {
|
|
299
|
+
case "start":
|
|
300
|
+
case "step":
|
|
301
|
+
if (node.next)
|
|
302
|
+
edges.push({ from: node.id, to: node.next });
|
|
303
|
+
break;
|
|
304
|
+
case "decision":
|
|
305
|
+
if (node.then)
|
|
306
|
+
edges.push({ from: node.id, to: node.then });
|
|
307
|
+
if (node.else)
|
|
308
|
+
edges.push({ from: node.id, to: node.else });
|
|
309
|
+
break;
|
|
310
|
+
case "while":
|
|
311
|
+
if (node.next)
|
|
312
|
+
edges.push({ from: node.id, to: node.next });
|
|
313
|
+
break;
|
|
314
|
+
case "end":
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return edges;
|
|
319
|
+
}
|
|
320
|
+
function reachableNodes(graph) {
|
|
321
|
+
const start = graph.nodes.find((n) => n.type === "start");
|
|
322
|
+
if (!start)
|
|
323
|
+
return [];
|
|
324
|
+
const seen = new Set;
|
|
325
|
+
const stack = [start.id];
|
|
326
|
+
while (stack.length > 0) {
|
|
327
|
+
const id = stack.pop();
|
|
328
|
+
if (seen.has(id))
|
|
329
|
+
continue;
|
|
330
|
+
seen.add(id);
|
|
331
|
+
const node = findNode(graph, id);
|
|
332
|
+
if (!node)
|
|
618
333
|
continue;
|
|
334
|
+
if (node.type === "start" || node.type === "step") {
|
|
335
|
+
if (node.next)
|
|
336
|
+
stack.push(node.next);
|
|
337
|
+
} else if (node.type === "decision") {
|
|
338
|
+
if (node.then)
|
|
339
|
+
stack.push(node.then);
|
|
340
|
+
if (node.else)
|
|
341
|
+
stack.push(node.else);
|
|
342
|
+
} else if (node.type === "while") {
|
|
343
|
+
for (const bodyId of node.body)
|
|
344
|
+
stack.push(bodyId);
|
|
345
|
+
if (node.next)
|
|
346
|
+
stack.push(node.next);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return [...seen];
|
|
350
|
+
}
|
|
351
|
+
function findCycles(graph) {
|
|
352
|
+
const edges = collectEdges(graph);
|
|
353
|
+
const adj = new Map;
|
|
354
|
+
for (const e of edges) {
|
|
355
|
+
const list = adj.get(e.from) ?? [];
|
|
356
|
+
list.push(e.to);
|
|
357
|
+
adj.set(e.from, list);
|
|
358
|
+
}
|
|
359
|
+
const color = new Map;
|
|
360
|
+
const stack = [];
|
|
361
|
+
const cycles = [];
|
|
362
|
+
function visit(id) {
|
|
363
|
+
color.set(id, "grey");
|
|
364
|
+
stack.push(id);
|
|
365
|
+
for (const to of adj.get(id) ?? []) {
|
|
366
|
+
const c = color.get(to);
|
|
367
|
+
if (c === "grey") {
|
|
368
|
+
const idx = stack.indexOf(to);
|
|
369
|
+
cycles.push([...stack.slice(idx), to]);
|
|
370
|
+
} else if (c === undefined) {
|
|
371
|
+
visit(to);
|
|
372
|
+
}
|
|
619
373
|
}
|
|
620
|
-
|
|
374
|
+
stack.pop();
|
|
375
|
+
color.set(id, "black");
|
|
621
376
|
}
|
|
622
|
-
|
|
377
|
+
for (const node of graph.nodes) {
|
|
378
|
+
if (color.get(node.id) === undefined)
|
|
379
|
+
visit(node.id);
|
|
380
|
+
}
|
|
381
|
+
return cycles;
|
|
623
382
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
constructor(tokens) {
|
|
629
|
-
this.tokens = tokens;
|
|
383
|
+
function validateGraph(graph) {
|
|
384
|
+
const issues = [];
|
|
385
|
+
if (graph === null || typeof graph !== "object" || Array.isArray(graph)) {
|
|
386
|
+
return { ok: false, issues: [{ path: "graph", message: "graph must be an object" }] };
|
|
630
387
|
}
|
|
631
|
-
|
|
632
|
-
|
|
388
|
+
if (typeof graph.name !== "string" || graph.name.trim() === "") {
|
|
389
|
+
issue(issues, "name", "graph requires a non-empty name");
|
|
633
390
|
}
|
|
634
|
-
|
|
635
|
-
|
|
391
|
+
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
392
|
+
issue(issues, "version", "graph requires a version string");
|
|
636
393
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
394
|
+
if (graph.memoWatch !== undefined) {
|
|
395
|
+
if (!Array.isArray(graph.memoWatch) || graph.memoWatch.length === 0) {
|
|
396
|
+
issue(issues, "memoWatch", "memoWatch must be a non-empty array of file paths or globs");
|
|
397
|
+
} else {
|
|
398
|
+
for (const entry of graph.memoWatch) {
|
|
399
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
400
|
+
issue(issues, "memoWatch", "each memoWatch entry must be a non-empty path or glob string");
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
641
404
|
}
|
|
642
405
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const node = this.parseOr();
|
|
647
|
-
if (this.pos < this.tokens.length) {
|
|
648
|
-
throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
|
|
649
|
-
}
|
|
650
|
-
return node;
|
|
406
|
+
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
407
|
+
issue(issues, "nodes", "graph requires at least one node");
|
|
408
|
+
return { ok: false, issues };
|
|
651
409
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
left = { kind: "or", left, right };
|
|
410
|
+
for (let index = 0;index < graph.nodes.length; index++) {
|
|
411
|
+
const node = graph.nodes[index];
|
|
412
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
413
|
+
issue(issues, `nodes.${index}`, "node must be an object");
|
|
414
|
+
continue;
|
|
658
415
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const right = this.parseNot();
|
|
666
|
-
left = { kind: "and", left, right };
|
|
416
|
+
if (!GRAPH_NODE_TYPES.includes(node.type))
|
|
417
|
+
issue(issues, `nodes.${index}.type`, "type must be start, step, decision, while, or end");
|
|
418
|
+
for (const key of ["next", "then", "else", "prompt", "command"]) {
|
|
419
|
+
const value = node[key];
|
|
420
|
+
if (value !== undefined && (typeof value !== "string" || value.trim() === ""))
|
|
421
|
+
issue(issues, `nodes.${index}.${key}`, `${key} must be a non-empty string`);
|
|
667
422
|
}
|
|
668
|
-
|
|
423
|
+
if (node.type === "while" && (!Array.isArray(node.body) || node.body.some((id) => typeof id !== "string")))
|
|
424
|
+
issue(issues, `nodes.${index}.body`, "body must be an array of node ids");
|
|
669
425
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
426
|
+
if (issues.length)
|
|
427
|
+
return { ok: false, issues };
|
|
428
|
+
const starts = graph.nodes.filter((n) => n.type === "start");
|
|
429
|
+
if (starts.length === 0)
|
|
430
|
+
issue(issues, "nodes", "graph requires exactly one start node (found none)");
|
|
431
|
+
if (starts.length > 1)
|
|
432
|
+
issue(issues, "nodes", "graph requires exactly one start node (found multiple)");
|
|
433
|
+
const ends = graph.nodes.filter((n) => n.type === "end");
|
|
434
|
+
if (ends.length === 0)
|
|
435
|
+
issue(issues, "nodes", "graph requires at least one end node");
|
|
436
|
+
const seenIds = new Map;
|
|
437
|
+
for (const node of graph.nodes) {
|
|
438
|
+
if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
|
|
439
|
+
issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
|
|
440
|
+
continue;
|
|
674
441
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const left = this.parsePrimary();
|
|
679
|
-
const t = this.peek();
|
|
680
|
-
if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
|
|
681
|
-
this.next();
|
|
682
|
-
const right = this.parsePrimary();
|
|
683
|
-
return { kind: "comparison", op: t.value, left, right };
|
|
442
|
+
if (seenIds.has(node.id)) {
|
|
443
|
+
issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
|
|
444
|
+
continue;
|
|
684
445
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (!t)
|
|
690
|
-
throw new ExprSyntaxError("unexpected end of expression");
|
|
691
|
-
if (t.kind === "lparen") {
|
|
692
|
-
const inner = this.parseOr();
|
|
693
|
-
this.expectCloseParen();
|
|
694
|
-
return inner;
|
|
446
|
+
seenIds.set(node.id, node);
|
|
447
|
+
if (!GRAPH_NODE_TYPES.includes(node.type)) {
|
|
448
|
+
issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
|
|
449
|
+
continue;
|
|
695
450
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
451
|
+
validateNode(graph, node, issues);
|
|
452
|
+
}
|
|
453
|
+
for (const node of graph.nodes) {
|
|
454
|
+
const edges = nodeEdges(node);
|
|
455
|
+
for (const { kind, target } of edges) {
|
|
456
|
+
if (!seenIds.has(target)) {
|
|
457
|
+
issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
|
|
458
|
+
}
|
|
701
459
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
return { kind: "path", segments: t.value.split(".") };
|
|
460
|
+
}
|
|
461
|
+
if (starts.length === 1) {
|
|
462
|
+
const reachable = new Set(reachableNodes(graph));
|
|
463
|
+
for (const node of graph.nodes) {
|
|
464
|
+
if (!reachable.has(node.id)) {
|
|
465
|
+
issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
|
|
466
|
+
}
|
|
710
467
|
}
|
|
711
|
-
throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
|
|
712
468
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
throw new ExprSyntaxError("expected )");
|
|
469
|
+
const cycles = findCycles(graph);
|
|
470
|
+
for (const cycle of cycles) {
|
|
471
|
+
issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
|
|
717
472
|
}
|
|
473
|
+
return { ok: issues.length === 0, issues };
|
|
718
474
|
}
|
|
719
|
-
function
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
475
|
+
function nodeEdges(node) {
|
|
476
|
+
switch (node.type) {
|
|
477
|
+
case "start":
|
|
478
|
+
case "step":
|
|
479
|
+
return node.next ? [{ kind: "next", target: node.next }] : [];
|
|
480
|
+
case "decision":
|
|
481
|
+
return [
|
|
482
|
+
...node.then ? [{ kind: "then", target: node.then }] : [],
|
|
483
|
+
...node.else ? [{ kind: "else", target: node.else }] : []
|
|
484
|
+
];
|
|
485
|
+
case "while":
|
|
486
|
+
return node.next ? [{ kind: "next", target: node.next }] : [];
|
|
487
|
+
case "end":
|
|
488
|
+
return [];
|
|
489
|
+
default:
|
|
490
|
+
return [];
|
|
734
491
|
}
|
|
735
|
-
return;
|
|
736
492
|
}
|
|
737
|
-
function
|
|
738
|
-
switch (node.
|
|
739
|
-
case "
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
case "
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (left === undefined || right === undefined) {
|
|
747
|
-
if (node.op === "==")
|
|
748
|
-
return left === undefined && right === undefined;
|
|
749
|
-
if (node.op === "!=")
|
|
750
|
-
return left !== right;
|
|
751
|
-
throw new ExprEvalError("cannot order-compare an unknown path");
|
|
493
|
+
function validateNode(graph, node, issues) {
|
|
494
|
+
switch (node.type) {
|
|
495
|
+
case "start":
|
|
496
|
+
if (!node.next)
|
|
497
|
+
issue(issues, `nodes.${node.id}`, "start node requires a next edge");
|
|
498
|
+
break;
|
|
499
|
+
case "step": {
|
|
500
|
+
if (!node.prompt && !node.command) {
|
|
501
|
+
issue(issues, `nodes.${node.id}`, "step node requires prompt or command");
|
|
752
502
|
}
|
|
753
|
-
if (
|
|
754
|
-
|
|
503
|
+
if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
|
|
504
|
+
issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
|
|
755
505
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
506
|
+
if (node.cwd !== undefined && (typeof node.cwd !== "string" || !node.cwd.trim() || node.cwd.includes("\x00"))) {
|
|
507
|
+
issue(issues, `nodes.${node.id}.cwd`, "cwd must be a non-empty path string without NUL characters on the executor");
|
|
508
|
+
}
|
|
509
|
+
if (node.codexMode !== undefined) {
|
|
510
|
+
if (!CODEX_EXECUTION_MODES.includes(node.codexMode)) {
|
|
511
|
+
issue(issues, `nodes.${node.id}.codexMode`, "codexMode must be repository or read-only");
|
|
512
|
+
} else if (node.lane !== "codex" || !node.prompt || node.command !== undefined) {
|
|
513
|
+
issue(issues, `nodes.${node.id}.codexMode`, "codexMode is supported only for a codex prompt step without command");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
|
|
517
|
+
issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
|
|
518
|
+
}
|
|
519
|
+
if (node.timeoutMs !== undefined && (!Number.isInteger(node.timeoutMs) || node.timeoutMs < 1)) {
|
|
520
|
+
issue(issues, `nodes.${node.id}`, "timeoutMs must be a positive integer");
|
|
771
521
|
}
|
|
522
|
+
break;
|
|
772
523
|
}
|
|
773
|
-
case "
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
524
|
+
case "decision": {
|
|
525
|
+
if (typeof node.condition !== "string" || node.condition.trim() === "") {
|
|
526
|
+
issue(issues, `nodes.${node.id}`, "decision node requires a condition expression");
|
|
527
|
+
} else {
|
|
528
|
+
try {
|
|
529
|
+
parseExpr(node.condition);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (err instanceof ExprSyntaxError) {
|
|
532
|
+
issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
|
|
533
|
+
} else {
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (!node.then && !node.else) {
|
|
539
|
+
issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
|
|
778
540
|
}
|
|
779
|
-
|
|
541
|
+
break;
|
|
780
542
|
}
|
|
781
|
-
case "
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
543
|
+
case "while": {
|
|
544
|
+
if (typeof node.condition !== "string" || node.condition.trim() === "") {
|
|
545
|
+
issue(issues, `nodes.${node.id}`, "while node requires a condition expression");
|
|
546
|
+
} else {
|
|
547
|
+
try {
|
|
548
|
+
parseExpr(node.condition);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (err instanceof ExprSyntaxError) {
|
|
551
|
+
issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
|
|
552
|
+
} else {
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
786
556
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
557
|
+
if (node.maxIterations === undefined) {
|
|
558
|
+
issue(issues, `nodes.${node.id}`, "while node requires maxIterations (finite loop bound)");
|
|
559
|
+
} else if (!Number.isInteger(node.maxIterations) || node.maxIterations < 1) {
|
|
560
|
+
issue(issues, `nodes.${node.id}`, "maxIterations must be a positive integer");
|
|
561
|
+
}
|
|
562
|
+
if (!Array.isArray(node.body) || node.body.length === 0) {
|
|
563
|
+
issue(issues, `nodes.${node.id}`, "while node requires a non-empty body");
|
|
564
|
+
} else {
|
|
565
|
+
for (const bodyId of node.body) {
|
|
566
|
+
const bodyNode = findNode(graph, bodyId);
|
|
567
|
+
if (!bodyNode) {
|
|
568
|
+
issue(issues, `nodes.${node.id}`, `while body references nonexistent node ${bodyId}`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (bodyNode.type === "start" || bodyNode.type === "end" || bodyNode.type === "while") {
|
|
572
|
+
issue(issues, `nodes.${node.id}`, `while body may only contain step/decision nodes in v1 (found ${bodyNode.type})`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const bodySet = new Set(node.body);
|
|
576
|
+
for (const bodyId of node.body) {
|
|
577
|
+
const bodyNode = findNode(graph, bodyId);
|
|
578
|
+
if (!bodyNode)
|
|
579
|
+
continue;
|
|
580
|
+
for (const { kind, target } of nodeEdges(bodyNode)) {
|
|
581
|
+
if (!bodySet.has(target)) {
|
|
582
|
+
issue(issues, `nodes.${node.id}`, `while body node ${bodyId} ${kind}-edge leaves the body (target ${target})`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
794
588
|
}
|
|
589
|
+
case "end":
|
|
590
|
+
break;
|
|
795
591
|
}
|
|
796
592
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
593
|
+
var GRAPH_NODE_TYPES, LANE_KINDS, CODEX_EXECUTION_MODES, NODE_ID_RE;
|
|
594
|
+
var init_graph = __esm(() => {
|
|
595
|
+
init_expr();
|
|
596
|
+
GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
|
|
597
|
+
LANE_KINDS = ["claude", "codex", "cursor", "grok"];
|
|
598
|
+
CODEX_EXECUTION_MODES = ["repository", "read-only"];
|
|
599
|
+
NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// src/lanes/types.ts
|
|
603
|
+
import { statSync as statSync2 } from "fs";
|
|
604
|
+
function binaryOnPath(binary) {
|
|
605
|
+
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
606
|
+
for (const entry of pathEntries) {
|
|
607
|
+
try {
|
|
608
|
+
const stat = statSync2(`${entry}/${binary}`);
|
|
609
|
+
if (stat.isFile())
|
|
610
|
+
return true;
|
|
611
|
+
} catch {}
|
|
802
612
|
}
|
|
803
|
-
return
|
|
613
|
+
return false;
|
|
804
614
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
615
|
+
function subscriptionEnvironment(overrides = {}) {
|
|
616
|
+
const paid = /^(OPENAI_API_KEY|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CODEX_API_KEY|CURSOR_API_KEY)$/;
|
|
617
|
+
const sensitive = /(?:API[_-]?KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL|CREDENTIAL)/i;
|
|
618
|
+
const env = {};
|
|
619
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
620
|
+
if (value !== undefined && !paid.test(name) && (!sensitive.test(name) || name === "CLAUDE_CODE_OAUTH_TOKEN"))
|
|
621
|
+
env[name] = value;
|
|
622
|
+
}
|
|
623
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
624
|
+
if (paid.test(name) || sensitive.test(name))
|
|
625
|
+
throw new Error(`lane environment field ${name} requires a supported scoped credential consumer`);
|
|
626
|
+
env[name] = value;
|
|
627
|
+
}
|
|
628
|
+
return env;
|
|
812
629
|
}
|
|
813
|
-
function
|
|
814
|
-
|
|
630
|
+
function subscriptionSdkEnvironment(overrides = {}) {
|
|
631
|
+
const safe = subscriptionEnvironment(overrides);
|
|
632
|
+
return { ...Object.fromEntries(Object.keys(process.env).map((name) => [name, undefined])), ...safe };
|
|
815
633
|
}
|
|
816
|
-
function
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
634
|
+
function withTimeout(promise, timeoutMs, what, cancel) {
|
|
635
|
+
return new Promise((resolve, reject) => {
|
|
636
|
+
const timer = setTimeout(() => {
|
|
637
|
+
cancel?.();
|
|
638
|
+
reject(new LaneTimeoutError(what, timeoutMs));
|
|
639
|
+
}, timeoutMs);
|
|
640
|
+
promise.then((value) => {
|
|
641
|
+
clearTimeout(timer);
|
|
642
|
+
resolve(value);
|
|
643
|
+
}, (err) => {
|
|
644
|
+
clearTimeout(timer);
|
|
645
|
+
reject(err);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
async function spawnCli(cmd, opts) {
|
|
650
|
+
const started = Date.now();
|
|
651
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
652
|
+
let proc;
|
|
653
|
+
try {
|
|
654
|
+
proc = Bun.spawn({
|
|
655
|
+
cmd,
|
|
656
|
+
cwd: opts.cwd,
|
|
657
|
+
env: subscriptionEnvironment(opts.env),
|
|
658
|
+
stdin: "ignore",
|
|
659
|
+
stdout: "pipe",
|
|
660
|
+
stderr: "pipe",
|
|
661
|
+
timeout: timeoutMs
|
|
662
|
+
});
|
|
663
|
+
} catch (err) {
|
|
664
|
+
const message = String(err instanceof Error ? err.message : err);
|
|
665
|
+
if (message.includes("ENOENT") || /not found/i.test(message)) {
|
|
666
|
+
throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
|
|
837
667
|
}
|
|
668
|
+
return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
|
|
838
669
|
}
|
|
839
|
-
|
|
670
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
671
|
+
new Response(proc.stdout).text(),
|
|
672
|
+
new Response(proc.stderr).text(),
|
|
673
|
+
proc.exited
|
|
674
|
+
]);
|
|
675
|
+
const output = opts.stdoutOnly ? stdout.trim() : stderr ? `${stdout}
|
|
676
|
+
${stderr}`.trim() : stdout.trim();
|
|
677
|
+
return {
|
|
678
|
+
ok: exitCode === 0,
|
|
679
|
+
exitCode,
|
|
680
|
+
output,
|
|
681
|
+
durationMs: Date.now() - started,
|
|
682
|
+
...opts.stdoutOnly && exitCode !== 0 && stderr.trim() ? { error: stderr.trim() } : {}
|
|
683
|
+
};
|
|
840
684
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
685
|
+
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000, LaneTimeoutError;
|
|
686
|
+
var init_types = __esm(() => {
|
|
687
|
+
LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
|
|
688
|
+
LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
|
|
689
|
+
constructor(message) {
|
|
690
|
+
super(message);
|
|
691
|
+
this.name = "LaneDependencyMissingError";
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
LaneAdapterShapeError = class LaneAdapterShapeError extends Error {
|
|
695
|
+
constructor(message) {
|
|
696
|
+
super(message);
|
|
697
|
+
this.name = "LaneAdapterShapeError";
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
LaneTimeoutError = class LaneTimeoutError extends Error {
|
|
701
|
+
constructor(what, timeoutMs) {
|
|
702
|
+
super(`${what} exceeded the ${timeoutMs}ms budget`);
|
|
703
|
+
this.name = "LaneTimeoutError";
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// src/lanes/claude.ts
|
|
709
|
+
function createClaudeAdapter(deps = {}) {
|
|
710
|
+
return {
|
|
711
|
+
kind: "claude",
|
|
712
|
+
async probe() {
|
|
713
|
+
const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
|
|
714
|
+
try {
|
|
715
|
+
await loader();
|
|
716
|
+
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
|
|
717
|
+
} catch {
|
|
718
|
+
if (binaryOnPath(deps.cliPath ?? "claude")) {
|
|
719
|
+
return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
kind: "claude",
|
|
723
|
+
sdk: "@anthropic-ai/claude-agent-sdk",
|
|
724
|
+
wired: false,
|
|
725
|
+
reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
async run(job) {
|
|
730
|
+
if (!job.prompt) {
|
|
731
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
|
|
732
|
+
}
|
|
733
|
+
const started = Date.now();
|
|
734
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
735
|
+
const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
|
|
736
|
+
let sdk;
|
|
737
|
+
try {
|
|
738
|
+
sdk = await loader();
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
|
|
741
|
+
}
|
|
742
|
+
const query = sdk.query;
|
|
743
|
+
if (typeof query !== "function") {
|
|
744
|
+
throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
|
|
745
|
+
}
|
|
746
|
+
const prompt = job.prompt;
|
|
747
|
+
const abortController = new AbortController;
|
|
748
|
+
try {
|
|
749
|
+
const { output, isError } = await withTimeout((async () => {
|
|
750
|
+
const generator = await query({
|
|
751
|
+
prompt,
|
|
752
|
+
options: { model: job.model, cwd: job.cwd, maxTurns: 32, abortController, env: subscriptionSdkEnvironment(job.env) }
|
|
753
|
+
});
|
|
754
|
+
let output2 = "";
|
|
755
|
+
let isError2 = false;
|
|
756
|
+
for await (const message of generator) {
|
|
757
|
+
if (message.type === "assistant") {
|
|
758
|
+
const content = message.message?.content;
|
|
759
|
+
if (Array.isArray(content)) {
|
|
760
|
+
for (const block of content) {
|
|
761
|
+
if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
|
762
|
+
output2 += block.text;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (message.type === "error")
|
|
768
|
+
isError2 = true;
|
|
769
|
+
}
|
|
770
|
+
return { output: output2, isError: isError2 };
|
|
771
|
+
})(), timeoutMs, "claude lane", () => abortController.abort());
|
|
772
|
+
const durationMs = Date.now() - started;
|
|
773
|
+
return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
|
|
774
|
+
} catch (err) {
|
|
775
|
+
const durationMs = Date.now() - started;
|
|
776
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function runClaudeCli(job, cliPath, started, timeoutMs) {
|
|
782
|
+
try {
|
|
783
|
+
const result = await spawnCli([cliPath, "-p", job.prompt], {
|
|
784
|
+
binaryName: cliPath,
|
|
785
|
+
packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
|
|
786
|
+
timeoutMs,
|
|
787
|
+
env: job.env,
|
|
788
|
+
cwd: job.cwd
|
|
789
|
+
});
|
|
790
|
+
return { ...result, durationMs: Date.now() - started };
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
793
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
868
794
|
}
|
|
795
|
+
throw err;
|
|
869
796
|
}
|
|
870
|
-
return [...seen];
|
|
871
797
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
798
|
+
var init_claude = __esm(() => {
|
|
799
|
+
init_types();
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// src/lanes/codex.ts
|
|
803
|
+
function createCodexAdapter(deps = {}) {
|
|
804
|
+
return {
|
|
805
|
+
kind: "codex",
|
|
806
|
+
async probe() {
|
|
807
|
+
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
808
|
+
try {
|
|
809
|
+
await loader();
|
|
810
|
+
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
|
|
811
|
+
} catch {
|
|
812
|
+
if (binaryOnPath(deps.cliPath ?? "codex")) {
|
|
813
|
+
return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
kind: "codex",
|
|
817
|
+
sdk: "@openai/codex-sdk",
|
|
818
|
+
wired: false,
|
|
819
|
+
reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
async run(job) {
|
|
824
|
+
if (!job.prompt) {
|
|
825
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
|
|
826
|
+
}
|
|
827
|
+
if (job.codexMode !== undefined && (!CODEX_EXECUTION_MODES.includes(job.codexMode) || job.lane !== "codex" || job.command !== undefined)) {
|
|
828
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codexMode must be repository or read-only on a codex prompt without command", retryable: false };
|
|
829
|
+
}
|
|
830
|
+
const started = Date.now();
|
|
831
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
832
|
+
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
833
|
+
let sdk;
|
|
834
|
+
try {
|
|
835
|
+
sdk = await loader();
|
|
836
|
+
} catch {
|
|
837
|
+
return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
|
|
838
|
+
}
|
|
839
|
+
const CodexClass = sdk.Codex;
|
|
840
|
+
if (typeof CodexClass !== "function") {
|
|
841
|
+
throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
|
|
842
|
+
}
|
|
843
|
+
const abortController = new AbortController;
|
|
844
|
+
try {
|
|
845
|
+
const client = await withTimeout(Promise.resolve(new CodexClass({
|
|
846
|
+
config: { model: job.model },
|
|
847
|
+
env: subscriptionEnvironment(job.env)
|
|
848
|
+
})), timeoutMs, "codex lane");
|
|
849
|
+
const thread = client.startThread?.({
|
|
850
|
+
model: job.model,
|
|
851
|
+
workingDirectory: job.cwd,
|
|
852
|
+
...job.codexMode === "read-only" ? { sandboxMode: "read-only", skipGitRepoCheck: true, approvalPolicy: "never" } : {}
|
|
853
|
+
});
|
|
854
|
+
if (!thread || typeof thread.run !== "function") {
|
|
855
|
+
throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
|
|
856
|
+
}
|
|
857
|
+
const turn = await withTimeout(thread.run(job.prompt, { signal: abortController.signal }), timeoutMs, "codex lane", () => abortController.abort());
|
|
858
|
+
const durationMs = Date.now() - started;
|
|
859
|
+
const output = turn?.finalResponse;
|
|
860
|
+
if (typeof output !== "string" || !output.trim()) {
|
|
861
|
+
return missingResponse(durationMs);
|
|
862
|
+
}
|
|
863
|
+
return { ok: true, exitCode: 0, output, durationMs };
|
|
864
|
+
} catch (err) {
|
|
865
|
+
const durationMs = Date.now() - started;
|
|
866
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
893
867
|
}
|
|
894
868
|
}
|
|
895
|
-
|
|
896
|
-
color.set(id, "black");
|
|
897
|
-
}
|
|
898
|
-
for (const node of graph.nodes) {
|
|
899
|
-
if (color.get(node.id) === undefined)
|
|
900
|
-
visit(node.id);
|
|
901
|
-
}
|
|
902
|
-
return cycles;
|
|
869
|
+
};
|
|
903
870
|
}
|
|
904
|
-
function
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
871
|
+
async function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
872
|
+
try {
|
|
873
|
+
const controls = job.codexMode === "read-only" ? ["--sandbox", "read-only", "--skip-git-repo-check", "--config", 'approval_policy="never"'] : [];
|
|
874
|
+
const result = await spawnCli([cliPath, "exec", ...controls, "--", job.prompt], {
|
|
875
|
+
binaryName: cliPath,
|
|
876
|
+
packageHint: "@openai/codex-sdk or the codex CLI",
|
|
877
|
+
timeoutMs,
|
|
878
|
+
env: job.env,
|
|
879
|
+
cwd: job.cwd,
|
|
880
|
+
stdoutOnly: true
|
|
881
|
+
});
|
|
882
|
+
const durationMs = Date.now() - started;
|
|
883
|
+
return result.ok && !result.output.trim() ? missingResponse(durationMs) : { ...result, durationMs };
|
|
884
|
+
} catch (err) {
|
|
885
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
886
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
887
|
+
}
|
|
888
|
+
throw err;
|
|
911
889
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
890
|
+
}
|
|
891
|
+
function missingResponse(durationMs) {
|
|
892
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: "Codex completed without an agent response; verify the native subscription and model route", retryable: false };
|
|
893
|
+
}
|
|
894
|
+
var init_codex = __esm(() => {
|
|
895
|
+
init_graph();
|
|
896
|
+
init_types();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// src/lanes/cursor.ts
|
|
900
|
+
function createCursorAdapter(deps = {}) {
|
|
901
|
+
return {
|
|
902
|
+
kind: "cursor",
|
|
903
|
+
async probe() {
|
|
904
|
+
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
905
|
+
try {
|
|
906
|
+
await loader();
|
|
907
|
+
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
|
|
908
|
+
} catch {
|
|
909
|
+
if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
|
|
910
|
+
return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
|
|
920
911
|
}
|
|
912
|
+
return {
|
|
913
|
+
kind: "cursor",
|
|
914
|
+
sdk: "@cursor/sdk",
|
|
915
|
+
wired: false,
|
|
916
|
+
reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
async run(job) {
|
|
921
|
+
if (!job.prompt) {
|
|
922
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
|
|
923
|
+
}
|
|
924
|
+
if (!deps.sdkLoader && binaryOnPath(deps.cliPath ?? "cursor-agent"))
|
|
925
|
+
return runCursorCli(job, deps.cliPath ?? "cursor-agent", Date.now(), job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS);
|
|
926
|
+
const started = Date.now();
|
|
927
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
928
|
+
const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
|
|
929
|
+
let sdk;
|
|
930
|
+
try {
|
|
931
|
+
sdk = await loader();
|
|
932
|
+
} catch {
|
|
933
|
+
return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
|
|
934
|
+
}
|
|
935
|
+
let cancel;
|
|
936
|
+
try {
|
|
937
|
+
subscriptionEnvironment(job.env);
|
|
938
|
+
if (!deps.sdkLoader && process.env.CURSOR_API_KEY)
|
|
939
|
+
throw new Error("cursor lane refuses ambient API billing; use the native subscription CLI");
|
|
940
|
+
const run2 = await withTimeout(runLocalAgent(sdk, job, (callback) => {
|
|
941
|
+
cancel = callback;
|
|
942
|
+
}), timeoutMs, "cursor lane", () => cancel?.());
|
|
943
|
+
const durationMs = Date.now() - started;
|
|
944
|
+
const status = run2.status;
|
|
945
|
+
const ok = status === "finished";
|
|
946
|
+
return { ok, exitCode: ok ? 0 : 1, output: run2.result, durationMs };
|
|
947
|
+
} catch (err) {
|
|
948
|
+
if (err instanceof LaneAdapterShapeError)
|
|
949
|
+
throw err;
|
|
950
|
+
const durationMs = Date.now() - started;
|
|
951
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
|
|
921
952
|
}
|
|
922
953
|
}
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async function runLocalAgent(sdk, job, onCancel) {
|
|
957
|
+
const AgentClass = sdk.Agent;
|
|
958
|
+
const CursorClass = sdk.Cursor;
|
|
959
|
+
if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
|
|
960
|
+
throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
|
|
923
961
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
962
|
+
const create = AgentClass.create;
|
|
963
|
+
const agent = typeof create === "function" ? await create.call(AgentClass, { local: { cwd: job.cwd, enableAgentRetries: false } }) : typeof AgentClass === "function" ? new AgentClass : undefined;
|
|
964
|
+
if (!agent || typeof agent.send !== "function") {
|
|
965
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
|
|
927
966
|
}
|
|
928
|
-
|
|
929
|
-
if (
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
for (const node of graph.nodes) {
|
|
938
|
-
if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
|
|
939
|
-
issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
|
|
940
|
-
continue;
|
|
941
|
-
}
|
|
942
|
-
if (seenIds.has(node.id)) {
|
|
943
|
-
issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
|
|
944
|
-
continue;
|
|
945
|
-
}
|
|
946
|
-
seenIds.set(node.id, node);
|
|
947
|
-
if (!GRAPH_NODE_TYPES.includes(node.type)) {
|
|
948
|
-
issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
|
|
949
|
-
continue;
|
|
950
|
-
}
|
|
951
|
-
validateNode(graph, node, issues);
|
|
967
|
+
let run2 = await agent.send(job.prompt, { model: job.model });
|
|
968
|
+
if (run2 && typeof run2 === "object") {
|
|
969
|
+
const handle = run2;
|
|
970
|
+
if (typeof handle.cancel === "function")
|
|
971
|
+
onCancel(() => {
|
|
972
|
+
handle.cancel().catch(() => {});
|
|
973
|
+
});
|
|
974
|
+
if (typeof handle.wait === "function")
|
|
975
|
+
run2 = await handle.wait();
|
|
952
976
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
for (const { kind, target } of edges) {
|
|
956
|
-
if (!seenIds.has(target)) {
|
|
957
|
-
issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
977
|
+
if (!run2 || typeof run2 !== "object") {
|
|
978
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
|
|
960
979
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if (!reachable.has(node.id)) {
|
|
965
|
-
issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
980
|
+
const candidate = run2;
|
|
981
|
+
if (typeof candidate.status !== "string" || !["finished", "error", "cancelled"].includes(candidate.status)) {
|
|
982
|
+
throw new LaneAdapterShapeError(`@cursor/sdk Agent.send() returned a Run without a terminal status (got ${JSON.stringify(candidate.status)}; expected finished|error|cancelled)`);
|
|
968
983
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
|
|
984
|
+
if (typeof candidate.result !== "string") {
|
|
985
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned a Run without a string result");
|
|
972
986
|
}
|
|
973
|
-
return {
|
|
987
|
+
return { status: candidate.status, result: candidate.result };
|
|
974
988
|
}
|
|
975
|
-
function
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
+
async function runCursorCli(job, cliPath, started, timeoutMs) {
|
|
990
|
+
try {
|
|
991
|
+
const result = await spawnCli([cliPath, "--print", job.prompt, ...job.model ? ["--model", job.model] : []], {
|
|
992
|
+
binaryName: cliPath,
|
|
993
|
+
packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
|
|
994
|
+
timeoutMs,
|
|
995
|
+
env: job.env,
|
|
996
|
+
cwd: job.cwd
|
|
997
|
+
});
|
|
998
|
+
return { ...result, durationMs: Date.now() - started };
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
1001
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
1002
|
+
}
|
|
1003
|
+
throw err;
|
|
989
1004
|
}
|
|
990
1005
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (
|
|
1002
|
-
|
|
1003
|
-
}
|
|
1004
|
-
if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
|
|
1005
|
-
issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
|
|
1006
|
+
var init_cursor = __esm(() => {
|
|
1007
|
+
init_types();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// src/lanes/grok.ts
|
|
1011
|
+
function createGrokAdapter(deps = {}) {
|
|
1012
|
+
return {
|
|
1013
|
+
kind: "grok",
|
|
1014
|
+
async probe() {
|
|
1015
|
+
const cliPath = deps.cliPath ?? "grok";
|
|
1016
|
+
if (binaryOnPath(cliPath)) {
|
|
1017
|
+
return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
|
|
1006
1018
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1019
|
+
return {
|
|
1020
|
+
kind: "grok",
|
|
1021
|
+
sdk: "no npm SDK (measured 2026-08-25)",
|
|
1022
|
+
wired: false,
|
|
1023
|
+
reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
|
|
1024
|
+
};
|
|
1025
|
+
},
|
|
1026
|
+
async run(job) {
|
|
1027
|
+
if (!job.prompt) {
|
|
1028
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
|
|
1009
1029
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1030
|
+
const started = Date.now();
|
|
1031
|
+
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
1032
|
+
const cliPath = deps.cliPath ?? "grok";
|
|
1033
|
+
try {
|
|
1034
|
+
const result = await spawnCli([cliPath, job.prompt], {
|
|
1035
|
+
binaryName: cliPath,
|
|
1036
|
+
packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
|
|
1037
|
+
timeoutMs,
|
|
1038
|
+
env: job.env,
|
|
1039
|
+
cwd: job.cwd
|
|
1040
|
+
});
|
|
1041
|
+
return { ...result, durationMs: Date.now() - started };
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
if (err instanceof LaneDependencyMissingError) {
|
|
1044
|
+
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
1024
1045
|
}
|
|
1046
|
+
throw err;
|
|
1025
1047
|
}
|
|
1026
|
-
if (!node.then && !node.else) {
|
|
1027
|
-
issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
|
|
1028
|
-
}
|
|
1029
|
-
break;
|
|
1030
1048
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
var init_grok = __esm(() => {
|
|
1052
|
+
init_types();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// src/lanes/index.ts
|
|
1056
|
+
var exports_lanes = {};
|
|
1057
|
+
__export(exports_lanes, {
|
|
1058
|
+
runLaneJob: () => runLaneJob,
|
|
1059
|
+
resolveLane: () => resolveLane,
|
|
1060
|
+
probeLane: () => probeLane,
|
|
1061
|
+
laneInventory: () => laneInventory,
|
|
1062
|
+
createGrokAdapter: () => createGrokAdapter,
|
|
1063
|
+
createCursorAdapter: () => createCursorAdapter,
|
|
1064
|
+
createCodexAdapter: () => createCodexAdapter,
|
|
1065
|
+
createClaudeAdapter: () => createClaudeAdapter,
|
|
1066
|
+
binaryOnPath: () => binaryOnPath,
|
|
1067
|
+
LaneDependencyMissingError: () => LaneDependencyMissingError,
|
|
1068
|
+
LaneAdapterShapeError: () => LaneAdapterShapeError,
|
|
1069
|
+
LANE_KINDS: () => LANE_KINDS2
|
|
1070
|
+
});
|
|
1071
|
+
function resolveLane(kind) {
|
|
1072
|
+
const adapter = adapters.get(kind);
|
|
1073
|
+
if (!adapter)
|
|
1074
|
+
throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
|
|
1075
|
+
return adapter;
|
|
1076
|
+
}
|
|
1077
|
+
async function runLaneJob(job) {
|
|
1078
|
+
return resolveLane(job.lane).run(job);
|
|
1079
|
+
}
|
|
1080
|
+
async function probeLane(kind) {
|
|
1081
|
+
return resolveLane(kind).probe();
|
|
1082
|
+
}
|
|
1083
|
+
async function laneInventory() {
|
|
1084
|
+
return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
|
|
1085
|
+
}
|
|
1086
|
+
var adapters;
|
|
1087
|
+
var init_lanes = __esm(() => {
|
|
1088
|
+
init_claude();
|
|
1089
|
+
init_codex();
|
|
1090
|
+
init_cursor();
|
|
1091
|
+
init_grok();
|
|
1092
|
+
init_types();
|
|
1093
|
+
init_types();
|
|
1094
|
+
init_claude();
|
|
1095
|
+
init_codex();
|
|
1096
|
+
init_cursor();
|
|
1097
|
+
init_grok();
|
|
1098
|
+
adapters = new Map([
|
|
1099
|
+
["claude", createClaudeAdapter()],
|
|
1100
|
+
["codex", createCodexAdapter()],
|
|
1101
|
+
["cursor", createCursorAdapter()],
|
|
1102
|
+
["grok", createGrokAdapter()]
|
|
1103
|
+
]);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// src/service.ts
|
|
1107
|
+
import { createHash as createHash3 } from "crypto";
|
|
1108
|
+
// package.json
|
|
1109
|
+
var package_default = {
|
|
1110
|
+
name: "@hasna/workflows",
|
|
1111
|
+
version: "0.2.1",
|
|
1112
|
+
description: "Universal graph workflow app \u2014 graph language (start/step/decision/while/end), three-table store, session WAL, daemon with claims/leases, four lane adapters (claude/codex/cursor/grok), and a full CLI on four surfaces",
|
|
1113
|
+
type: "module",
|
|
1114
|
+
main: "./dist/index.js",
|
|
1115
|
+
types: "./types/index.d.ts",
|
|
1116
|
+
bin: {
|
|
1117
|
+
workflows: "dist/cli/index.js",
|
|
1118
|
+
"workflows-mcp": "dist/mcp/index.js",
|
|
1119
|
+
"workflows-serve": "dist/serve/index.js"
|
|
1120
|
+
},
|
|
1121
|
+
exports: {
|
|
1122
|
+
".": {
|
|
1123
|
+
types: "./types/index.d.ts",
|
|
1124
|
+
import: "./dist/index.js"
|
|
1125
|
+
},
|
|
1126
|
+
"./sdk": {
|
|
1127
|
+
types: "./types/sdk.d.ts",
|
|
1128
|
+
import: "./dist/sdk.js"
|
|
1129
|
+
},
|
|
1130
|
+
"./cli": {
|
|
1131
|
+
types: "./types/cli/index.d.ts",
|
|
1132
|
+
import: "./dist/cli/index.js"
|
|
1076
1133
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1134
|
+
},
|
|
1135
|
+
files: [
|
|
1136
|
+
"dist",
|
|
1137
|
+
"types",
|
|
1138
|
+
"README.md",
|
|
1139
|
+
"LICENSE",
|
|
1140
|
+
"hasna.contract.json"
|
|
1141
|
+
],
|
|
1142
|
+
scripts: {
|
|
1143
|
+
build: "bun run check:sdk && bun run build:runtime && bun run build:types",
|
|
1144
|
+
"generate:sdk": "bun scripts/generate-sdk.ts --write",
|
|
1145
|
+
"check:sdk": "bun scripts/generate-sdk.ts",
|
|
1146
|
+
"test:artifacts": "bun scripts/artifact-acceptance.ts",
|
|
1147
|
+
"build:runtime": "bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/mcp/index.ts --outdir dist/mcp --target bun && bun build src/serve/index.ts src/serve/admin.ts --outdir dist/serve --target bun --packages external && bun build src/index.ts src/service.ts src/handlers.ts src/agent-tools.ts src/types.ts --root src --outdir dist --target bun --packages external && bun build src/sdk.ts --outdir dist --target node",
|
|
1148
|
+
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir types",
|
|
1149
|
+
"artifact-scan": "bun scripts/contracts-cli.mjs artifact-scan dist",
|
|
1150
|
+
"contract:check": "bun scripts/contracts-cli.mjs repo-conformance .",
|
|
1151
|
+
typecheck: "tsc --noEmit",
|
|
1152
|
+
test: "bun test",
|
|
1153
|
+
"test:pg": 'WORKFLOWS_TEST_DATABASE_URL="${WORKFLOWS_TEST_DATABASE_URL:?point it at a fresh test Postgres database}" WORKFLOWS_REQUIRE_POSTGRES=1 bun test src/db/postgres-store.test.ts src/db/postgres-http.test.ts src/db/postgres-fencing.test.ts',
|
|
1154
|
+
prepack: "bun run build && bun run artifact-scan",
|
|
1155
|
+
prepublishOnly: "bun run test && bun run build"
|
|
1156
|
+
},
|
|
1157
|
+
keywords: [
|
|
1158
|
+
"workflows",
|
|
1159
|
+
"graph",
|
|
1160
|
+
"automation",
|
|
1161
|
+
"cli",
|
|
1162
|
+
"typescript",
|
|
1163
|
+
"bun",
|
|
1164
|
+
"hasna"
|
|
1165
|
+
],
|
|
1166
|
+
publishConfig: {
|
|
1167
|
+
registry: "https://registry.npmjs.org",
|
|
1168
|
+
access: "public"
|
|
1169
|
+
},
|
|
1170
|
+
repository: {
|
|
1171
|
+
type: "git",
|
|
1172
|
+
url: "https://github.com/hasna/apps.git",
|
|
1173
|
+
directory: "apps/workflows"
|
|
1174
|
+
},
|
|
1175
|
+
homepage: "https://github.com/hasna/apps/tree/main/apps/workflows",
|
|
1176
|
+
bugs: {
|
|
1177
|
+
url: "https://github.com/hasna/apps/issues"
|
|
1178
|
+
},
|
|
1179
|
+
engines: {
|
|
1180
|
+
bun: ">=1.0.0"
|
|
1181
|
+
},
|
|
1182
|
+
author: "Hasna",
|
|
1183
|
+
license: "Apache-2.0",
|
|
1184
|
+
dependencies: {
|
|
1185
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
1186
|
+
"@cursor/sdk": "^1.0.28",
|
|
1187
|
+
"@hasna/contracts": "1.3.3",
|
|
1188
|
+
"@openai/codex-sdk": "0.160.0",
|
|
1189
|
+
commander: "^13.1.0",
|
|
1190
|
+
pg: "^8.16.3"
|
|
1191
|
+
},
|
|
1192
|
+
devDependencies: {
|
|
1193
|
+
"@types/bun": "1.3.14",
|
|
1194
|
+
"@types/pg": "^8.15.6",
|
|
1195
|
+
typescript: "^5.7.3"
|
|
1079
1196
|
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
// src/version.ts
|
|
1200
|
+
function packageVersion() {
|
|
1201
|
+
return package_default.version;
|
|
1080
1202
|
}
|
|
1081
1203
|
|
|
1204
|
+
// src/daemon.ts
|
|
1205
|
+
init_expr();
|
|
1206
|
+
init_graph();
|
|
1207
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1208
|
+
|
|
1082
1209
|
// src/secrets.ts
|
|
1083
1210
|
class SecretsGateError extends Error {
|
|
1084
1211
|
constructor(message) {
|
|
@@ -1159,34 +1286,49 @@ import { readFileSync, statSync } from "fs";
|
|
|
1159
1286
|
import { isAbsolute, join } from "path";
|
|
1160
1287
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1161
1288
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1289
|
+
const sequence = repairSteps(store, wal, opts);
|
|
1290
|
+
let step = sequence.next();
|
|
1291
|
+
while (!step.done) {
|
|
1292
|
+
if (step.value instanceof Promise) {
|
|
1293
|
+
return (async () => {
|
|
1294
|
+
while (!step.done)
|
|
1295
|
+
step = sequence.next(await step.value);
|
|
1296
|
+
return step.value;
|
|
1297
|
+
})();
|
|
1298
|
+
}
|
|
1299
|
+
step = sequence.next(step.value);
|
|
1300
|
+
}
|
|
1301
|
+
return step.value;
|
|
1302
|
+
}
|
|
1303
|
+
function* repairSteps(store, wal, opts) {
|
|
1162
1304
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
1163
1305
|
const replay = wal.replay();
|
|
1164
1306
|
const liveClaims = replay.liveClaims(opts.now);
|
|
1165
1307
|
const report = { interrupted: 0, requeued: 0, failed: 0 };
|
|
1166
|
-
const running = store.listRuns({ status: "running", limit: 1000 });
|
|
1308
|
+
const running = yield store.listRuns({ status: "running", limit: 1000 });
|
|
1167
1309
|
for (const run of running) {
|
|
1168
1310
|
if (liveClaims.has(run.id))
|
|
1169
1311
|
continue;
|
|
1170
1312
|
report.interrupted++;
|
|
1171
|
-
const nodes = store.listRunNodes(run.id);
|
|
1313
|
+
const nodes = yield store.listRunNodes(run.id);
|
|
1172
1314
|
if (run.attempts < maxAttempts) {
|
|
1173
|
-
store.bumpAttempts(run.id);
|
|
1174
|
-
store.setRunStatus(run.id, "interrupted", {
|
|
1315
|
+
yield store.bumpAttempts(run.id);
|
|
1316
|
+
yield store.setRunStatus(run.id, "interrupted", {
|
|
1175
1317
|
error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
|
|
1176
1318
|
});
|
|
1177
1319
|
for (const node of nodes) {
|
|
1178
1320
|
if (node.status === "running") {
|
|
1179
|
-
store.setRunNodeStatus(node.id, "pending", { error: undefined });
|
|
1321
|
+
yield store.setRunNodeStatus(node.id, "pending", { error: undefined });
|
|
1180
1322
|
}
|
|
1181
1323
|
}
|
|
1182
1324
|
report.requeued++;
|
|
1183
1325
|
} else {
|
|
1184
|
-
store.setRunStatus(run.id, "failed", {
|
|
1326
|
+
yield store.setRunStatus(run.id, "failed", {
|
|
1185
1327
|
error: `torn run failed after ${maxAttempts} interrupted attempts`
|
|
1186
1328
|
});
|
|
1187
1329
|
for (const node of nodes) {
|
|
1188
1330
|
if (node.status === "running") {
|
|
1189
|
-
store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
|
|
1331
|
+
yield store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
|
|
1190
1332
|
}
|
|
1191
1333
|
}
|
|
1192
1334
|
report.failed++;
|
|
@@ -1202,15 +1344,17 @@ function memoKey(graphName, nodeId, input) {
|
|
|
1202
1344
|
}
|
|
1203
1345
|
function tryMemoHit(store, graphName, nodeId, input) {
|
|
1204
1346
|
const key = memoKey(graphName, nodeId, input);
|
|
1205
|
-
const
|
|
1206
|
-
if (
|
|
1207
|
-
return
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1347
|
+
const value = store.memoGet(key);
|
|
1348
|
+
if (value instanceof Promise) {
|
|
1349
|
+
return value.then(async (row) => {
|
|
1350
|
+
if (row)
|
|
1351
|
+
await store.memoHit(key);
|
|
1352
|
+
return row;
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
if (value)
|
|
1356
|
+
store.memoHit(key);
|
|
1357
|
+
return value;
|
|
1214
1358
|
}
|
|
1215
1359
|
function memoWatchFingerprint(graph, dataDir) {
|
|
1216
1360
|
if (!graph.memoWatch || graph.memoWatch.length === 0)
|
|
@@ -1252,20 +1396,377 @@ function fingerprintFile(pattern, path) {
|
|
|
1252
1396
|
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1253
1397
|
}
|
|
1254
1398
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1399
|
+
|
|
1400
|
+
// src/db/postgres-store.ts
|
|
1401
|
+
import { randomUUID } from "crypto";
|
|
1402
|
+
import { Pool } from "pg";
|
|
1403
|
+
var run = (r) => ({
|
|
1404
|
+
id: String(r.id),
|
|
1405
|
+
graphName: String(r.graph_name),
|
|
1406
|
+
graphVersion: String(r.graph_version),
|
|
1407
|
+
status: r.status,
|
|
1408
|
+
attempts: Number(r.attempts),
|
|
1409
|
+
contextJson: String(r.context_json),
|
|
1410
|
+
resultJson: r.result_json == null ? null : String(r.result_json),
|
|
1411
|
+
error: r.error == null ? null : String(r.error),
|
|
1412
|
+
startedAt: r.started_at == null ? null : String(r.started_at),
|
|
1413
|
+
finishedAt: r.finished_at == null ? null : String(r.finished_at),
|
|
1414
|
+
createdAt: String(r.created_at),
|
|
1415
|
+
updatedAt: String(r.updated_at)
|
|
1416
|
+
});
|
|
1417
|
+
var node = (r) => ({
|
|
1418
|
+
id: String(r.id),
|
|
1419
|
+
runId: String(r.run_id),
|
|
1420
|
+
nodeId: String(r.node_id),
|
|
1421
|
+
status: r.status,
|
|
1422
|
+
attempts: Number(r.attempts),
|
|
1423
|
+
lane: r.lane == null ? null : String(r.lane),
|
|
1424
|
+
exitCode: r.exit_code == null ? null : Number(r.exit_code),
|
|
1425
|
+
outputJson: r.output_json == null ? null : String(r.output_json),
|
|
1426
|
+
error: r.error == null ? null : String(r.error),
|
|
1427
|
+
startedAt: r.started_at == null ? null : String(r.started_at),
|
|
1428
|
+
finishedAt: r.finished_at == null ? null : String(r.finished_at)
|
|
1429
|
+
});
|
|
1430
|
+
var memo = (r) => ({
|
|
1431
|
+
key: String(r.key),
|
|
1432
|
+
graphName: String(r.graph_name),
|
|
1433
|
+
nodeId: String(r.node_id),
|
|
1434
|
+
inputHash: String(r.input_hash),
|
|
1435
|
+
outputJson: String(r.output_json),
|
|
1436
|
+
hitCount: Number(r.hit_count),
|
|
1437
|
+
createdAt: String(r.created_at)
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
class WorkflowStorageError extends Error {
|
|
1441
|
+
constructor() {
|
|
1442
|
+
super("PostgreSQL workflow storage is unavailable or its schema is invalid");
|
|
1443
|
+
this.name = "WorkflowStorageError";
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
class PostgresWorkflowsStore {
|
|
1448
|
+
pool;
|
|
1449
|
+
prefix;
|
|
1450
|
+
dataDir;
|
|
1451
|
+
execution;
|
|
1452
|
+
constructor(options) {
|
|
1453
|
+
const schema = options.schema ?? "workflows";
|
|
1454
|
+
if (!/^[a-z][a-z0-9_]{0,62}$/.test(schema))
|
|
1455
|
+
throw new Error("PostgreSQL schema must be a lowercase SQL identifier");
|
|
1456
|
+
this.prefix = `"${schema}"`;
|
|
1457
|
+
this.dataDir = options.dataDir;
|
|
1458
|
+
this.pool = new Pool({
|
|
1459
|
+
connectionString: options.databaseUrl,
|
|
1460
|
+
max: 4,
|
|
1461
|
+
connectionTimeoutMillis: 5000,
|
|
1462
|
+
idleTimeoutMillis: 1e4,
|
|
1463
|
+
statement_timeout: 1e4,
|
|
1464
|
+
application_name: "workflows"
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
async rows(sql, values = []) {
|
|
1258
1468
|
try {
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
} catch {
|
|
1469
|
+
if (this.execution?.lost)
|
|
1470
|
+
throw new WorkflowStorageError;
|
|
1471
|
+
return (await (this.execution?.client ?? this.pool).query(sql, values)).rows;
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
if (error?.code === "23505")
|
|
1474
|
+
throw Object.assign(new Error("workflow uniqueness conflict"), { code: "23505" });
|
|
1475
|
+
throw new WorkflowStorageError;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
async close() {
|
|
1479
|
+
await this.releaseExecution();
|
|
1480
|
+
await this.pool.end();
|
|
1481
|
+
}
|
|
1482
|
+
async acquireExecution(runId) {
|
|
1483
|
+
if (this.execution)
|
|
1484
|
+
return this.execution.runId === runId && !this.execution.lost;
|
|
1485
|
+
const client = await this.pool.connect();
|
|
1486
|
+
let lost = false;
|
|
1487
|
+
client.on("error", () => {
|
|
1488
|
+
lost = true;
|
|
1489
|
+
if (this.execution?.client === client)
|
|
1490
|
+
this.execution.lost = true;
|
|
1491
|
+
});
|
|
1492
|
+
try {
|
|
1493
|
+
const acquired = await client.query("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS owned", [`${this.prefix}:${runId}`]);
|
|
1494
|
+
if (!acquired.rows[0]?.owned) {
|
|
1495
|
+
client.release();
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1498
|
+
this.execution = { runId, client, lost };
|
|
1499
|
+
return !lost;
|
|
1500
|
+
} catch {
|
|
1501
|
+
client.release(true);
|
|
1502
|
+
throw new WorkflowStorageError;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
async releaseExecution() {
|
|
1506
|
+
const owned = this.execution;
|
|
1507
|
+
if (!owned)
|
|
1508
|
+
return;
|
|
1509
|
+
this.execution = undefined;
|
|
1510
|
+
try {
|
|
1511
|
+
if (!owned.lost)
|
|
1512
|
+
await owned.client.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", [`${this.prefix}:${owned.runId}`]);
|
|
1513
|
+
} finally {
|
|
1514
|
+
owned.client.release(owned.lost);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
async transaction(action) {
|
|
1518
|
+
if (this.execution?.lost)
|
|
1519
|
+
throw new WorkflowStorageError;
|
|
1520
|
+
const client = this.execution?.client ?? await this.pool.connect();
|
|
1521
|
+
const retained = client === this.execution?.client;
|
|
1522
|
+
try {
|
|
1523
|
+
await client.query("BEGIN");
|
|
1524
|
+
const result = await action(client);
|
|
1525
|
+
await client.query("COMMIT");
|
|
1526
|
+
return result;
|
|
1527
|
+
} catch {
|
|
1528
|
+
try {
|
|
1529
|
+
await client.query("ROLLBACK");
|
|
1530
|
+
} catch {}
|
|
1531
|
+
throw new WorkflowStorageError;
|
|
1532
|
+
} finally {
|
|
1533
|
+
if (!retained)
|
|
1534
|
+
client.release();
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
async listTables() {
|
|
1538
|
+
return (await this.rows("SELECT table_name FROM information_schema.tables WHERE table_schema=$1 AND table_type='BASE TABLE' ORDER BY table_name", [this.prefix.slice(1, -1)])).map((row) => String(row.table_name));
|
|
1539
|
+
}
|
|
1540
|
+
async checkSchema() {
|
|
1541
|
+
if ((await this.listTables()).join(",") !== "memos,run_nodes,runs")
|
|
1542
|
+
return false;
|
|
1543
|
+
await this.rows(`SELECT id,graph_name,graph_version,status,attempts,context_json,result_json,error,started_at,finished_at,created_at,updated_at FROM ${this.prefix}.runs LIMIT 0`);
|
|
1544
|
+
await this.rows(`SELECT id,sequence,run_id,node_id,status,attempts,lane,exit_code,output_json,error,started_at,finished_at FROM ${this.prefix}.run_nodes LIMIT 0`);
|
|
1545
|
+
await this.rows(`SELECT key,graph_name,node_id,input_hash,output_json,hit_count,created_at FROM ${this.prefix}.memos LIMIT 0`);
|
|
1546
|
+
const schema = this.prefix.slice(1, -1);
|
|
1547
|
+
const indices = await this.rows(`SELECT i.indisunique,i.indisvalid,i.indisready,i.indnkeyatts,i.indnatts,i.indpred IS NULL AS unfiltered,pg_get_expr(i.indexprs,i.indrelid) AS expression FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname='runs_idempotency' AND t.relname='runs'`, [schema]);
|
|
1548
|
+
const index = indices[0];
|
|
1549
|
+
if (indices.length !== 1 || !index?.indisunique || !index.indisvalid || !index.indisready || !index.unfiltered || Number(index.indnkeyatts) !== 1 || Number(index.indnatts) !== 1 || String(index.expression).replace(/[()\s]/g, "") !== "context_json::jsonb#>>'{__wf,idempotencyKey}'::text[]")
|
|
1550
|
+
return false;
|
|
1551
|
+
const columns = await this.rows("SELECT table_name,column_name,data_type,is_nullable,is_identity,identity_generation FROM information_schema.columns WHERE table_schema=$1", [schema]);
|
|
1552
|
+
const required = {
|
|
1553
|
+
runs: ["id:text", "graph_name:text", "graph_version:text", "status:text", "attempts:integer", "context_json:text", "created_at:text", "updated_at:text"],
|
|
1554
|
+
run_nodes: ["id:text", "sequence:bigint", "run_id:text", "node_id:text", "status:text", "attempts:integer"],
|
|
1555
|
+
memos: ["key:text", "graph_name:text", "node_id:text", "input_hash:text", "output_json:text", "hit_count:integer", "created_at:text"]
|
|
1556
|
+
};
|
|
1557
|
+
for (const [table, fields] of Object.entries(required))
|
|
1558
|
+
for (const field of fields) {
|
|
1559
|
+
const [name, type] = field.split(":");
|
|
1560
|
+
const c = columns.find((row) => row.table_name === table && row.column_name === name);
|
|
1561
|
+
if (!c || c.data_type !== type || c.is_nullable !== "NO")
|
|
1562
|
+
return false;
|
|
1563
|
+
if (name === "sequence" && (c.is_identity !== "YES" || c.identity_generation !== "ALWAYS"))
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
const constraints = await this.rows(`SELECT c.relname AS table_name,co.contype,pg_get_constraintdef(co.oid) AS definition FROM pg_constraint co JOIN pg_class c ON c.oid=co.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1`, [schema]);
|
|
1567
|
+
for (const [table, key] of [["runs", "id"], ["run_nodes", "id"], ["memos", "key"]])
|
|
1568
|
+
if (!constraints.some((row) => row.table_name === table && row.contype === "p" && row.definition === `PRIMARY KEY (${key})`))
|
|
1569
|
+
return false;
|
|
1570
|
+
return constraints.some((row) => row.table_name === "run_nodes" && row.contype === "f" && String(row.definition).replaceAll('"', "") === `FOREIGN KEY (run_id) REFERENCES ${schema}.runs(id)`);
|
|
1571
|
+
}
|
|
1572
|
+
async initialize() {
|
|
1573
|
+
const tables = await this.listTables();
|
|
1574
|
+
if (tables.length) {
|
|
1575
|
+
if (await this.checkSchema())
|
|
1576
|
+
return;
|
|
1577
|
+
throw new WorkflowStorageError;
|
|
1578
|
+
}
|
|
1579
|
+
const client = await this.pool.connect();
|
|
1580
|
+
try {
|
|
1581
|
+
await client.query("BEGIN");
|
|
1582
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${this.prefix};
|
|
1583
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}.runs (
|
|
1584
|
+
id TEXT PRIMARY KEY, graph_name TEXT NOT NULL, graph_version TEXT NOT NULL, status TEXT NOT NULL,
|
|
1585
|
+
attempts INTEGER NOT NULL DEFAULT 0, context_json TEXT NOT NULL, result_json TEXT, error TEXT,
|
|
1586
|
+
started_at TEXT, finished_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
1587
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}.run_nodes (
|
|
1588
|
+
id TEXT PRIMARY KEY, sequence BIGINT GENERATED ALWAYS AS IDENTITY, run_id TEXT NOT NULL REFERENCES ${this.prefix}.runs(id), node_id TEXT NOT NULL,
|
|
1589
|
+
status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, lane TEXT, exit_code INTEGER,
|
|
1590
|
+
output_json TEXT, error TEXT, started_at TEXT, finished_at TEXT);
|
|
1591
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}.memos (
|
|
1592
|
+
key TEXT PRIMARY KEY, graph_name TEXT NOT NULL, node_id TEXT NOT NULL, input_hash TEXT NOT NULL,
|
|
1593
|
+
output_json TEXT NOT NULL, hit_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
|
|
1594
|
+
CREATE UNIQUE INDEX IF NOT EXISTS runs_idempotency ON ${this.prefix}.runs ((context_json::jsonb #>> '{__wf,idempotencyKey}'));
|
|
1595
|
+
CREATE INDEX IF NOT EXISTS runs_status ON ${this.prefix}.runs(status);
|
|
1596
|
+
CREATE INDEX IF NOT EXISTS nodes_run ON ${this.prefix}.run_nodes(run_id);`);
|
|
1597
|
+
await client.query("COMMIT");
|
|
1598
|
+
} catch {
|
|
1599
|
+
await client.query("ROLLBACK");
|
|
1600
|
+
throw new WorkflowStorageError;
|
|
1601
|
+
} finally {
|
|
1602
|
+
client.release();
|
|
1603
|
+
}
|
|
1604
|
+
if (!await this.checkSchema())
|
|
1605
|
+
throw new WorkflowStorageError;
|
|
1606
|
+
}
|
|
1607
|
+
async createRun(input) {
|
|
1608
|
+
const at = new Date().toISOString();
|
|
1609
|
+
const rows = await this.rows(`INSERT INTO ${this.prefix}.runs(id,graph_name,graph_version,status,context_json,created_at,updated_at)
|
|
1610
|
+
VALUES($1,$2,$3,'pending',$4,$5,$5) RETURNING *`, [randomUUID(), input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), at]);
|
|
1611
|
+
return run(rows[0]);
|
|
1612
|
+
}
|
|
1613
|
+
async getRun(id) {
|
|
1614
|
+
const rows = await this.rows(`SELECT * FROM ${this.prefix}.runs WHERE id=$1`, [id]);
|
|
1615
|
+
return rows[0] ? run(rows[0]) : undefined;
|
|
1616
|
+
}
|
|
1617
|
+
async getRunByIdempotencyKey(key) {
|
|
1618
|
+
const rows = await this.rows(`SELECT * FROM ${this.prefix}.runs WHERE context_json::jsonb #>> '{__wf,idempotencyKey}'=$1`, [key]);
|
|
1619
|
+
return rows[0] ? run(rows[0]) : undefined;
|
|
1620
|
+
}
|
|
1621
|
+
async listRuns(options = {}) {
|
|
1622
|
+
const limit = Math.max(1, Math.min(1000, options.limit ?? 100));
|
|
1623
|
+
return (await this.rows(`SELECT * FROM ${this.prefix}.runs ${options.status ? "WHERE status=$2" : ""} ORDER BY created_at DESC,id DESC LIMIT $1`, options.status ? [limit, options.status] : [limit])).map(run);
|
|
1624
|
+
}
|
|
1625
|
+
async setRunStatus(id, status, patch = {}) {
|
|
1626
|
+
const values = [id, status, new Date().toISOString()];
|
|
1627
|
+
const sets = ["status=$2", "updated_at=$3"];
|
|
1628
|
+
for (const [field, key] of [["result_json", "result"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
|
|
1629
|
+
if (Object.hasOwn(patch, key)) {
|
|
1630
|
+
values.push(key === "result" ? JSON.stringify(patch.result) : patch[key]);
|
|
1631
|
+
sets.push(`${field}=$${values.length}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (status === "running" && !Object.hasOwn(patch, "startedAt"))
|
|
1635
|
+
sets.push("started_at=COALESCE(started_at,$3)");
|
|
1636
|
+
await this.rows(`UPDATE ${this.prefix}.runs SET ${sets.join(",")} WHERE id=$1`, values);
|
|
1637
|
+
}
|
|
1638
|
+
async compareRunStatus(id, expected, status, patch = {}) {
|
|
1639
|
+
const values = [id, expected, status, new Date().toISOString()];
|
|
1640
|
+
const sets = ["status=$3", "updated_at=$4"];
|
|
1641
|
+
for (const [field, key] of [["result_json", "result"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
|
|
1642
|
+
if (patch && Object.hasOwn(patch, key)) {
|
|
1643
|
+
values.push(key === "result" ? JSON.stringify(patch.result) : patch[key]);
|
|
1644
|
+
sets.push(`${field}=$${values.length}`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return (await this.rows(`UPDATE ${this.prefix}.runs SET ${sets.join(",")} WHERE id=$1 AND status=ANY($2::text[]) RETURNING id`, values)).length === 1;
|
|
1648
|
+
}
|
|
1649
|
+
async setRunContext(id, context) {
|
|
1650
|
+
await this.rows(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1`, [id, JSON.stringify(context), new Date().toISOString()]);
|
|
1651
|
+
}
|
|
1652
|
+
async setRunningContext(id, context) {
|
|
1653
|
+
return (await this.rows(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1 AND status='running' RETURNING id`, [id, JSON.stringify(context), new Date().toISOString()])).length === 1;
|
|
1654
|
+
}
|
|
1655
|
+
async blockRunResume(id) {
|
|
1656
|
+
await this.rows(`UPDATE ${this.prefix}.runs SET context_json=jsonb_set(context_json::jsonb,'{__wf,resumeBlocked}',to_jsonb($2::text))::text,updated_at=$3 WHERE id=$1`, [id, "execution cancellation has not been confirmed", new Date().toISOString()]);
|
|
1657
|
+
}
|
|
1658
|
+
async dispatchIfRunning(id, action) {
|
|
1659
|
+
const marked = await this.rows(`UPDATE ${this.prefix}.runs SET context_json=jsonb_set(context_json::jsonb,'{__wf,executionMayBeActive}','true'::jsonb)::text WHERE id=$1 AND status='running' RETURNING id`, [id]);
|
|
1660
|
+
if (!marked.length)
|
|
1661
|
+
return { started: false };
|
|
1662
|
+
return this.transaction(async (client) => {
|
|
1663
|
+
const run2 = await client.query(`SELECT status FROM ${this.prefix}.runs WHERE id=$1 FOR UPDATE`, [id]);
|
|
1664
|
+
if (run2.rows[0]?.status !== "running")
|
|
1665
|
+
return { started: false };
|
|
1666
|
+
return { started: true, result: action() };
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
async finishDispatch(id, unconfirmed) {
|
|
1670
|
+
await this.rows(`UPDATE ${this.prefix}.runs SET context_json=(CASE WHEN $2 THEN jsonb_set(context_json::jsonb #- '{__wf,executionMayBeActive}','{__wf,resumeBlocked}',to_jsonb($3::text)) ELSE context_json::jsonb #- '{__wf,executionMayBeActive}' END)::text,updated_at=$4 WHERE id=$1`, [id, unconfirmed, "execution cancellation has not been confirmed", new Date().toISOString()]);
|
|
1671
|
+
}
|
|
1672
|
+
async commitStep(input) {
|
|
1673
|
+
return this.transaction(async (client) => {
|
|
1674
|
+
const nodes = await client.query(`SELECT run_id FROM ${this.prefix}.run_nodes WHERE id=$1 FOR UPDATE`, [input.nodeRowId]);
|
|
1675
|
+
if (nodes.rows[0]?.run_id !== input.runId)
|
|
1676
|
+
return false;
|
|
1677
|
+
const run2 = await client.query(`SELECT status FROM ${this.prefix}.runs WHERE id=$1 FOR UPDATE`, [input.runId]);
|
|
1678
|
+
if (run2.rows[0]?.status !== "running")
|
|
1679
|
+
return false;
|
|
1680
|
+
const patch = input.patch ?? {}, values = [input.nodeRowId, input.status];
|
|
1681
|
+
const sets = ["status=$2"];
|
|
1682
|
+
for (const [field, key] of [["exit_code", "exitCode"], ["output_json", "output"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
|
|
1683
|
+
if (Object.hasOwn(patch, key)) {
|
|
1684
|
+
values.push(key === "output" ? JSON.stringify(patch.output) : patch[key]);
|
|
1685
|
+
sets.push(`${field}=$${values.length}`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (input.status === "running") {
|
|
1689
|
+
values.push(new Date().toISOString());
|
|
1690
|
+
sets.push(`started_at=COALESCE(started_at,$${values.length})`);
|
|
1691
|
+
}
|
|
1692
|
+
if (["completed", "failed", "skipped"].includes(input.status)) {
|
|
1693
|
+
values.push(new Date().toISOString());
|
|
1694
|
+
sets.push(`finished_at=$${values.length}`);
|
|
1695
|
+
}
|
|
1696
|
+
if (input.incrementAttempts)
|
|
1697
|
+
sets.push("attempts=attempts+1");
|
|
1698
|
+
await client.query(`UPDATE ${this.prefix}.run_nodes SET ${sets.join(",")} WHERE id=$1`, values);
|
|
1699
|
+
if (input.context !== undefined)
|
|
1700
|
+
await client.query(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1`, [input.runId, JSON.stringify(input.context), new Date().toISOString()]);
|
|
1701
|
+
if (input.memo) {
|
|
1702
|
+
const m = input.memo;
|
|
1703
|
+
await client.query(`INSERT INTO ${this.prefix}.memos(key,graph_name,node_id,input_hash,output_json,created_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(key) DO UPDATE SET output_json=EXCLUDED.output_json`, [m.key, m.graphName, m.nodeId, m.inputHash, m.outputJson, new Date().toISOString()]);
|
|
1704
|
+
}
|
|
1705
|
+
return true;
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
async bumpAttempts(id) {
|
|
1709
|
+
await this.rows(`UPDATE ${this.prefix}.runs SET attempts=attempts+1,updated_at=$2 WHERE id=$1`, [id, new Date().toISOString()]);
|
|
1710
|
+
}
|
|
1711
|
+
async createRunNode(input) {
|
|
1712
|
+
const rows = await this.rows(`INSERT INTO ${this.prefix}.run_nodes(id,run_id,node_id,lane,status) VALUES($1,$2,$3,$4,'pending') RETURNING *`, [randomUUID(), input.runId, input.nodeId, input.lane ?? null]);
|
|
1713
|
+
return node(rows[0]);
|
|
1714
|
+
}
|
|
1715
|
+
async getRunNode(id) {
|
|
1716
|
+
const rows = await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE id=$1`, [id]);
|
|
1717
|
+
return rows[0] ? node(rows[0]) : undefined;
|
|
1718
|
+
}
|
|
1719
|
+
async listRunNodes(runId) {
|
|
1720
|
+
return (await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE run_id=$1 ORDER BY sequence`, [runId])).map(node);
|
|
1721
|
+
}
|
|
1722
|
+
async getLatestRunNode(runId, nodeId) {
|
|
1723
|
+
const rows = await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE run_id=$1 AND node_id=$2 ORDER BY sequence DESC LIMIT 1`, [runId, nodeId]);
|
|
1724
|
+
return rows[0] ? node(rows[0]) : undefined;
|
|
1725
|
+
}
|
|
1726
|
+
async setRunNodeStatus(id, status, patch = {}) {
|
|
1727
|
+
const values = [id, status];
|
|
1728
|
+
const sets = ["status=$2"];
|
|
1729
|
+
for (const [field, key] of [["exit_code", "exitCode"], ["output_json", "output"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
|
|
1730
|
+
if (Object.hasOwn(patch, key)) {
|
|
1731
|
+
values.push(key === "output" ? JSON.stringify(patch.output) : patch[key]);
|
|
1732
|
+
sets.push(`${field}=$${values.length}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (status === "running" && !Object.hasOwn(patch, "startedAt")) {
|
|
1736
|
+
values.push(new Date().toISOString());
|
|
1737
|
+
sets.push(`started_at=COALESCE(started_at,$${values.length})`);
|
|
1738
|
+
}
|
|
1739
|
+
if (["completed", "failed", "skipped"].includes(status) && !Object.hasOwn(patch, "finishedAt")) {
|
|
1740
|
+
values.push(new Date().toISOString());
|
|
1741
|
+
sets.push(`finished_at=$${values.length}`);
|
|
1742
|
+
}
|
|
1743
|
+
await this.rows(`UPDATE ${this.prefix}.run_nodes SET ${sets.join(",")} WHERE id=$1`, values);
|
|
1744
|
+
}
|
|
1745
|
+
async bumpAttemptsNode(id) {
|
|
1746
|
+
await this.rows(`UPDATE ${this.prefix}.run_nodes SET attempts=attempts+1 WHERE id=$1`, [id]);
|
|
1747
|
+
}
|
|
1748
|
+
async memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1749
|
+
await this.rows(`INSERT INTO ${this.prefix}.memos(key,graph_name,node_id,input_hash,output_json,created_at) VALUES($1,$2,$3,$4,$5,$6)
|
|
1750
|
+
ON CONFLICT(key) DO UPDATE SET output_json=EXCLUDED.output_json`, [key, graphName, nodeId, inputHash2, outputJson, new Date().toISOString()]);
|
|
1751
|
+
}
|
|
1752
|
+
async memoGet(key) {
|
|
1753
|
+
const rows = await this.rows(`SELECT * FROM ${this.prefix}.memos WHERE key=$1`, [key]);
|
|
1754
|
+
return rows[0] ? memo(rows[0]) : undefined;
|
|
1755
|
+
}
|
|
1756
|
+
async memoHit(key) {
|
|
1757
|
+
await this.rows(`UPDATE ${this.prefix}.memos SET hit_count=hit_count+1 WHERE key=$1`, [key]);
|
|
1758
|
+
}
|
|
1759
|
+
async memoList() {
|
|
1760
|
+
return (await this.rows(`SELECT * FROM ${this.prefix}.memos ORDER BY created_at DESC,key LIMIT 1000`)).map(memo);
|
|
1761
|
+
}
|
|
1762
|
+
async memoClear() {
|
|
1763
|
+
throw new Error("PostgreSQL memo removal requires the reviewed server-operator preservation procedure");
|
|
1263
1764
|
}
|
|
1264
|
-
return;
|
|
1265
1765
|
}
|
|
1266
1766
|
|
|
1267
1767
|
// src/daemon.ts
|
|
1268
1768
|
init_types();
|
|
1769
|
+
init_types();
|
|
1269
1770
|
var DEFAULT_LEASE_TTL_MS = 120000;
|
|
1270
1771
|
var DEFAULT_MAX_ATTEMPTS2 = 3;
|
|
1271
1772
|
var DEFAULT_MAX_DISPATCH = 4;
|
|
@@ -1278,27 +1779,28 @@ function stripRunState(context) {
|
|
|
1278
1779
|
const { __wf: _wf, steps: _steps, ...user } = context;
|
|
1279
1780
|
return user;
|
|
1280
1781
|
}
|
|
1281
|
-
function runCommand(job) {
|
|
1782
|
+
async function runCommand(job) {
|
|
1282
1783
|
if (!job.command) {
|
|
1283
1784
|
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
|
|
1284
1785
|
}
|
|
1285
1786
|
const started = Date.now();
|
|
1286
1787
|
const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1287
|
-
const proc = Bun.
|
|
1788
|
+
const proc = Bun.spawn({
|
|
1288
1789
|
cmd: ["/bin/sh", "-c", job.command],
|
|
1289
1790
|
cwd: job.cwd,
|
|
1290
|
-
env:
|
|
1791
|
+
env: subscriptionEnvironment(job.env),
|
|
1291
1792
|
stdout: "pipe",
|
|
1292
1793
|
stderr: "pipe",
|
|
1293
1794
|
timeout: timeoutMs
|
|
1294
1795
|
});
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1796
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1797
|
+
new Response(proc.stdout).text(),
|
|
1798
|
+
new Response(proc.stderr).text(),
|
|
1799
|
+
proc.exited
|
|
1800
|
+
]);
|
|
1298
1801
|
const output = stderr ? `${stdout}
|
|
1299
1802
|
${stderr}`.trim() : stdout.trim();
|
|
1300
|
-
|
|
1301
|
-
return { ok: exitCode === 0, exitCode, output, durationMs };
|
|
1803
|
+
return { ok: exitCode === 0, exitCode, output, durationMs: Date.now() - started };
|
|
1302
1804
|
}
|
|
1303
1805
|
|
|
1304
1806
|
class WorkflowsDaemon {
|
|
@@ -1319,7 +1821,7 @@ class WorkflowsDaemon {
|
|
|
1319
1821
|
constructor(store, wal, options = {}) {
|
|
1320
1822
|
this.store = store;
|
|
1321
1823
|
this.wal = wal;
|
|
1322
|
-
this.worker = options.worker ??
|
|
1824
|
+
this.worker = options.worker ?? `worker-${randomUUID2()}`;
|
|
1323
1825
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
1324
1826
|
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
|
|
1325
1827
|
this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
|
|
@@ -1351,17 +1853,22 @@ class WorkflowsDaemon {
|
|
|
1351
1853
|
return this.leases;
|
|
1352
1854
|
}
|
|
1353
1855
|
claim(runId) {
|
|
1856
|
+
return this.wal.exclusive(() => this.claimLocked(runId));
|
|
1857
|
+
}
|
|
1858
|
+
claimLocked(runId) {
|
|
1354
1859
|
const now = this.time();
|
|
1355
1860
|
const existing = this.leases.get(runId);
|
|
1356
1861
|
if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
|
|
1357
1862
|
return null;
|
|
1358
1863
|
}
|
|
1359
|
-
const
|
|
1864
|
+
const replay = this.wal.replay();
|
|
1865
|
+
const liveClaims = replay.liveClaims(now);
|
|
1360
1866
|
const foreign = liveClaims.get(runId);
|
|
1361
1867
|
if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
|
|
1362
1868
|
return null;
|
|
1363
1869
|
}
|
|
1364
|
-
const
|
|
1870
|
+
const lastFencing = replay.entries.reduce((max, entry) => entry.op.op === "claim_acquired" && entry.op.runId === runId ? Math.max(max, entry.op.fencing) : max, 0);
|
|
1871
|
+
const fencing = Math.max(lastFencing, this.fencingCounter.get(runId) ?? 0) + 1;
|
|
1365
1872
|
this.fencingCounter.set(runId, fencing);
|
|
1366
1873
|
const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
|
|
1367
1874
|
this.leases.set(runId, lease);
|
|
@@ -1376,16 +1883,36 @@ class WorkflowsDaemon {
|
|
|
1376
1883
|
return lease;
|
|
1377
1884
|
}
|
|
1378
1885
|
heartbeat(runId, fencing) {
|
|
1886
|
+
return this.wal.exclusive(() => this.heartbeatLocked(runId, fencing));
|
|
1887
|
+
}
|
|
1888
|
+
heartbeatLocked(runId, fencing) {
|
|
1379
1889
|
const lease = this.leases.get(runId);
|
|
1380
1890
|
if (!lease || lease.fencing !== fencing)
|
|
1381
1891
|
return false;
|
|
1892
|
+
const current = this.wal.replay({ repair: false }).liveClaims(this.time()).get(runId);
|
|
1893
|
+
if (!current || current.worker !== lease.worker || current.fencing !== fencing)
|
|
1894
|
+
return false;
|
|
1382
1895
|
lease.expiresAtMs = this.time() + this.leaseTtlMs;
|
|
1896
|
+
this.wal.append({
|
|
1897
|
+
op: "claim_acquired",
|
|
1898
|
+
runId,
|
|
1899
|
+
worker: lease.worker,
|
|
1900
|
+
fencing,
|
|
1901
|
+
expiresAt: new Date(lease.expiresAtMs).toISOString(),
|
|
1902
|
+
at: nowIso()
|
|
1903
|
+
});
|
|
1383
1904
|
return true;
|
|
1384
1905
|
}
|
|
1385
1906
|
release(runId, fencing) {
|
|
1907
|
+
return this.wal.exclusive(() => this.releaseLocked(runId, fencing));
|
|
1908
|
+
}
|
|
1909
|
+
releaseLocked(runId, fencing) {
|
|
1386
1910
|
const lease = this.leases.get(runId);
|
|
1387
1911
|
if (!lease || lease.fencing !== fencing)
|
|
1388
1912
|
return false;
|
|
1913
|
+
const current = this.wal.replay().liveClaims(this.time()).get(runId);
|
|
1914
|
+
if (current && (current.worker !== lease.worker || current.fencing !== fencing))
|
|
1915
|
+
return false;
|
|
1389
1916
|
this.leases.delete(runId);
|
|
1390
1917
|
this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
|
|
1391
1918
|
return true;
|
|
@@ -1400,30 +1927,30 @@ class WorkflowsDaemon {
|
|
|
1400
1927
|
}
|
|
1401
1928
|
}
|
|
1402
1929
|
if (this.isolatedRunId === null) {
|
|
1403
|
-
const repair = repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
|
|
1930
|
+
const repair = await repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
|
|
1404
1931
|
report.interrupted += repair.interrupted;
|
|
1405
1932
|
report.requeued += repair.requeued;
|
|
1406
1933
|
report.failed += repair.failed;
|
|
1407
1934
|
}
|
|
1408
|
-
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);
|
|
1409
|
-
for (const
|
|
1410
|
-
if (this.isolatedRunId !== null &&
|
|
1935
|
+
const pending = (await this.store.listRuns({ status: "pending", limit: this.maxDispatchPerCycle })).concat(await 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);
|
|
1936
|
+
for (const run2 of pending) {
|
|
1937
|
+
if (this.isolatedRunId !== null && run2.id !== this.isolatedRunId)
|
|
1411
1938
|
continue;
|
|
1412
|
-
const lease = this.claim(
|
|
1939
|
+
const lease = this.claim(run2.id);
|
|
1413
1940
|
if (!lease)
|
|
1414
1941
|
continue;
|
|
1415
|
-
this.store.setRunStatus(
|
|
1416
|
-
this.wal.append({ op: "run_started", runId:
|
|
1942
|
+
await this.store.setRunStatus(run2.id, "running");
|
|
1943
|
+
this.wal.append({ op: "run_started", runId: run2.id, at: nowIso() });
|
|
1417
1944
|
report.dispatched++;
|
|
1418
1945
|
}
|
|
1419
|
-
const running = this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
|
|
1946
|
+
const running = await this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
|
|
1420
1947
|
let steps = 0;
|
|
1421
|
-
for (const
|
|
1422
|
-
if (this.isolatedRunId !== null &&
|
|
1948
|
+
for (const run2 of running) {
|
|
1949
|
+
if (this.isolatedRunId !== null && run2.id !== this.isolatedRunId)
|
|
1423
1950
|
continue;
|
|
1424
1951
|
if (steps >= this.maxStepsPerCycle)
|
|
1425
1952
|
break;
|
|
1426
|
-
const outcome = await this.advanceRun(
|
|
1953
|
+
const outcome = await this.advanceRun(run2.id);
|
|
1427
1954
|
steps++;
|
|
1428
1955
|
if (outcome.kind === "completed")
|
|
1429
1956
|
report.completed++;
|
|
@@ -1449,66 +1976,78 @@ class WorkflowsDaemon {
|
|
|
1449
1976
|
} else {
|
|
1450
1977
|
this.graphCache.set(graph.name, existing);
|
|
1451
1978
|
}
|
|
1452
|
-
|
|
1979
|
+
const initial = context !== null && typeof context === "object" ? context : {};
|
|
1980
|
+
return this.store.createRun({
|
|
1981
|
+
graphName: graph.name,
|
|
1982
|
+
graphVersion: graph.version,
|
|
1983
|
+
context: { ...initial, __wf: { ...initial.__wf ?? {}, graph } }
|
|
1984
|
+
});
|
|
1453
1985
|
}
|
|
1454
1986
|
async advanceRun(runId) {
|
|
1455
|
-
const
|
|
1456
|
-
if (!
|
|
1987
|
+
const run2 = await this.store.getRun(runId);
|
|
1988
|
+
if (!run2 || run2.status !== "running")
|
|
1457
1989
|
return { kind: "noop" };
|
|
1458
1990
|
const lease = this.leases.get(runId);
|
|
1459
1991
|
if (!lease || lease.expiresAtMs <= this.time())
|
|
1460
1992
|
return { kind: "blocked" };
|
|
1461
|
-
const
|
|
1993
|
+
const storedContext = JSON.parse(run2.contextJson);
|
|
1994
|
+
const pinned = storedContext.__wf?.graph;
|
|
1995
|
+
const graph = pinned ?? this.loadGraph(run2.graphName);
|
|
1462
1996
|
if (!graph) {
|
|
1463
|
-
this.failRun(
|
|
1997
|
+
await this.failRun(run2, `graph ${run2.graphName} not found in daemon graph cache`);
|
|
1464
1998
|
return { kind: "failed", error: "graph not found" };
|
|
1465
1999
|
}
|
|
1466
|
-
const context =
|
|
2000
|
+
const context = storedContext;
|
|
1467
2001
|
const rawWf = context.__wf ?? {};
|
|
1468
2002
|
const wf = {
|
|
1469
2003
|
...rawWf,
|
|
1470
2004
|
cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
|
|
1471
|
-
loops: rawWf.loops ?? {},
|
|
1472
|
-
completedLoops: rawWf.completedLoops ?? {}
|
|
2005
|
+
loops: Object.assign(Object.create(null), rawWf.loops ?? {}),
|
|
2006
|
+
completedLoops: Object.assign(Object.create(null), rawWf.completedLoops ?? {})
|
|
1473
2007
|
};
|
|
1474
2008
|
try {
|
|
2009
|
+
if (rawWf.stepTerminal === true) {
|
|
2010
|
+
context.__wf = wf;
|
|
2011
|
+
await this.completeRun(run2, context);
|
|
2012
|
+
return { kind: "completed" };
|
|
2013
|
+
}
|
|
1475
2014
|
const cursor = wf.cursor;
|
|
1476
|
-
const
|
|
1477
|
-
if (!
|
|
1478
|
-
this.failRun(
|
|
2015
|
+
const node2 = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
|
|
2016
|
+
if (!node2) {
|
|
2017
|
+
await this.failRun(run2, `cursor ${cursor ?? "<start>"} resolves to no node`);
|
|
1479
2018
|
return { kind: "failed", error: "no node at cursor" };
|
|
1480
2019
|
}
|
|
1481
|
-
switch (
|
|
2020
|
+
switch (node2.type) {
|
|
1482
2021
|
case "start": {
|
|
1483
|
-
wf.cursor =
|
|
1484
|
-
this.persistContext(
|
|
1485
|
-
return { kind: "advanced", nodeId:
|
|
2022
|
+
wf.cursor = node2.next;
|
|
2023
|
+
await this.persistContext(run2, context, wf);
|
|
2024
|
+
return { kind: "advanced", nodeId: node2.id };
|
|
1486
2025
|
}
|
|
1487
2026
|
case "step": {
|
|
1488
|
-
return await this.executeStep(
|
|
2027
|
+
return await this.executeStep(run2, graph, context, wf, node2);
|
|
1489
2028
|
}
|
|
1490
2029
|
case "decision": {
|
|
1491
|
-
const value = evaluateExpr(
|
|
1492
|
-
const next = value ?
|
|
2030
|
+
const value = evaluateExpr(node2.condition, this.conditionScope(context));
|
|
2031
|
+
const next = value ? node2.then : node2.else;
|
|
1493
2032
|
if (!next) {
|
|
1494
|
-
this.failRun(
|
|
2033
|
+
await this.failRun(run2, `decision ${node2.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
|
|
1495
2034
|
return { kind: "failed", error: "missing decision edge" };
|
|
1496
2035
|
}
|
|
1497
2036
|
wf.cursor = next;
|
|
1498
|
-
this.persistContext(
|
|
1499
|
-
return { kind: "advanced", nodeId:
|
|
2037
|
+
await this.persistContext(run2, context, wf);
|
|
2038
|
+
return { kind: "advanced", nodeId: node2.id };
|
|
1500
2039
|
}
|
|
1501
2040
|
case "while": {
|
|
1502
|
-
return this.executeWhile(
|
|
2041
|
+
return await this.executeWhile(run2, graph, context, wf, node2);
|
|
1503
2042
|
}
|
|
1504
2043
|
case "end": {
|
|
1505
|
-
this.completeRun(
|
|
2044
|
+
await this.completeRun(run2, context);
|
|
1506
2045
|
return { kind: "completed" };
|
|
1507
2046
|
}
|
|
1508
2047
|
}
|
|
1509
2048
|
} catch (err) {
|
|
1510
2049
|
if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
|
|
1511
|
-
this.failRun(
|
|
2050
|
+
await this.failRun(run2, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
|
|
1512
2051
|
return { kind: "failed", error: err.message };
|
|
1513
2052
|
}
|
|
1514
2053
|
throw err;
|
|
@@ -1520,131 +2059,189 @@ class WorkflowsDaemon {
|
|
|
1520
2059
|
conditionScope(context) {
|
|
1521
2060
|
return { ...context, steps: context.steps ?? {} };
|
|
1522
2061
|
}
|
|
1523
|
-
persistContext(
|
|
2062
|
+
async persistContext(run2, context, wf) {
|
|
1524
2063
|
context.__wf = wf;
|
|
1525
|
-
this.store.
|
|
1526
|
-
}
|
|
1527
|
-
async
|
|
1528
|
-
const
|
|
1529
|
-
const
|
|
2064
|
+
await this.store.setRunningContext(run2.id, context);
|
|
2065
|
+
}
|
|
2066
|
+
async discardStep(run2, nodeRowId) {
|
|
2067
|
+
const current = await this.store.getRun(run2.id);
|
|
2068
|
+
const node2 = await this.store.getRunNode(nodeRowId);
|
|
2069
|
+
if (current?.status === "cancelled" && node2 && ["pending", "running"].includes(node2.status))
|
|
2070
|
+
await this.store.setRunNodeStatus(nodeRowId, "skipped", { error: "run cancelled", finishedAt: nowIso() });
|
|
2071
|
+
return { kind: "noop" };
|
|
2072
|
+
}
|
|
2073
|
+
async executeStep(run2, graph, context, wf, node2) {
|
|
2074
|
+
const nodeId = node2.id;
|
|
2075
|
+
const latest = await this.store.getLatestRunNode(run2.id, nodeId);
|
|
1530
2076
|
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
1531
|
-
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? this.store.createRunNode({ runId:
|
|
1532
|
-
const memoWatch =
|
|
1533
|
-
const memoSource = {
|
|
1534
|
-
|
|
2077
|
+
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? await this.store.createRunNode({ runId: run2.id, nodeId, lane: node2.lane ?? "claude" }) : latest;
|
|
2078
|
+
const memoWatch = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
2079
|
+
const memoSource = {
|
|
2080
|
+
command: node2.command,
|
|
2081
|
+
prompt: node2.prompt,
|
|
2082
|
+
context: stripRunState(context),
|
|
2083
|
+
lane: node2.lane ?? "claude",
|
|
2084
|
+
cwd: node2.cwd,
|
|
2085
|
+
codexMode: node2.lane === "codex" ? node2.codexMode ?? "repository" : undefined
|
|
2086
|
+
};
|
|
2087
|
+
const memoInput = node2.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
1535
2088
|
if (memoInput !== undefined) {
|
|
1536
|
-
const hit = tryMemoHit(this.store,
|
|
2089
|
+
const hit = await tryMemoHit(this.store, run2.graphName, nodeId, memoInput);
|
|
1537
2090
|
if (hit) {
|
|
1538
2091
|
const cached = JSON.parse(hit.outputJson);
|
|
1539
|
-
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } });
|
|
1540
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1541
2092
|
this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
|
|
1542
|
-
|
|
2093
|
+
const continuation2 = this.advanceAfterStep(graph, context, wf, node2.next);
|
|
2094
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "completed", patch: { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } }, context }))
|
|
2095
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2096
|
+
this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "completed", at: nowIso() });
|
|
2097
|
+
if (continuation2.kind === "completed")
|
|
2098
|
+
await this.completeRun(run2, context);
|
|
2099
|
+
return continuation2;
|
|
1543
2100
|
}
|
|
1544
2101
|
}
|
|
1545
2102
|
if (nodeRow.status !== "running") {
|
|
1546
|
-
this.store.
|
|
1547
|
-
|
|
2103
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "running" }))
|
|
2104
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2105
|
+
this.wal.append({ op: "node_started", runId: run2.id, nodeId, at: nowIso() });
|
|
1548
2106
|
}
|
|
1549
2107
|
const job = {
|
|
1550
|
-
lane:
|
|
1551
|
-
prompt:
|
|
1552
|
-
command:
|
|
1553
|
-
|
|
2108
|
+
lane: node2.lane ?? "claude",
|
|
2109
|
+
prompt: node2.prompt,
|
|
2110
|
+
command: node2.command,
|
|
2111
|
+
cwd: node2.cwd,
|
|
2112
|
+
codexMode: node2.codexMode,
|
|
2113
|
+
timeoutMs: node2.timeoutMs,
|
|
1554
2114
|
env: this.env
|
|
1555
2115
|
};
|
|
1556
2116
|
let result;
|
|
2117
|
+
const activeLease = this.leases.get(run2.id);
|
|
2118
|
+
const pulse = setInterval(() => {
|
|
2119
|
+
this.heartbeat(run2.id, activeLease.fencing);
|
|
2120
|
+
}, Math.max(1, Math.floor(this.leaseTtlMs / 3)));
|
|
1557
2121
|
try {
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2122
|
+
const dispatched = await this.store.dispatchIfRunning(run2.id, async () => {
|
|
2123
|
+
try {
|
|
2124
|
+
return await this.laneRunner(job);
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
return { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
if (!dispatched.started) {
|
|
2130
|
+
await this.store.finishDispatch(run2.id, false);
|
|
2131
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2132
|
+
}
|
|
2133
|
+
result = await dispatched.result;
|
|
2134
|
+
} finally {
|
|
2135
|
+
clearInterval(pulse);
|
|
2136
|
+
}
|
|
2137
|
+
const unconfirmed = result.retryable === false && /exceeded.*budget/.test(result.error ?? "");
|
|
2138
|
+
await this.store.finishDispatch(run2.id, unconfirmed);
|
|
2139
|
+
if (unconfirmed)
|
|
2140
|
+
Object.assign(wf, { resumeBlocked: "execution cancellation has not been confirmed" });
|
|
2141
|
+
const current = await this.store.getRun(run2.id);
|
|
2142
|
+
const currentClaim = this.wal.replay({ repair: false }).liveClaims(this.time()).get(run2.id);
|
|
2143
|
+
const lease = this.leases.get(run2.id);
|
|
2144
|
+
if (!current || current.status !== "running" || !currentClaim || !lease || currentClaim.worker !== lease.worker || currentClaim.fencing !== lease.fencing) {
|
|
2145
|
+
if (current?.status === "cancelled")
|
|
2146
|
+
await this.store.setRunNodeStatus(nodeRow.id, "skipped", { error: "run cancelled", finishedAt: nowIso() });
|
|
2147
|
+
return { kind: "noop" };
|
|
2148
|
+
}
|
|
2149
|
+
try {
|
|
2150
|
+
assertNoSecrets({ output: result.output, error: result.error }, `node ${nodeId} execution`);
|
|
2151
|
+
} catch {
|
|
2152
|
+
result = { ok: false, exitCode: 1, output: "", durationMs: result.durationMs, error: "execution result refused by secrets write-gate", retryable: false };
|
|
1561
2153
|
}
|
|
1562
2154
|
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1563
2155
|
if (!result.ok) {
|
|
1564
|
-
const shouldRetry = nodeRow.attempts < (
|
|
1565
|
-
this.store.bumpAttemptsNode(nodeRow.id);
|
|
2156
|
+
const shouldRetry = result.retryable !== false && nodeRow.attempts < (node2.maxRetries ?? 0);
|
|
1566
2157
|
if (shouldRetry) {
|
|
1567
|
-
this.store.
|
|
2158
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "pending", patch: { error: result.error ?? `exit ${result.exitCode}` }, incrementAttempts: true }))
|
|
2159
|
+
return this.discardStep(run2, nodeRow.id);
|
|
1568
2160
|
return { kind: "advanced", nodeId };
|
|
1569
2161
|
}
|
|
1570
2162
|
const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
|
|
1571
|
-
this.store.
|
|
1572
|
-
|
|
1573
|
-
this.
|
|
2163
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "failed", patch: { exitCode: result.exitCode, error }, incrementAttempts: true }))
|
|
2164
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2165
|
+
this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "failed", at: nowIso() });
|
|
2166
|
+
await this.failRun(run2, error);
|
|
1574
2167
|
return { kind: "failed", error };
|
|
1575
2168
|
}
|
|
1576
2169
|
try {
|
|
1577
2170
|
assertNoSecrets(output, `node ${nodeId} output`);
|
|
1578
2171
|
} catch (err) {
|
|
1579
2172
|
const message = err instanceof Error ? err.message : String(err);
|
|
1580
|
-
this.store.
|
|
1581
|
-
|
|
1582
|
-
this.
|
|
2173
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "failed", patch: { error: message } }))
|
|
2174
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2175
|
+
this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "failed", at: nowIso() });
|
|
2176
|
+
await this.failRun(run2, message);
|
|
1583
2177
|
return { kind: "failed", error: message };
|
|
1584
2178
|
}
|
|
1585
|
-
|
|
1586
|
-
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
2179
|
+
let memo2;
|
|
1587
2180
|
if (memoInput !== undefined) {
|
|
1588
|
-
const memoInputAfter =
|
|
2181
|
+
const memoInputAfter = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1589
2182
|
const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
|
|
1590
2183
|
if (stable) {
|
|
1591
|
-
|
|
1592
|
-
this.wal.append({ op: "memo_set", key: memoKey(run.graphName, nodeId, memoInput), at: nowIso() });
|
|
2184
|
+
memo2 = { key: memoKey(run2.graphName, nodeId, memoInput), graphName: run2.graphName, nodeId, inputHash: inputHash(memoInput), outputJson: JSON.stringify(output) };
|
|
1593
2185
|
}
|
|
1594
2186
|
}
|
|
1595
2187
|
this.recordStepResult(context, nodeId, output);
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
2188
|
+
const continuation = this.advanceAfterStep(graph, context, wf, node2.next);
|
|
2189
|
+
if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "completed", patch: { exitCode: result.exitCode, output }, context, memo: memo2 }))
|
|
2190
|
+
return this.discardStep(run2, nodeRow.id);
|
|
2191
|
+
this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "completed", at: nowIso() });
|
|
2192
|
+
if (memo2)
|
|
2193
|
+
this.wal.append({ op: "memo_set", key: memo2.key, at: nowIso() });
|
|
2194
|
+
if (continuation.kind === "completed")
|
|
2195
|
+
await this.completeRun(run2, context);
|
|
2196
|
+
return continuation;
|
|
2197
|
+
}
|
|
2198
|
+
advanceAfterStep(graph, context, wf, next) {
|
|
2199
|
+
context.__wf = wf;
|
|
1599
2200
|
if (next) {
|
|
1600
2201
|
wf.cursor = next;
|
|
1601
|
-
this.persistContext(run, context, wf);
|
|
1602
2202
|
return { kind: "advanced", nodeId: wf.cursor };
|
|
1603
2203
|
}
|
|
1604
2204
|
const cursor = wf.cursor;
|
|
1605
|
-
const graph = this.loadGraph(run.graphName);
|
|
1606
2205
|
const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
|
|
1607
2206
|
if (whileNode) {
|
|
1608
2207
|
const index = whileNode.body.indexOf(cursor ?? "");
|
|
1609
2208
|
if (index >= 0 && index < whileNode.body.length - 1) {
|
|
1610
2209
|
wf.cursor = whileNode.body[index + 1];
|
|
1611
|
-
this.persistContext(run, context, wf);
|
|
1612
2210
|
return { kind: "advanced", nodeId: wf.cursor };
|
|
1613
2211
|
}
|
|
1614
2212
|
wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
|
|
1615
2213
|
wf.cursor = whileNode.id;
|
|
1616
|
-
this.persistContext(run, context, wf);
|
|
1617
2214
|
return { kind: "advanced", nodeId: whileNode.id };
|
|
1618
2215
|
}
|
|
1619
|
-
|
|
2216
|
+
wf.stepTerminal = true;
|
|
1620
2217
|
return { kind: "completed" };
|
|
1621
2218
|
}
|
|
1622
|
-
executeWhile(
|
|
1623
|
-
const iteration = wf.loops[
|
|
1624
|
-
if (iteration >=
|
|
1625
|
-
const error = `while node ${
|
|
1626
|
-
this.failRun(
|
|
2219
|
+
async executeWhile(run2, graph, context, wf, node2) {
|
|
2220
|
+
const iteration = wf.loops[node2.id] ?? 0;
|
|
2221
|
+
if (iteration >= node2.maxIterations) {
|
|
2222
|
+
const error = `while node ${node2.id} exceeded maxIterations (${node2.maxIterations})`;
|
|
2223
|
+
await this.failRun(run2, error);
|
|
1627
2224
|
return { kind: "failed", error };
|
|
1628
2225
|
}
|
|
1629
2226
|
const scope = { ...this.conditionScope(context), i: iteration };
|
|
1630
|
-
const keepGoing = evaluateExpr(
|
|
2227
|
+
const keepGoing = evaluateExpr(node2.condition, scope);
|
|
1631
2228
|
if (!keepGoing) {
|
|
1632
|
-
wf.completedLoops[
|
|
1633
|
-
delete wf.loops[
|
|
1634
|
-
wf.cursor =
|
|
1635
|
-
this.persistContext(
|
|
1636
|
-
return { kind: "advanced", nodeId:
|
|
2229
|
+
wf.completedLoops[node2.id] = iteration;
|
|
2230
|
+
delete wf.loops[node2.id];
|
|
2231
|
+
wf.cursor = node2.next;
|
|
2232
|
+
await this.persistContext(run2, context, wf);
|
|
2233
|
+
return { kind: "advanced", nodeId: node2.id };
|
|
1637
2234
|
}
|
|
1638
|
-
wf.cursor =
|
|
1639
|
-
this.persistContext(
|
|
1640
|
-
return { kind: "advanced", nodeId:
|
|
2235
|
+
wf.cursor = node2.body[0];
|
|
2236
|
+
await this.persistContext(run2, context, wf);
|
|
2237
|
+
return { kind: "advanced", nodeId: node2.id };
|
|
1641
2238
|
}
|
|
1642
2239
|
recordStepResult(context, nodeId, output) {
|
|
1643
|
-
const steps = context.steps ?? {};
|
|
2240
|
+
const steps = Object.assign(Object.create(null), context.steps ?? {});
|
|
1644
2241
|
steps[nodeId] = output;
|
|
1645
2242
|
context.steps = steps;
|
|
1646
2243
|
}
|
|
1647
|
-
completeRun(
|
|
2244
|
+
async completeRun(run2, context) {
|
|
1648
2245
|
const result = {
|
|
1649
2246
|
steps: context.steps ?? {},
|
|
1650
2247
|
iterations: context.__wf?.completedLoops ?? {}
|
|
@@ -1653,21 +2250,23 @@ class WorkflowsDaemon {
|
|
|
1653
2250
|
assertNoSecrets(result, "run result");
|
|
1654
2251
|
} catch (err) {
|
|
1655
2252
|
const message = err instanceof Error ? err.message : String(err);
|
|
1656
|
-
this.failRun(
|
|
2253
|
+
await this.failRun(run2, message);
|
|
1657
2254
|
return;
|
|
1658
2255
|
}
|
|
1659
|
-
this.store.
|
|
1660
|
-
|
|
1661
|
-
|
|
2256
|
+
if (!await this.store.compareRunStatus(run2.id, ["running"], "completed", { result, finishedAt: nowIso(), error: null }))
|
|
2257
|
+
return;
|
|
2258
|
+
this.wal.append({ op: "run_finished", runId: run2.id, status: "completed", at: nowIso() });
|
|
2259
|
+
const lease = this.leases.get(run2.id);
|
|
1662
2260
|
if (lease)
|
|
1663
|
-
this.release(
|
|
2261
|
+
this.release(run2.id, lease.fencing);
|
|
1664
2262
|
}
|
|
1665
|
-
failRun(
|
|
1666
|
-
this.store.
|
|
1667
|
-
|
|
1668
|
-
|
|
2263
|
+
async failRun(run2, error) {
|
|
2264
|
+
if (!await this.store.compareRunStatus(run2.id, ["running"], "failed", { error, finishedAt: nowIso() }))
|
|
2265
|
+
return;
|
|
2266
|
+
this.wal.append({ op: "run_finished", runId: run2.id, status: "failed", at: nowIso() });
|
|
2267
|
+
const lease = this.leases.get(run2.id);
|
|
1669
2268
|
if (lease)
|
|
1670
|
-
this.release(
|
|
2269
|
+
this.release(run2.id, lease.fencing);
|
|
1671
2270
|
}
|
|
1672
2271
|
}
|
|
1673
2272
|
var defaultLaneRunner = async (job) => {
|
|
@@ -1678,33 +2277,109 @@ var defaultLaneRunner = async (job) => {
|
|
|
1678
2277
|
};
|
|
1679
2278
|
async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
1680
2279
|
const daemon = new WorkflowsDaemon(store, wal, {
|
|
1681
|
-
worker:
|
|
2280
|
+
worker: `foreground-${randomUUID2()}`,
|
|
1682
2281
|
laneRunner: options.laneRunner,
|
|
1683
2282
|
time: options.time,
|
|
1684
2283
|
env: options.env,
|
|
1685
2284
|
isolatedRunId: options.isolatedRunId
|
|
1686
2285
|
});
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
2286
|
+
const run2 = options.existingRunId ? await store.getRun(options.existingRunId) : await daemon.startRun(graph, context);
|
|
2287
|
+
if (!run2)
|
|
2288
|
+
throw new Error("run not found");
|
|
2289
|
+
options.onRunCreated?.(run2);
|
|
2290
|
+
daemon.isolateRun(run2.id);
|
|
1690
2291
|
const maxCycles = options.maxCycles ?? 500;
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
2292
|
+
const hosted = store instanceof PostgresWorkflowsStore ? store : undefined;
|
|
2293
|
+
if (hosted && !await hosted.acquireExecution(run2.id))
|
|
2294
|
+
return await store.getRun(run2.id);
|
|
2295
|
+
const lease = daemon.claim(run2.id);
|
|
2296
|
+
if (!lease) {
|
|
2297
|
+
await hosted?.releaseExecution();
|
|
2298
|
+
return await store.getRun(run2.id);
|
|
2299
|
+
}
|
|
2300
|
+
try {
|
|
2301
|
+
if (!await store.compareRunStatus(run2.id, ["pending"], "running", { startedAt: nowIso() }))
|
|
2302
|
+
return await store.getRun(run2.id);
|
|
2303
|
+
wal.append({ op: "run_started", runId: run2.id, at: nowIso() });
|
|
2304
|
+
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
2305
|
+
daemon.heartbeat(run2.id, lease.fencing);
|
|
2306
|
+
const outcome = await daemon.advanceRun(run2.id);
|
|
2307
|
+
const current = await store.getRun(run2.id);
|
|
2308
|
+
if (current.status !== "running" || outcome.kind === "blocked" || outcome.kind === "noop")
|
|
2309
|
+
return current;
|
|
2310
|
+
}
|
|
2311
|
+
await store.compareRunStatus(run2.id, ["running"], "interrupted", { error: "run reached maxCycles; resume to continue" });
|
|
2312
|
+
return await store.getRun(run2.id);
|
|
2313
|
+
} finally {
|
|
2314
|
+
daemon.release(run2.id, lease.fencing);
|
|
2315
|
+
await hosted?.releaseExecution();
|
|
1699
2316
|
}
|
|
1700
|
-
return store.getRun(run.id);
|
|
1701
2317
|
}
|
|
1702
2318
|
|
|
1703
2319
|
// src/paths.ts
|
|
1704
2320
|
import { existsSync } from "fs";
|
|
1705
2321
|
import { homedir } from "os";
|
|
1706
2322
|
import { join as join2 } from "path";
|
|
1707
|
-
import {
|
|
2323
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
2324
|
+
import { join as pathsResolverJoin } from "path";
|
|
2325
|
+
var PATHS_RESOLVER_KIND_ENV = {
|
|
2326
|
+
config: "HASNA_CONFIG_HOME",
|
|
2327
|
+
data: "HASNA_DATA_HOME",
|
|
2328
|
+
state: "HASNA_STATE_HOME",
|
|
2329
|
+
cache: "HASNA_CACHE_HOME"
|
|
2330
|
+
};
|
|
2331
|
+
var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2332
|
+
function pathsResolverAssertApp(app) {
|
|
2333
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
2334
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
2335
|
+
}
|
|
2336
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
2337
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
function pathsResolverAssertKind(kind) {
|
|
2341
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
2342
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
function pathsResolverBaseDir(kind, options) {
|
|
2346
|
+
pathsResolverAssertKind(kind);
|
|
2347
|
+
const env = options.env ?? process.env;
|
|
2348
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
2349
|
+
if (typeof override === "string" && override.length > 0)
|
|
2350
|
+
return override;
|
|
2351
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
2352
|
+
const platform = options.platform ?? process.platform;
|
|
2353
|
+
if (platform === "darwin") {
|
|
2354
|
+
switch (kind) {
|
|
2355
|
+
case "config":
|
|
2356
|
+
case "data":
|
|
2357
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
2358
|
+
case "cache":
|
|
2359
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
2360
|
+
case "state":
|
|
2361
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
switch (kind) {
|
|
2365
|
+
case "config":
|
|
2366
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
2367
|
+
case "data":
|
|
2368
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
2369
|
+
case "state":
|
|
2370
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
2371
|
+
case "cache":
|
|
2372
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
function pathsResolverResolve(kind, options) {
|
|
2376
|
+
pathsResolverAssertApp(options.app);
|
|
2377
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
2378
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
2379
|
+
}
|
|
2380
|
+
function dataDir(options) {
|
|
2381
|
+
return pathsResolverResolve("data", options);
|
|
2382
|
+
}
|
|
1708
2383
|
var APP = "workflows";
|
|
1709
2384
|
var STORE_DB_FILE = "workflows.db";
|
|
1710
2385
|
function getHomeDir(env = process.env) {
|
|
@@ -1713,8 +2388,8 @@ function getHomeDir(env = process.env) {
|
|
|
1713
2388
|
function getLegacyDataDir(env = process.env) {
|
|
1714
2389
|
return join2(getHomeDir(env), ".hasna", APP);
|
|
1715
2390
|
}
|
|
1716
|
-
function getResolverDataDir(env = process.env) {
|
|
1717
|
-
return dataDir({ app: APP, home: getHomeDir(env), env });
|
|
2391
|
+
function getResolverDataDir(env = process.env, platform = process.platform) {
|
|
2392
|
+
return dataDir({ app: APP, home: getHomeDir(env), env, platform });
|
|
1718
2393
|
}
|
|
1719
2394
|
function adoptResolverDataDir(resolved, env = process.env) {
|
|
1720
2395
|
const dataOverride = env.HASNA_DATA_HOME;
|
|
@@ -1731,7 +2406,8 @@ function getEffectiveDataDir(env = process.env) {
|
|
|
1731
2406
|
import { Database } from "bun:sqlite";
|
|
1732
2407
|
import { mkdirSync } from "fs";
|
|
1733
2408
|
import { join as join3 } from "path";
|
|
1734
|
-
import { randomUUID } from "crypto";
|
|
2409
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2410
|
+
var RUN_STATUSES = ["pending", "running", "completed", "failed", "cancelled", "interrupted"];
|
|
1735
2411
|
function nowIso2() {
|
|
1736
2412
|
return new Date().toISOString();
|
|
1737
2413
|
}
|
|
@@ -1801,6 +2477,8 @@ function openStore(dataDir2) {
|
|
|
1801
2477
|
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
1802
2478
|
created_at TEXT NOT NULL
|
|
1803
2479
|
);
|
|
2480
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_idempotency ON runs(json_extract(context_json, '$.__wf.idempotencyKey'))
|
|
2481
|
+
WHERE json_type(context_json, '$.__wf.idempotencyKey') = 'text';
|
|
1804
2482
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1805
2483
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1806
2484
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
@@ -1851,7 +2529,7 @@ function openStore(dataDir2) {
|
|
|
1851
2529
|
return rows.map((r) => r.name);
|
|
1852
2530
|
},
|
|
1853
2531
|
createRun(input) {
|
|
1854
|
-
const id =
|
|
2532
|
+
const id = randomUUID3();
|
|
1855
2533
|
const now = nowIso2();
|
|
1856
2534
|
retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1857
2535
|
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
|
|
@@ -1861,6 +2539,10 @@ function openStore(dataDir2) {
|
|
|
1861
2539
|
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1862
2540
|
return row ? rowToRun(row) : undefined;
|
|
1863
2541
|
},
|
|
2542
|
+
getRunByIdempotencyKey(key) {
|
|
2543
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE json_valid(context_json) AND json_extract(context_json, '$.__wf.idempotencyKey') = ? ORDER BY created_at DESC LIMIT 1`).get(key));
|
|
2544
|
+
return row ? rowToRun(row) : undefined;
|
|
2545
|
+
},
|
|
1864
2546
|
listRuns(opts = {}) {
|
|
1865
2547
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1866
2548
|
if (opts.status) {
|
|
@@ -1881,14 +2563,81 @@ function openStore(dataDir2) {
|
|
|
1881
2563
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1882
2564
|
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));
|
|
1883
2565
|
},
|
|
2566
|
+
compareRunStatus(id, expected, status, patch = {}) {
|
|
2567
|
+
return retryBusy(() => db.transaction(() => {
|
|
2568
|
+
const current = this.getRun(id);
|
|
2569
|
+
if (!current || !expected.includes(current.status))
|
|
2570
|
+
return false;
|
|
2571
|
+
this.setRunStatus(id, status, patch);
|
|
2572
|
+
return true;
|
|
2573
|
+
}).immediate());
|
|
2574
|
+
},
|
|
1884
2575
|
bumpAttempts(id) {
|
|
1885
2576
|
retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
|
|
1886
2577
|
},
|
|
1887
2578
|
setRunContext(id, context) {
|
|
1888
2579
|
retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
|
|
1889
2580
|
},
|
|
2581
|
+
setRunningContext(id, context) {
|
|
2582
|
+
return retryBusy(() => db.transaction(() => {
|
|
2583
|
+
if (this.getRun(id)?.status !== "running")
|
|
2584
|
+
return false;
|
|
2585
|
+
this.setRunContext(id, context);
|
|
2586
|
+
return true;
|
|
2587
|
+
}).immediate());
|
|
2588
|
+
},
|
|
2589
|
+
blockRunResume(id) {
|
|
2590
|
+
retryBusy(() => db.transaction(() => {
|
|
2591
|
+
const run2 = this.getRun(id);
|
|
2592
|
+
if (!run2)
|
|
2593
|
+
return;
|
|
2594
|
+
const context = JSON.parse(run2.contextJson);
|
|
2595
|
+
context.__wf = { ...context.__wf, resumeBlocked: "execution cancellation has not been confirmed" };
|
|
2596
|
+
this.setRunContext(id, context);
|
|
2597
|
+
}).immediate());
|
|
2598
|
+
},
|
|
2599
|
+
dispatchIfRunning(id, action) {
|
|
2600
|
+
return retryBusy(() => db.transaction(() => {
|
|
2601
|
+
const run2 = this.getRun(id);
|
|
2602
|
+
if (run2?.status !== "running")
|
|
2603
|
+
return { started: false };
|
|
2604
|
+
const context = JSON.parse(run2.contextJson);
|
|
2605
|
+
context.__wf = { ...context.__wf, executionMayBeActive: true };
|
|
2606
|
+
this.setRunContext(id, context);
|
|
2607
|
+
return { started: true, result: action() };
|
|
2608
|
+
}).immediate());
|
|
2609
|
+
},
|
|
2610
|
+
finishDispatch(id, unconfirmed) {
|
|
2611
|
+
retryBusy(() => db.transaction(() => {
|
|
2612
|
+
const run2 = this.getRun(id);
|
|
2613
|
+
if (!run2)
|
|
2614
|
+
return;
|
|
2615
|
+
const context = JSON.parse(run2.contextJson);
|
|
2616
|
+
delete context.__wf?.executionMayBeActive;
|
|
2617
|
+
if (unconfirmed)
|
|
2618
|
+
context.__wf = { ...context.__wf, resumeBlocked: "execution cancellation has not been confirmed" };
|
|
2619
|
+
this.setRunContext(id, context);
|
|
2620
|
+
}).immediate());
|
|
2621
|
+
},
|
|
2622
|
+
commitStep(input) {
|
|
2623
|
+
return retryBusy(() => db.transaction(() => {
|
|
2624
|
+
if (this.getRun(input.runId)?.status !== "running")
|
|
2625
|
+
return false;
|
|
2626
|
+
const node2 = this.getRunNode(input.nodeRowId);
|
|
2627
|
+
if (!node2 || node2.runId !== input.runId)
|
|
2628
|
+
return false;
|
|
2629
|
+
this.setRunNodeStatus(input.nodeRowId, input.status, input.patch);
|
|
2630
|
+
if (input.incrementAttempts)
|
|
2631
|
+
this.bumpAttemptsNode(input.nodeRowId);
|
|
2632
|
+
if (input.context !== undefined)
|
|
2633
|
+
this.setRunContext(input.runId, input.context);
|
|
2634
|
+
if (input.memo)
|
|
2635
|
+
this.memoPut(input.memo.key, input.memo.graphName, input.memo.nodeId, input.memo.inputHash, input.memo.outputJson);
|
|
2636
|
+
return true;
|
|
2637
|
+
}).immediate());
|
|
2638
|
+
},
|
|
1890
2639
|
createRunNode(input) {
|
|
1891
|
-
const id =
|
|
2640
|
+
const id = randomUUID3();
|
|
1892
2641
|
retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1893
2642
|
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
|
|
1894
2643
|
return this.getRunNode(id);
|
|
@@ -1944,9 +2693,10 @@ function openStore(dataDir2) {
|
|
|
1944
2693
|
}
|
|
1945
2694
|
|
|
1946
2695
|
// src/wal.ts
|
|
2696
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
1947
2697
|
import { createHash as createHash2 } from "crypto";
|
|
1948
|
-
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1949
|
-
import { join as join4 } from "path";
|
|
2698
|
+
import { appendFileSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
|
|
2699
|
+
import { basename, dirname, join as join4 } from "path";
|
|
1950
2700
|
var WAL_FILE_NAME = "session.wal";
|
|
1951
2701
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1952
2702
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
@@ -1958,15 +2708,17 @@ function checksumOf(op) {
|
|
|
1958
2708
|
|
|
1959
2709
|
class SessionWAL {
|
|
1960
2710
|
filePath;
|
|
2711
|
+
journalLocked = false;
|
|
1961
2712
|
constructor(filePath) {
|
|
1962
2713
|
this.filePath = filePath;
|
|
1963
2714
|
}
|
|
1964
2715
|
static open(dataDir2) {
|
|
1965
|
-
mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true });
|
|
2716
|
+
mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true, mode: 448 });
|
|
1966
2717
|
const canonical = join4(dataDir2, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
1967
2718
|
const legacy = join4(dataDir2, WAL_FILE_NAME);
|
|
1968
2719
|
if (existsSync2(legacy) && !existsSync2(canonical)) {
|
|
1969
2720
|
try {
|
|
2721
|
+
preserveOriginal(legacy);
|
|
1970
2722
|
renameSync(legacy, canonical);
|
|
1971
2723
|
} catch {
|
|
1972
2724
|
return new SessionWAL(legacy);
|
|
@@ -1978,19 +2730,53 @@ class SessionWAL {
|
|
|
1978
2730
|
return new SessionWAL(filePath);
|
|
1979
2731
|
}
|
|
1980
2732
|
append(op) {
|
|
2733
|
+
return this.withJournalLock(() => this.appendLocked(op));
|
|
2734
|
+
}
|
|
2735
|
+
withJournalLock(operation) {
|
|
2736
|
+
if (this.journalLocked)
|
|
2737
|
+
return operation();
|
|
2738
|
+
const parent = dirname(this.filePath);
|
|
2739
|
+
const dataDir2 = basename(parent) === SESSIONS_DIR_NAME ? dirname(parent) : parent;
|
|
2740
|
+
mkdirSync2(dataDir2, { recursive: true, mode: 448 });
|
|
2741
|
+
const mutex = new Database2(join4(dataDir2, "workflows.db"));
|
|
2742
|
+
try {
|
|
2743
|
+
mutex.exec("PRAGMA busy_timeout = 5000");
|
|
2744
|
+
mutex.exec("BEGIN IMMEDIATE");
|
|
2745
|
+
this.journalLocked = true;
|
|
2746
|
+
try {
|
|
2747
|
+
const result = operation();
|
|
2748
|
+
mutex.exec("COMMIT");
|
|
2749
|
+
return result;
|
|
2750
|
+
} catch (error) {
|
|
2751
|
+
mutex.exec("ROLLBACK");
|
|
2752
|
+
throw error;
|
|
2753
|
+
} finally {
|
|
2754
|
+
this.journalLocked = false;
|
|
2755
|
+
}
|
|
2756
|
+
} finally {
|
|
2757
|
+
mutex.close();
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
exclusive(operation) {
|
|
2761
|
+
return this.withJournalLock(operation);
|
|
2762
|
+
}
|
|
2763
|
+
appendLocked(op) {
|
|
1981
2764
|
const at = op.at ?? new Date().toISOString();
|
|
1982
2765
|
const full = { ...op, at };
|
|
1983
2766
|
const nextSeq = this.nextSeq();
|
|
1984
2767
|
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
1985
2768
|
appendFileSync(this.filePath, JSON.stringify(entry) + `
|
|
1986
|
-
`, "utf8");
|
|
2769
|
+
`, { encoding: "utf8", mode: 384 });
|
|
1987
2770
|
return entry;
|
|
1988
2771
|
}
|
|
1989
2772
|
nextSeq() {
|
|
1990
2773
|
const replay = this.replay();
|
|
1991
2774
|
return replay.entries.length + 1;
|
|
1992
2775
|
}
|
|
1993
|
-
replay() {
|
|
2776
|
+
replay(options = {}) {
|
|
2777
|
+
return this.withJournalLock(() => this.replayLocked(options));
|
|
2778
|
+
}
|
|
2779
|
+
replayLocked(options) {
|
|
1994
2780
|
const entries = [];
|
|
1995
2781
|
let torn = false;
|
|
1996
2782
|
let repaired = false;
|
|
@@ -2034,6 +2820,9 @@ class SessionWAL {
|
|
|
2034
2820
|
if (badOffset !== null) {
|
|
2035
2821
|
torn = true;
|
|
2036
2822
|
try {
|
|
2823
|
+
if (options.repair === false)
|
|
2824
|
+
return { entries, torn, repaired, liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs) };
|
|
2825
|
+
preserveOriginal(this.filePath);
|
|
2037
2826
|
truncateSync(this.filePath, badOffset);
|
|
2038
2827
|
repaired = true;
|
|
2039
2828
|
} catch {}
|
|
@@ -2073,13 +2862,22 @@ function reconstructLiveClaims(entries, nowMs = Date.now()) {
|
|
|
2073
2862
|
}
|
|
2074
2863
|
return claims;
|
|
2075
2864
|
}
|
|
2865
|
+
function preserveOriginal(filePath) {
|
|
2866
|
+
const original = readFileSync2(filePath);
|
|
2867
|
+
const digest = createHash2("sha256").update(original).digest("hex");
|
|
2868
|
+
const backup = `${filePath}.preserved-${digest}`;
|
|
2869
|
+
if (!existsSync2(backup))
|
|
2870
|
+
copyFileSync(filePath, backup, constants.COPYFILE_EXCL);
|
|
2871
|
+
if (!readFileSync2(backup).equals(original))
|
|
2872
|
+
throw new Error("session WAL preservation readback failed; source retained");
|
|
2873
|
+
return backup;
|
|
2874
|
+
}
|
|
2076
2875
|
|
|
2077
2876
|
// src/service.ts
|
|
2877
|
+
import { hostname } from "os";
|
|
2878
|
+
import { resolveDatabaseUrl } from "@hasna/contracts";
|
|
2078
2879
|
var DEFAULT_PORT = 8790;
|
|
2079
2880
|
var DEFAULT_HOST = "127.0.0.1";
|
|
2080
|
-
function packageVersion() {
|
|
2081
|
-
return package_default.version;
|
|
2082
|
-
}
|
|
2083
2881
|
function envString(names) {
|
|
2084
2882
|
for (const name of names) {
|
|
2085
2883
|
const value = process.env[name];
|
|
@@ -2096,69 +2894,218 @@ function envInt(names, fallback) {
|
|
|
2096
2894
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2097
2895
|
}
|
|
2098
2896
|
function resolveWorkflowsConfig(overrides = {}) {
|
|
2897
|
+
const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
|
|
2898
|
+
if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
|
|
2899
|
+
throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
|
|
2099
2900
|
return {
|
|
2100
2901
|
port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
|
|
2101
2902
|
host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
|
|
2102
2903
|
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? getEffectiveDataDir(),
|
|
2103
|
-
apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
|
|
2104
|
-
apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
|
|
2904
|
+
apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
|
|
2905
|
+
apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
|
|
2906
|
+
originAuth,
|
|
2907
|
+
storageBackend: overrides.storageBackend ?? "postgresql",
|
|
2908
|
+
databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
|
|
2909
|
+
databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
|
|
2105
2910
|
};
|
|
2106
2911
|
}
|
|
2107
2912
|
|
|
2108
2913
|
class WorkflowsService {
|
|
2914
|
+
execution;
|
|
2109
2915
|
name = "workflows";
|
|
2110
2916
|
version;
|
|
2111
2917
|
config;
|
|
2112
2918
|
startedAt = Date.now();
|
|
2113
|
-
|
|
2919
|
+
databaseEnv;
|
|
2920
|
+
constructor(config = {}, execution = {}) {
|
|
2921
|
+
this.execution = execution;
|
|
2114
2922
|
this.config = resolveWorkflowsConfig(config);
|
|
2923
|
+
this.databaseEnv = config.databaseUrl !== undefined ? { HASNA_WORKFLOWS_DATABASE_URL: config.databaseUrl } : {
|
|
2924
|
+
HASNA_WORKFLOWS_DATABASE_URL: process.env.HASNA_WORKFLOWS_DATABASE_URL,
|
|
2925
|
+
WORKFLOWS_DATABASE_URL: process.env.WORKFLOWS_DATABASE_URL
|
|
2926
|
+
};
|
|
2115
2927
|
this.version = packageVersion();
|
|
2116
2928
|
}
|
|
2117
2929
|
health() {
|
|
2118
2930
|
return {
|
|
2119
2931
|
ok: true,
|
|
2932
|
+
status: "ok",
|
|
2933
|
+
backend: this.config.storageBackend,
|
|
2120
2934
|
service: this.name,
|
|
2121
2935
|
version: this.version,
|
|
2122
2936
|
pid: process.pid,
|
|
2123
2937
|
uptimeMs: Date.now() - this.startedAt
|
|
2124
2938
|
};
|
|
2125
2939
|
}
|
|
2126
|
-
ready() {
|
|
2940
|
+
async ready() {
|
|
2941
|
+
const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
|
|
2942
|
+
if (this.config.apiKey) {
|
|
2943
|
+
try {
|
|
2944
|
+
checks.storage = await this.withStore(async (store) => (store instanceof PostgresWorkflowsStore ? await store.checkSchema() : (await store.listTables()).sort().join(",") === "memos,run_nodes,runs") ? "ok" : "invalid-schema");
|
|
2945
|
+
} catch (error) {
|
|
2946
|
+
checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
|
|
2950
|
+
}
|
|
2951
|
+
async openEngineStore(verifySchema = true) {
|
|
2952
|
+
if (this.config.storageBackend === "sqlite")
|
|
2953
|
+
return openStore(this.config.dataDir);
|
|
2954
|
+
if (this.config.storageBackend !== "postgresql")
|
|
2955
|
+
throw new WorkflowRequestError("invalid_storage_backend", "storage backend must be postgresql or explicit embedded sqlite", 503);
|
|
2956
|
+
let databaseUrl;
|
|
2957
|
+
try {
|
|
2958
|
+
databaseUrl = resolveDatabaseUrl("workflows", this.databaseEnv);
|
|
2959
|
+
} catch (error) {
|
|
2960
|
+
throw new WorkflowRequestError("invalid_database_configuration", error instanceof Error ? error.message : "HASNA_WORKFLOWS_DATABASE_URL must configure PostgreSQL", 503);
|
|
2961
|
+
}
|
|
2962
|
+
let store;
|
|
2963
|
+
try {
|
|
2964
|
+
store = new PostgresWorkflowsStore({ databaseUrl, dataDir: this.config.dataDir, schema: this.config.databaseSchema });
|
|
2965
|
+
} catch {
|
|
2966
|
+
throw new WorkflowRequestError("invalid_database_schema", "HASNA_WORKFLOWS_DATABASE_SCHEMA must be a lowercase SQL identifier", 503);
|
|
2967
|
+
}
|
|
2968
|
+
try {
|
|
2969
|
+
if (verifySchema && !await store.checkSchema())
|
|
2970
|
+
throw new WorkflowRequestError("invalid_schema", "PostgreSQL workflow schema requires explicit initialization or a reviewed migration", 503);
|
|
2971
|
+
return store;
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
await store.close();
|
|
2974
|
+
throw error;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
async withStore(operation) {
|
|
2978
|
+
const store = await this.openEngineStore();
|
|
2979
|
+
try {
|
|
2980
|
+
return await operation(store);
|
|
2981
|
+
} finally {
|
|
2982
|
+
await store.close();
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
async initializeStorage() {
|
|
2986
|
+
const store = await this.openEngineStore(false);
|
|
2987
|
+
try {
|
|
2988
|
+
if (!(store instanceof PostgresWorkflowsStore))
|
|
2989
|
+
throw new WorkflowRequestError("invalid_storage_backend", "init-db requires PostgreSQL", 503);
|
|
2990
|
+
await store.initialize();
|
|
2991
|
+
} finally {
|
|
2992
|
+
await store.close();
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
listRuns(options = {}) {
|
|
2996
|
+
return this.withStore((store) => store.listRuns(options));
|
|
2997
|
+
}
|
|
2998
|
+
getRun(id) {
|
|
2999
|
+
return this.withStore((store) => store.getRun(id));
|
|
3000
|
+
}
|
|
3001
|
+
listNodes(id) {
|
|
3002
|
+
return this.withStore((store) => store.listRunNodes(id));
|
|
3003
|
+
}
|
|
3004
|
+
events(id) {
|
|
3005
|
+
return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
|
|
3006
|
+
}
|
|
3007
|
+
cancelRun(id) {
|
|
3008
|
+
return this.withStore(async (store) => {
|
|
3009
|
+
const run2 = await store.getRun(id);
|
|
3010
|
+
if (!run2)
|
|
3011
|
+
return;
|
|
3012
|
+
if (["completed", "failed", "cancelled"].includes(run2.status))
|
|
3013
|
+
throw new WorkflowRequestError("invalid_run_status", "cancellation requires a pending, running, or interrupted run", 409);
|
|
3014
|
+
if (!await store.compareRunStatus(id, ["pending", "running", "interrupted"], "cancelled", { finishedAt: new Date().toISOString() }))
|
|
3015
|
+
throw new WorkflowRequestError("run_status_conflict", "run changed before cancellation", 409);
|
|
3016
|
+
return store.getRun(id);
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
async resumeRun(id) {
|
|
3020
|
+
const store = await this.openEngineStore();
|
|
3021
|
+
const wal = SessionWAL.open(this.config.dataDir);
|
|
3022
|
+
try {
|
|
3023
|
+
const run2 = await store.getRun(id);
|
|
3024
|
+
if (!run2)
|
|
3025
|
+
throw new WorkflowRequestError("run_not_found", "run id does not exist", 404);
|
|
3026
|
+
if (!["failed", "cancelled", "interrupted"].includes(run2.status))
|
|
3027
|
+
throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
|
|
3028
|
+
const context = JSON.parse(run2.contextJson);
|
|
3029
|
+
if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
|
|
3030
|
+
throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
|
|
3031
|
+
if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
|
|
3032
|
+
throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
|
|
3033
|
+
const graph = context.__wf?.graph;
|
|
3034
|
+
if (!graph)
|
|
3035
|
+
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3036
|
+
if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
|
|
3037
|
+
throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
|
|
3038
|
+
for (const node2 of await store.listRunNodes(id)) {
|
|
3039
|
+
if (node2.status !== "completed")
|
|
3040
|
+
await store.setRunNodeStatus(node2.id, "pending", { error: null });
|
|
3041
|
+
}
|
|
3042
|
+
const final = await runGraphToCompletion(store, wal, graph, context, { ...this.execution, existingRunId: id });
|
|
3043
|
+
return runSummary(final, false);
|
|
3044
|
+
} finally {
|
|
3045
|
+
await store.close();
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
sessions(action) {
|
|
3049
|
+
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3050
|
+
if (action === "list")
|
|
3051
|
+
return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
|
|
2127
3052
|
return {
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
3053
|
+
entries: replay.entries.length,
|
|
3054
|
+
torn: replay.torn,
|
|
3055
|
+
repaired: false,
|
|
3056
|
+
liveClaims: [...replay.liveClaims().entries()].map(([runId, claim]) => ({ runId, worker: claim.worker, fencing: claim.fencing, expiresAt: new Date(claim.expiresAtMs).toISOString() }))
|
|
2132
3057
|
};
|
|
2133
3058
|
}
|
|
3059
|
+
machines(action) {
|
|
3060
|
+
const local = { name: hostname(), role: "server", version: this.version };
|
|
3061
|
+
if (action === "list")
|
|
3062
|
+
return [local];
|
|
3063
|
+
return { ...local, health: this.health(), sessions: this.sessions("pull") };
|
|
3064
|
+
}
|
|
3065
|
+
daemonStatus() {
|
|
3066
|
+
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3067
|
+
return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
|
|
3068
|
+
}
|
|
3069
|
+
listMemos() {
|
|
3070
|
+
return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
|
|
3071
|
+
}
|
|
2134
3072
|
async triggerRun(graph, context = {}, opts = {}) {
|
|
2135
|
-
const store =
|
|
3073
|
+
const store = await this.openEngineStore();
|
|
2136
3074
|
const wal = SessionWAL.open(this.config.dataDir);
|
|
2137
3075
|
try {
|
|
2138
|
-
if (
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
3076
|
+
if (context === null || typeof context !== "object" || Array.isArray(context))
|
|
3077
|
+
throw new WorkflowRequestError("invalid_context", "context must be an object");
|
|
3078
|
+
if (Object.hasOwn(context, "__wf") || Object.hasOwn(context, "steps"))
|
|
3079
|
+
throw new WorkflowRequestError("reserved_context", "context.__wf and context.steps are reserved for the workflow engine");
|
|
3080
|
+
const maxCycles = opts.maxCycles ?? 500;
|
|
3081
|
+
if (!Number.isSafeInteger(maxCycles) || maxCycles < 1 || maxCycles > 2000)
|
|
3082
|
+
throw new WorkflowRequestError("invalid_max_cycles", "maxCycles must be an integer from 1 to 2000");
|
|
3083
|
+
if (opts.idempotencyKey !== undefined && !/^[A-Za-z0-9_.:-]{1,128}$/.test(opts.idempotencyKey))
|
|
3084
|
+
throw new WorkflowRequestError("invalid_idempotency_key", "idempotencyKey must contain 1 to 128 ASCII letters, digits, or _.:-");
|
|
3085
|
+
const requestHash = createHash3("sha256").update(canonicalJson({ graph, context, maxCycles })).digest("hex");
|
|
3086
|
+
const reuse = async () => {
|
|
3087
|
+
const existing2 = opts.idempotencyKey ? await store.getRunByIdempotencyKey(opts.idempotencyKey) : undefined;
|
|
3088
|
+
if (!existing2)
|
|
3089
|
+
return;
|
|
3090
|
+
if (JSON.parse(existing2.contextJson).__wf?.requestHash !== requestHash)
|
|
3091
|
+
throw new WorkflowRequestError("idempotency_conflict", "idempotencyKey is already bound to a different request", 409);
|
|
3092
|
+
return runSummary(existing2, true);
|
|
3093
|
+
};
|
|
3094
|
+
const existing = await reuse();
|
|
3095
|
+
if (existing)
|
|
3096
|
+
return existing;
|
|
3097
|
+
const contextWithKey = { ...context, __wf: { requestHash, ...opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} } };
|
|
3098
|
+
let final;
|
|
3099
|
+
try {
|
|
3100
|
+
final = await runGraphToCompletion(store, wal, graph, contextWithKey, { ...this.execution, maxCycles });
|
|
3101
|
+
} catch (err) {
|
|
3102
|
+
if (err instanceof Error && (/UNIQUE constraint failed/.test(err.message) || err.code === "23505")) {
|
|
3103
|
+
const winner = await reuse();
|
|
3104
|
+
if (winner)
|
|
3105
|
+
return winner;
|
|
2148
3106
|
}
|
|
3107
|
+
throw err;
|
|
2149
3108
|
}
|
|
2150
|
-
const contextWithKey = opts.idempotencyKey ? {
|
|
2151
|
-
...context,
|
|
2152
|
-
__wf: {
|
|
2153
|
-
cursor: undefined,
|
|
2154
|
-
loops: {},
|
|
2155
|
-
completedLoops: {},
|
|
2156
|
-
...context?.__wf ?? {},
|
|
2157
|
-
idempotencyKey: opts.idempotencyKey
|
|
2158
|
-
}
|
|
2159
|
-
} : context;
|
|
2160
|
-
const maxCycles = Math.min(Math.max(opts.maxCycles ?? 500, 1), 2000);
|
|
2161
|
-
const final = await runGraphToCompletion(store, wal, graph, contextWithKey, { maxCycles });
|
|
2162
3109
|
return {
|
|
2163
3110
|
runId: final.id,
|
|
2164
3111
|
status: final.status,
|
|
@@ -2167,16 +3114,111 @@ class WorkflowsService {
|
|
|
2167
3114
|
reused: false
|
|
2168
3115
|
};
|
|
2169
3116
|
} finally {
|
|
2170
|
-
store.close();
|
|
3117
|
+
await store.close();
|
|
2171
3118
|
}
|
|
2172
3119
|
}
|
|
2173
3120
|
}
|
|
2174
|
-
function createWorkflowsService(config = {}) {
|
|
2175
|
-
return new WorkflowsService(config);
|
|
3121
|
+
function createWorkflowsService(config = {}, execution = {}) {
|
|
3122
|
+
return new WorkflowsService(config, execution);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
class WorkflowRequestError extends Error {
|
|
3126
|
+
code;
|
|
3127
|
+
status;
|
|
3128
|
+
constructor(code, message, status = 400) {
|
|
3129
|
+
super(message);
|
|
3130
|
+
this.code = code;
|
|
3131
|
+
this.status = status;
|
|
3132
|
+
this.name = "WorkflowRequestError";
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
function canonicalJson(value) {
|
|
3136
|
+
if (Array.isArray(value))
|
|
3137
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
3138
|
+
if (value && typeof value === "object")
|
|
3139
|
+
return `{${Object.keys(value).sort().filter((k) => value[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
3140
|
+
return JSON.stringify(value) ?? "null";
|
|
3141
|
+
}
|
|
3142
|
+
function runSummary(run2, reused) {
|
|
3143
|
+
return { runId: run2.id, status: run2.status, error: run2.error ?? null, result: run2.resultJson ? JSON.parse(run2.resultJson) : null, reused };
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
// src/handlers.ts
|
|
3147
|
+
import { createHash as createHash4, timingSafeEqual } from "crypto";
|
|
3148
|
+
|
|
3149
|
+
// src/openapi.ts
|
|
3150
|
+
function buildOpenApi(version) {
|
|
3151
|
+
const string = { type: "string" }, integer = { type: "integer" }, nullableString = { type: "string", nullable: true };
|
|
3152
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
3153
|
+
const object = (properties, required = Object.keys(properties)) => ({ type: "object", properties, required });
|
|
3154
|
+
const response = (schema, description = "Success") => ({ description, content: { "application/json": { schema } } });
|
|
3155
|
+
const id = [{ name: "id", in: "path", required: true, schema: { type: "string", pattern: "^[a-zA-Z0-9_-]{1,128}$" } }];
|
|
3156
|
+
const body = (schema) => ({ required: true, description: "JSON object, at most 1 MiB", content: { "application/json": { schema } } });
|
|
3157
|
+
const statuses = ["pending", "running", "completed", "failed", "interrupted", "cancelled"];
|
|
3158
|
+
const operation = (operationId, summary, schema, extra = {}, publicRoute = false) => ({
|
|
3159
|
+
operationId,
|
|
3160
|
+
summary,
|
|
3161
|
+
...publicRoute ? {} : { security: [{ bearerAuth: [] }] },
|
|
3162
|
+
...extra,
|
|
3163
|
+
responses: { "200": response(schema), ...publicRoute ? {} : {
|
|
3164
|
+
"400": response(ref("ApiError"), "Invalid field or request shape"),
|
|
3165
|
+
"401": response(ref("ApiError"), "Bearer credential missing or refused"),
|
|
3166
|
+
"404": response(ref("ApiError"), "Run or route absent"),
|
|
3167
|
+
"409": response(ref("ApiError"), "Conflicting key, run state or execution ownership"),
|
|
3168
|
+
"503": response(ref("ApiError"), "Authentication or PostgreSQL storage unavailable")
|
|
3169
|
+
} }
|
|
3170
|
+
});
|
|
3171
|
+
const doc = {
|
|
3172
|
+
openapi: "3.0.3",
|
|
3173
|
+
info: { title: "workflows", version },
|
|
3174
|
+
servers: [{ url: "./" }],
|
|
3175
|
+
paths: {
|
|
3176
|
+
"/health": { get: operation("getHealth", "Service health", ref("HealthReport"), {}, true) },
|
|
3177
|
+
"/ready": { get: { ...operation("getReadiness", "Authentication and actual schema readiness", ref("ReadinessReport"), {}, true), responses: { "200": response(ref("ReadinessReport")), "503": response(ref("ReadinessReport"), "Not ready") } } },
|
|
3178
|
+
"/version": { get: operation("getVersion", "Package version", object({ service: string, version: string }), {}, true) },
|
|
3179
|
+
"/validate": { post: operation("validateWorkflow", "Validate a workflow graph", ref("GraphValidation"), { requestBody: body(object({ graph: ref("WorkflowGraph") })) }) },
|
|
3180
|
+
"/trigger": { post: operation("triggerWorkflow", "Execute a bounded workflow; identical keyed retries reuse the original run", ref("RunSummary"), { requestBody: body(object({ graph: ref("WorkflowGraph"), context: { type: "object", description: "__wf and steps are reserved", additionalProperties: true }, idempotencyKey: { type: "string", pattern: "^[A-Za-z0-9_.:-]{1,128}$" }, maxCycles: { type: "integer", minimum: 1, maximum: 2000, default: 500 } }, ["graph"])) }) },
|
|
3181
|
+
"/runs": { get: operation("listRunRecords", "List recent runs", { type: "array", items: ref("RunRow") }, { parameters: [{ name: "status", in: "query", schema: { type: "string", enum: statuses } }, { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 1000, default: 100 } }] }) },
|
|
3182
|
+
"/runs/{id}": { get: operation("getRunRecord", "Read a durable run", ref("RunRow"), { parameters: id }) },
|
|
3183
|
+
"/runs/{id}/nodes": { get: operation("getRunNodes", "Ordered node execution history", { type: "array", items: ref("RunNodeRow") }, { parameters: id }) },
|
|
3184
|
+
"/runs/{id}/events": { get: operation("getRunEvents", "Preserved execution journal events", { type: "array", items: { type: "object" } }, { parameters: id }) },
|
|
3185
|
+
"/runs/{id}/cancel": { post: operation("cancelWorkflow", "Cancel a pending, running or interrupted run and fence late results", ref("RunRow"), { parameters: id }) },
|
|
3186
|
+
"/runs/{id}/resume": { post: operation("resumeWorkflow", "Continue a failed, cancelled or interrupted run using its pinned graph; active or unconfirmed execution refuses resume", ref("RunSummary"), { parameters: id }) },
|
|
3187
|
+
"/lanes": { get: operation("listLanes", "Inspect lane substrates; this does not prove sign-in or model capacity", { type: "array", items: { type: "object" } }) },
|
|
3188
|
+
"/lanes/{lane}": { get: operation("probeLane", "Inspect one lane substrate", { type: "object" }, { parameters: [{ name: "lane", in: "path", required: true, schema: { type: "string", enum: ["claude", "codex", "cursor", "grok"] } }] }) },
|
|
3189
|
+
"/sessions/list": { get: operation("listSessionEvents", "Recent journal event metadata", { type: "array", items: { type: "object" } }) },
|
|
3190
|
+
"/sessions/pull": { get: operation("inspectSession", "Read journal and claim state without repair", { type: "object" }) },
|
|
3191
|
+
"/machines/list": { get: operation("listServerMachines", "This executor, not a fleet census", { type: "array", items: { type: "object" } }) },
|
|
3192
|
+
"/machines/status": { get: operation("inspectServerMachine", "This executor's health and claims", { type: "object" }) },
|
|
3193
|
+
"/daemon/status": { get: operation("inspectDaemon", "Bounded-trigger executor status", { type: "object" }) },
|
|
3194
|
+
"/memos/list": { get: operation("listMemoRecords", "Recent memo results", { type: "array", items: { type: "object" } }) }
|
|
3195
|
+
},
|
|
3196
|
+
components: {
|
|
3197
|
+
securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
|
|
3198
|
+
schemas: {
|
|
3199
|
+
ApiError: object({ error: string, message: string }, ["error"]),
|
|
3200
|
+
HealthReport: object({ ok: { type: "boolean" }, status: { type: "string", enum: ["ok"] }, backend: { type: "string", enum: ["postgresql", "sqlite"] }, service: string, version: string, pid: integer, uptimeMs: integer }),
|
|
3201
|
+
ReadinessReport: object({ ok: { type: "boolean" }, service: string, version: string, checks: { type: "object", additionalProperties: string } }),
|
|
3202
|
+
GraphValidation: object({ ok: { type: "boolean" }, issues: { type: "array", items: object({ path: string, message: string }) } }),
|
|
3203
|
+
StartNode: object({ id: string, type: { type: "string", enum: ["start"] }, next: string }, ["id", "type"]),
|
|
3204
|
+
StepNode: object({ id: string, type: { type: "string", enum: ["step"] }, next: string, lane: { type: "string", enum: ["claude", "codex", "cursor", "grok"] }, prompt: string, command: string, cwd: { type: "string", minLength: 1 }, codexMode: { type: "string", enum: ["repository", "read-only"], default: "repository", description: "Codex prompt steps only. Read-only permits nonrepository execution with a read-only sandbox and no approvals. Repository retains native trust, sandbox and approval defaults." }, maxRetries: { type: "integer", minimum: 0 }, memo: { type: "boolean" }, timeoutMs: { type: "integer", minimum: 1 } }, ["id", "type"]),
|
|
3205
|
+
DecisionNode: object({ id: string, type: { type: "string", enum: ["decision"] }, condition: string, then: string, else: string }, ["id", "type", "condition"]),
|
|
3206
|
+
WhileNode: object({ id: string, type: { type: "string", enum: ["while"] }, condition: string, body: { type: "array", items: string }, maxIterations: { type: "integer", minimum: 1 }, next: string }, ["id", "type", "condition", "body", "maxIterations"]),
|
|
3207
|
+
EndNode: object({ id: string, type: { type: "string", enum: ["end"] } }),
|
|
3208
|
+
GraphNode: { oneOf: ["StartNode", "StepNode", "DecisionNode", "WhileNode", "EndNode"].map(ref) },
|
|
3209
|
+
WorkflowGraph: object({ name: string, version: string, nodes: { type: "array", items: ref("GraphNode") }, memoWatch: { type: "array", items: string } }, ["name", "version", "nodes"]),
|
|
3210
|
+
RunSummary: object({ runId: string, status: { type: "string", enum: statuses }, error: nullableString, result: {}, reused: { type: "boolean" } }),
|
|
3211
|
+
RunRow: object({ id: string, graphName: string, graphVersion: string, status: { type: "string", enum: statuses }, attempts: integer, contextJson: string, resultJson: nullableString, error: nullableString, startedAt: nullableString, finishedAt: nullableString, createdAt: string, updatedAt: string }),
|
|
3212
|
+
RunNodeRow: object({ id: string, runId: string, nodeId: string, status: { type: "string", enum: ["pending", "running", "completed", "failed", "skipped"] }, attempts: integer, lane: nullableString, exitCode: { type: "integer", nullable: true }, outputJson: nullableString, error: nullableString, startedAt: nullableString, finishedAt: nullableString })
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
3216
|
+
return doc;
|
|
2176
3217
|
}
|
|
2177
3218
|
|
|
2178
3219
|
// src/handlers.ts
|
|
2179
|
-
|
|
3220
|
+
init_graph();
|
|
3221
|
+
init_lanes();
|
|
2180
3222
|
function jsonResponse(body, status = 200) {
|
|
2181
3223
|
return new Response(JSON.stringify(body), {
|
|
2182
3224
|
status,
|
|
@@ -2184,47 +3226,41 @@ function jsonResponse(body, status = 200) {
|
|
|
2184
3226
|
});
|
|
2185
3227
|
}
|
|
2186
3228
|
function openApiDoc(service) {
|
|
2187
|
-
return
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
}
|
|
2213
|
-
},
|
|
2214
|
-
responses: {
|
|
2215
|
-
"200": { description: "run summary" },
|
|
2216
|
-
"401": { description: "missing or invalid Bearer token" },
|
|
2217
|
-
"503": { description: "trigger not configured (WORKFLOWS_API_KEY unset)" }
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
},
|
|
2222
|
-
components: {
|
|
2223
|
-
securitySchemes: {
|
|
2224
|
-
bearerAuth: { type: "http", scheme: "bearer" }
|
|
3229
|
+
return buildOpenApi(service.version);
|
|
3230
|
+
}
|
|
3231
|
+
function authenticate(service, req) {
|
|
3232
|
+
if (!service.config.apiKey)
|
|
3233
|
+
return jsonResponse({ error: "auth_not_configured" }, 503);
|
|
3234
|
+
if (!bearerMatches(req.headers.get("authorization") ?? "", service.config.apiKey)) {
|
|
3235
|
+
return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
async function readObject(req) {
|
|
3239
|
+
const limit = 1048576;
|
|
3240
|
+
const reader = req.body?.getReader();
|
|
3241
|
+
if (!reader)
|
|
3242
|
+
throw new Error("invalid_json");
|
|
3243
|
+
const chunks = [];
|
|
3244
|
+
let size = 0;
|
|
3245
|
+
try {
|
|
3246
|
+
for (;; ) {
|
|
3247
|
+
const { value, done } = await reader.read();
|
|
3248
|
+
if (done)
|
|
3249
|
+
break;
|
|
3250
|
+
size += value.byteLength;
|
|
3251
|
+
if (size > limit) {
|
|
3252
|
+
await reader.cancel();
|
|
3253
|
+
throw new Error("body_too_large");
|
|
2225
3254
|
}
|
|
3255
|
+
chunks.push(value);
|
|
2226
3256
|
}
|
|
2227
|
-
}
|
|
3257
|
+
} finally {
|
|
3258
|
+
reader.releaseLock();
|
|
3259
|
+
}
|
|
3260
|
+
const body = JSON.parse(await new Blob(chunks).text());
|
|
3261
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
3262
|
+
throw new Error("invalid_body");
|
|
3263
|
+
return body;
|
|
2228
3264
|
}
|
|
2229
3265
|
function bearerMatches(authHeader, expected) {
|
|
2230
3266
|
if (!authHeader.startsWith("Bearer "))
|
|
@@ -2232,8 +3268,8 @@ function bearerMatches(authHeader, expected) {
|
|
|
2232
3268
|
const supplied = authHeader.slice("Bearer ".length);
|
|
2233
3269
|
if (supplied.length === 0)
|
|
2234
3270
|
return false;
|
|
2235
|
-
const a =
|
|
2236
|
-
const b =
|
|
3271
|
+
const a = createHash4("sha256").update(supplied).digest();
|
|
3272
|
+
const b = createHash4("sha256").update(expected).digest();
|
|
2237
3273
|
return timingSafeEqual(a, b);
|
|
2238
3274
|
}
|
|
2239
3275
|
async function handleTrigger(service, req) {
|
|
@@ -2245,57 +3281,148 @@ async function handleTrigger(service, req) {
|
|
|
2245
3281
|
if (!bearerMatches(auth, expected)) {
|
|
2246
3282
|
return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
|
|
2247
3283
|
}
|
|
2248
|
-
let
|
|
3284
|
+
let record;
|
|
2249
3285
|
try {
|
|
2250
|
-
|
|
3286
|
+
record = await readObject(req);
|
|
2251
3287
|
} catch {
|
|
2252
|
-
return jsonResponse({ error: "invalid_json", message: "request body must be JSON" }, 400);
|
|
3288
|
+
return jsonResponse({ error: "invalid_json", message: "request body must be a JSON object within 1 MiB" }, 400);
|
|
2253
3289
|
}
|
|
2254
|
-
const record = body;
|
|
2255
3290
|
if (record.graph === null || typeof record.graph !== "object" || Array.isArray(record.graph)) {
|
|
2256
3291
|
return jsonResponse({ error: "graph_required", message: "body.graph must be a workflow graph object" }, 400);
|
|
2257
3292
|
}
|
|
2258
3293
|
const context = record.context ?? {};
|
|
2259
|
-
if (typeof context !== "object" || Array.isArray(context)) {
|
|
3294
|
+
if (context === null || typeof context !== "object" || Array.isArray(context)) {
|
|
2260
3295
|
return jsonResponse({ error: "invalid_context", message: "body.context must be an object" }, 400);
|
|
2261
3296
|
}
|
|
3297
|
+
if (record.maxCycles !== undefined && (typeof record.maxCycles !== "number" || !Number.isSafeInteger(record.maxCycles) || record.maxCycles < 1 || record.maxCycles > 2000)) {
|
|
3298
|
+
return jsonResponse({ error: "invalid_max_cycles", message: "maxCycles must be an integer from 1 to 2000" }, 400);
|
|
3299
|
+
}
|
|
3300
|
+
if (record.idempotencyKey !== undefined && (typeof record.idempotencyKey !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(record.idempotencyKey))) {
|
|
3301
|
+
return jsonResponse({ error: "invalid_idempotency_key", message: "idempotencyKey must contain 1 to 128 ASCII letters, digits, or _.:-" }, 400);
|
|
3302
|
+
}
|
|
3303
|
+
const validation = validateGraph(record.graph);
|
|
3304
|
+
if (!validation.ok)
|
|
3305
|
+
return jsonResponse({ error: "invalid_graph", issues: validation.issues }, 400);
|
|
2262
3306
|
const maxCycles = typeof record.maxCycles === "number" ? record.maxCycles : undefined;
|
|
2263
3307
|
const idempotencyKey = typeof record.idempotencyKey === "string" ? record.idempotencyKey : undefined;
|
|
2264
3308
|
try {
|
|
2265
3309
|
const summary = await service.triggerRun(record.graph, context, { maxCycles, idempotencyKey });
|
|
2266
3310
|
return jsonResponse(summary);
|
|
2267
3311
|
} catch (err) {
|
|
2268
|
-
|
|
2269
|
-
|
|
3312
|
+
if (err instanceof WorkflowStorageError)
|
|
3313
|
+
return jsonResponse({ error: "storage_unavailable", message: err.message }, 503);
|
|
3314
|
+
if (err instanceof WorkflowRequestError)
|
|
3315
|
+
return jsonResponse({ error: err.code, message: err.message }, err.status);
|
|
3316
|
+
return jsonResponse({ error: "trigger_failed", message: "workflow execution was refused" }, 400);
|
|
2270
3317
|
}
|
|
2271
3318
|
}
|
|
2272
3319
|
function createRequestHandler(service) {
|
|
2273
|
-
return (req) => {
|
|
3320
|
+
return async (req) => {
|
|
2274
3321
|
const url = new URL(req.url);
|
|
2275
|
-
|
|
3322
|
+
if (service.config.originAuth !== undefined && !["/health", "/ready"].includes(url.pathname)) {
|
|
3323
|
+
if (!bearerMatches(`Bearer ${req.headers.get("x-hasna-origin-auth") ?? ""}`, service.config.originAuth)) {
|
|
3324
|
+
return jsonResponse({ error: "WORKFLOWS_ORIGIN_DENIED" }, 403);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
const path = url.pathname.replace(/^\/v1(?=\/|$)/, "") || "/";
|
|
3328
|
+
const method = req.method;
|
|
3329
|
+
const allowed = path === "/trigger" || path === "/validate" || /^\/runs\/[^/]+\/(cancel|resume)$/.test(path) ? "POST" : "GET";
|
|
3330
|
+
if (method !== allowed)
|
|
3331
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), { status: 405, headers: { "content-type": "application/json", allow: allowed } });
|
|
3332
|
+
switch (path) {
|
|
2276
3333
|
case "/health":
|
|
2277
3334
|
return jsonResponse(service.health());
|
|
2278
|
-
case "/ready":
|
|
2279
|
-
|
|
3335
|
+
case "/ready": {
|
|
3336
|
+
const ready = await service.ready();
|
|
3337
|
+
return jsonResponse(ready, ready.ok ? 200 : 503);
|
|
3338
|
+
}
|
|
2280
3339
|
case "/version":
|
|
2281
3340
|
return jsonResponse({ service: service.name, version: service.version });
|
|
2282
3341
|
case "/trigger":
|
|
2283
3342
|
return handleTrigger(service, req);
|
|
2284
3343
|
case "/openapi.json":
|
|
2285
3344
|
return jsonResponse(openApiDoc(service));
|
|
2286
|
-
|
|
2287
|
-
|
|
3345
|
+
}
|
|
3346
|
+
if (path !== "/validate" && path !== "/runs" && path !== "/lanes" && !/^\/lanes\/[^/]+$/.test(path) && !/^\/runs\/[^/]+(?:\/(nodes|events|cancel|resume))?$/.test(path) && !/^\/(sessions|machines|daemon|memos)\/(list|pull|status)$/.test(path)) {
|
|
3347
|
+
return jsonResponse({ error: "not_found" }, 404);
|
|
3348
|
+
}
|
|
3349
|
+
const refused = authenticate(service, req);
|
|
3350
|
+
if (refused)
|
|
3351
|
+
return refused;
|
|
3352
|
+
try {
|
|
3353
|
+
if (path === "/validate") {
|
|
3354
|
+
const body = await readObject(req);
|
|
3355
|
+
return jsonResponse(validateGraph(body.graph));
|
|
3356
|
+
}
|
|
3357
|
+
if (path === "/runs") {
|
|
3358
|
+
const limit = Number(url.searchParams.get("limit") ?? 100);
|
|
3359
|
+
const status = url.searchParams.get("status") ?? undefined;
|
|
3360
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || status !== undefined && !RUN_STATUSES.includes(status))
|
|
3361
|
+
return jsonResponse({ error: "invalid_query" }, 400);
|
|
3362
|
+
return jsonResponse(await service.listRuns({ limit, status }));
|
|
3363
|
+
}
|
|
3364
|
+
if (path === "/lanes")
|
|
3365
|
+
return jsonResponse(await laneInventory());
|
|
3366
|
+
if (path.startsWith("/lanes/")) {
|
|
3367
|
+
const lane = path.slice("/lanes/".length);
|
|
3368
|
+
if (!LANE_KINDS2.includes(lane))
|
|
3369
|
+
return jsonResponse({ error: "invalid_lane" }, 400);
|
|
3370
|
+
return jsonResponse(await probeLane(lane));
|
|
3371
|
+
}
|
|
3372
|
+
if (path === "/sessions/list")
|
|
3373
|
+
return jsonResponse(service.sessions("list"));
|
|
3374
|
+
if (path === "/sessions/pull")
|
|
3375
|
+
return jsonResponse(service.sessions("pull"));
|
|
3376
|
+
if (path === "/machines/list")
|
|
3377
|
+
return jsonResponse(service.machines("list"));
|
|
3378
|
+
if (path === "/machines/status")
|
|
3379
|
+
return jsonResponse(service.machines("status"));
|
|
3380
|
+
if (path === "/daemon/status")
|
|
3381
|
+
return jsonResponse(service.daemonStatus());
|
|
3382
|
+
if (path === "/memos/list")
|
|
3383
|
+
return jsonResponse(await service.listMemos());
|
|
3384
|
+
const match = /^\/runs\/([^/]+)(?:\/(nodes|events|cancel|resume))?$/.exec(path);
|
|
3385
|
+
if (match) {
|
|
3386
|
+
const id = decodeURIComponent(match[1]);
|
|
3387
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(id))
|
|
3388
|
+
return jsonResponse({ error: "invalid_run_id" }, 400);
|
|
3389
|
+
const run2 = await service.getRun(id);
|
|
3390
|
+
if (!run2)
|
|
3391
|
+
return jsonResponse({ error: "run_not_found" }, 404);
|
|
3392
|
+
switch (match[2]) {
|
|
3393
|
+
case "nodes":
|
|
3394
|
+
return jsonResponse(await service.listNodes(id));
|
|
3395
|
+
case "events":
|
|
3396
|
+
return jsonResponse(service.events(id));
|
|
3397
|
+
case "cancel":
|
|
3398
|
+
return jsonResponse(await service.cancelRun(id));
|
|
3399
|
+
case "resume":
|
|
3400
|
+
return jsonResponse(await service.resumeRun(id));
|
|
3401
|
+
default:
|
|
3402
|
+
return jsonResponse(run2);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
return jsonResponse({ error: "not_found" }, 404);
|
|
3406
|
+
} catch (err) {
|
|
3407
|
+
if (err instanceof WorkflowStorageError)
|
|
3408
|
+
return jsonResponse({ error: "storage_unavailable", message: err.message }, 503);
|
|
3409
|
+
if (err instanceof WorkflowRequestError)
|
|
3410
|
+
return jsonResponse({ error: err.code, message: err.message }, err.status);
|
|
3411
|
+
return jsonResponse({ error: "invalid_request", message: "request was refused" }, 400);
|
|
2288
3412
|
}
|
|
2289
3413
|
};
|
|
2290
3414
|
}
|
|
2291
3415
|
|
|
2292
3416
|
// src/agent-tools.ts
|
|
3417
|
+
init_graph();
|
|
2293
3418
|
init_lanes();
|
|
3419
|
+
|
|
3420
|
+
// src/tool-definitions.ts
|
|
2294
3421
|
function textResult(payload) {
|
|
2295
3422
|
return { content: [{ type: "text", text: JSON.stringify(payload) }], isError: false };
|
|
2296
3423
|
}
|
|
2297
3424
|
function workflowsTools(_service) {
|
|
2298
|
-
|
|
3425
|
+
const tools = [
|
|
2299
3426
|
{
|
|
2300
3427
|
name: "workflows_version",
|
|
2301
3428
|
description: "Report the installed @hasna/workflows version.",
|
|
@@ -2308,7 +3435,7 @@ function workflowsTools(_service) {
|
|
|
2308
3435
|
},
|
|
2309
3436
|
{
|
|
2310
3437
|
name: "workflows_ready",
|
|
2311
|
-
description: "Report the workflows service readiness
|
|
3438
|
+
description: "Report the hosted workflows service readiness.",
|
|
2312
3439
|
inputSchema: { type: "object", properties: {} }
|
|
2313
3440
|
},
|
|
2314
3441
|
{
|
|
@@ -2327,14 +3454,16 @@ function workflowsTools(_service) {
|
|
|
2327
3454
|
type: "object",
|
|
2328
3455
|
properties: {
|
|
2329
3456
|
graph: { type: "object", description: "the workflow graph" },
|
|
2330
|
-
context: { type: "object", description: "optional run context" }
|
|
3457
|
+
context: { type: "object", description: "optional run context" },
|
|
3458
|
+
idempotencyKey: { type: "string", description: "stable request key" },
|
|
3459
|
+
maxCycles: { type: "integer", minimum: 1, maximum: 2000 }
|
|
2331
3460
|
},
|
|
2332
|
-
required: ["graph"]
|
|
3461
|
+
required: ["graph", "idempotencyKey"]
|
|
2333
3462
|
}
|
|
2334
3463
|
},
|
|
2335
3464
|
{
|
|
2336
3465
|
name: "workflows_runs_list",
|
|
2337
|
-
description: "List recent runs from the
|
|
3466
|
+
description: "List recent runs from the configured hosted service.",
|
|
2338
3467
|
inputSchema: {
|
|
2339
3468
|
type: "object",
|
|
2340
3469
|
properties: {
|
|
@@ -2349,7 +3478,13 @@ function workflowsTools(_service) {
|
|
|
2349
3478
|
inputSchema: { type: "object", properties: {} }
|
|
2350
3479
|
}
|
|
2351
3480
|
];
|
|
3481
|
+
return tools.map((tool) => ({ ...tool, inputSchema: { ...tool.inputSchema, properties: {
|
|
3482
|
+
...tool.inputSchema.properties,
|
|
3483
|
+
agent: { type: "string", description: "calling agent identity" }
|
|
3484
|
+
} } }));
|
|
2352
3485
|
}
|
|
3486
|
+
|
|
3487
|
+
// src/agent-tools.ts
|
|
2353
3488
|
async function callWorkflowTool(service, name, args) {
|
|
2354
3489
|
switch (name) {
|
|
2355
3490
|
case "workflows_version":
|
|
@@ -2357,42 +3492,26 @@ async function callWorkflowTool(service, name, args) {
|
|
|
2357
3492
|
case "workflows_health":
|
|
2358
3493
|
return textResult(service.health());
|
|
2359
3494
|
case "workflows_ready":
|
|
2360
|
-
return textResult(service.ready());
|
|
3495
|
+
return textResult(await service.ready());
|
|
2361
3496
|
case "workflows_validate": {
|
|
2362
3497
|
const graph = args.graph;
|
|
2363
3498
|
const result = validateGraph(graph);
|
|
2364
3499
|
return textResult(result);
|
|
2365
3500
|
}
|
|
2366
|
-
case "workflows_run":
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
result: final.resultJson ? JSON.parse(final.resultJson) : null
|
|
2377
|
-
});
|
|
2378
|
-
} finally {
|
|
2379
|
-
store.close();
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
case "workflows_runs_list": {
|
|
2383
|
-
const store = openStore(service.config.dataDir);
|
|
2384
|
-
try {
|
|
2385
|
-
const status = typeof args.status === "string" ? args.status : undefined;
|
|
2386
|
-
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
2387
|
-
return textResult(store.listRuns({ status, limit }));
|
|
2388
|
-
} finally {
|
|
2389
|
-
store.close();
|
|
2390
|
-
}
|
|
2391
|
-
}
|
|
3501
|
+
case "workflows_run":
|
|
3502
|
+
return textResult(await service.triggerRun(args.graph, args.context ?? {}, {
|
|
3503
|
+
idempotencyKey: typeof args.idempotencyKey === "string" ? args.idempotencyKey : undefined,
|
|
3504
|
+
maxCycles: typeof args.maxCycles === "number" ? args.maxCycles : undefined
|
|
3505
|
+
}));
|
|
3506
|
+
case "workflows_runs_list":
|
|
3507
|
+
return textResult(await service.listRuns({
|
|
3508
|
+
status: typeof args.status === "string" ? args.status : undefined,
|
|
3509
|
+
limit: typeof args.limit === "number" ? args.limit : 100
|
|
3510
|
+
}));
|
|
2392
3511
|
case "workflows_lanes_list":
|
|
2393
3512
|
return textResult(await laneInventory());
|
|
2394
3513
|
default:
|
|
2395
|
-
throw new Error(
|
|
3514
|
+
throw new Error("Unknown workflows tool");
|
|
2396
3515
|
}
|
|
2397
3516
|
}
|
|
2398
3517
|
export {
|