@hasna/loops 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4187 @@
1
+ // @bun
2
+ // src/lib/store.ts
3
+ import { Database } from "bun:sqlite";
4
+ import { chmodSync, existsSync, mkdirSync as mkdirSync3, mkdtempSync, rmSync as rmSync2 } from "fs";
5
+ import { tmpdir } from "os";
6
+ import { basename as basename2, dirname as dirname2, join as join3 } from "path";
7
+
8
+ // src/lib/errors.ts
9
+ class CodedError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = new.target.name;
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ class LoopNotFoundError extends CodedError {
19
+ constructor(idOrName) {
20
+ super("LOOP_NOT_FOUND", `loop not found: ${idOrName}`);
21
+ }
22
+ }
23
+
24
+ class LoopArchivedError extends CodedError {
25
+ constructor(idOrName) {
26
+ super("LOOP_ARCHIVED", `loop is archived: ${idOrName}; unarchive it before modifying`);
27
+ }
28
+ }
29
+
30
+ class AmbiguousNameError extends CodedError {
31
+ constructor(name) {
32
+ super("AMBIGUOUS_NAME", `ambiguous loop name: ${name}; use a loop id`);
33
+ }
34
+ }
35
+
36
+ class ValidationError extends CodedError {
37
+ constructor(message) {
38
+ super("VALIDATION_ERROR", message);
39
+ }
40
+ }
41
+
42
+ // src/lib/ids.ts
43
+ import { randomBytes } from "crypto";
44
+ var TIME_HEX_LENGTH = 12;
45
+ function genId() {
46
+ const time = Date.now().toString(16).padStart(TIME_HEX_LENGTH, "0");
47
+ return `${time}${randomBytes(10).toString("hex")}`;
48
+ }
49
+ function nowIso() {
50
+ return new Date().toISOString();
51
+ }
52
+
53
+ // src/lib/paths.ts
54
+ import { mkdirSync } from "fs";
55
+ import { homedir } from "os";
56
+ import { join } from "path";
57
+ function dataDir() {
58
+ return process.env.LOOPS_DATA_DIR || join(homedir(), ".hasna", "loops");
59
+ }
60
+ function ensureDataDir() {
61
+ const dir = dataDir();
62
+ mkdirSync(dir, { recursive: true, mode: 448 });
63
+ return dir;
64
+ }
65
+ function dbPath() {
66
+ return join(dataDir(), "loops.db");
67
+ }
68
+ function pidFilePath() {
69
+ return join(dataDir(), "daemon.pid");
70
+ }
71
+ function daemonLogPath() {
72
+ return join(dataDir(), "daemon.log");
73
+ }
74
+ function systemdServicePath() {
75
+ return join(homedir(), ".config", "systemd", "user", "loops-daemon.service");
76
+ }
77
+ function launchdPlistPath() {
78
+ return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
79
+ }
80
+
81
+ // src/lib/process-identity.ts
82
+ import { readFileSync } from "fs";
83
+ import { spawnSync } from "child_process";
84
+ var START_TIME_TOLERANCE_MS = 5000;
85
+ var clockTicksCache;
86
+ function clockTicksPerSecond() {
87
+ if (clockTicksCache !== undefined)
88
+ return clockTicksCache;
89
+ try {
90
+ const run = spawnSync("getconf", ["CLK_TCK"], { encoding: "utf8" });
91
+ const value = Number(run.stdout.trim());
92
+ clockTicksCache = run.status === 0 && Number.isFinite(value) && value > 0 ? value : 100;
93
+ } catch {
94
+ clockTicksCache = 100;
95
+ }
96
+ return clockTicksCache;
97
+ }
98
+ var bootTimeCache;
99
+ var bootTimeResolved = false;
100
+ function bootTimeMs() {
101
+ if (bootTimeResolved)
102
+ return bootTimeCache;
103
+ bootTimeResolved = true;
104
+ try {
105
+ const match = readFileSync("/proc/stat", "utf8").match(/^btime (\d+)$/m);
106
+ bootTimeCache = match ? Number(match[1]) * 1000 : undefined;
107
+ } catch {
108
+ bootTimeCache = undefined;
109
+ }
110
+ return bootTimeCache;
111
+ }
112
+ function procStatFields(path) {
113
+ try {
114
+ const stat = readFileSync(path, "utf8");
115
+ const closeParen = stat.lastIndexOf(")");
116
+ if (closeParen < 0)
117
+ return;
118
+ return stat.slice(closeParen + 2).split(" ");
119
+ } catch {
120
+ return;
121
+ }
122
+ }
123
+ function processStartTimeMs(pid) {
124
+ if (!Number.isInteger(pid) || pid <= 0)
125
+ return;
126
+ if (process.platform === "linux") {
127
+ const fields = procStatFields(`/proc/${pid}/stat`);
128
+ const startTicks = fields ? Number(fields[19]) : Number.NaN;
129
+ const boot = bootTimeMs();
130
+ if (Number.isFinite(startTicks) && boot !== undefined) {
131
+ return boot + Math.round(startTicks / clockTicksPerSecond() * 1000);
132
+ }
133
+ }
134
+ try {
135
+ const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
136
+ if (run.status === 0) {
137
+ const parsed = Date.parse(run.stdout.trim());
138
+ if (Number.isFinite(parsed))
139
+ return parsed;
140
+ }
141
+ } catch {}
142
+ return;
143
+ }
144
+ function sameProcessStart(recorded, actualMs, toleranceMs = START_TIME_TOLERANCE_MS) {
145
+ if (recorded === undefined || actualMs === undefined)
146
+ return true;
147
+ const recordedMs = typeof recorded === "number" ? recorded : Date.parse(recorded);
148
+ if (!Number.isFinite(recordedMs))
149
+ return true;
150
+ return Math.abs(recordedMs - actualMs) <= toleranceMs;
151
+ }
152
+ function verifiedProcessStart(recorded, actualMs, toleranceMs = START_TIME_TOLERANCE_MS) {
153
+ if (recorded === undefined || actualMs === undefined)
154
+ return false;
155
+ const recordedMs = typeof recorded === "number" ? recorded : Date.parse(recorded);
156
+ if (!Number.isFinite(recordedMs))
157
+ return false;
158
+ return Math.abs(recordedMs - actualMs) <= toleranceMs;
159
+ }
160
+
161
+ // src/lib/redact.ts
162
+ var SCRUBBED = "[SCRUBBED]";
163
+ var TOKEN_PATTERNS = [
164
+ /\bsk-ant-[A-Za-z0-9_-]{8,}/g,
165
+ /\bsk-proj-[A-Za-z0-9_-]{8,}/g,
166
+ /\bAKIA[0-9A-Z]{16}\b/g,
167
+ /\bghp_[A-Za-z0-9]{16,}\b/g,
168
+ /\bgithub_pat_[A-Za-z0-9_]{16,}\b/g,
169
+ /\bxox[a-z]-[A-Za-z0-9-]{8,}/g,
170
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{10,}\b/g
171
+ ];
172
+ var AUTHORIZATION_PATTERN = /(\bAuthorization(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{16,}/gi;
173
+ var PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
174
+ var ASSIGNMENT_PATTERN = /([A-Za-z0-9_.-]{0,64}(?:key|token|secret|passwd|password|passphrase|credentials?)(?:\\?["'])?\s*[:=]\s*)(\\"([^"\\\r\n]{12,})\\"|"((?:[^"\\\r\n]|\\[^\r\n]){12,})"|'([^'\\\r\n]{12,})'|([A-Za-z0-9+/_.=-]{16,}))/gi;
175
+ var BENIGN_KEY_NAME_PATTERN = /(?:^|[._-])(?:idempotency|dedupe|route)[_-]?key$/i;
176
+ var ASSIGNMENT_HINTS = ["key", "token", "secret", "pass", "credential"];
177
+ function mayContainAssignment(text) {
178
+ const lowered = text.toLowerCase();
179
+ return ASSIGNMENT_HINTS.some((hint) => lowered.includes(hint));
180
+ }
181
+ function shannonEntropy(value) {
182
+ const counts = new Map;
183
+ for (const char of value)
184
+ counts.set(char, (counts.get(char) ?? 0) + 1);
185
+ let entropy = 0;
186
+ for (const count of counts.values()) {
187
+ const probability = count / value.length;
188
+ entropy -= probability * Math.log2(probability);
189
+ }
190
+ return entropy;
191
+ }
192
+ function isLikelySecretValue(value) {
193
+ if (value.length < 12)
194
+ return false;
195
+ if (value.includes(SCRUBBED))
196
+ return false;
197
+ if (value.startsWith("/") || value.startsWith("~") || value.startsWith("./"))
198
+ return false;
199
+ return shannonEntropy(value) >= 3.2;
200
+ }
201
+ function scrubSecrets(text) {
202
+ if (!text)
203
+ return text;
204
+ let scrubbed = text;
205
+ for (const pattern of TOKEN_PATTERNS)
206
+ scrubbed = scrubbed.replace(pattern, SCRUBBED);
207
+ scrubbed = scrubbed.replace(AUTHORIZATION_PATTERN, `$1${SCRUBBED}`);
208
+ scrubbed = scrubbed.replace(PEM_PATTERN, SCRUBBED);
209
+ if (!mayContainAssignment(scrubbed))
210
+ return scrubbed;
211
+ scrubbed = scrubbed.replace(ASSIGNMENT_PATTERN, (match, prefix, quotedValue, escapedQuoted, doubleQuoted, singleQuoted, bare) => {
212
+ const value = escapedQuoted ?? doubleQuoted ?? singleQuoted ?? bare ?? "";
213
+ const keyName = /^[A-Za-z0-9_.-]*/.exec(prefix)?.[0] ?? "";
214
+ if (BENIGN_KEY_NAME_PATTERN.test(keyName))
215
+ return match;
216
+ if (!isLikelySecretValue(value))
217
+ return match;
218
+ const quote = quotedValue.startsWith("\\\"") ? "\\\"" : quotedValue.startsWith('"') ? '"' : quotedValue.startsWith("'") ? "'" : "";
219
+ return `${prefix}${quote}${SCRUBBED}${quote}`;
220
+ });
221
+ return scrubbed;
222
+ }
223
+ function scrubSecretsDeep(value) {
224
+ if (typeof value === "string")
225
+ return scrubSecrets(value);
226
+ if (Array.isArray(value))
227
+ return value.map((entry) => scrubSecretsDeep(entry));
228
+ if (value !== null && typeof value === "object") {
229
+ const toJSON = value.toJSON;
230
+ if (typeof toJSON === "function")
231
+ return scrubSecretsDeep(toJSON.call(value));
232
+ const scrubbed = {};
233
+ for (const [key, entry] of Object.entries(value))
234
+ scrubbed[key] = scrubSecretsDeep(entry);
235
+ return scrubbed;
236
+ }
237
+ return value;
238
+ }
239
+
240
+ // src/lib/recurrence.ts
241
+ function assertDate(value, label) {
242
+ const date = new Date(value);
243
+ if (Number.isNaN(date.getTime()))
244
+ throw new Error(`invalid ${label}: ${value}`);
245
+ return date;
246
+ }
247
+ function parseField(expr, min, max) {
248
+ const out = new Set;
249
+ for (const part of expr.split(",")) {
250
+ const [rangePart, stepPart] = part.split("/");
251
+ const step = stepPart ? Number(stepPart) : 1;
252
+ if (!Number.isInteger(step) || step <= 0)
253
+ throw new Error(`invalid cron step: ${part}`);
254
+ let lo = min;
255
+ let hi = max;
256
+ if (rangePart && rangePart !== "*") {
257
+ const bounds = rangePart.split("-");
258
+ if (bounds.length === 1) {
259
+ lo = hi = Number(bounds[0]);
260
+ } else if (bounds.length === 2) {
261
+ lo = Number(bounds[0]);
262
+ hi = Number(bounds[1]);
263
+ } else {
264
+ throw new Error(`invalid cron range: ${part}`);
265
+ }
266
+ if (!Number.isInteger(lo) || !Number.isInteger(hi))
267
+ throw new Error(`invalid cron field: ${part}`);
268
+ }
269
+ for (let v = lo;v <= hi; v += step) {
270
+ if (v < min || v > max)
271
+ throw new Error(`cron value out of range: ${v} in ${expr}`);
272
+ out.add(v);
273
+ }
274
+ }
275
+ return out;
276
+ }
277
+ var MINUTE_MS = 60000;
278
+ var PARSED_CRON_CACHE_LIMIT = 256;
279
+ var parsedCronCache = new Map;
280
+ var emittedScheduleWarnings = new Set;
281
+ function warnOnce(key, message) {
282
+ if (emittedScheduleWarnings.has(key))
283
+ return;
284
+ emittedScheduleWarnings.add(key);
285
+ console.warn(`[open-loops] WARN ${message}`);
286
+ }
287
+ function parseCron(expr) {
288
+ const cached = parsedCronCache.get(expr);
289
+ if (cached)
290
+ return cached;
291
+ const fields = expr.trim().split(/\s+/);
292
+ if (fields.length !== 5)
293
+ throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
294
+ const [minute, hour, dom, month, dow] = fields;
295
+ const dowSet = parseField(dow, 0, 7);
296
+ if (dowSet.has(7)) {
297
+ dowSet.delete(7);
298
+ dowSet.add(0);
299
+ }
300
+ const parsed = {
301
+ minute: parseField(minute, 0, 59),
302
+ hour: parseField(hour, 0, 23),
303
+ dom: parseField(dom, 1, 31),
304
+ month: parseField(month, 1, 12),
305
+ dow: dowSet,
306
+ domRestricted: dom !== "*",
307
+ dowRestricted: dow !== "*"
308
+ };
309
+ if (parsedCronCache.size >= PARSED_CRON_CACHE_LIMIT) {
310
+ const oldest = parsedCronCache.keys().next().value;
311
+ if (oldest !== undefined)
312
+ parsedCronCache.delete(oldest);
313
+ }
314
+ parsedCronCache.set(expr, parsed);
315
+ return parsed;
316
+ }
317
+ function cronMatches(cron, date) {
318
+ if (!cron.minute.has(date.getMinutes()))
319
+ return false;
320
+ if (!cron.hour.has(date.getHours()))
321
+ return false;
322
+ if (!cron.month.has(date.getMonth() + 1))
323
+ return false;
324
+ const domOk = cron.dom.has(date.getDate());
325
+ const dowOk = cron.dow.has(date.getDay());
326
+ if (cron.domRestricted && cron.dowRestricted)
327
+ return domOk || dowOk;
328
+ if (cron.domRestricted)
329
+ return domOk;
330
+ if (cron.dowRestricted)
331
+ return dowOk;
332
+ return true;
333
+ }
334
+ function nextCronRun(expr, from) {
335
+ const cron = parseCron(expr);
336
+ const date = new Date(from.getTime());
337
+ date.setSeconds(0, 0);
338
+ date.setMinutes(date.getMinutes() + 1);
339
+ const limit = from.getTime() + 366 * 24 * 60 * 60 * 1000;
340
+ while (date.getTime() <= limit) {
341
+ if (cronMatches(cron, date))
342
+ return date;
343
+ date.setMinutes(date.getMinutes() + 1);
344
+ }
345
+ throw new Error(`no cron match within one year for: ${expr}`);
346
+ }
347
+ function initialNextRun(schedule, from = new Date) {
348
+ switch (schedule.type) {
349
+ case "once":
350
+ return assertDate(schedule.at, "schedule.at").toISOString();
351
+ case "interval":
352
+ if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0)
353
+ throw new Error("interval everyMs must be > 0");
354
+ return new Date(from.getTime() + schedule.everyMs).toISOString();
355
+ case "cron":
356
+ return nextCronRun(schedule.expression, from).toISOString();
357
+ case "dynamic":
358
+ return new Date(from.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
359
+ }
360
+ }
361
+ function computeNextAfter(schedule, scheduledFor, finishedAt) {
362
+ switch (schedule.type) {
363
+ case "once":
364
+ return;
365
+ case "interval": {
366
+ const anchor = schedule.anchor ?? "fixed_rate";
367
+ const base = anchor === "fixed_delay" ? finishedAt : scheduledFor;
368
+ let next = new Date(base.getTime() + schedule.everyMs);
369
+ while (next.getTime() <= finishedAt.getTime())
370
+ next = new Date(next.getTime() + schedule.everyMs);
371
+ return next.toISOString();
372
+ }
373
+ case "cron": {
374
+ let next = nextCronRun(schedule.expression, scheduledFor);
375
+ while (next.getTime() <= finishedAt.getTime())
376
+ next = nextCronRun(schedule.expression, next);
377
+ return next.toISOString();
378
+ }
379
+ case "dynamic":
380
+ return new Date(finishedAt.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
381
+ }
382
+ }
383
+ function latestIntervalSlot(first, now, everyMs) {
384
+ if (first.getTime() > now.getTime())
385
+ return first;
386
+ const steps = Math.floor((now.getTime() - first.getTime()) / everyMs);
387
+ return new Date(first.getTime() + steps * everyMs);
388
+ }
389
+ function floorToMinute(date) {
390
+ const floored = new Date(date.getTime());
391
+ floored.setSeconds(0, 0);
392
+ return floored;
393
+ }
394
+ var LATEST_CRON_SCAN_LIMIT_MINUTES = 366 * 24 * 60;
395
+ function latestDailyCronSlot(cron, now) {
396
+ const hoursDesc = [...cron.hour].sort((a, b) => b - a);
397
+ const minutesDesc = [...cron.minute].sort((a, b) => b - a);
398
+ const candidate = floorToMinute(now);
399
+ for (let dayOffset = 0;dayOffset < 2; dayOffset += 1) {
400
+ const isToday = dayOffset === 0;
401
+ for (const hour of hoursDesc) {
402
+ if (isToday && hour > now.getHours())
403
+ continue;
404
+ const minuteCap = isToday && hour === now.getHours() ? now.getMinutes() : 59;
405
+ const minute = minutesDesc.find((value) => value <= minuteCap);
406
+ if (minute === undefined)
407
+ continue;
408
+ candidate.setHours(hour, minute, 0, 0);
409
+ return candidate;
410
+ }
411
+ candidate.setDate(candidate.getDate() - 1);
412
+ }
413
+ throw new Error("unreachable: daily cron pattern must match within two days");
414
+ }
415
+ function latestCronSlot(first, now, expression) {
416
+ if (first.getTime() > now.getTime())
417
+ return first;
418
+ const cron = parseCron(expression);
419
+ if (!cron.domRestricted && !cron.dowRestricted && cron.month.size === 12) {
420
+ const latest = latestDailyCronSlot(cron, now);
421
+ return latest.getTime() >= first.getTime() ? latest : first;
422
+ }
423
+ const scanFloorMs = Math.max(first.getTime(), now.getTime() - LATEST_CRON_SCAN_LIMIT_MINUTES * MINUTE_MS);
424
+ const cursor = floorToMinute(now);
425
+ while (cursor.getTime() >= scanFloorMs) {
426
+ if (cronMatches(cron, cursor))
427
+ return cursor;
428
+ cursor.setMinutes(cursor.getMinutes() - 1);
429
+ }
430
+ if (first.getTime() < scanFloorMs) {
431
+ warnOnce(`latest-cron:${expression}`, `latestCronSlot: no match for "${expression}" within the ${LATEST_CRON_SCAN_LIMIT_MINUTES}-minute scan window; using the stored next run as the latest slot`);
432
+ }
433
+ return first;
434
+ }
435
+ var MAX_CATCH_UP_SLOTS = 1000;
436
+ function catchUpSlotLimit(loop) {
437
+ if (loop.catchUpLimit > MAX_CATCH_UP_SLOTS) {
438
+ warnOnce(`catch-up-limit:${loop.id}`, `dueSlots: loop ${loop.id} catchUpLimit ${loop.catchUpLimit} exceeds the per-plan cap; using ${MAX_CATCH_UP_SLOTS}`);
439
+ return MAX_CATCH_UP_SLOTS;
440
+ }
441
+ return loop.catchUpLimit;
442
+ }
443
+ function dueSlots(loop, now) {
444
+ if (!loop.nextRunAt || loop.status !== "active")
445
+ return { slots: [] };
446
+ if (loop.expiresAt && new Date(loop.expiresAt).getTime() <= now.getTime())
447
+ return { slots: [] };
448
+ const next = assertDate(loop.nextRunAt, "loop.nextRunAt");
449
+ if (next.getTime() > now.getTime())
450
+ return { slots: [] };
451
+ if (loop.retryScheduledFor)
452
+ return { slots: [loop.retryScheduledFor] };
453
+ const catchUp = loop.catchUp;
454
+ switch (loop.schedule.type) {
455
+ case "once":
456
+ case "dynamic":
457
+ return { slots: [next.toISOString()] };
458
+ case "interval": {
459
+ if (catchUp === "all") {
460
+ const limit = catchUpSlotLimit(loop);
461
+ const slots = [];
462
+ let cursor = next;
463
+ while (cursor.getTime() <= now.getTime() && slots.length < limit) {
464
+ slots.push(cursor.toISOString());
465
+ cursor = new Date(cursor.getTime() + loop.schedule.everyMs);
466
+ }
467
+ return { slots };
468
+ }
469
+ if (catchUp === "latest")
470
+ return { slots: [latestIntervalSlot(next, now, loop.schedule.everyMs).toISOString()] };
471
+ return { slots: [next.toISOString()] };
472
+ }
473
+ case "cron": {
474
+ if (catchUp === "all") {
475
+ const limit = catchUpSlotLimit(loop);
476
+ const slots = [];
477
+ let cursor = next;
478
+ while (cursor.getTime() <= now.getTime() && slots.length < limit) {
479
+ slots.push(cursor.toISOString());
480
+ cursor = nextCronRun(loop.schedule.expression, cursor);
481
+ }
482
+ return { slots };
483
+ }
484
+ if (catchUp === "latest")
485
+ return { slots: [latestCronSlot(next, now, loop.schedule.expression).toISOString()] };
486
+ return { slots: [next.toISOString()] };
487
+ }
488
+ }
489
+ }
490
+ function parseDuration(input) {
491
+ const match = input.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
492
+ if (!match)
493
+ throw new Error(`invalid duration: ${input}`);
494
+ const value = Number(match[1]);
495
+ const unit = match[2] ?? "ms";
496
+ const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
497
+ return Math.round(value * multiplier);
498
+ }
499
+
500
+ // src/lib/goal/types.ts
501
+ var GOAL_TERMINAL = ["budgetLimited", "complete", "cancelled"];
502
+ var GOAL_OBJECTIVE_MAX_CHARS = 4000;
503
+
504
+ // src/lib/goal/status.ts
505
+ function isTerminal(status) {
506
+ return GOAL_TERMINAL.includes(status);
507
+ }
508
+ function nodeBudgetExhausted(node) {
509
+ return node.tokenBudget !== undefined && node.tokensUsed >= node.tokenBudget;
510
+ }
511
+ function readyNodeKeys(plan) {
512
+ if (plan.status !== "active")
513
+ return [];
514
+ const byKey = new Map(plan.nodes.map((node) => [node.key, node]));
515
+ return plan.nodes.filter((node) => {
516
+ if (node.status !== "pending")
517
+ return false;
518
+ if (nodeBudgetExhausted(node))
519
+ return false;
520
+ return node.dependsOn.every((dependency) => byKey.get(dependency)?.status === "complete");
521
+ }).sort((a, b) => b.priority - a.priority || a.sequence - b.sequence || a.key.localeCompare(b.key)).map((node) => node.key);
522
+ }
523
+ function rollupSummary(nodes) {
524
+ const rollup = {
525
+ total: nodes.length,
526
+ pending: 0,
527
+ active: 0,
528
+ paused: 0,
529
+ blocked: 0,
530
+ usageLimited: 0,
531
+ budgetLimited: 0,
532
+ complete: 0,
533
+ cancelled: 0
534
+ };
535
+ for (const node of nodes) {
536
+ if (node.status in rollup) {
537
+ rollup[node.status] += 1;
538
+ }
539
+ }
540
+ return rollup;
541
+ }
542
+ function assertGoalTransition(from, to) {
543
+ if (isTerminal(from) && from !== to) {
544
+ throw new Error(`cannot transition terminal goal status ${from} to ${to}`);
545
+ }
546
+ }
547
+ function assertAcyclicNodes(nodes) {
548
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
549
+ const visiting = new Set;
550
+ const visited = new Set;
551
+ function visit(key) {
552
+ if (visited.has(key))
553
+ return;
554
+ if (visiting.has(key))
555
+ throw new Error(`goal dependency cycle includes node ${key}`);
556
+ const node = byKey.get(key);
557
+ if (!node)
558
+ throw new Error(`goal plan references missing node ${key}`);
559
+ visiting.add(key);
560
+ for (const dependency of node.dependsOn) {
561
+ if (!byKey.has(dependency))
562
+ throw new Error(`goal node ${key} depends on missing node ${dependency}`);
563
+ visit(dependency);
564
+ }
565
+ visiting.delete(key);
566
+ visited.add(key);
567
+ }
568
+ for (const node of nodes)
569
+ visit(node.key);
570
+ }
571
+ function updateReadyFlags(nodes, planStatus) {
572
+ const ready = new Set(readyNodeKeys({ status: planStatus, nodes }));
573
+ return nodes.map((node) => ({ ...node, ready: ready.has(node.key) }));
574
+ }
575
+
576
+ // src/lib/workflow-spec.ts
577
+ import { readFileSync as readFileSync2 } from "fs";
578
+ import { isAbsolute, resolve } from "path";
579
+
580
+ // src/lib/agent-adapter.ts
581
+ import { spawn } from "child_process";
582
+ var UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS = new Set([
583
+ "e",
584
+ "exec",
585
+ "agent",
586
+ "start",
587
+ "--ephemeral",
588
+ "--ignore-rules",
589
+ "--skip-git-repo-check",
590
+ "--json",
591
+ "--output-last-message",
592
+ "-o",
593
+ "--output-schema",
594
+ "--dangerously-bypass-approvals-and-sandbox"
595
+ ]);
596
+ var CODEX_LIKE_SANDBOXES = ["read-only", "workspace-write", "danger-full-access"];
597
+ var CURSOR_SANDBOXES = ["enabled", "disabled"];
598
+ var PERMISSION_MODES = ["default", "plan", "auto", "bypass"];
599
+ function assertOptionalNonEmptyString(value, label) {
600
+ if (value === undefined)
601
+ return;
602
+ if (typeof value !== "string" || value.trim() === "")
603
+ throw new Error(`${label} must be a non-empty string`);
604
+ }
605
+ function validateAgentOptions(target, label, capabilities) {
606
+ const provider = target.provider;
607
+ if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
608
+ throw new Error(`${label}.prompt must be a non-empty string`);
609
+ }
610
+ assertOptionalNonEmptyString(target.model, `${label}.model`);
611
+ assertOptionalNonEmptyString(target.variant, `${label}.variant`);
612
+ assertOptionalNonEmptyString(target.agent, `${label}.agent`);
613
+ assertOptionalNonEmptyString(target.authProfile, `${label}.authProfile`);
614
+ assertOptionalNonEmptyString(target.configIsolation, `${label}.configIsolation`);
615
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
616
+ throw new Error(`${label}.configIsolation must be safe or none`);
617
+ }
618
+ if (target.authProfile !== undefined && provider !== "codewith") {
619
+ throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
620
+ }
621
+ if (provider === "opencode" && (typeof target.model !== "string" || target.model.trim() === "")) {
622
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
623
+ }
624
+ if (provider === "cursor" && target.variant !== undefined) {
625
+ throw new Error(`${label}.variant is not supported for provider cursor`);
626
+ }
627
+ if (provider === "codex" && target.agent !== undefined) {
628
+ throw new Error(`${label}.agent is not supported for provider codex`);
629
+ }
630
+ if (provider === "codewith" && target.agent !== undefined) {
631
+ throw new Error(`${label}.agent is not supported for provider codewith durable background-agent execution`);
632
+ }
633
+ if (provider === "codewith") {
634
+ const unsafe = target.extraArgs?.find((arg) => UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS.has(arg));
635
+ if (unsafe) {
636
+ throw new Error(`${label}.extraArgs cannot include ${unsafe}; codewith agent steps use durable agent start, not exec/ephemeral flags`);
637
+ }
638
+ }
639
+ if (target.addDirs?.length && !["codewith", "codex"].includes(provider)) {
640
+ throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
641
+ }
642
+ if (target.permissionMode !== undefined) {
643
+ if (!PERMISSION_MODES.includes(target.permissionMode)) {
644
+ throw new Error(`${label}.permissionMode must be one of ${PERMISSION_MODES.join(", ")}`);
645
+ }
646
+ if (target.permissionMode === "plan" && !["claude", "cursor"].includes(provider)) {
647
+ throw new Error(`${label}.permissionMode plan is currently supported only for provider claude or cursor`);
648
+ }
649
+ if (target.permissionMode === "auto" && provider !== "claude") {
650
+ throw new Error(`${label}.permissionMode auto is currently supported only for provider claude`);
651
+ }
652
+ }
653
+ if (target.sandbox !== undefined) {
654
+ if (!capabilities.sandbox.length) {
655
+ throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
656
+ }
657
+ if (!capabilities.sandbox.includes(target.sandbox)) {
658
+ throw new Error(`${label}.sandbox must be one of ${capabilities.sandbox.join(", ")}`);
659
+ }
660
+ }
661
+ }
662
+ function codewithLikeSandbox(target) {
663
+ return target.sandbox ?? (target.permissionMode === "bypass" ? "danger-full-access" : "workspace-write");
664
+ }
665
+ function configStringValue(value) {
666
+ return JSON.stringify(value);
667
+ }
668
+ function buildAgentInvocation(target) {
669
+ const isolation = target.configIsolation ?? "safe";
670
+ const permissionMode = target.permissionMode ?? "default";
671
+ const args = [];
672
+ switch (target.provider) {
673
+ case "claude": {
674
+ if (isolation === "safe")
675
+ args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
676
+ if (permissionMode !== "default") {
677
+ const mode = permissionMode === "bypass" ? "bypassPermissions" : permissionMode;
678
+ args.push("--permission-mode", mode);
679
+ }
680
+ args.push("-p", "--output-format", "json");
681
+ if (target.model)
682
+ args.push("--model", target.model);
683
+ if (target.variant)
684
+ args.push("--effort", target.variant);
685
+ if (target.agent)
686
+ args.push("--agent", target.agent);
687
+ args.push(...target.extraArgs ?? []);
688
+ return { command: "claude", args, stdin: target.prompt };
689
+ }
690
+ case "cursor": {
691
+ args.push("-c", [
692
+ "set -eu",
693
+ "if command -v agent >/dev/null 2>&1; then",
694
+ ' exec agent "$@"',
695
+ "else",
696
+ " echo 'Executable not found in PATH: agent' >&2",
697
+ " exit 127",
698
+ "fi"
699
+ ].join(`
700
+ `), "openloops-cursor", "-p");
701
+ if (permissionMode === "plan")
702
+ args.push("--mode", "plan");
703
+ if (permissionMode === "bypass")
704
+ args.push("--force");
705
+ const cursorSandbox = target.sandbox ?? (isolation === "safe" ? "enabled" : undefined);
706
+ if (cursorSandbox)
707
+ args.push("--sandbox", cursorSandbox);
708
+ if (target.model)
709
+ args.push("--model", target.model);
710
+ if (target.agent)
711
+ args.push("--agent", target.agent);
712
+ args.push(...target.extraArgs ?? []);
713
+ return { command: "sh", args, stdin: target.prompt, preflightAnyOf: ["agent"] };
714
+ }
715
+ case "codewith": {
716
+ args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : []);
717
+ if (target.variant)
718
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
719
+ args.push("--ask-for-approval", "never", "--sandbox", codewithLikeSandbox(target));
720
+ if (target.cwd)
721
+ args.push("--cd", target.cwd);
722
+ for (const dir of target.addDirs ?? [])
723
+ args.push("--add-dir", dir);
724
+ if (target.model)
725
+ args.push("--model", target.model);
726
+ args.push(...target.extraArgs ?? []);
727
+ args.push("agent", "start");
728
+ if (target.cwd)
729
+ args.push("--cwd", target.cwd);
730
+ args.push(target.prompt);
731
+ return { command: "codewith", args };
732
+ }
733
+ case "codex": {
734
+ if (target.variant)
735
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
736
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
737
+ if (isolation === "safe")
738
+ args.push("--ignore-rules");
739
+ if (target.cwd)
740
+ args.push("--cd", target.cwd);
741
+ for (const dir of target.addDirs ?? [])
742
+ args.push("--add-dir", dir);
743
+ if (target.model)
744
+ args.push("--model", target.model);
745
+ args.push(...target.extraArgs ?? []);
746
+ return { command: "codex", args, stdin: target.prompt };
747
+ }
748
+ case "aicopilot":
749
+ case "opencode": {
750
+ args.push("run", "--format", "json");
751
+ if (isolation === "safe")
752
+ args.push("--pure");
753
+ if (permissionMode === "bypass")
754
+ args.push("--dangerously-skip-permissions");
755
+ if (target.cwd)
756
+ args.push("--dir", target.cwd);
757
+ if (target.model)
758
+ args.push("--model", target.model);
759
+ if (target.variant)
760
+ args.push("--variant", target.variant);
761
+ if (target.agent)
762
+ args.push("--agent", target.agent);
763
+ args.push(...target.extraArgs ?? []);
764
+ return { command: target.provider, args, stdin: target.prompt };
765
+ }
766
+ }
767
+ }
768
+ function adapterFor(provider, capabilities) {
769
+ return {
770
+ provider,
771
+ capabilities,
772
+ validate(target, label = provider) {
773
+ validateAgentOptions(target, label, capabilities);
774
+ },
775
+ buildInvocation(target) {
776
+ validateAgentOptions(target, provider, capabilities);
777
+ return buildAgentInvocation(target);
778
+ }
779
+ };
780
+ }
781
+ var PROVIDER_ADAPTERS = {
782
+ claude: adapterFor("claude", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
783
+ cursor: adapterFor("cursor", { sandbox: CURSOR_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
784
+ codewith: adapterFor("codewith", { sandbox: CODEX_LIKE_SANDBOXES, durable: true, remote: false, promptChannel: "argv" }),
785
+ codex: adapterFor("codex", { sandbox: CODEX_LIKE_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
786
+ aicopilot: adapterFor("aicopilot", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
787
+ opencode: adapterFor("opencode", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" })
788
+ };
789
+ var AGENT_PROVIDERS = Object.keys(PROVIDER_ADAPTERS);
790
+ function providerAdapter(provider) {
791
+ const adapter = PROVIDER_ADAPTERS[provider];
792
+ if (!adapter)
793
+ throw new Error(`unsupported agent provider: ${String(provider)}`);
794
+ return adapter;
795
+ }
796
+ var DEFAULT_CAPTURE_MAX_OUTPUT_BYTES = 256 * 1024;
797
+ function killProcessGroup(pgid) {
798
+ try {
799
+ process.kill(-pgid, "SIGTERM");
800
+ } catch {
801
+ try {
802
+ process.kill(pgid, "SIGTERM");
803
+ } catch {}
804
+ }
805
+ setTimeout(() => {
806
+ try {
807
+ process.kill(-pgid, "SIGKILL");
808
+ } catch {
809
+ try {
810
+ process.kill(pgid, "SIGKILL");
811
+ } catch {}
812
+ }
813
+ }, 2000).unref();
814
+ }
815
+
816
+ class BoundedOutputBuffer {
817
+ maxBytes;
818
+ text = "";
819
+ truncatedBytes = 0;
820
+ constructor(maxBytes) {
821
+ this.maxBytes = maxBytes;
822
+ }
823
+ append(chunk) {
824
+ if (!chunk)
825
+ return;
826
+ this.text += chunk;
827
+ if (Buffer.byteLength(this.text, "utf8") <= this.maxBytes)
828
+ return;
829
+ this.text = scrubSecrets(this.text);
830
+ const encoded = Buffer.from(this.text, "utf8");
831
+ if (encoded.length <= this.maxBytes)
832
+ return;
833
+ let start = encoded.length - this.maxBytes;
834
+ while (start < encoded.length && (encoded[start] & 192) === 128)
835
+ start++;
836
+ this.truncatedBytes += start;
837
+ this.text = encoded.subarray(start).toString("utf8");
838
+ }
839
+ value() {
840
+ if (this.truncatedBytes === 0)
841
+ return this.text;
842
+ return `[truncated ${this.truncatedBytes} bytes]
843
+ ${this.text}`;
844
+ }
845
+ }
846
+ async function spawnCapture(command, args, opts) {
847
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_CAPTURE_MAX_OUTPUT_BYTES;
848
+ const stdout = new BoundedOutputBuffer(maxOutputBytes);
849
+ const stderr = new BoundedOutputBuffer(maxOutputBytes);
850
+ let timedOut = false;
851
+ let error;
852
+ const child = spawn(command, args, {
853
+ cwd: opts.cwd,
854
+ env: opts.env,
855
+ detached: true,
856
+ stdio: ["ignore", "pipe", "pipe"]
857
+ });
858
+ const timer = setTimeout(() => {
859
+ timedOut = true;
860
+ if (child.pid)
861
+ killProcessGroup(child.pid);
862
+ }, opts.timeoutMs);
863
+ timer.unref();
864
+ child.stdout?.setEncoding("utf8");
865
+ child.stderr?.setEncoding("utf8");
866
+ child.stdout?.on("data", (chunk) => stdout.append(chunk));
867
+ child.stderr?.on("data", (chunk) => stderr.append(chunk));
868
+ const [status, signal] = await new Promise((resolve) => {
869
+ child.once("error", (err) => {
870
+ error = err.message;
871
+ resolve([null, null]);
872
+ });
873
+ child.once("close", (code, sig) => resolve([code, sig]));
874
+ });
875
+ clearTimeout(timer);
876
+ if (timedOut && !error)
877
+ error = `timed out after ${opts.timeoutMs}ms`;
878
+ return { status, signal, stdout: stdout.value(), stderr: stderr.value(), error, timedOut };
879
+ }
880
+
881
+ // src/lib/workflow-spec.ts
882
+ function assertObject(value, label) {
883
+ if (!value || typeof value !== "object" || Array.isArray(value))
884
+ throw new Error(`${label} must be an object`);
885
+ }
886
+ function assertString(value, label) {
887
+ if (typeof value !== "string" || value.trim() === "")
888
+ throw new Error(`${label} must be a non-empty string`);
889
+ }
890
+ function optionalPositiveInteger(value, label) {
891
+ if (value === undefined)
892
+ return;
893
+ if (!Number.isInteger(value) || value <= 0)
894
+ throw new Error(`${label} must be a positive integer`);
895
+ return value;
896
+ }
897
+ function optionalTimeoutMs(value, label) {
898
+ if (value === undefined)
899
+ return;
900
+ if (value === null)
901
+ return null;
902
+ if (!Number.isInteger(value) || value <= 0)
903
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
904
+ return value;
905
+ }
906
+ function optionalBoolean(value, label) {
907
+ if (value === undefined)
908
+ return;
909
+ if (typeof value !== "boolean")
910
+ throw new Error(`${label} must be a boolean`);
911
+ return value;
912
+ }
913
+ function optionalStringArray(value, label) {
914
+ if (value === undefined)
915
+ return;
916
+ if (!Array.isArray(value))
917
+ throw new Error(`${label} must be an array`);
918
+ const values = value.map((entry, index) => {
919
+ assertString(entry, `${label}[${index}]`);
920
+ return entry.trim();
921
+ }).filter(Boolean);
922
+ return values.length ? values : undefined;
923
+ }
924
+ function optionalAccountRef(value, label) {
925
+ if (value === undefined)
926
+ return;
927
+ assertObject(value, label);
928
+ assertString(value.profile, `${label}.profile`);
929
+ if (value.tool !== undefined)
930
+ assertString(value.tool, `${label}.tool`);
931
+ return {
932
+ profile: value.profile.trim(),
933
+ tool: typeof value.tool === "string" ? value.tool.trim() : undefined
934
+ };
935
+ }
936
+ function promptFilePath(rawPath, opts) {
937
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
938
+ }
939
+ function readPromptFile(rawPath, label, opts) {
940
+ assertString(rawPath, `${label}.promptFile`);
941
+ const path = promptFilePath(rawPath.trim(), opts);
942
+ let prompt;
943
+ try {
944
+ prompt = readFileSync2(path, "utf8");
945
+ } catch (error) {
946
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
947
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
948
+ }
949
+ if (prompt.trim() === "")
950
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
951
+ return { prompt, promptSource: { type: "file", path } };
952
+ }
953
+ function matchingPromptSource(value, prompt, opts) {
954
+ if (!value || typeof value !== "object" || Array.isArray(value))
955
+ return;
956
+ if (value.type !== "file")
957
+ return;
958
+ const rawPath = value.path;
959
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
960
+ return;
961
+ const path = promptFilePath(rawPath.trim(), opts);
962
+ try {
963
+ return readFileSync2(path, "utf8") === prompt ? { type: "file", path } : undefined;
964
+ } catch {
965
+ return;
966
+ }
967
+ }
968
+ function normalizeGoalSpec(value, label = "goal") {
969
+ if (value === undefined)
970
+ return;
971
+ assertObject(value, label);
972
+ assertString(value.objective, `${label}.objective`);
973
+ const objective = value.objective.trim();
974
+ if (objective.length > GOAL_OBJECTIVE_MAX_CHARS) {
975
+ throw new Error(`${label}.objective must be ${GOAL_OBJECTIVE_MAX_CHARS} characters or fewer`);
976
+ }
977
+ const autoExecute = value.autoExecute === undefined ? undefined : String(value.autoExecute);
978
+ if (autoExecute !== undefined && !["off", "readyOnly", "aiDirected"].includes(autoExecute)) {
979
+ throw new Error(`${label}.autoExecute must be off, readyOnly, or aiDirected`);
980
+ }
981
+ return {
982
+ objective,
983
+ tokenBudget: optionalPositiveInteger(value.tokenBudget, `${label}.tokenBudget`),
984
+ maxTurns: optionalPositiveInteger(value.maxTurns, `${label}.maxTurns`),
985
+ maxTokens: optionalPositiveInteger(value.maxTokens, `${label}.maxTokens`),
986
+ model: typeof value.model === "string" && value.model.trim() ? value.model.trim() : undefined,
987
+ autoExecute
988
+ };
989
+ }
990
+ function validateTarget(value, label, opts) {
991
+ assertObject(value, label);
992
+ if (value.type === "command") {
993
+ assertString(value.command, `${label}.command`);
994
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
995
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
996
+ if (value.shell !== true && /\s/.test(value.command.trim())) {
997
+ throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
998
+ }
999
+ return value;
1000
+ }
1001
+ if (value.type === "agent") {
1002
+ assertString(value.provider, `${label}.provider`);
1003
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
1004
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
1005
+ if (hasPrompt && hasPromptFile)
1006
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
1007
+ if (!hasPrompt && !hasPromptFile)
1008
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
1009
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
1010
+ if (!AGENT_PROVIDERS.includes(value.provider)) {
1011
+ throw new Error(`${label}.provider must be one of ${AGENT_PROVIDERS.join(", ")}`);
1012
+ }
1013
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
1014
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
1015
+ const extraArgs = optionalStringArray(value.extraArgs, `${label}.extraArgs`);
1016
+ optionalStringArray(value.addDirs, `${label}.addDirs`);
1017
+ providerAdapter(value.provider).validate({ ...value, extraArgs, ...promptFields }, label);
1018
+ if (value.allowlist !== undefined) {
1019
+ assertObject(value.allowlist, `${label}.allowlist`);
1020
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
1021
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
1022
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
1023
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
1024
+ }
1025
+ }
1026
+ if (value.worktree !== undefined) {
1027
+ assertObject(value.worktree, `${label}.worktree`);
1028
+ assertString(value.worktree.mode, `${label}.worktree.mode`);
1029
+ const modes = ["auto", "required", "off", "main"];
1030
+ if (!modes.includes(value.worktree.mode))
1031
+ throw new Error(`${label}.worktree.mode must be one of ${modes.join(", ")}`);
1032
+ if (typeof value.worktree.enabled !== "boolean")
1033
+ throw new Error(`${label}.worktree.enabled must be a boolean`);
1034
+ assertString(value.worktree.originalCwd, `${label}.worktree.originalCwd`);
1035
+ assertString(value.worktree.cwd, `${label}.worktree.cwd`);
1036
+ if (value.worktree.repoRoot !== undefined)
1037
+ assertString(value.worktree.repoRoot, `${label}.worktree.repoRoot`);
1038
+ if (value.worktree.root !== undefined)
1039
+ assertString(value.worktree.root, `${label}.worktree.root`);
1040
+ if (value.worktree.path !== undefined)
1041
+ assertString(value.worktree.path, `${label}.worktree.path`);
1042
+ if (value.worktree.branch !== undefined)
1043
+ assertString(value.worktree.branch, `${label}.worktree.branch`);
1044
+ if (value.worktree.reason !== undefined)
1045
+ assertString(value.worktree.reason, `${label}.worktree.reason`);
1046
+ }
1047
+ if (value.routing !== undefined) {
1048
+ assertObject(value.routing, `${label}.routing`);
1049
+ if (value.routing.projectPath !== undefined)
1050
+ assertString(value.routing.projectPath, `${label}.routing.projectPath`);
1051
+ if (value.routing.projectGroup !== undefined)
1052
+ assertString(value.routing.projectGroup, `${label}.routing.projectGroup`);
1053
+ if (value.routing.taskId !== undefined)
1054
+ assertString(value.routing.taskId, `${label}.routing.taskId`);
1055
+ if (value.routing.eventId !== undefined)
1056
+ assertString(value.routing.eventId, `${label}.routing.eventId`);
1057
+ if (value.routing.eventType !== undefined)
1058
+ assertString(value.routing.eventType, `${label}.routing.eventType`);
1059
+ if (value.routing.eventSource !== undefined)
1060
+ assertString(value.routing.eventSource, `${label}.routing.eventSource`);
1061
+ }
1062
+ const target = { ...value, extraArgs };
1063
+ if (!extraArgs)
1064
+ delete target.extraArgs;
1065
+ delete target.promptFile;
1066
+ delete target.promptSource;
1067
+ return { ...target, ...promptFields };
1068
+ }
1069
+ throw new Error(`${label}.type must be command or agent`);
1070
+ }
1071
+ function normalizeCreateWorkflowInput(input, opts = {}) {
1072
+ assertString(input.name, "workflow.name");
1073
+ const goal = normalizeGoalSpec(input.goal, "goal");
1074
+ if (!Array.isArray(input.steps) || input.steps.length === 0)
1075
+ throw new Error("workflow.steps must contain at least one step");
1076
+ const seen = new Set;
1077
+ const steps = input.steps.map((step, index) => {
1078
+ assertObject(step, `workflow.steps[${index}]`);
1079
+ assertString(step.id, `workflow.steps[${index}].id`);
1080
+ if (seen.has(step.id))
1081
+ throw new Error(`duplicate workflow step id: ${step.id}`);
1082
+ seen.add(step.id);
1083
+ return {
1084
+ ...step,
1085
+ id: step.id,
1086
+ goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
1087
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
1088
+ dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
1089
+ continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
1090
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
1091
+ account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
1092
+ };
1093
+ });
1094
+ for (const step of steps) {
1095
+ for (const dependency of step.dependsOn ?? []) {
1096
+ if (!seen.has(dependency))
1097
+ throw new Error(`step ${step.id} depends on missing step ${dependency}`);
1098
+ if (dependency === step.id)
1099
+ throw new Error(`step ${step.id} cannot depend on itself`);
1100
+ }
1101
+ }
1102
+ workflowExecutionOrder({ steps });
1103
+ return { ...input, name: input.name.trim(), goal, version: input.version ?? 1, steps };
1104
+ }
1105
+ function workflowExecutionOrder(workflow) {
1106
+ const byId = new Map(workflow.steps.map((step) => [step.id, step]));
1107
+ const visiting = new Set;
1108
+ const visited = new Set;
1109
+ const order = [];
1110
+ function visit(step) {
1111
+ if (visited.has(step.id))
1112
+ return;
1113
+ if (visiting.has(step.id))
1114
+ throw new Error(`workflow dependency cycle includes step ${step.id}`);
1115
+ visiting.add(step.id);
1116
+ for (const dependencyId of step.dependsOn ?? []) {
1117
+ const dependency = byId.get(dependencyId);
1118
+ if (!dependency)
1119
+ throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
1120
+ visit(dependency);
1121
+ }
1122
+ visiting.delete(step.id);
1123
+ visited.add(step.id);
1124
+ order.push(step);
1125
+ }
1126
+ for (const step of workflow.steps)
1127
+ visit(step);
1128
+ return order;
1129
+ }
1130
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
1131
+ assertObject(value, "workflow file");
1132
+ const rawName = fallbackName ?? value.name;
1133
+ assertString(rawName, "workflow.name");
1134
+ if (!Array.isArray(value.steps))
1135
+ throw new Error("workflow.steps must be an array");
1136
+ return normalizeCreateWorkflowInput({
1137
+ name: rawName,
1138
+ description: typeof value.description === "string" ? value.description : undefined,
1139
+ goal: normalizeGoalSpec(value.goal, "goal"),
1140
+ version: typeof value.version === "number" ? value.version : undefined,
1141
+ steps: value.steps
1142
+ }, opts);
1143
+ }
1144
+
1145
+ // src/lib/run-artifacts.ts
1146
+ import { createHash } from "crypto";
1147
+ import { mkdirSync as mkdirSync2, renameSync, rmdirSync, rmSync, writeFileSync } from "fs";
1148
+ import { basename, dirname, join as join2 } from "path";
1149
+ function shortHash(value) {
1150
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
1151
+ }
1152
+ function safeRunPathSlug(value, fallback) {
1153
+ const raw = value?.trim() || fallback;
1154
+ const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
1155
+ return slug || fallback;
1156
+ }
1157
+ function workflowRunSubjectKey(kind, rawSubjectRef) {
1158
+ const raw = rawSubjectRef?.trim() || "subject";
1159
+ const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
1160
+ const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
1161
+ return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
1162
+ ${raw}`)}`;
1163
+ }
1164
+ function workflowRunProjectSlug(projectKey) {
1165
+ if (!projectKey?.trim())
1166
+ return "global";
1167
+ return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
1168
+ }
1169
+ function stageWorkflowRunManifest(args) {
1170
+ const projectSlug = workflowRunProjectSlug(args.projectKey);
1171
+ const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
1172
+ const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
1173
+ mkdirSync2(dir, { recursive: true, mode: 448 });
1174
+ const manifestPath = join2(dir, "manifest.json");
1175
+ const tmpPath = `${manifestPath}.tmp`;
1176
+ writeFileSync(tmpPath, JSON.stringify({
1177
+ version: 1,
1178
+ workflowRunId: args.workflowRunId,
1179
+ workflowId: args.workflowId,
1180
+ workflowName: args.workflowName,
1181
+ invocationId: args.invocationId,
1182
+ workItemId: args.workItemId,
1183
+ projectSlug,
1184
+ subjectKey,
1185
+ requiredReading: [],
1186
+ createdAt: new Date().toISOString(),
1187
+ ...args.payload
1188
+ }, null, 2), { mode: 384 });
1189
+ return { manifestPath, tmpPath };
1190
+ }
1191
+ function commitWorkflowRunManifest(staged) {
1192
+ renameSync(staged.tmpPath, staged.manifestPath);
1193
+ return staged.manifestPath;
1194
+ }
1195
+ function discardWorkflowRunManifest(staged) {
1196
+ rmSync(staged.tmpPath, { force: true });
1197
+ try {
1198
+ rmdirSync(dirname(staged.manifestPath));
1199
+ } catch {}
1200
+ }
1201
+
1202
+ // src/lib/store.ts
1203
+ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1204
+ var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1205
+ var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1206
+ var SCHEMA_USER_VERSION = 7;
1207
+ var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1208
+ var PRUNE_BATCH_SIZE = 400;
1209
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
1210
+ var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
1211
+ function rowToLoop(row) {
1212
+ return {
1213
+ id: row.id,
1214
+ name: row.name,
1215
+ description: row.description ?? undefined,
1216
+ status: row.status,
1217
+ archivedAt: row.archived_at ?? undefined,
1218
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
1219
+ schedule: JSON.parse(row.schedule_json),
1220
+ target: JSON.parse(row.target_json),
1221
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
1222
+ machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
1223
+ nextRunAt: row.next_run_at ?? undefined,
1224
+ retryScheduledFor: row.retry_scheduled_for ?? undefined,
1225
+ catchUp: row.catch_up,
1226
+ catchUpLimit: row.catch_up_limit,
1227
+ overlap: row.overlap,
1228
+ maxAttempts: row.max_attempts,
1229
+ retryDelayMs: row.retry_delay_ms,
1230
+ leaseMs: row.lease_ms,
1231
+ expiresAt: row.expires_at ?? undefined,
1232
+ createdAt: row.created_at,
1233
+ updatedAt: row.updated_at
1234
+ };
1235
+ }
1236
+ function rowToRun(row) {
1237
+ return {
1238
+ id: row.id,
1239
+ loopId: row.loop_id,
1240
+ loopName: row.loop_name,
1241
+ scheduledFor: row.scheduled_for,
1242
+ attempt: row.attempt,
1243
+ status: row.status,
1244
+ startedAt: row.started_at ?? undefined,
1245
+ finishedAt: row.finished_at ?? undefined,
1246
+ claimedBy: row.claimed_by ?? undefined,
1247
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
1248
+ pid: row.pid ?? undefined,
1249
+ pgid: row.pgid ?? undefined,
1250
+ processStartedAt: row.process_started_at ?? undefined,
1251
+ exitCode: row.exit_code ?? undefined,
1252
+ durationMs: row.duration_ms ?? undefined,
1253
+ stdout: row.stdout ?? undefined,
1254
+ stderr: row.stderr ?? undefined,
1255
+ error: row.error ?? undefined,
1256
+ goalRunId: row.goal_run_id ?? undefined,
1257
+ createdAt: row.created_at,
1258
+ updatedAt: row.updated_at
1259
+ };
1260
+ }
1261
+ function rowToWorkflow(row) {
1262
+ return {
1263
+ id: row.id,
1264
+ name: row.name,
1265
+ description: row.description ?? undefined,
1266
+ version: row.version,
1267
+ status: row.status,
1268
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
1269
+ steps: JSON.parse(row.steps_json),
1270
+ createdAt: row.created_at,
1271
+ updatedAt: row.updated_at
1272
+ };
1273
+ }
1274
+ function rowToWorkflowRun(row) {
1275
+ return {
1276
+ id: row.id,
1277
+ workflowId: row.workflow_id,
1278
+ workflowName: row.workflow_name,
1279
+ loopId: row.loop_id ?? undefined,
1280
+ loopRunId: row.loop_run_id ?? undefined,
1281
+ invocationId: row.invocation_id ?? undefined,
1282
+ workItemId: row.work_item_id ?? undefined,
1283
+ scheduledFor: row.scheduled_for ?? undefined,
1284
+ idempotencyKey: row.idempotency_key ?? undefined,
1285
+ manifestPath: row.manifest_path ?? undefined,
1286
+ status: row.status,
1287
+ startedAt: row.started_at ?? undefined,
1288
+ finishedAt: row.finished_at ?? undefined,
1289
+ durationMs: row.duration_ms ?? undefined,
1290
+ error: row.error ?? undefined,
1291
+ goalRunId: row.goal_run_id ?? undefined,
1292
+ createdAt: row.created_at,
1293
+ updatedAt: row.updated_at
1294
+ };
1295
+ }
1296
+ function rowToWorkflowInvocation(row) {
1297
+ return {
1298
+ id: row.id,
1299
+ workflowId: row.workflow_id ?? undefined,
1300
+ templateId: row.template_id ?? undefined,
1301
+ sourceRef: JSON.parse(row.source_json),
1302
+ subjectRef: JSON.parse(row.subject_json),
1303
+ intent: row.intent,
1304
+ scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
1305
+ outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
1306
+ createdAt: row.created_at,
1307
+ updatedAt: row.updated_at
1308
+ };
1309
+ }
1310
+ function rowToWorkflowWorkItem(row) {
1311
+ return {
1312
+ id: row.id,
1313
+ routeKey: row.route_key,
1314
+ idempotencyKey: row.idempotency_key,
1315
+ invocationId: row.invocation_id,
1316
+ sourceType: row.source_type,
1317
+ sourceRef: row.source_ref,
1318
+ subjectRef: row.subject_ref,
1319
+ projectKey: row.project_key ?? undefined,
1320
+ projectGroup: row.project_group ?? undefined,
1321
+ priority: row.priority,
1322
+ status: row.status,
1323
+ attempts: row.attempts,
1324
+ nextAttemptAt: row.next_attempt_at ?? undefined,
1325
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
1326
+ workflowId: row.workflow_id ?? undefined,
1327
+ loopId: row.loop_id ?? undefined,
1328
+ workflowRunId: row.workflow_run_id ?? undefined,
1329
+ lastReason: row.last_reason ?? undefined,
1330
+ createdAt: row.created_at,
1331
+ updatedAt: row.updated_at
1332
+ };
1333
+ }
1334
+ function rowToWorkflowStepRun(row) {
1335
+ return {
1336
+ id: row.id,
1337
+ workflowRunId: row.workflow_run_id,
1338
+ stepId: row.step_id,
1339
+ sequence: row.sequence,
1340
+ status: row.status,
1341
+ startedAt: row.started_at ?? undefined,
1342
+ finishedAt: row.finished_at ?? undefined,
1343
+ exitCode: row.exit_code ?? undefined,
1344
+ pid: row.pid ?? undefined,
1345
+ durationMs: row.duration_ms ?? undefined,
1346
+ stdout: row.stdout ?? undefined,
1347
+ stderr: row.stderr ?? undefined,
1348
+ error: row.error ?? undefined,
1349
+ accountProfile: row.account_profile ?? undefined,
1350
+ accountTool: row.account_tool ?? undefined,
1351
+ goalRunId: row.goal_run_id ?? undefined,
1352
+ createdAt: row.created_at,
1353
+ updatedAt: row.updated_at
1354
+ };
1355
+ }
1356
+ function rowToGoal(row) {
1357
+ return {
1358
+ goalId: row.id,
1359
+ planId: row.plan_id,
1360
+ objective: row.objective,
1361
+ status: row.status,
1362
+ tokenBudget: row.token_budget ?? undefined,
1363
+ tokensUsed: row.tokens_used,
1364
+ timeUsedSeconds: row.time_used_seconds,
1365
+ autoExecute: row.auto_execute,
1366
+ maxTokens: row.max_tokens ?? undefined,
1367
+ sourceType: row.source_type ?? undefined,
1368
+ sourceId: row.source_id ?? undefined,
1369
+ loopId: row.loop_id ?? undefined,
1370
+ loopRunId: row.loop_run_id ?? undefined,
1371
+ workflowId: row.workflow_id ?? undefined,
1372
+ workflowRunId: row.workflow_run_id ?? undefined,
1373
+ workflowStepId: row.workflow_step_id ?? undefined,
1374
+ createdAt: row.created_at,
1375
+ updatedAt: row.updated_at
1376
+ };
1377
+ }
1378
+ function rowToGoalPlanNode(row) {
1379
+ return {
1380
+ nodeId: row.id,
1381
+ planId: row.plan_id,
1382
+ key: row.key,
1383
+ sequence: row.sequence,
1384
+ priority: row.priority,
1385
+ objective: row.objective,
1386
+ status: row.status,
1387
+ ready: row.ready === 1,
1388
+ tokenBudget: row.token_budget ?? undefined,
1389
+ tokensUsed: row.tokens_used,
1390
+ timeUsedSeconds: row.time_used_seconds,
1391
+ dependsOn: JSON.parse(row.depends_on_json),
1392
+ createdAt: row.created_at,
1393
+ updatedAt: row.updated_at
1394
+ };
1395
+ }
1396
+ function rowToGoalRun(row) {
1397
+ return {
1398
+ runId: row.id,
1399
+ goalId: row.goal_id,
1400
+ planId: row.plan_id,
1401
+ loopId: row.loop_id ?? undefined,
1402
+ loopRunId: row.loop_run_id ?? undefined,
1403
+ workflowId: row.workflow_id ?? undefined,
1404
+ workflowRunId: row.workflow_run_id ?? undefined,
1405
+ workflowStepId: row.workflow_step_id ?? undefined,
1406
+ turn: row.turn,
1407
+ phase: row.phase,
1408
+ status: row.status,
1409
+ nodeKey: row.node_key ?? undefined,
1410
+ tokensUsed: row.tokens_used,
1411
+ evidence: row.evidence_json ? JSON.parse(row.evidence_json) : undefined,
1412
+ rawResponse: row.raw_response_json ? JSON.parse(row.raw_response_json) : undefined,
1413
+ createdAt: row.created_at,
1414
+ updatedAt: row.updated_at
1415
+ };
1416
+ }
1417
+ function rowToWorkflowEvent(row) {
1418
+ return {
1419
+ id: row.id,
1420
+ workflowRunId: row.workflow_run_id,
1421
+ sequence: row.sequence,
1422
+ eventType: row.event_type,
1423
+ stepId: row.step_id ?? undefined,
1424
+ payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
1425
+ createdAt: row.created_at
1426
+ };
1427
+ }
1428
+ function isProcessAlive(pid) {
1429
+ try {
1430
+ process.kill(pid, 0);
1431
+ return true;
1432
+ } catch {
1433
+ return false;
1434
+ }
1435
+ }
1436
+ function isRecordedProcessAlive(pid, processStartedAt) {
1437
+ if (!pid || !isProcessAlive(pid))
1438
+ return false;
1439
+ return sameProcessStart(processStartedAt ?? undefined, processStartTimeMs(pid));
1440
+ }
1441
+ function isoProcessStart(pid) {
1442
+ const startedMs = processStartTimeMs(pid);
1443
+ return startedMs === undefined ? undefined : new Date(startedMs).toISOString();
1444
+ }
1445
+ function isLiveStepProcess(pid, stepStartedAt) {
1446
+ if (!isProcessAlive(pid))
1447
+ return false;
1448
+ const actualMs = processStartTimeMs(pid);
1449
+ const stepStartMs = stepStartedAt ? Date.parse(stepStartedAt) : Number.NaN;
1450
+ if (actualMs === undefined || !Number.isFinite(stepStartMs))
1451
+ return true;
1452
+ return actualMs >= stepStartMs - START_TIME_TOLERANCE_MS;
1453
+ }
1454
+ function rowToLease(row) {
1455
+ return {
1456
+ id: row.id,
1457
+ pid: row.pid,
1458
+ hostname: row.hostname,
1459
+ heartbeatAt: row.heartbeat_at,
1460
+ expiresAt: row.expires_at,
1461
+ createdAt: row.created_at,
1462
+ updatedAt: row.updated_at
1463
+ };
1464
+ }
1465
+ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1466
+ if (status === "succeeded")
1467
+ return "succeeded";
1468
+ if (["failed", "timed_out", "abandoned"].includes(status)) {
1469
+ return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
1470
+ }
1471
+ return;
1472
+ }
1473
+ function scrubbedOrNull(value) {
1474
+ return value == null ? null : scrubSecrets(value);
1475
+ }
1476
+ function chmodIfExists(path, mode) {
1477
+ try {
1478
+ if (existsSync(path))
1479
+ chmodSync(path, mode);
1480
+ } catch {}
1481
+ }
1482
+ function ensurePrivateStorePath(file) {
1483
+ const dir = dirname2(file);
1484
+ mkdirSync3(dir, { recursive: true, mode: 448 });
1485
+ chmodIfExists(dir, 448);
1486
+ chmodIfExists(file, 384);
1487
+ chmodIfExists(`${file}-wal`, 384);
1488
+ chmodIfExists(`${file}-shm`, 384);
1489
+ }
1490
+
1491
+ class Store {
1492
+ db;
1493
+ rootDir;
1494
+ constructor(path) {
1495
+ const file = path ?? dbPath();
1496
+ if (file !== ":memory:")
1497
+ ensurePrivateStorePath(file);
1498
+ this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1499
+ this.db = new Database(file);
1500
+ this.db.exec("PRAGMA foreign_keys = ON;");
1501
+ this.db.exec("PRAGMA busy_timeout = 5000;");
1502
+ this.db.exec("PRAGMA journal_mode = WAL;");
1503
+ if (file !== ":memory:")
1504
+ ensurePrivateStorePath(file);
1505
+ try {
1506
+ this.migrate();
1507
+ } catch (error) {
1508
+ this.db.close();
1509
+ throw error;
1510
+ }
1511
+ }
1512
+ migrate() {
1513
+ this.db.exec(`
1514
+ CREATE TABLE IF NOT EXISTS schema_migrations (
1515
+ id TEXT PRIMARY KEY,
1516
+ applied_at TEXT NOT NULL
1517
+ );
1518
+ `);
1519
+ const versionRow = this.db.query("PRAGMA user_version").get();
1520
+ const userVersion = versionRow?.user_version ?? 0;
1521
+ if (userVersion > SCHEMA_USER_VERSION) {
1522
+ throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1523
+ }
1524
+ const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1525
+ for (const migration of this.migrations()) {
1526
+ if (!migration.baseline && applied.has(migration.id))
1527
+ continue;
1528
+ migration.apply();
1529
+ if (!applied.has(migration.id)) {
1530
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(migration.id, nowIso());
1531
+ }
1532
+ }
1533
+ if (userVersion !== SCHEMA_USER_VERSION)
1534
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_USER_VERSION}`);
1535
+ }
1536
+ migrations() {
1537
+ return [
1538
+ { id: "0001_initial_and_workflows", baseline: true, apply: () => this.createBaseSchema() },
1539
+ { id: "0002_loop_machines", baseline: true, apply: () => this.addColumnIfMissing("loops", "machine_json", "TEXT") },
1540
+ {
1541
+ id: "0003_goals",
1542
+ baseline: true,
1543
+ apply: () => {
1544
+ this.addColumnIfMissing("loops", "goal_json", "TEXT");
1545
+ this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
1546
+ this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
1547
+ this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1548
+ this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
1549
+ }
1550
+ },
1551
+ {
1552
+ id: "0004_loop_archive_metadata",
1553
+ baseline: true,
1554
+ apply: () => {
1555
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
1556
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
1557
+ }
1558
+ },
1559
+ {
1560
+ id: "0005_workflow_invocations_and_admission",
1561
+ baseline: true,
1562
+ apply: () => {
1563
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1564
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1565
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
1566
+ this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
1567
+ this.createWorkflowRunBackfillIndexes();
1568
+ }
1569
+ },
1570
+ {
1571
+ id: "0006_run_process_tracking",
1572
+ apply: () => {
1573
+ this.addColumnIfMissing("loop_runs", "pgid", "INTEGER");
1574
+ this.addColumnIfMissing("loop_runs", "process_started_at", "TEXT");
1575
+ }
1576
+ },
1577
+ {
1578
+ id: "0007_run_claim_tokens",
1579
+ apply: () => {
1580
+ this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1581
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1582
+ }
1583
+ }
1584
+ ];
1585
+ }
1586
+ createBaseSchema() {
1587
+ this.db.exec(`
1588
+ CREATE TABLE IF NOT EXISTS loops (
1589
+ id TEXT PRIMARY KEY,
1590
+ name TEXT NOT NULL,
1591
+ description TEXT,
1592
+ status TEXT NOT NULL,
1593
+ archived_at TEXT,
1594
+ archived_from_status TEXT,
1595
+ schedule_json TEXT NOT NULL,
1596
+ target_json TEXT NOT NULL,
1597
+ goal_json TEXT,
1598
+ machine_json TEXT,
1599
+ next_run_at TEXT,
1600
+ retry_scheduled_for TEXT,
1601
+ catch_up TEXT NOT NULL,
1602
+ catch_up_limit INTEGER NOT NULL,
1603
+ overlap TEXT NOT NULL,
1604
+ max_attempts INTEGER NOT NULL,
1605
+ retry_delay_ms INTEGER NOT NULL,
1606
+ lease_ms INTEGER NOT NULL,
1607
+ expires_at TEXT,
1608
+ created_at TEXT NOT NULL,
1609
+ updated_at TEXT NOT NULL
1610
+ );
1611
+ CREATE INDEX IF NOT EXISTS idx_loops_status_next ON loops(status, next_run_at);
1612
+ CREATE INDEX IF NOT EXISTS idx_loops_name ON loops(name);
1613
+
1614
+ CREATE TABLE IF NOT EXISTS loop_runs (
1615
+ id TEXT PRIMARY KEY,
1616
+ loop_id TEXT NOT NULL,
1617
+ loop_name TEXT NOT NULL,
1618
+ scheduled_for TEXT NOT NULL,
1619
+ attempt INTEGER NOT NULL,
1620
+ status TEXT NOT NULL,
1621
+ started_at TEXT,
1622
+ finished_at TEXT,
1623
+ claimed_by TEXT,
1624
+ claim_token TEXT,
1625
+ lease_expires_at TEXT,
1626
+ pid INTEGER,
1627
+ pgid INTEGER,
1628
+ process_started_at TEXT,
1629
+ exit_code INTEGER,
1630
+ duration_ms INTEGER,
1631
+ stdout TEXT,
1632
+ stderr TEXT,
1633
+ error TEXT,
1634
+ goal_run_id TEXT,
1635
+ created_at TEXT NOT NULL,
1636
+ updated_at TEXT NOT NULL,
1637
+ UNIQUE(loop_id, scheduled_for)
1638
+ );
1639
+ CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
1640
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
1641
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1642
+ CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1643
+ CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
1644
+
1645
+ CREATE TABLE IF NOT EXISTS daemon_lease (
1646
+ id TEXT PRIMARY KEY,
1647
+ pid INTEGER NOT NULL,
1648
+ hostname TEXT NOT NULL,
1649
+ heartbeat_at TEXT NOT NULL,
1650
+ expires_at TEXT NOT NULL,
1651
+ created_at TEXT NOT NULL,
1652
+ updated_at TEXT NOT NULL
1653
+ );
1654
+
1655
+ CREATE TABLE IF NOT EXISTS workflow_specs (
1656
+ id TEXT PRIMARY KEY,
1657
+ name TEXT NOT NULL,
1658
+ description TEXT,
1659
+ version INTEGER NOT NULL,
1660
+ status TEXT NOT NULL,
1661
+ goal_json TEXT,
1662
+ steps_json TEXT NOT NULL,
1663
+ created_at TEXT NOT NULL,
1664
+ updated_at TEXT NOT NULL
1665
+ );
1666
+ CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
1667
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
1668
+
1669
+ CREATE TABLE IF NOT EXISTS workflow_runs (
1670
+ id TEXT PRIMARY KEY,
1671
+ workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
1672
+ workflow_name TEXT NOT NULL,
1673
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
1674
+ loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
1675
+ invocation_id TEXT,
1676
+ work_item_id TEXT,
1677
+ scheduled_for TEXT,
1678
+ idempotency_key TEXT,
1679
+ manifest_path TEXT,
1680
+ status TEXT NOT NULL,
1681
+ started_at TEXT,
1682
+ finished_at TEXT,
1683
+ duration_ms INTEGER,
1684
+ error TEXT,
1685
+ goal_run_id TEXT,
1686
+ created_at TEXT NOT NULL,
1687
+ updated_at TEXT NOT NULL
1688
+ );
1689
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
1690
+ ON workflow_runs(workflow_id, idempotency_key)
1691
+ WHERE idempotency_key IS NOT NULL;
1692
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
1693
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
1694
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
1695
+
1696
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
1697
+ id TEXT PRIMARY KEY,
1698
+ workflow_id TEXT,
1699
+ template_id TEXT,
1700
+ source_kind TEXT NOT NULL,
1701
+ source_id TEXT,
1702
+ source_dedupe_key TEXT,
1703
+ source_json TEXT NOT NULL,
1704
+ subject_kind TEXT NOT NULL,
1705
+ subject_id TEXT,
1706
+ subject_path TEXT,
1707
+ subject_url TEXT,
1708
+ subject_json TEXT NOT NULL,
1709
+ intent TEXT NOT NULL,
1710
+ scope_json TEXT,
1711
+ output_policy_json TEXT,
1712
+ created_at TEXT NOT NULL,
1713
+ updated_at TEXT NOT NULL
1714
+ );
1715
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
1716
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
1717
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
1718
+ ON workflow_invocations(source_kind, source_dedupe_key)
1719
+ WHERE source_dedupe_key IS NOT NULL;
1720
+
1721
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
1722
+ id TEXT PRIMARY KEY,
1723
+ route_key TEXT NOT NULL,
1724
+ idempotency_key TEXT NOT NULL,
1725
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
1726
+ source_type TEXT NOT NULL,
1727
+ source_ref TEXT NOT NULL,
1728
+ subject_ref TEXT NOT NULL,
1729
+ project_key TEXT,
1730
+ project_group TEXT,
1731
+ priority INTEGER NOT NULL,
1732
+ status TEXT NOT NULL,
1733
+ attempts INTEGER NOT NULL,
1734
+ next_attempt_at TEXT,
1735
+ lease_expires_at TEXT,
1736
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
1737
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
1738
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
1739
+ last_reason TEXT,
1740
+ created_at TEXT NOT NULL,
1741
+ updated_at TEXT NOT NULL,
1742
+ UNIQUE(route_key, idempotency_key)
1743
+ );
1744
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
1745
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1746
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1747
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1748
+
1749
+ CREATE TABLE IF NOT EXISTS workflow_step_runs (
1750
+ id TEXT PRIMARY KEY,
1751
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
1752
+ step_id TEXT NOT NULL,
1753
+ sequence INTEGER NOT NULL,
1754
+ status TEXT NOT NULL,
1755
+ started_at TEXT,
1756
+ finished_at TEXT,
1757
+ exit_code INTEGER,
1758
+ pid INTEGER,
1759
+ duration_ms INTEGER,
1760
+ stdout TEXT,
1761
+ stderr TEXT,
1762
+ error TEXT,
1763
+ account_profile TEXT,
1764
+ account_tool TEXT,
1765
+ goal_run_id TEXT,
1766
+ created_at TEXT NOT NULL,
1767
+ updated_at TEXT NOT NULL,
1768
+ UNIQUE(workflow_run_id, step_id)
1769
+ );
1770
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
1771
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
1772
+
1773
+ CREATE TABLE IF NOT EXISTS workflow_events (
1774
+ id TEXT PRIMARY KEY,
1775
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
1776
+ sequence INTEGER NOT NULL,
1777
+ event_type TEXT NOT NULL,
1778
+ step_id TEXT,
1779
+ payload_json TEXT,
1780
+ created_at TEXT NOT NULL,
1781
+ UNIQUE(workflow_run_id, sequence)
1782
+ );
1783
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
1784
+
1785
+ CREATE TABLE IF NOT EXISTS goals (
1786
+ id TEXT PRIMARY KEY,
1787
+ plan_id TEXT NOT NULL,
1788
+ objective TEXT NOT NULL,
1789
+ status TEXT NOT NULL,
1790
+ token_budget INTEGER,
1791
+ tokens_used INTEGER NOT NULL,
1792
+ time_used_seconds INTEGER NOT NULL,
1793
+ auto_execute TEXT NOT NULL,
1794
+ max_tokens INTEGER,
1795
+ source_type TEXT,
1796
+ source_id TEXT,
1797
+ loop_id TEXT,
1798
+ loop_run_id TEXT,
1799
+ workflow_id TEXT,
1800
+ workflow_run_id TEXT,
1801
+ workflow_step_id TEXT,
1802
+ created_at TEXT NOT NULL,
1803
+ updated_at TEXT NOT NULL
1804
+ );
1805
+ CREATE INDEX IF NOT EXISTS idx_goals_status_updated ON goals(status, updated_at DESC);
1806
+ CREATE INDEX IF NOT EXISTS idx_goals_loop_run ON goals(loop_run_id);
1807
+ CREATE INDEX IF NOT EXISTS idx_goals_workflow_run ON goals(workflow_run_id);
1808
+ CREATE INDEX IF NOT EXISTS idx_goals_source ON goals(source_type, source_id);
1809
+
1810
+ CREATE TABLE IF NOT EXISTS goal_plan_nodes (
1811
+ id TEXT PRIMARY KEY,
1812
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
1813
+ plan_id TEXT NOT NULL,
1814
+ key TEXT NOT NULL,
1815
+ sequence INTEGER NOT NULL,
1816
+ priority INTEGER NOT NULL,
1817
+ objective TEXT NOT NULL,
1818
+ status TEXT NOT NULL,
1819
+ ready INTEGER NOT NULL,
1820
+ token_budget INTEGER,
1821
+ tokens_used INTEGER NOT NULL,
1822
+ time_used_seconds INTEGER NOT NULL,
1823
+ depends_on_json TEXT NOT NULL,
1824
+ created_at TEXT NOT NULL,
1825
+ updated_at TEXT NOT NULL,
1826
+ UNIQUE(plan_id, key)
1827
+ );
1828
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_goal_sequence ON goal_plan_nodes(goal_id, sequence);
1829
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_status ON goal_plan_nodes(status);
1830
+
1831
+ CREATE TABLE IF NOT EXISTS goal_runs (
1832
+ id TEXT PRIMARY KEY,
1833
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
1834
+ plan_id TEXT NOT NULL,
1835
+ loop_id TEXT,
1836
+ loop_run_id TEXT,
1837
+ workflow_id TEXT,
1838
+ workflow_run_id TEXT,
1839
+ workflow_step_id TEXT,
1840
+ turn INTEGER NOT NULL,
1841
+ phase TEXT NOT NULL,
1842
+ status TEXT NOT NULL,
1843
+ node_key TEXT,
1844
+ tokens_used INTEGER NOT NULL,
1845
+ evidence_json TEXT,
1846
+ raw_response_json TEXT,
1847
+ created_at TEXT NOT NULL,
1848
+ updated_at TEXT NOT NULL
1849
+ );
1850
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_goal_created ON goal_runs(goal_id, created_at);
1851
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_loop_run ON goal_runs(loop_run_id);
1852
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
1853
+ `);
1854
+ }
1855
+ addColumnIfMissing(table, column, definition) {
1856
+ const columns = this.db.query(`PRAGMA table_info(${table})`).all();
1857
+ if (columns.some((c) => c.name === column))
1858
+ return;
1859
+ this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
1860
+ }
1861
+ createWorkflowRunBackfillIndexes() {
1862
+ this.db.exec(`
1863
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
1864
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
1865
+ `);
1866
+ }
1867
+ transact(fn) {
1868
+ return this.db.inTransaction ? fn() : this.writeTransaction(fn);
1869
+ }
1870
+ assertDaemonLeaseFence(opts = {}, now = nowIso()) {
1871
+ if (!opts.daemonLeaseId)
1872
+ return;
1873
+ const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
1874
+ if (!row)
1875
+ throw new Error("daemon lease lost");
1876
+ }
1877
+ assertNoNestedWorkflowGoal(target, goal) {
1878
+ if (!goal || target.type !== "workflow")
1879
+ return;
1880
+ const workflow = this.getWorkflow(target.workflowId);
1881
+ if (workflow?.goal) {
1882
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1883
+ }
1884
+ }
1885
+ createLoop(input, from = new Date) {
1886
+ const now = nowIso();
1887
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1888
+ name: "loop-target-validation",
1889
+ steps: [{ id: "target", target: input.target }]
1890
+ }).steps[0].target;
1891
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1892
+ const loop = {
1893
+ id: genId(),
1894
+ name: input.name,
1895
+ description: input.description,
1896
+ status: "active",
1897
+ schedule: input.schedule,
1898
+ target,
1899
+ goal: input.goal,
1900
+ machine: input.machine,
1901
+ nextRunAt: initialNextRun(input.schedule, from),
1902
+ catchUp: input.catchUp ?? "latest",
1903
+ catchUpLimit: input.catchUpLimit ?? 50,
1904
+ overlap: input.overlap ?? "skip",
1905
+ maxAttempts: input.maxAttempts ?? 1,
1906
+ retryDelayMs: input.retryDelayMs ?? 60000,
1907
+ leaseMs: input.leaseMs ?? 30 * 60000,
1908
+ expiresAt: input.expiresAt,
1909
+ createdAt: now,
1910
+ updatedAt: now
1911
+ };
1912
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
1913
+ goal_json, catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
1914
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $goal, $catchUp, $catchUpLimit,
1915
+ $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
1916
+ $id: loop.id,
1917
+ $name: loop.name,
1918
+ $description: loop.description ?? null,
1919
+ $status: loop.status,
1920
+ $schedule: JSON.stringify(loop.schedule),
1921
+ $target: JSON.stringify(loop.target),
1922
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
1923
+ $goal: loop.goal ? JSON.stringify(loop.goal) : null,
1924
+ $nextRun: loop.nextRunAt ?? null,
1925
+ $catchUp: loop.catchUp,
1926
+ $catchUpLimit: loop.catchUpLimit,
1927
+ $overlap: loop.overlap,
1928
+ $maxAttempts: loop.maxAttempts,
1929
+ $retryDelay: loop.retryDelayMs,
1930
+ $leaseMs: loop.leaseMs,
1931
+ $expiresAt: loop.expiresAt ?? null,
1932
+ $created: loop.createdAt,
1933
+ $updated: loop.updatedAt
1934
+ });
1935
+ return loop;
1936
+ }
1937
+ getLoop(id) {
1938
+ const row = this.db.query("SELECT * FROM loops WHERE id = ?").get(id);
1939
+ return row ? rowToLoop(row) : undefined;
1940
+ }
1941
+ findLoopByName(name) {
1942
+ const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1943
+ return row ? rowToLoop(row) : undefined;
1944
+ }
1945
+ requireUniqueLoop(idOrName) {
1946
+ const byId = this.getLoop(idOrName);
1947
+ if (byId)
1948
+ return byId;
1949
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1950
+ if (rows.length === 0)
1951
+ throw new LoopNotFoundError(idOrName);
1952
+ if (rows.length > 1)
1953
+ throw new AmbiguousNameError(idOrName);
1954
+ return rowToLoop(rows[0]);
1955
+ }
1956
+ requireLoop(idOrName) {
1957
+ return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1958
+ throw new LoopNotFoundError(idOrName);
1959
+ })();
1960
+ }
1961
+ listLoops(opts = {}) {
1962
+ const limit = opts.limit ?? 200;
1963
+ let rows;
1964
+ if (opts.status && opts.archived) {
1965
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1966
+ } else if (opts.status && opts.includeArchived) {
1967
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1968
+ } else if (opts.status) {
1969
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1970
+ } else if (opts.archived) {
1971
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
1972
+ } else if (opts.includeArchived) {
1973
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1974
+ } else {
1975
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1976
+ }
1977
+ return rows.map(rowToLoop);
1978
+ }
1979
+ dueLoops(now, limit = 500) {
1980
+ const rows = this.db.query(`SELECT * FROM loops
1981
+ WHERE status = 'active'
1982
+ AND archived_at IS NULL
1983
+ AND next_run_at IS NOT NULL
1984
+ AND next_run_at <= ?
1985
+ ORDER BY next_run_at ASC
1986
+ LIMIT ?`).all(now.toISOString(), limit);
1987
+ return rows.map(rowToLoop);
1988
+ }
1989
+ updateLoop(id, patch, opts = {}) {
1990
+ const updated = (opts.now ?? new Date).toISOString();
1991
+ this.db.exec("BEGIN IMMEDIATE");
1992
+ try {
1993
+ const current = this.getLoop(id);
1994
+ if (!current)
1995
+ throw new LoopNotFoundError(id);
1996
+ if (current.archivedAt)
1997
+ throw new LoopArchivedError(current.name || id);
1998
+ const merged = { ...current, ...patch, updatedAt: updated };
1999
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
2000
+ expires_at=$expiresAt, updated_at=$updated
2001
+ WHERE id=$id
2002
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2003
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2004
+ ))`).run({
2005
+ $id: id,
2006
+ $status: merged.status,
2007
+ $nextRun: merged.nextRunAt ?? null,
2008
+ $retrySlot: merged.retryScheduledFor ?? null,
2009
+ $expiresAt: merged.expiresAt ?? null,
2010
+ $updated: merged.updatedAt,
2011
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2012
+ $now: updated
2013
+ });
2014
+ if (res.changes !== 1)
2015
+ throw new Error("daemon lease lost");
2016
+ if (patch.status && patch.status !== "active") {
2017
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
2018
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
2019
+ }
2020
+ this.db.exec("COMMIT");
2021
+ } catch (error) {
2022
+ try {
2023
+ this.db.exec("ROLLBACK");
2024
+ } catch {}
2025
+ throw error;
2026
+ }
2027
+ const after = this.getLoop(id);
2028
+ if (!after)
2029
+ throw new Error(`loop not found after update: ${id}`);
2030
+ return after;
2031
+ }
2032
+ activeLoopReferenceCount(workflowId) {
2033
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
2034
+ let count = 0;
2035
+ for (const row of rows) {
2036
+ try {
2037
+ const target = JSON.parse(row.target_json);
2038
+ if (target.type === "workflow" && target.workflowId === workflowId)
2039
+ count += 1;
2040
+ } catch {}
2041
+ }
2042
+ return count;
2043
+ }
2044
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
2045
+ if (this.activeLoopReferenceCount(workflowId) > 0)
2046
+ return;
2047
+ const workflow = this.getWorkflow(workflowId);
2048
+ if (!workflow || workflow.status !== "active")
2049
+ return;
2050
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
2051
+ if (res.changes !== 1)
2052
+ return;
2053
+ return this.getWorkflow(workflowId);
2054
+ }
2055
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
2056
+ const updated = (opts.now ?? new Date).toISOString();
2057
+ this.db.exec("BEGIN IMMEDIATE");
2058
+ try {
2059
+ const current = this.requireLoop(idOrName);
2060
+ if (current.target.type !== "workflow")
2061
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
2062
+ if (this.hasRunningRun(current.id))
2063
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
2064
+ const workflow = this.requireWorkflow(workflowId);
2065
+ if (current.goal && workflow.goal) {
2066
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
2067
+ }
2068
+ const target = { ...current.target, workflowId: workflow.id };
2069
+ if (opts.workflowTimeoutMs !== undefined)
2070
+ target.timeoutMs = opts.workflowTimeoutMs;
2071
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
2072
+ WHERE id=$id
2073
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2074
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2075
+ ))`).run({
2076
+ $id: current.id,
2077
+ $target: JSON.stringify(target),
2078
+ $updated: updated,
2079
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2080
+ $now: updated
2081
+ });
2082
+ if (res.changes !== 1)
2083
+ throw new Error("daemon lease lost");
2084
+ this.db.exec("COMMIT");
2085
+ const after = this.getLoop(current.id);
2086
+ if (!after)
2087
+ throw new Error(`loop not found after retarget: ${current.id}`);
2088
+ return after;
2089
+ } catch (error) {
2090
+ try {
2091
+ this.db.exec("ROLLBACK");
2092
+ } catch {}
2093
+ throw error;
2094
+ }
2095
+ }
2096
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
2097
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
2098
+ const updated = (opts.now ?? new Date).toISOString();
2099
+ this.db.exec("BEGIN IMMEDIATE");
2100
+ try {
2101
+ const current = this.requireUniqueLoop(idOrName);
2102
+ if (current.target.type !== "workflow")
2103
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
2104
+ if (this.hasRunningRun(current.id))
2105
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
2106
+ if (current.goal && normalized.goal) {
2107
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
2108
+ }
2109
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
2110
+ const workflow = {
2111
+ id: genId(),
2112
+ name: normalized.name,
2113
+ description: normalized.description,
2114
+ version: normalized.version ?? 1,
2115
+ status: "active",
2116
+ goal: normalized.goal,
2117
+ steps: normalized.steps,
2118
+ createdAt: updated,
2119
+ updatedAt: updated
2120
+ };
2121
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
2122
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
2123
+ $id: workflow.id,
2124
+ $name: workflow.name,
2125
+ $description: workflow.description ?? null,
2126
+ $version: workflow.version,
2127
+ $status: workflow.status,
2128
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
2129
+ $steps: JSON.stringify(workflow.steps),
2130
+ $created: workflow.createdAt,
2131
+ $updated: workflow.updatedAt
2132
+ });
2133
+ const target = { ...current.target, workflowId: workflow.id };
2134
+ if (opts.workflowTimeoutMs !== undefined)
2135
+ target.timeoutMs = opts.workflowTimeoutMs;
2136
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
2137
+ WHERE id=$id
2138
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2139
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2140
+ ))`).run({
2141
+ $id: current.id,
2142
+ $target: JSON.stringify(target),
2143
+ $updated: updated,
2144
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2145
+ $now: updated
2146
+ });
2147
+ if (res.changes !== 1)
2148
+ throw new Error("daemon lease lost");
2149
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
2150
+ this.db.exec("COMMIT");
2151
+ const loop = this.getLoop(current.id);
2152
+ if (!loop)
2153
+ throw new Error(`loop not found after retarget: ${current.id}`);
2154
+ return { loop, workflow, previousWorkflow, archivedOld };
2155
+ } catch (error) {
2156
+ try {
2157
+ this.db.exec("ROLLBACK");
2158
+ } catch {}
2159
+ throw error;
2160
+ }
2161
+ }
2162
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
2163
+ const updated = (opts.now ?? new Date).toISOString();
2164
+ this.db.exec("BEGIN IMMEDIATE");
2165
+ try {
2166
+ const current = this.requireUniqueLoop(idOrName);
2167
+ if (current.target.type !== "workflow")
2168
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
2169
+ if (this.hasRunningRun(current.id))
2170
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
2171
+ if (!current.goal)
2172
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
2173
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
2174
+ if (!previousWorkflow.goal)
2175
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
2176
+ const workflow = {
2177
+ id: genId(),
2178
+ name: opts.workflowName,
2179
+ description: previousWorkflow.description,
2180
+ version: previousWorkflow.version,
2181
+ status: "active",
2182
+ steps: previousWorkflow.steps,
2183
+ createdAt: updated,
2184
+ updatedAt: updated
2185
+ };
2186
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
2187
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
2188
+ $id: workflow.id,
2189
+ $name: workflow.name,
2190
+ $description: workflow.description ?? null,
2191
+ $version: workflow.version,
2192
+ $status: workflow.status,
2193
+ $steps: JSON.stringify(workflow.steps),
2194
+ $created: workflow.createdAt,
2195
+ $updated: workflow.updatedAt
2196
+ });
2197
+ const target = { ...current.target, workflowId: workflow.id };
2198
+ if (opts.workflowTimeoutMs !== undefined)
2199
+ target.timeoutMs = opts.workflowTimeoutMs;
2200
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
2201
+ WHERE id=$id
2202
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2203
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2204
+ ))`).run({
2205
+ $id: current.id,
2206
+ $target: JSON.stringify(target),
2207
+ $updated: updated,
2208
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2209
+ $now: updated
2210
+ });
2211
+ if (res.changes !== 1)
2212
+ throw new Error("daemon lease lost");
2213
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
2214
+ this.db.exec("COMMIT");
2215
+ const loop = this.getLoop(current.id);
2216
+ if (!loop)
2217
+ throw new Error(`loop not found after retarget: ${current.id}`);
2218
+ return { loop, workflow, previousWorkflow, archivedOld };
2219
+ } catch (error) {
2220
+ try {
2221
+ this.db.exec("ROLLBACK");
2222
+ } catch {}
2223
+ throw error;
2224
+ }
2225
+ }
2226
+ renameLoop(id, name, opts = {}) {
2227
+ const current = this.getLoop(id);
2228
+ if (!current)
2229
+ throw new LoopNotFoundError(id);
2230
+ const trimmed = name.trim();
2231
+ if (!trimmed)
2232
+ throw new ValidationError("loop name must not be empty");
2233
+ const updated = (opts.now ?? new Date).toISOString();
2234
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
2235
+ WHERE id=$id
2236
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2237
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2238
+ ))`).run({
2239
+ $id: id,
2240
+ $name: trimmed,
2241
+ $updated: updated,
2242
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2243
+ $now: updated
2244
+ });
2245
+ const after = this.getLoop(id);
2246
+ if (!after)
2247
+ throw new Error(`loop not found after rename: ${id}`);
2248
+ return after;
2249
+ }
2250
+ archiveLoop(idOrName) {
2251
+ return this.transact(() => {
2252
+ const loop = this.requireLoop(idOrName);
2253
+ if (loop.archivedAt)
2254
+ return loop;
2255
+ const updated = nowIso();
2256
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
2257
+ this.db.query(`UPDATE loops
2258
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
2259
+ WHERE id=$id`).run({
2260
+ $id: loop.id,
2261
+ $status: archivedStatus,
2262
+ $archivedAt: updated,
2263
+ $archivedFromStatus: loop.status,
2264
+ $updated: updated
2265
+ });
2266
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
2267
+ const archived = this.getLoop(loop.id);
2268
+ if (!archived)
2269
+ throw new Error(`loop not found after archive: ${loop.id}`);
2270
+ return archived;
2271
+ });
2272
+ }
2273
+ unarchiveLoop(idOrName) {
2274
+ const loop = this.requireLoop(idOrName);
2275
+ if (!loop.archivedAt)
2276
+ return loop;
2277
+ const updated = nowIso();
2278
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
2279
+ this.db.query(`UPDATE loops
2280
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
2281
+ WHERE id=$id`).run({
2282
+ $id: loop.id,
2283
+ $status: restoredStatus,
2284
+ $updated: updated
2285
+ });
2286
+ const unarchived = this.getLoop(loop.id);
2287
+ if (!unarchived)
2288
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
2289
+ return unarchived;
2290
+ }
2291
+ deleteLoop(idOrName) {
2292
+ return this.transact(() => {
2293
+ const loop = this.requireLoop(idOrName);
2294
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2295
+ const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2296
+ return res.changes > 0;
2297
+ });
2298
+ }
2299
+ createWorkflow(input) {
2300
+ const normalized = normalizeCreateWorkflowInput(input);
2301
+ const now = nowIso();
2302
+ const workflow = {
2303
+ id: genId(),
2304
+ name: normalized.name,
2305
+ description: normalized.description,
2306
+ version: normalized.version ?? 1,
2307
+ status: "active",
2308
+ goal: normalized.goal,
2309
+ steps: normalized.steps,
2310
+ createdAt: now,
2311
+ updatedAt: now
2312
+ };
2313
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
2314
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
2315
+ $id: workflow.id,
2316
+ $name: workflow.name,
2317
+ $description: workflow.description ?? null,
2318
+ $version: workflow.version,
2319
+ $status: workflow.status,
2320
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
2321
+ $steps: JSON.stringify(workflow.steps),
2322
+ $created: workflow.createdAt,
2323
+ $updated: workflow.updatedAt
2324
+ });
2325
+ return workflow;
2326
+ }
2327
+ getWorkflow(id) {
2328
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
2329
+ return row ? rowToWorkflow(row) : undefined;
2330
+ }
2331
+ findWorkflowByName(name) {
2332
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
2333
+ return row ? rowToWorkflow(row) : undefined;
2334
+ }
2335
+ requireWorkflow(idOrName) {
2336
+ return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
2337
+ throw new Error(`workflow not found: ${idOrName}`);
2338
+ })();
2339
+ }
2340
+ listWorkflows(opts = {}) {
2341
+ const offset = Math.max(0, opts.offset ?? 0);
2342
+ let rows;
2343
+ if (opts.status && opts.limit !== undefined) {
2344
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
2345
+ } else if (opts.status) {
2346
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
2347
+ } else if (opts.limit !== undefined) {
2348
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
2349
+ } else {
2350
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
2351
+ }
2352
+ return rows.map(rowToWorkflow);
2353
+ }
2354
+ countWorkflows(opts = {}) {
2355
+ const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
2356
+ return row?.count ?? 0;
2357
+ }
2358
+ archiveWorkflow(idOrName) {
2359
+ const workflow = this.requireWorkflow(idOrName);
2360
+ const updated = nowIso();
2361
+ this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
2362
+ const archived = this.getWorkflow(workflow.id);
2363
+ if (!archived)
2364
+ throw new Error(`workflow not found after archive: ${workflow.id}`);
2365
+ return archived;
2366
+ }
2367
+ generatedRouteArchiveContext(args) {
2368
+ if (!args.loopId || !args.workItemId)
2369
+ return;
2370
+ const workItem = this.getWorkflowWorkItem(args.workItemId);
2371
+ if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
2372
+ return;
2373
+ const invocation = this.getWorkflowInvocation(workItem.invocationId);
2374
+ if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
2375
+ return;
2376
+ const loop = this.getLoop(args.loopId);
2377
+ if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
2378
+ return;
2379
+ const input = loop.target.input ?? {};
2380
+ if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
2381
+ return;
2382
+ if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
2383
+ return;
2384
+ if (workItem.workflowId && workItem.workflowId !== args.workflowId)
2385
+ return;
2386
+ const workflow = this.getWorkflow(args.workflowId);
2387
+ if (!workflow)
2388
+ return;
2389
+ return { workflow, loop, workItem, invocation };
2390
+ }
2391
+ maybeArchiveGeneratedRouteWorkflow(args) {
2392
+ const context = this.generatedRouteArchiveContext(args);
2393
+ if (!context)
2394
+ return;
2395
+ const { workflow, loop, workItem } = context;
2396
+ if (!workflow || workflow.status !== "active")
2397
+ return;
2398
+ const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
2399
+ WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
2400
+ if (nonTerminal > 0)
2401
+ return;
2402
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
2403
+ if (res.changes === 1 && args.workflowRunId) {
2404
+ this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
2405
+ workflowId: args.workflowId,
2406
+ loopId: loop.id,
2407
+ workItemId: workItem.id,
2408
+ routeKey: workItem.routeKey,
2409
+ reason: "terminal generated one-shot route workflow"
2410
+ });
2411
+ }
2412
+ }
2413
+ maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
2414
+ const run = this.getWorkflowRun(workflowRunId);
2415
+ if (!run)
2416
+ return;
2417
+ this.maybeArchiveGeneratedRouteWorkflow({
2418
+ workflowId: run.workflowId,
2419
+ loopId: run.loopId,
2420
+ workItemId: run.workItemId,
2421
+ workflowRunId,
2422
+ updated
2423
+ });
2424
+ }
2425
+ createWorkflowInvocation(input) {
2426
+ const now = nowIso();
2427
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
2428
+ if (sourceDedupeKey) {
2429
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
2430
+ if (existing)
2431
+ return rowToWorkflowInvocation(existing);
2432
+ }
2433
+ const id = input.id ?? genId();
2434
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
2435
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
2436
+ output_policy_json, created_at, updated_at)
2437
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
2438
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
2439
+ $outputPolicyJson, $created, $updated)`).run({
2440
+ $id: id,
2441
+ $workflowId: input.workflowId ?? null,
2442
+ $templateId: input.templateId ?? null,
2443
+ $sourceKind: input.sourceRef.kind,
2444
+ $sourceId: input.sourceRef.id ?? null,
2445
+ $sourceDedupeKey: sourceDedupeKey ?? null,
2446
+ $sourceJson: JSON.stringify(input.sourceRef),
2447
+ $subjectKind: input.subjectRef.kind,
2448
+ $subjectId: input.subjectRef.id ?? null,
2449
+ $subjectPath: input.subjectRef.path ?? null,
2450
+ $subjectUrl: input.subjectRef.url ?? null,
2451
+ $subjectJson: JSON.stringify(input.subjectRef),
2452
+ $intent: input.intent,
2453
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
2454
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
2455
+ $created: now,
2456
+ $updated: now
2457
+ });
2458
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
2459
+ if (!row)
2460
+ throw new Error(`workflow invocation not found after create: ${id}`);
2461
+ return rowToWorkflowInvocation(row);
2462
+ }
2463
+ refreshWorkflowInvocationForWorkItem(workItemId, input) {
2464
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
2465
+ if (!sourceDedupeKey)
2466
+ throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
2467
+ const now = nowIso();
2468
+ const claimableStatuses = ["queued", "deferred"];
2469
+ const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
2470
+ const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
2471
+ const result = this.db.query(`UPDATE workflow_invocations
2472
+ SET workflow_id=COALESCE($workflowId, workflow_id),
2473
+ template_id=COALESCE($templateId, template_id),
2474
+ source_id=COALESCE($sourceId, source_id),
2475
+ source_json=$sourceJson,
2476
+ subject_kind=$subjectKind,
2477
+ subject_id=COALESCE($subjectId, subject_id),
2478
+ subject_path=COALESCE($subjectPath, subject_path),
2479
+ subject_url=COALESCE($subjectUrl, subject_url),
2480
+ subject_json=$subjectJson,
2481
+ intent=$intent,
2482
+ scope_json=COALESCE($scopeJson, scope_json),
2483
+ output_policy_json=COALESCE($outputPolicyJson, output_policy_json),
2484
+ updated_at=$updated
2485
+ WHERE source_kind=$sourceKind
2486
+ AND source_dedupe_key=$sourceDedupeKey
2487
+ AND EXISTS (
2488
+ SELECT 1
2489
+ FROM workflow_work_items
2490
+ WHERE id=$workItemId
2491
+ AND invocation_id=workflow_invocations.id
2492
+ AND status IN (${placeholders})
2493
+ )`).run({
2494
+ $workItemId: workItemId,
2495
+ $sourceKind: input.sourceRef.kind,
2496
+ $sourceDedupeKey: sourceDedupeKey,
2497
+ $workflowId: input.workflowId ?? null,
2498
+ $templateId: input.templateId ?? null,
2499
+ $sourceId: input.sourceRef.id ?? null,
2500
+ $sourceJson: JSON.stringify(input.sourceRef),
2501
+ $subjectKind: input.subjectRef.kind,
2502
+ $subjectId: input.subjectRef.id ?? null,
2503
+ $subjectPath: input.subjectRef.path ?? null,
2504
+ $subjectUrl: input.subjectRef.url ?? null,
2505
+ $subjectJson: JSON.stringify(input.subjectRef),
2506
+ $intent: input.intent,
2507
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
2508
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
2509
+ $updated: now,
2510
+ ...statusBindings
2511
+ });
2512
+ if (result.changes !== 1)
2513
+ throw new Error(`workflow work item is not refreshable: ${workItemId}`);
2514
+ const updated = this.db.query(`SELECT workflow_invocations.*
2515
+ FROM workflow_invocations
2516
+ JOIN workflow_work_items ON workflow_work_items.invocation_id = workflow_invocations.id
2517
+ WHERE workflow_work_items.id = ?`).get(workItemId);
2518
+ if (!updated)
2519
+ throw new Error(`workflow invocation not found after refresh for work item: ${workItemId}`);
2520
+ return rowToWorkflowInvocation(updated);
2521
+ }
2522
+ getWorkflowInvocation(id) {
2523
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
2524
+ return row ? rowToWorkflowInvocation(row) : undefined;
2525
+ }
2526
+ listWorkflowInvocations(opts = {}) {
2527
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
2528
+ return rows.map(rowToWorkflowInvocation);
2529
+ }
2530
+ upsertWorkflowWorkItem(input) {
2531
+ const now = nowIso();
2532
+ const id = genId();
2533
+ const status = input.status ?? "queued";
2534
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2535
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
2536
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2537
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2538
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2539
+ $lastReason, $created, $updated)
2540
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2541
+ invocation_id=excluded.invocation_id,
2542
+ source_type=excluded.source_type,
2543
+ source_ref=excluded.source_ref,
2544
+ subject_ref=excluded.subject_ref,
2545
+ project_key=excluded.project_key,
2546
+ project_group=excluded.project_group,
2547
+ priority=excluded.priority,
2548
+ status=CASE
2549
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
2550
+ THEN workflow_work_items.status
2551
+ ELSE excluded.status
2552
+ END,
2553
+ workflow_id=CASE
2554
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
2555
+ ELSE NULL
2556
+ END,
2557
+ loop_id=CASE
2558
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
2559
+ ELSE NULL
2560
+ END,
2561
+ workflow_run_id=CASE
2562
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
2563
+ ELSE NULL
2564
+ END,
2565
+ lease_expires_at=CASE
2566
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
2567
+ ELSE NULL
2568
+ END,
2569
+ next_attempt_at=excluded.next_attempt_at,
2570
+ last_reason=CASE
2571
+ WHEN workflow_work_items.attempts > 0
2572
+ AND workflow_work_items.status IN ('queued', 'deferred')
2573
+ AND workflow_work_items.last_reason IS NOT NULL
2574
+ AND excluded.last_reason IS NOT NULL
2575
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
2576
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
2577
+ END,
2578
+ updated_at=excluded.updated_at`).run({
2579
+ $id: id,
2580
+ $routeKey: input.routeKey,
2581
+ $idempotencyKey: input.idempotencyKey,
2582
+ $invocationId: input.invocationId,
2583
+ $sourceType: input.sourceType,
2584
+ $sourceRef: input.sourceRef,
2585
+ $subjectRef: input.subjectRef,
2586
+ $projectKey: input.projectKey ?? null,
2587
+ $projectGroup: input.projectGroup ?? null,
2588
+ $priority: input.priority ?? 0,
2589
+ $status: status,
2590
+ $nextAttemptAt: input.nextAttemptAt ?? null,
2591
+ $lastReason: input.lastReason ?? null,
2592
+ $created: now,
2593
+ $updated: now
2594
+ });
2595
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
2596
+ if (!row)
2597
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
2598
+ return rowToWorkflowWorkItem(row);
2599
+ }
2600
+ getWorkflowWorkItem(id) {
2601
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
2602
+ return row ? rowToWorkflowWorkItem(row) : undefined;
2603
+ }
2604
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
2605
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
2606
+ return row ? rowToWorkflowWorkItem(row) : undefined;
2607
+ }
2608
+ listWorkflowWorkItems(opts = {}) {
2609
+ const limit = opts.limit ?? 100;
2610
+ let rows;
2611
+ if (opts.status && opts.routeKey) {
2612
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.routeKey, opts.status, limit);
2613
+ } else if (opts.status) {
2614
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
2615
+ } else if (opts.routeKey) {
2616
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
2617
+ } else {
2618
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
2619
+ }
2620
+ return rows.map(rowToWorkflowWorkItem);
2621
+ }
2622
+ countActiveWorkflowWorkItems(args = {}) {
2623
+ const active = ["admitted", "running"];
2624
+ const placeholders = active.map(() => "?").join(",");
2625
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2626
+ const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
2627
+ const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
2628
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
2629
+ }
2630
+ requeueWorkflowWorkItem(id, patch = {}) {
2631
+ const current = this.getWorkflowWorkItem(id);
2632
+ if (!current)
2633
+ throw new Error(`workflow work item not found: ${id}`);
2634
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
2635
+ if (!requeueableStatuses.includes(current.status)) {
2636
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
2637
+ }
2638
+ const now = nowIso();
2639
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
2640
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
2641
+ const res = this.db.query(`UPDATE workflow_work_items
2642
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
2643
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
2644
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
2645
+ const item = this.getWorkflowWorkItem(id);
2646
+ if (!item)
2647
+ throw new Error(`workflow work item not found after requeue: ${id}`);
2648
+ if (res.changes !== 1)
2649
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
2650
+ return item;
2651
+ }
2652
+ admitWorkflowWorkItem(id, patch) {
2653
+ const now = nowIso();
2654
+ const res = this.db.query(`UPDATE workflow_work_items
2655
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
2656
+ next_attempt_at=NULL,
2657
+ lease_expires_at=NULL,
2658
+ last_reason=CASE
2659
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
2660
+ ELSE COALESCE($reason, last_reason)
2661
+ END,
2662
+ updated_at=$updated
2663
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
2664
+ $id: id,
2665
+ $workflowId: patch.workflowId,
2666
+ $loopId: patch.loopId,
2667
+ $reason: patch.reason ?? null,
2668
+ $updated: now
2669
+ });
2670
+ const item = this.getWorkflowWorkItem(id);
2671
+ if (!item)
2672
+ throw new Error(`workflow work item not found after admit: ${id}`);
2673
+ if (res.changes !== 1)
2674
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
2675
+ return item;
2676
+ }
2677
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
2678
+ const placeholders = statuses.map(() => "?").join(",");
2679
+ this.db.query(`UPDATE workflow_work_items
2680
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
2681
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
2682
+ }
2683
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
2684
+ const placeholders = statuses.map(() => "?").join(",");
2685
+ this.db.query(`UPDATE workflow_work_items
2686
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
2687
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
2688
+ }
2689
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
2690
+ const loop = this.getLoop(run.loopId);
2691
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
2692
+ if (!status)
2693
+ return;
2694
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2695
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
2696
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
2697
+ }
2698
+ createGoal(input, opts = {}) {
2699
+ const now = nowIso();
2700
+ this.db.exec("BEGIN IMMEDIATE");
2701
+ try {
2702
+ this.assertDaemonLeaseFence(opts, now);
2703
+ const id = genId();
2704
+ this.db.query(`INSERT INTO goals (id, plan_id, objective, status, token_budget, tokens_used, time_used_seconds, auto_execute,
2705
+ max_tokens, source_type, source_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
2706
+ created_at, updated_at)
2707
+ VALUES ($id, $planId, $objective, 'active', $tokenBudget, 0, 0, $autoExecute, $maxTokens, $sourceType,
2708
+ $sourceId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId, $created, $updated)`).run({
2709
+ $id: id,
2710
+ $planId: id,
2711
+ $objective: input.objective,
2712
+ $tokenBudget: input.tokenBudget ?? null,
2713
+ $autoExecute: input.autoExecute ?? "readyOnly",
2714
+ $maxTokens: input.maxTokens ?? input.tokenBudget ?? null,
2715
+ $sourceType: input.sourceType ?? null,
2716
+ $sourceId: input.sourceId ?? null,
2717
+ $loopId: input.loopId ?? null,
2718
+ $loopRunId: input.loopRunId ?? null,
2719
+ $workflowId: input.workflowId ?? null,
2720
+ $workflowRunId: input.workflowRunId ?? null,
2721
+ $workflowStepId: input.workflowStepId ?? null,
2722
+ $created: now,
2723
+ $updated: now
2724
+ });
2725
+ this.db.exec("COMMIT");
2726
+ return this.requireGoal(id);
2727
+ } catch (error) {
2728
+ try {
2729
+ this.db.exec("ROLLBACK");
2730
+ } catch {}
2731
+ throw error;
2732
+ }
2733
+ }
2734
+ getGoal(id) {
2735
+ const row = this.db.query("SELECT * FROM goals WHERE id = ?").get(id);
2736
+ return row ? rowToGoal(row) : undefined;
2737
+ }
2738
+ requireGoal(id) {
2739
+ const goal = this.getGoal(id);
2740
+ if (!goal)
2741
+ throw new Error(`goal not found: ${id}`);
2742
+ return goal;
2743
+ }
2744
+ findGoalByLoop(idOrName) {
2745
+ const loop = this.getLoop(idOrName) ?? this.findLoopByName(idOrName);
2746
+ if (!loop)
2747
+ return;
2748
+ const row = this.db.query("SELECT * FROM goals WHERE loop_id = ? ORDER BY created_at DESC LIMIT 1").get(loop.id);
2749
+ return row ? rowToGoal(row) : undefined;
2750
+ }
2751
+ findGoalByRunId(id) {
2752
+ const direct = this.getGoal(id);
2753
+ if (direct)
2754
+ return direct;
2755
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
2756
+ if (event)
2757
+ return this.getGoal(event.goal_id);
2758
+ const row = this.db.query(`SELECT * FROM goals
2759
+ WHERE loop_run_id = ? OR workflow_run_id = ? OR workflow_step_id = ?
2760
+ ORDER BY created_at DESC LIMIT 1`).get(id, id, id);
2761
+ return row ? rowToGoal(row) : undefined;
2762
+ }
2763
+ findGoalByContext(context) {
2764
+ if (context.loopRunId) {
2765
+ const row = this.db.query(`SELECT * FROM goals
2766
+ WHERE loop_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
2767
+ ORDER BY created_at DESC LIMIT 1`).get(context.loopRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
2768
+ if (row)
2769
+ return rowToGoal(row);
2770
+ }
2771
+ if (context.workflowRunId) {
2772
+ const row = this.db.query(`SELECT * FROM goals
2773
+ WHERE workflow_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
2774
+ ORDER BY created_at DESC LIMIT 1`).get(context.workflowRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
2775
+ if (row)
2776
+ return rowToGoal(row);
2777
+ }
2778
+ if (context.sourceType && context.sourceId) {
2779
+ const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
2780
+ if (row)
2781
+ return rowToGoal(row);
2782
+ }
2783
+ return;
2784
+ }
2785
+ listGoals(opts = {}) {
2786
+ const limit = opts.limit ?? 100;
2787
+ const rows = opts.status ? this.db.query("SELECT * FROM goals WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM goals ORDER BY created_at DESC LIMIT ?").all(limit);
2788
+ return rows.map(rowToGoal);
2789
+ }
2790
+ createGoalPlanNodes(goalId, nodes, opts = {}) {
2791
+ const goal = this.requireGoal(goalId);
2792
+ const now = nowIso();
2793
+ const materialized = nodes.map((node, sequence) => ({
2794
+ nodeId: genId(),
2795
+ planId: goal.planId,
2796
+ key: node.key,
2797
+ sequence,
2798
+ priority: node.priority ?? 0,
2799
+ objective: node.objective,
2800
+ status: "pending",
2801
+ ready: false,
2802
+ tokenBudget: node.tokenBudget,
2803
+ tokensUsed: 0,
2804
+ timeUsedSeconds: 0,
2805
+ dependsOn: node.dependsOn ?? [],
2806
+ createdAt: now,
2807
+ updatedAt: now
2808
+ }));
2809
+ const withReady = updateReadyFlags(materialized, "active");
2810
+ this.db.exec("BEGIN IMMEDIATE");
2811
+ try {
2812
+ this.assertDaemonLeaseFence(opts, now);
2813
+ for (const node of withReady) {
2814
+ this.insertGoalPlanNode(goal, node);
2815
+ }
2816
+ this.db.exec("COMMIT");
2817
+ } catch (error) {
2818
+ try {
2819
+ this.db.exec("ROLLBACK");
2820
+ } catch {}
2821
+ throw error;
2822
+ }
2823
+ return this.listGoalPlanNodes(goalId);
2824
+ }
2825
+ insertGoalPlanNode(goal, node) {
2826
+ let id = node.nodeId;
2827
+ for (let attempt = 0;attempt < 3; attempt += 1) {
2828
+ const res = this.db.query(`INSERT OR IGNORE INTO goal_plan_nodes (id, goal_id, plan_id, key, sequence, priority, objective, status, ready,
2829
+ token_budget, tokens_used, time_used_seconds, depends_on_json, created_at, updated_at)
2830
+ VALUES ($id, $goalId, $planId, $key, $sequence, $priority, $objective, $status, $ready, $tokenBudget,
2831
+ $tokensUsed, $timeUsedSeconds, $dependsOn, $created, $updated)`).run({
2832
+ $id: id,
2833
+ $goalId: goal.goalId,
2834
+ $planId: goal.planId,
2835
+ $key: node.key,
2836
+ $sequence: node.sequence,
2837
+ $priority: node.priority,
2838
+ $objective: node.objective,
2839
+ $status: node.status,
2840
+ $ready: node.ready ? 1 : 0,
2841
+ $tokenBudget: node.tokenBudget ?? null,
2842
+ $tokensUsed: node.tokensUsed,
2843
+ $timeUsedSeconds: node.timeUsedSeconds,
2844
+ $dependsOn: JSON.stringify(node.dependsOn),
2845
+ $created: node.createdAt,
2846
+ $updated: node.updatedAt
2847
+ });
2848
+ if (res.changes === 1)
2849
+ return;
2850
+ const existingByKey = this.db.query("SELECT id FROM goal_plan_nodes WHERE plan_id = ? AND key = ? LIMIT 1").get(goal.planId, node.key);
2851
+ if (existingByKey)
2852
+ return;
2853
+ id = genId();
2854
+ }
2855
+ throw new Error(`goal plan node was not inserted after id retries: ${goal.planId}/${node.key}`);
2856
+ }
2857
+ listGoalPlanNodes(goalIdOrPlanId) {
2858
+ const rows = this.db.query("SELECT * FROM goal_plan_nodes WHERE goal_id = ? OR plan_id = ? ORDER BY sequence ASC").all(goalIdOrPlanId, goalIdOrPlanId);
2859
+ return rows.map(rowToGoalPlanNode);
2860
+ }
2861
+ updateGoalStatus(goalId, status, opts = {}) {
2862
+ const current = this.requireGoal(goalId);
2863
+ assertGoalTransition(current.status, status);
2864
+ const now = (opts.now ?? new Date).toISOString();
2865
+ this.db.query(`UPDATE goals SET status=$status, updated_at=$updated
2866
+ WHERE id=$id
2867
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2868
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2869
+ ))`).run({
2870
+ $id: goalId,
2871
+ $status: status,
2872
+ $updated: now,
2873
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2874
+ $now: now
2875
+ });
2876
+ return this.requireGoal(goalId);
2877
+ }
2878
+ addGoalUsage(goalId, tokens, timeUsedSeconds = 0, opts = {}) {
2879
+ const now = (opts.now ?? new Date).toISOString();
2880
+ this.db.query(`UPDATE goals
2881
+ SET tokens_used=tokens_used + $tokens, time_used_seconds=time_used_seconds + $seconds, updated_at=$updated
2882
+ WHERE id=$id
2883
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2884
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2885
+ ))`).run({
2886
+ $id: goalId,
2887
+ $tokens: tokens,
2888
+ $seconds: timeUsedSeconds,
2889
+ $updated: now,
2890
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2891
+ $now: now
2892
+ });
2893
+ return this.requireGoal(goalId);
2894
+ }
2895
+ updateGoalPlanNode(goalId, key, patch, opts = {}) {
2896
+ const now = (opts.now ?? new Date).toISOString();
2897
+ this.db.query(`UPDATE goal_plan_nodes
2898
+ SET status=COALESCE($status, status),
2899
+ tokens_used=COALESCE($tokensUsed, tokens_used),
2900
+ time_used_seconds=COALESCE($timeUsedSeconds, time_used_seconds),
2901
+ ready=COALESCE($ready, ready),
2902
+ updated_at=$updated
2903
+ WHERE goal_id=$goalId AND key=$key
2904
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2905
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2906
+ ))`).run({
2907
+ $goalId: goalId,
2908
+ $key: key,
2909
+ $status: patch.status ?? null,
2910
+ $tokensUsed: patch.tokensUsed ?? null,
2911
+ $timeUsedSeconds: patch.timeUsedSeconds ?? null,
2912
+ $ready: patch.ready === undefined ? null : patch.ready ? 1 : 0,
2913
+ $updated: now,
2914
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2915
+ $now: now
2916
+ });
2917
+ const node = this.listGoalPlanNodes(goalId).find((entry) => entry.key === key);
2918
+ if (!node)
2919
+ throw new Error(`goal node not found: ${goalId}/${key}`);
2920
+ return node;
2921
+ }
2922
+ recordGoalEvent(input, opts = {}) {
2923
+ const goal = this.requireGoal(input.goalId);
2924
+ const now = nowIso();
2925
+ this.db.exec("BEGIN IMMEDIATE");
2926
+ try {
2927
+ this.assertDaemonLeaseFence(opts, now);
2928
+ const previous = this.db.query("SELECT MAX(turn) AS turn FROM goal_runs WHERE goal_id = ?").get(goal.goalId);
2929
+ const turn = input.turn ?? (previous?.turn ?? 0) + 1;
2930
+ const id = genId();
2931
+ this.db.query(`INSERT INTO goal_runs (id, goal_id, plan_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
2932
+ turn, phase, status, node_key, tokens_used, evidence_json, raw_response_json, created_at, updated_at)
2933
+ VALUES ($id, $goalId, $planId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId,
2934
+ $turn, $phase, $status, $nodeKey, $tokensUsed, $evidence, $rawResponse, $created, $updated)`).run({
2935
+ $id: id,
2936
+ $goalId: goal.goalId,
2937
+ $planId: goal.planId,
2938
+ $loopId: goal.loopId ?? null,
2939
+ $loopRunId: goal.loopRunId ?? null,
2940
+ $workflowId: goal.workflowId ?? null,
2941
+ $workflowRunId: goal.workflowRunId ?? null,
2942
+ $workflowStepId: goal.workflowStepId ?? null,
2943
+ $turn: turn,
2944
+ $phase: input.phase,
2945
+ $status: input.status,
2946
+ $nodeKey: input.nodeKey ?? null,
2947
+ $tokensUsed: input.tokensUsed ?? 0,
2948
+ $evidence: input.evidence ? scrubSecrets(JSON.stringify(scrubSecretsDeep(input.evidence))) : null,
2949
+ $rawResponse: input.rawResponse === undefined ? null : scrubSecrets(JSON.stringify(scrubSecretsDeep(input.rawResponse))),
2950
+ $created: now,
2951
+ $updated: now
2952
+ });
2953
+ if (input.tokensUsed && input.tokensUsed > 0) {
2954
+ this.db.query("UPDATE goals SET tokens_used=tokens_used + ?, updated_at=? WHERE id=?").run(input.tokensUsed, now, goal.goalId);
2955
+ }
2956
+ this.db.exec("COMMIT");
2957
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
2958
+ if (!event)
2959
+ throw new Error(`goal run not found after record: ${id}`);
2960
+ return rowToGoalRun(event);
2961
+ } catch (error) {
2962
+ try {
2963
+ this.db.exec("ROLLBACK");
2964
+ } catch {}
2965
+ throw error;
2966
+ }
2967
+ }
2968
+ listGoalRuns(opts = {}) {
2969
+ const limit = opts.limit ?? 200;
2970
+ let rows;
2971
+ if (opts.goalId) {
2972
+ rows = this.db.query("SELECT * FROM goal_runs WHERE goal_id = ? ORDER BY created_at ASC LIMIT ?").all(opts.goalId, limit);
2973
+ } else if (opts.runId) {
2974
+ rows = this.db.query(`SELECT * FROM goal_runs
2975
+ WHERE id = ? OR loop_run_id = ? OR workflow_run_id = ?
2976
+ ORDER BY created_at ASC LIMIT ?`).all(opts.runId, opts.runId, opts.runId, limit);
2977
+ } else {
2978
+ rows = this.db.query("SELECT * FROM goal_runs ORDER BY created_at DESC LIMIT ?").all(limit);
2979
+ }
2980
+ return rows.map(rowToGoalRun);
2981
+ }
2982
+ createWorkflowRun(input) {
2983
+ const now = nowIso();
2984
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
2985
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
2986
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
2987
+ if (input.idempotencyKey) {
2988
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
2989
+ if (existing) {
2990
+ this.assertDaemonLeaseFence(input);
2991
+ return rowToWorkflowRun(existing);
2992
+ }
2993
+ }
2994
+ const runId = genId();
2995
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
2996
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
2997
+ const staged = stageWorkflowRunManifest({
2998
+ loopsDataDir: this.rootDir,
2999
+ workflowRunId: runId,
3000
+ workflowId: input.workflow.id,
3001
+ workflowName: input.workflow.name,
3002
+ invocationId,
3003
+ workItemId,
3004
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
3005
+ subjectKind: invocation?.subjectRef.kind ?? (input.loop ? "loop" : "workflow"),
3006
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url ?? input.loop?.name ?? input.workflow.name,
3007
+ payload: {
3008
+ workflowInvocation: invocation,
3009
+ workflowWorkItem: workItem,
3010
+ loopId: input.loop?.id,
3011
+ loopRunId: input.loopRun?.id,
3012
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
3013
+ }
3014
+ });
3015
+ const manifestPath = staged.manifestPath;
3016
+ this.db.exec("BEGIN IMMEDIATE");
3017
+ try {
3018
+ this.assertDaemonLeaseFence(input, now);
3019
+ if (input.idempotencyKey) {
3020
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
3021
+ if (existing) {
3022
+ this.db.exec("COMMIT");
3023
+ discardWorkflowRunManifest(staged);
3024
+ return rowToWorkflowRun(existing);
3025
+ }
3026
+ }
3027
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
3028
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
3029
+ created_at, updated_at)
3030
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
3031
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
3032
+ $id: runId,
3033
+ $workflowId: input.workflow.id,
3034
+ $workflowName: input.workflow.name,
3035
+ $loopId: input.loop?.id ?? null,
3036
+ $loopRunId: input.loopRun?.id ?? null,
3037
+ $invocationId: invocationId ?? null,
3038
+ $workItemId: workItemId ?? null,
3039
+ $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
3040
+ $idempotencyKey: input.idempotencyKey ?? null,
3041
+ $manifestPath: manifestPath ?? null,
3042
+ $started: now,
3043
+ $created: now,
3044
+ $updated: now
3045
+ });
3046
+ if (workItemId) {
3047
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
3048
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
3049
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
3050
+ $id: workItemId,
3051
+ $workflowRunId: runId,
3052
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
3053
+ $updated: now
3054
+ });
3055
+ if (workItemRes.changes !== 1) {
3056
+ const current = this.getWorkflowWorkItem(workItemId);
3057
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
3058
+ }
3059
+ }
3060
+ input.workflow.steps.forEach((step, sequence) => {
3061
+ const account = step.account ?? step.target.account;
3062
+ this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
3063
+ exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
3064
+ VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
3065
+ $accountProfile, $accountTool, $created, $updated)`).run({
3066
+ $id: genId(),
3067
+ $workflowRunId: runId,
3068
+ $stepId: step.id,
3069
+ $sequence: sequence,
3070
+ $accountProfile: account?.profile ?? null,
3071
+ $accountTool: account?.tool ?? null,
3072
+ $created: now,
3073
+ $updated: now
3074
+ });
3075
+ });
3076
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
3077
+ VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
3078
+ $id: genId(),
3079
+ $workflowRunId: runId,
3080
+ $payload: JSON.stringify({
3081
+ workflowId: input.workflow.id,
3082
+ workflowName: input.workflow.name,
3083
+ stepCount: input.workflow.steps.length,
3084
+ loopId: input.loop?.id,
3085
+ loopRunId: input.loopRun?.id,
3086
+ invocationId,
3087
+ workItemId,
3088
+ manifestPath
3089
+ }),
3090
+ $created: now
3091
+ });
3092
+ this.db.exec("COMMIT");
3093
+ commitWorkflowRunManifest(staged);
3094
+ const run = this.getWorkflowRun(runId);
3095
+ if (!run)
3096
+ throw new Error(`workflow run not found after create: ${runId}`);
3097
+ return run;
3098
+ } catch (error) {
3099
+ try {
3100
+ this.db.exec("ROLLBACK");
3101
+ } catch {}
3102
+ discardWorkflowRunManifest(staged);
3103
+ throw error;
3104
+ }
3105
+ }
3106
+ getWorkflowRun(id) {
3107
+ const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
3108
+ return row ? rowToWorkflowRun(row) : undefined;
3109
+ }
3110
+ requireWorkflowRun(id) {
3111
+ const run = this.getWorkflowRun(id);
3112
+ if (!run)
3113
+ throw new Error(`workflow run not found: ${id}`);
3114
+ return run;
3115
+ }
3116
+ listWorkflowRuns(opts = {}) {
3117
+ const limit = opts.limit ?? 100;
3118
+ let rows;
3119
+ if (opts.workflowId) {
3120
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
3121
+ } else if (opts.loopRunId) {
3122
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
3123
+ } else {
3124
+ rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
3125
+ }
3126
+ return rows.map(rowToWorkflowRun);
3127
+ }
3128
+ listWorkflowStepRuns(workflowRunId) {
3129
+ const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
3130
+ return rows.map(rowToWorkflowStepRun);
3131
+ }
3132
+ getWorkflowStepRun(workflowRunId, stepId) {
3133
+ const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
3134
+ return row ? rowToWorkflowStepRun(row) : undefined;
3135
+ }
3136
+ isWorkflowRunTerminal(workflowRunId) {
3137
+ const run = this.getWorkflowRun(workflowRunId);
3138
+ return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
3139
+ }
3140
+ startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
3141
+ const now = (opts.now ?? new Date).toISOString();
3142
+ this.db.exec("BEGIN IMMEDIATE");
3143
+ try {
3144
+ const res = this.db.query(`UPDATE workflow_step_runs
3145
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
3146
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3147
+ WHERE workflow_run_id=$workflowRunId
3148
+ AND step_id=$stepId
3149
+ AND status IN ('pending', 'failed', 'timed_out')
3150
+ AND EXISTS (
3151
+ SELECT 1 FROM workflow_runs
3152
+ WHERE id=$workflowRunId AND status='running'
3153
+ )
3154
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3155
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3156
+ ))`).run({
3157
+ $workflowRunId: workflowRunId,
3158
+ $stepId: stepId,
3159
+ $started: now,
3160
+ $updated: now,
3161
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3162
+ $now: now
3163
+ });
3164
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3165
+ if (!run)
3166
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
3167
+ if (res.changes !== 1) {
3168
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
3169
+ }
3170
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
3171
+ this.db.exec("COMMIT");
3172
+ return run;
3173
+ } catch (error) {
3174
+ try {
3175
+ this.db.exec("ROLLBACK");
3176
+ } catch {}
3177
+ throw error;
3178
+ }
3179
+ }
3180
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
3181
+ const now = (opts.now ?? new Date).toISOString();
3182
+ this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
3183
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3184
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3185
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3186
+ ))`).run({
3187
+ $workflowRunId: workflowRunId,
3188
+ $stepId: stepId,
3189
+ $pid: pid,
3190
+ $updated: now,
3191
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3192
+ $now: now
3193
+ });
3194
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3195
+ if (!run)
3196
+ throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3197
+ return run;
3198
+ }
3199
+ recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3200
+ return this.transact(() => {
3201
+ const now = nowIso();
3202
+ const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
3203
+ const live = before.filter((step) => step.pid !== undefined && isLiveStepProcess(step.pid, step.startedAt));
3204
+ if (live.length > 0) {
3205
+ throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
3206
+ }
3207
+ this.db.query(`UPDATE workflow_step_runs
3208
+ SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
3209
+ stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
3210
+ WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
3211
+ if (before.length > 0) {
3212
+ this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
3213
+ reason,
3214
+ recoveredSteps: before.map((step) => step.stepId)
3215
+ });
3216
+ }
3217
+ return {
3218
+ run: this.requireWorkflowRun(workflowRunId),
3219
+ recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
3220
+ };
3221
+ });
3222
+ }
3223
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
3224
+ const finishedAt = patch.finishedAt ?? nowIso();
3225
+ const error = patch.error === undefined ? undefined : scrubSecrets(patch.error);
3226
+ this.db.exec("BEGIN IMMEDIATE");
3227
+ try {
3228
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
3229
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3230
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3231
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3232
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3233
+ ))`).run({
3234
+ $workflowRunId: workflowRunId,
3235
+ $stepId: stepId,
3236
+ $status: patch.status,
3237
+ $finished: finishedAt,
3238
+ $exitCode: patch.exitCode ?? null,
3239
+ $durationMs: patch.durationMs ?? null,
3240
+ $stdout: scrubbedOrNull(patch.stdout),
3241
+ $stderr: scrubbedOrNull(patch.stderr),
3242
+ $error: error ?? null,
3243
+ $updated: finishedAt,
3244
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3245
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
3246
+ });
3247
+ if (res.changes === 1) {
3248
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
3249
+ exitCode: patch.exitCode,
3250
+ error
3251
+ });
3252
+ }
3253
+ this.db.exec("COMMIT");
3254
+ } catch (error2) {
3255
+ try {
3256
+ this.db.exec("ROLLBACK");
3257
+ } catch {}
3258
+ throw error2;
3259
+ }
3260
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3261
+ if (!run)
3262
+ throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
3263
+ return run;
3264
+ }
3265
+ skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
3266
+ const now = (opts.now ?? new Date).toISOString();
3267
+ this.db.exec("BEGIN IMMEDIATE");
3268
+ try {
3269
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
3270
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
3271
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3272
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3273
+ ))`).run({
3274
+ $workflowRunId: workflowRunId,
3275
+ $stepId: stepId,
3276
+ $finished: now,
3277
+ $error: reason,
3278
+ $updated: now,
3279
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3280
+ $now: now
3281
+ });
3282
+ if (res.changes === 1)
3283
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
3284
+ this.db.exec("COMMIT");
3285
+ } catch (error) {
3286
+ try {
3287
+ this.db.exec("ROLLBACK");
3288
+ } catch {}
3289
+ throw error;
3290
+ }
3291
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3292
+ if (!run)
3293
+ throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
3294
+ return run;
3295
+ }
3296
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
3297
+ const finishedAt = patch.finishedAt ?? nowIso();
3298
+ const error = patch.error === undefined ? undefined : scrubSecrets(patch.error);
3299
+ let changed = false;
3300
+ this.db.exec("BEGIN IMMEDIATE");
3301
+ try {
3302
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
3303
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
3304
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3305
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3306
+ ))`).run({
3307
+ $id: workflowRunId,
3308
+ $status: status,
3309
+ $finished: finishedAt,
3310
+ $durationMs: patch.durationMs ?? null,
3311
+ $error: error ?? null,
3312
+ $updated: finishedAt,
3313
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3314
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
3315
+ });
3316
+ changed = res.changes === 1;
3317
+ if (changed)
3318
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error });
3319
+ if (changed) {
3320
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
3321
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, error, finishedAt);
3322
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
3323
+ }
3324
+ this.db.exec("COMMIT");
3325
+ } catch (error2) {
3326
+ try {
3327
+ this.db.exec("ROLLBACK");
3328
+ } catch {}
3329
+ throw error2;
3330
+ }
3331
+ const run = this.getWorkflowRun(workflowRunId);
3332
+ if (!run)
3333
+ throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
3334
+ return run;
3335
+ }
3336
+ cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
3337
+ const now = nowIso();
3338
+ this.db.exec("BEGIN IMMEDIATE");
3339
+ try {
3340
+ const run = this.requireWorkflowRun(workflowRunId);
3341
+ if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
3342
+ this.db.query(`UPDATE workflow_runs
3343
+ SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
3344
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
3345
+ this.db.query(`UPDATE workflow_step_runs
3346
+ SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
3347
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
3348
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
3349
+ this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
3350
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
3351
+ }
3352
+ this.db.exec("COMMIT");
3353
+ return this.requireWorkflowRun(workflowRunId);
3354
+ } catch (error) {
3355
+ try {
3356
+ this.db.exec("ROLLBACK");
3357
+ } catch {}
3358
+ throw error;
3359
+ }
3360
+ }
3361
+ appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
3362
+ return this.transact(() => {
3363
+ const now = nowIso();
3364
+ const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
3365
+ const sequence = (current?.sequence ?? 0) + 1;
3366
+ const id = genId();
3367
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
3368
+ VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
3369
+ $id: id,
3370
+ $workflowRunId: workflowRunId,
3371
+ $sequence: sequence,
3372
+ $eventType: eventType,
3373
+ $stepId: stepId ?? null,
3374
+ $payload: payload ? JSON.stringify(payload) : null,
3375
+ $created: now
3376
+ });
3377
+ const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
3378
+ if (!event)
3379
+ throw new Error(`workflow event not found after append: ${id}`);
3380
+ return rowToWorkflowEvent(event);
3381
+ });
3382
+ }
3383
+ listWorkflowEvents(workflowRunId, limit = 200) {
3384
+ const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
3385
+ return rows.map(rowToWorkflowEvent);
3386
+ }
3387
+ hasRunningRun(loopId) {
3388
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
3389
+ return (row?.count ?? 0) > 0;
3390
+ }
3391
+ hasRunningRunForSlot(loopId, scheduledFor) {
3392
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
3393
+ return (row?.count ?? 0) > 0;
3394
+ }
3395
+ markRunPid(id, pid, claimedBy, opts = {}) {
3396
+ const now = (opts.now ?? new Date).toISOString();
3397
+ const startedMs = processStartTimeMs(pid);
3398
+ const processStartedAt = startedMs === undefined ? null : new Date(startedMs).toISOString();
3399
+ const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, process_started_at=$processStartedAt, updated_at=$updated
3400
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy
3401
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3402
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3403
+ ))`).run({
3404
+ $id: id,
3405
+ $pid: pid,
3406
+ $processStartedAt: processStartedAt,
3407
+ $updated: now,
3408
+ $claimedBy: claimedBy,
3409
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3410
+ $now: now
3411
+ }) : this.db.query(`UPDATE loop_runs SET pid=$pid, process_started_at=$processStartedAt, updated_at=$updated
3412
+ WHERE id=$id AND status='running'
3413
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3414
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3415
+ ))`).run({
3416
+ $id: id,
3417
+ $pid: pid,
3418
+ $processStartedAt: processStartedAt,
3419
+ $updated: now,
3420
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3421
+ $now: now
3422
+ });
3423
+ if (res.changes !== 1)
3424
+ return;
3425
+ return this.getRun(id);
3426
+ }
3427
+ recordRunProcess(runId, info, opts = {}) {
3428
+ const now = (opts.now ?? new Date).toISOString();
3429
+ const res = this.db.query(`UPDATE loop_runs SET pid=$pid, pgid=$pgid, process_started_at=$processStartedAt, updated_at=$updated
3430
+ WHERE id=$id AND status='running'
3431
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3432
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3433
+ ))`).run({
3434
+ $id: runId,
3435
+ $pid: info.pid,
3436
+ $pgid: info.pgid ?? null,
3437
+ $processStartedAt: info.processStartedAt ?? isoProcessStart(info.pid) ?? now,
3438
+ $updated: now,
3439
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3440
+ $now: now
3441
+ });
3442
+ if (res.changes !== 1)
3443
+ return;
3444
+ return this.getRun(runId);
3445
+ }
3446
+ hasLiveWorkflowStepProcesses(loopRunId) {
3447
+ const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid, wsr.started_at AS started_at
3448
+ FROM workflow_runs wr
3449
+ JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
3450
+ WHERE wr.loop_run_id = ?
3451
+ AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
3452
+ AND wsr.status = 'running'
3453
+ AND wsr.pid IS NOT NULL`).all(loopRunId);
3454
+ return liveWorkflowSteps.some((step) => isLiveStepProcess(step.pid, step.started_at));
3455
+ }
3456
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
3457
+ const now = nowIso();
3458
+ const run = {
3459
+ id: genId(),
3460
+ loopId: loop.id,
3461
+ loopName: loop.name,
3462
+ scheduledFor,
3463
+ attempt: 1,
3464
+ status: "skipped",
3465
+ finishedAt: now,
3466
+ error: reason,
3467
+ createdAt: now,
3468
+ updatedAt: now
3469
+ };
3470
+ this.db.exec("BEGIN IMMEDIATE");
3471
+ try {
3472
+ this.assertDaemonLeaseFence(opts, now);
3473
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3474
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3475
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
3476
+ NULL, NULL, $error, $created, $updated)`).run({
3477
+ $id: run.id,
3478
+ $loopId: run.loopId,
3479
+ $loopName: run.loopName,
3480
+ $scheduledFor: run.scheduledFor,
3481
+ $attempt: run.attempt,
3482
+ $status: run.status,
3483
+ $finished: run.finishedAt ?? null,
3484
+ $error: run.error ?? null,
3485
+ $created: run.createdAt,
3486
+ $updated: run.updatedAt
3487
+ });
3488
+ this.db.exec("COMMIT");
3489
+ } catch (error) {
3490
+ try {
3491
+ this.db.exec("ROLLBACK");
3492
+ } catch {}
3493
+ throw error;
3494
+ }
3495
+ return this.getRunBySlot(loop.id, scheduledFor) ?? run;
3496
+ }
3497
+ getRun(id) {
3498
+ const row = this.db.query("SELECT * FROM loop_runs WHERE id = ?").get(id);
3499
+ return row ? rowToRun(row) : undefined;
3500
+ }
3501
+ getRunBySlot(loopId, scheduledFor) {
3502
+ const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
3503
+ return row ? rowToRun(row) : undefined;
3504
+ }
3505
+ nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
3506
+ const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
3507
+ WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
3508
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
3509
+ WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
3510
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
3511
+ return row ? rowToRun(row) : undefined;
3512
+ }
3513
+ claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
3514
+ const startedAt = now.toISOString();
3515
+ const claimToken = opts.claimToken ?? genId();
3516
+ this.db.exec("BEGIN IMMEDIATE");
3517
+ try {
3518
+ this.assertDaemonLeaseFence(opts, startedAt);
3519
+ const currentLoop = this.getLoop(loop.id);
3520
+ if (!currentLoop || currentLoop.archivedAt) {
3521
+ this.db.exec("COMMIT");
3522
+ return;
3523
+ }
3524
+ loop = currentLoop;
3525
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
3526
+ const existing = this.getRunBySlot(loop.id, scheduledFor);
3527
+ if (existing) {
3528
+ if (existing.status === "running") {
3529
+ if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && isRecordedProcessAlive(existing.pid, existing.processStartedAt)) {
3530
+ this.db.exec("COMMIT");
3531
+ return;
3532
+ }
3533
+ if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
3534
+ this.db.exec("COMMIT");
3535
+ return;
3536
+ }
3537
+ const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
3538
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3539
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3540
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
3541
+ $id: existing.id,
3542
+ $started: startedAt,
3543
+ $claimedBy: runnerId,
3544
+ $claimToken: claimToken,
3545
+ $lease: leaseExpiresAt,
3546
+ $updated: startedAt,
3547
+ $now: startedAt
3548
+ });
3549
+ this.db.exec("COMMIT");
3550
+ if (res3.changes !== 1)
3551
+ return;
3552
+ const run3 = this.getRun(existing.id);
3553
+ return run3 ? { run: run3, loop, claimToken } : undefined;
3554
+ }
3555
+ if (existing.status === "succeeded" || existing.status === "skipped") {
3556
+ this.db.exec("COMMIT");
3557
+ return;
3558
+ }
3559
+ const attempt = existing.attempt + 1;
3560
+ const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
3561
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3562
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3563
+ WHERE id=$id
3564
+ AND status IN ('failed', 'timed_out', 'abandoned')
3565
+ AND attempt < $maxAttempts`).run({
3566
+ $id: existing.id,
3567
+ $attempt: attempt,
3568
+ $started: startedAt,
3569
+ $claimedBy: runnerId,
3570
+ $claimToken: claimToken,
3571
+ $lease: leaseExpiresAt,
3572
+ $updated: startedAt,
3573
+ $maxAttempts: loop.maxAttempts
3574
+ });
3575
+ this.db.exec("COMMIT");
3576
+ if (res2.changes !== 1)
3577
+ return;
3578
+ const run2 = this.getRun(existing.id);
3579
+ return run2 ? { run: run2, loop, claimToken } : undefined;
3580
+ }
3581
+ const id = genId();
3582
+ const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3583
+ claimed_by, claim_token, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3584
+ VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $claimToken, $lease,
3585
+ NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
3586
+ $id: id,
3587
+ $loopId: loop.id,
3588
+ $loopName: loop.name,
3589
+ $scheduledFor: scheduledFor,
3590
+ $started: startedAt,
3591
+ $claimedBy: runnerId,
3592
+ $claimToken: claimToken,
3593
+ $lease: leaseExpiresAt,
3594
+ $created: startedAt,
3595
+ $updated: startedAt
3596
+ });
3597
+ this.db.exec("COMMIT");
3598
+ if (res.changes !== 1)
3599
+ return;
3600
+ const run = this.getRun(id);
3601
+ return run ? { run, loop, claimToken } : undefined;
3602
+ } catch (error) {
3603
+ try {
3604
+ this.db.exec("ROLLBACK");
3605
+ } catch {}
3606
+ throw error;
3607
+ }
3608
+ }
3609
+ finalizeRun(id, patch, opts = {}) {
3610
+ const finishedAt = patch.finishedAt ?? nowIso();
3611
+ const error = patch.error === undefined ? undefined : scrubSecrets(patch.error);
3612
+ const params = {
3613
+ $id: id,
3614
+ $status: patch.status,
3615
+ $finished: finishedAt,
3616
+ $pid: patch.pid ?? null,
3617
+ $exitCode: patch.exitCode ?? null,
3618
+ $durationMs: patch.durationMs ?? null,
3619
+ $stdout: scrubbedOrNull(patch.stdout),
3620
+ $stderr: scrubbedOrNull(patch.stderr),
3621
+ $error: error ?? null,
3622
+ $updated: finishedAt,
3623
+ $claimedBy: opts.claimedBy ?? null,
3624
+ $claimToken: opts.claimToken ?? null,
3625
+ $now: (opts.now ?? new Date).toISOString(),
3626
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3627
+ };
3628
+ return this.transact(() => {
3629
+ const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3630
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3631
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3632
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3633
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3634
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3635
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3636
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3637
+ const run = this.getRun(id);
3638
+ if (!run)
3639
+ throw new Error(`run not found after finalize: ${id}`);
3640
+ if (opts.claimedBy && res.changes !== 1)
3641
+ return run;
3642
+ if (res.changes === 1) {
3643
+ this.setWorkflowWorkItemsForLoopRun(run, error, finishedAt);
3644
+ const loop = this.getLoop(run.loopId);
3645
+ const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
3646
+ if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
3647
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
3648
+ this.maybeArchiveGeneratedRouteWorkflow({
3649
+ workflowId: loop.target.workflowId,
3650
+ loopId: loop.id,
3651
+ workItemId,
3652
+ updated: finishedAt
3653
+ });
3654
+ }
3655
+ }
3656
+ return run;
3657
+ });
3658
+ }
3659
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
3660
+ const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
3661
+ const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
3662
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3663
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3664
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3665
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3666
+ ))`).run({
3667
+ $id: id,
3668
+ $claimedBy: claimedBy,
3669
+ $claimToken: opts.claimToken ?? null,
3670
+ $expires: expiresAt,
3671
+ $updated: now.toISOString(),
3672
+ $now: now.toISOString(),
3673
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3674
+ });
3675
+ if (res.changes !== 1)
3676
+ return;
3677
+ return this.getRun(id);
3678
+ }
3679
+ listRuns(opts = {}) {
3680
+ const limit = opts.limit ?? 100;
3681
+ let rows;
3682
+ if (opts.loopId && opts.status) {
3683
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, opts.status, limit);
3684
+ } else if (opts.loopId) {
3685
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, limit);
3686
+ } else if (opts.status) {
3687
+ rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit);
3688
+ } else {
3689
+ rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ?").all(limit);
3690
+ }
3691
+ return rows.map(rowToRun);
3692
+ }
3693
+ deferLiveExpiredRun(id, now, opts = {}) {
3694
+ const updated = now.toISOString();
3695
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
3696
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
3697
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
3698
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3699
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3700
+ ))`).run({
3701
+ $id: id,
3702
+ $deferredUntil: deferredUntil,
3703
+ $updated: updated,
3704
+ $now: updated,
3705
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3706
+ });
3707
+ }
3708
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
3709
+ return this.recoverExpiredRunLeasesDetailed(now, opts).abandoned;
3710
+ }
3711
+ recoverExpiredRunLeasesDetailed(now = new Date, opts = {}) {
3712
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
3713
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
3714
+ const rows = this.db.query(`SELECT * FROM loop_runs
3715
+ WHERE status = 'running' AND lease_expires_at <= ?
3716
+ ORDER BY lease_expires_at ASC
3717
+ LIMIT ?`).all(now.toISOString(), scanLimit);
3718
+ const recovered = [];
3719
+ const deferred = [];
3720
+ for (const row of rows) {
3721
+ if (recovered.length >= limit)
3722
+ break;
3723
+ if (isRecordedProcessAlive(row.pid, row.process_started_at) || this.hasLiveWorkflowStepProcesses(row.id)) {
3724
+ this.deferLiveExpiredRun(row.id, now, opts);
3725
+ const deferredRun = this.getRun(row.id);
3726
+ if (deferredRun)
3727
+ deferred.push(deferredRun);
3728
+ continue;
3729
+ }
3730
+ const finished = now.toISOString();
3731
+ this.db.exec("BEGIN IMMEDIATE");
3732
+ try {
3733
+ const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
3734
+ error='run lease expired before completion', updated_at=$updated
3735
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
3736
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3737
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3738
+ ))`).run({
3739
+ $id: row.id,
3740
+ $finished: finished,
3741
+ $updated: finished,
3742
+ $now: finished,
3743
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3744
+ });
3745
+ if (res.changes !== 1) {
3746
+ this.db.exec("COMMIT");
3747
+ continue;
3748
+ }
3749
+ const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
3750
+ for (const workflowRow of workflowRows) {
3751
+ const workflowRes = this.db.query(`UPDATE workflow_runs
3752
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
3753
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
3754
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3755
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3756
+ ))`).run({
3757
+ $id: workflowRow.id,
3758
+ $finished: finished,
3759
+ $updated: finished,
3760
+ $now: finished,
3761
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3762
+ });
3763
+ if (workflowRes.changes !== 1)
3764
+ continue;
3765
+ this.db.query(`UPDATE workflow_step_runs
3766
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
3767
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
3768
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3769
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3770
+ ))`).run({
3771
+ $workflowRunId: workflowRow.id,
3772
+ $finished: finished,
3773
+ $updated: finished,
3774
+ $now: finished,
3775
+ $daemonLeaseId: opts.daemonLeaseId ?? null
3776
+ });
3777
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
3778
+ error: "parent loop run lease expired before completion",
3779
+ loopRunId: row.id
3780
+ });
3781
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
3782
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
3783
+ }
3784
+ const loop = this.getLoop(row.loop_id);
3785
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
3786
+ if (itemStatus) {
3787
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
3788
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
3789
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
3790
+ if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
3791
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
3792
+ this.maybeArchiveGeneratedRouteWorkflow({
3793
+ workflowId: loop.target.workflowId,
3794
+ loopId: loop.id,
3795
+ workItemId,
3796
+ updated: finished
3797
+ });
3798
+ }
3799
+ }
3800
+ this.db.exec("COMMIT");
3801
+ } catch (error) {
3802
+ try {
3803
+ this.db.exec("ROLLBACK");
3804
+ } catch {}
3805
+ throw error;
3806
+ }
3807
+ const run = this.getRun(row.id);
3808
+ if (run)
3809
+ recovered.push(run);
3810
+ }
3811
+ return { abandoned: recovered, deferred };
3812
+ }
3813
+ expireLoops(now = new Date, opts = {}) {
3814
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND archived_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
3815
+ const expired = [];
3816
+ for (const row of rows) {
3817
+ const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
3818
+ if (updated.status === "expired")
3819
+ expired.push(updated);
3820
+ }
3821
+ return expired;
3822
+ }
3823
+ countLoops(status, opts = {}) {
3824
+ let row;
3825
+ if (status && opts.archived) {
3826
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
3827
+ } else if (status && opts.includeArchived) {
3828
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
3829
+ } else if (status) {
3830
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
3831
+ } else if (opts.archived) {
3832
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
3833
+ } else if (opts.includeArchived) {
3834
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
3835
+ } else {
3836
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
3837
+ }
3838
+ return row?.count ?? 0;
3839
+ }
3840
+ countRuns(status) {
3841
+ const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loop_runs").get();
3842
+ return row?.count ?? 0;
3843
+ }
3844
+ pruneHistory(opts) {
3845
+ const { maxAgeDays, keepPerLoop } = opts;
3846
+ if (maxAgeDays === undefined && keepPerLoop === undefined) {
3847
+ throw new ValidationError("pruneHistory requires maxAgeDays and/or keepPerLoop");
3848
+ }
3849
+ if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) {
3850
+ throw new ValidationError(`pruneHistory maxAgeDays must be a non-negative number: ${maxAgeDays}`);
3851
+ }
3852
+ if (keepPerLoop !== undefined && (!Number.isInteger(keepPerLoop) || keepPerLoop < 0)) {
3853
+ throw new ValidationError(`pruneHistory keepPerLoop must be a non-negative integer: ${keepPerLoop}`);
3854
+ }
3855
+ const now = opts.now ?? new Date;
3856
+ const dryRun = opts.dryRun ?? false;
3857
+ const cutoff = maxAgeDays === undefined ? undefined : new Date(now.getTime() - maxAgeDays * 86400000).toISOString();
3858
+ const terminal = TERMINAL_RUN_STATUSES.map((status) => `'${status}'`).join(",");
3859
+ const candidateIds = this.db.query(`WITH ranked AS (
3860
+ SELECT id, status, created_at,
3861
+ ROW_NUMBER() OVER (PARTITION BY loop_id ORDER BY created_at DESC, id DESC) AS recency
3862
+ FROM loop_runs
3863
+ )
3864
+ SELECT id FROM ranked
3865
+ WHERE status IN (${terminal})
3866
+ AND ($cutoff IS NULL OR created_at < $cutoff)
3867
+ AND ($keep IS NULL OR recency > $keep)`).all({ $cutoff: cutoff ?? null, $keep: keepPerLoop ?? null }).map((row) => row.id);
3868
+ const summary = {
3869
+ dryRun,
3870
+ cutoff,
3871
+ keepPerLoop,
3872
+ loopRuns: dryRun ? candidateIds.length : 0,
3873
+ workflowRuns: 0,
3874
+ goalRuns: 0
3875
+ };
3876
+ const manifestPaths = [];
3877
+ for (let offset = 0;offset < candidateIds.length; offset += PRUNE_BATCH_SIZE) {
3878
+ const batch = candidateIds.slice(offset, offset + PRUNE_BATCH_SIZE);
3879
+ const batchPlaceholders = batch.map(() => "?").join(",");
3880
+ if (dryRun) {
3881
+ const workflowRunIds = this.db.query(`SELECT id FROM workflow_runs WHERE loop_run_id IN (${batchPlaceholders})`).all(...batch).map((row) => row.id);
3882
+ const workflowPlaceholders = workflowRunIds.map(() => "?").join(",") || "''";
3883
+ summary.workflowRuns += workflowRunIds.length;
3884
+ summary.goalRuns += this.db.query(`SELECT COUNT(*) AS count FROM goal_runs
3885
+ WHERE loop_run_id IN (${batchPlaceholders}) OR workflow_run_id IN (${workflowPlaceholders})`).get(...batch, ...workflowRunIds)?.count ?? 0;
3886
+ continue;
3887
+ }
3888
+ this.transact(() => {
3889
+ const confirmed = this.db.query(`SELECT id FROM loop_runs WHERE id IN (${batchPlaceholders}) AND status IN (${terminal})`).all(...batch).map((row) => row.id);
3890
+ if (confirmed.length === 0)
3891
+ return;
3892
+ const runPlaceholders = confirmed.map(() => "?").join(",");
3893
+ const workflowRuns = this.db.query(`SELECT id, manifest_path FROM workflow_runs WHERE loop_run_id IN (${runPlaceholders})`).all(...confirmed);
3894
+ const workflowRunIds = workflowRuns.map((row) => row.id);
3895
+ const workflowPlaceholders = workflowRunIds.map(() => "?").join(",") || "''";
3896
+ summary.loopRuns += confirmed.length;
3897
+ summary.workflowRuns += workflowRunIds.length;
3898
+ summary.goalRuns += this.db.query(`DELETE FROM goal_runs WHERE loop_run_id IN (${runPlaceholders}) OR workflow_run_id IN (${workflowPlaceholders})`).run(...confirmed, ...workflowRunIds).changes;
3899
+ if (workflowRunIds.length > 0) {
3900
+ this.db.query(`DELETE FROM workflow_runs WHERE id IN (${workflowPlaceholders})`).run(...workflowRunIds);
3901
+ }
3902
+ this.db.query(`DELETE FROM loop_runs WHERE id IN (${runPlaceholders}) AND status IN (${terminal})`).run(...confirmed);
3903
+ for (const row of workflowRuns) {
3904
+ if (row.manifest_path)
3905
+ manifestPaths.push(row.manifest_path);
3906
+ }
3907
+ });
3908
+ }
3909
+ for (const manifestPath of manifestPaths) {
3910
+ const runDir = dirname2(manifestPath);
3911
+ try {
3912
+ if (/^[0-9a-f]{12,64}$/.test(basename2(runDir))) {
3913
+ rmSync2(runDir, { recursive: true, force: true });
3914
+ } else {
3915
+ rmSync2(manifestPath, { force: true });
3916
+ }
3917
+ } catch {}
3918
+ }
3919
+ return summary;
3920
+ }
3921
+ acquireDaemonLease(input) {
3922
+ const now = input.now ?? new Date;
3923
+ const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
3924
+ this.db.exec("BEGIN IMMEDIATE");
3925
+ try {
3926
+ const existing = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
3927
+ if (existing && existing.expires_at > now.toISOString() && existing.id !== input.id) {
3928
+ this.db.exec("COMMIT");
3929
+ return;
3930
+ }
3931
+ this.db.query("DELETE FROM daemon_lease").run();
3932
+ this.db.query(`INSERT INTO daemon_lease (id, pid, hostname, heartbeat_at, expires_at, created_at, updated_at)
3933
+ VALUES ($id, $pid, $hostname, $heartbeat, $expires, $created, $updated)`).run({
3934
+ $id: input.id,
3935
+ $pid: input.pid,
3936
+ $hostname: input.hostname,
3937
+ $heartbeat: now.toISOString(),
3938
+ $expires: expiresAt,
3939
+ $created: now.toISOString(),
3940
+ $updated: now.toISOString()
3941
+ });
3942
+ this.db.exec("COMMIT");
3943
+ return this.getDaemonLease();
3944
+ } catch (error) {
3945
+ try {
3946
+ this.db.exec("ROLLBACK");
3947
+ } catch {}
3948
+ throw error;
3949
+ }
3950
+ }
3951
+ heartbeatDaemonLease(id, ttlMs, now = new Date) {
3952
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
3953
+ const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id AND expires_at > $now`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString(), $now: now.toISOString() });
3954
+ if (res.changes !== 1)
3955
+ return;
3956
+ return this.getDaemonLease();
3957
+ }
3958
+ releaseDaemonLease(id) {
3959
+ this.db.query("DELETE FROM daemon_lease WHERE id = ?").run(id);
3960
+ }
3961
+ getDaemonLease() {
3962
+ const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
3963
+ return row ? rowToLease(row) : undefined;
3964
+ }
3965
+ writeTransaction(fn) {
3966
+ this.db.exec("BEGIN IMMEDIATE;");
3967
+ try {
3968
+ const result = fn();
3969
+ this.db.exec("COMMIT;");
3970
+ return result;
3971
+ } catch (error) {
3972
+ try {
3973
+ this.db.exec("ROLLBACK;");
3974
+ } catch {}
3975
+ throw error;
3976
+ }
3977
+ }
3978
+ close() {
3979
+ this.db.close();
3980
+ }
3981
+ }
3982
+
3983
+ // src/lib/storage/sqlite.ts
3984
+ class SqliteLoopStorage {
3985
+ store;
3986
+ backend = "sqlite";
3987
+ supportsRemoteRunners = false;
3988
+ constructor(store = new Store) {
3989
+ this.store = store;
3990
+ }
3991
+ async close() {
3992
+ this.store.close();
3993
+ }
3994
+ async call(method, ...args) {
3995
+ return this.store[method](...args);
3996
+ }
3997
+ createLoop(...args) {
3998
+ return this.call("createLoop", ...args);
3999
+ }
4000
+ getLoop(...args) {
4001
+ return this.call("getLoop", ...args);
4002
+ }
4003
+ findLoopByName(...args) {
4004
+ return this.call("findLoopByName", ...args);
4005
+ }
4006
+ requireLoop(...args) {
4007
+ return this.call("requireLoop", ...args);
4008
+ }
4009
+ listLoops(...args) {
4010
+ return this.call("listLoops", ...args);
4011
+ }
4012
+ dueLoops(...args) {
4013
+ return this.call("dueLoops", ...args);
4014
+ }
4015
+ updateLoop(...args) {
4016
+ return this.call("updateLoop", ...args);
4017
+ }
4018
+ renameLoop(...args) {
4019
+ return this.call("renameLoop", ...args);
4020
+ }
4021
+ archiveLoop(...args) {
4022
+ return this.call("archiveLoop", ...args);
4023
+ }
4024
+ unarchiveLoop(...args) {
4025
+ return this.call("unarchiveLoop", ...args);
4026
+ }
4027
+ deleteLoop(...args) {
4028
+ return this.call("deleteLoop", ...args);
4029
+ }
4030
+ createWorkflow(...args) {
4031
+ return this.call("createWorkflow", ...args);
4032
+ }
4033
+ getWorkflow(...args) {
4034
+ return this.call("getWorkflow", ...args);
4035
+ }
4036
+ listWorkflows(...args) {
4037
+ return this.call("listWorkflows", ...args);
4038
+ }
4039
+ countWorkflows(...args) {
4040
+ return this.call("countWorkflows", ...args);
4041
+ }
4042
+ archiveWorkflow(...args) {
4043
+ return this.call("archiveWorkflow", ...args);
4044
+ }
4045
+ createWorkflowInvocation(...args) {
4046
+ return this.call("createWorkflowInvocation", ...args);
4047
+ }
4048
+ getWorkflowInvocation(...args) {
4049
+ return this.call("getWorkflowInvocation", ...args);
4050
+ }
4051
+ listWorkflowInvocations(...args) {
4052
+ return this.call("listWorkflowInvocations", ...args);
4053
+ }
4054
+ upsertWorkflowWorkItem(...args) {
4055
+ return this.call("upsertWorkflowWorkItem", ...args);
4056
+ }
4057
+ getWorkflowWorkItem(...args) {
4058
+ return this.call("getWorkflowWorkItem", ...args);
4059
+ }
4060
+ listWorkflowWorkItems(...args) {
4061
+ return this.call("listWorkflowWorkItems", ...args);
4062
+ }
4063
+ countActiveWorkflowWorkItems(...args) {
4064
+ return this.call("countActiveWorkflowWorkItems", ...args);
4065
+ }
4066
+ admitWorkflowWorkItem(...args) {
4067
+ return this.call("admitWorkflowWorkItem", ...args);
4068
+ }
4069
+ createGoal(...args) {
4070
+ return this.call("createGoal", ...args);
4071
+ }
4072
+ getGoal(...args) {
4073
+ return this.call("getGoal", ...args);
4074
+ }
4075
+ listGoals(...args) {
4076
+ return this.call("listGoals", ...args);
4077
+ }
4078
+ createGoalPlanNodes(...args) {
4079
+ return this.call("createGoalPlanNodes", ...args);
4080
+ }
4081
+ listGoalPlanNodes(...args) {
4082
+ return this.call("listGoalPlanNodes", ...args);
4083
+ }
4084
+ updateGoalStatus(...args) {
4085
+ return this.call("updateGoalStatus", ...args);
4086
+ }
4087
+ updateGoalPlanNode(...args) {
4088
+ return this.call("updateGoalPlanNode", ...args);
4089
+ }
4090
+ recordGoalEvent(...args) {
4091
+ return this.call("recordGoalEvent", ...args);
4092
+ }
4093
+ listGoalRuns(...args) {
4094
+ return this.call("listGoalRuns", ...args);
4095
+ }
4096
+ createWorkflowRun(...args) {
4097
+ return this.call("createWorkflowRun", ...args);
4098
+ }
4099
+ getWorkflowRun(...args) {
4100
+ return this.call("getWorkflowRun", ...args);
4101
+ }
4102
+ listWorkflowRuns(...args) {
4103
+ return this.call("listWorkflowRuns", ...args);
4104
+ }
4105
+ listWorkflowStepRuns(...args) {
4106
+ return this.call("listWorkflowStepRuns", ...args);
4107
+ }
4108
+ getWorkflowStepRun(...args) {
4109
+ return this.call("getWorkflowStepRun", ...args);
4110
+ }
4111
+ startWorkflowStepRun(...args) {
4112
+ return this.call("startWorkflowStepRun", ...args);
4113
+ }
4114
+ recoverWorkflowRun(...args) {
4115
+ return this.call("recoverWorkflowRun", ...args);
4116
+ }
4117
+ finalizeWorkflowStepRun(...args) {
4118
+ return this.call("finalizeWorkflowStepRun", ...args);
4119
+ }
4120
+ finalizeWorkflowRun(...args) {
4121
+ return this.call("finalizeWorkflowRun", ...args);
4122
+ }
4123
+ appendWorkflowEvent(...args) {
4124
+ return this.call("appendWorkflowEvent", ...args);
4125
+ }
4126
+ listWorkflowEvents(...args) {
4127
+ return this.call("listWorkflowEvents", ...args);
4128
+ }
4129
+ recordRunProcess(...args) {
4130
+ return this.call("recordRunProcess", ...args);
4131
+ }
4132
+ createSkippedRun(...args) {
4133
+ return this.call("createSkippedRun", ...args);
4134
+ }
4135
+ getRun(...args) {
4136
+ return this.call("getRun", ...args);
4137
+ }
4138
+ getRunBySlot(...args) {
4139
+ return this.call("getRunBySlot", ...args);
4140
+ }
4141
+ claimRun(...args) {
4142
+ return this.call("claimRun", ...args);
4143
+ }
4144
+ finalizeRun(...args) {
4145
+ return this.call("finalizeRun", ...args);
4146
+ }
4147
+ heartbeatRunLease(...args) {
4148
+ return this.call("heartbeatRunLease", ...args);
4149
+ }
4150
+ listRuns(...args) {
4151
+ return this.call("listRuns", ...args);
4152
+ }
4153
+ recoverExpiredRunLeases(...args) {
4154
+ return this.call("recoverExpiredRunLeases", ...args);
4155
+ }
4156
+ recoverExpiredRunLeasesDetailed(...args) {
4157
+ return this.call("recoverExpiredRunLeasesDetailed", ...args);
4158
+ }
4159
+ countLoops(...args) {
4160
+ return this.call("countLoops", ...args);
4161
+ }
4162
+ countRuns(...args) {
4163
+ return this.call("countRuns", ...args);
4164
+ }
4165
+ pruneHistory(...args) {
4166
+ return this.call("pruneHistory", ...args);
4167
+ }
4168
+ acquireDaemonLease(...args) {
4169
+ return this.call("acquireDaemonLease", ...args);
4170
+ }
4171
+ heartbeatDaemonLease(...args) {
4172
+ return this.call("heartbeatDaemonLease", ...args);
4173
+ }
4174
+ releaseDaemonLease(...args) {
4175
+ return this.call("releaseDaemonLease", ...args);
4176
+ }
4177
+ getDaemonLease(...args) {
4178
+ return this.call("getDaemonLease", ...args);
4179
+ }
4180
+ }
4181
+ function createSqliteLoopStorage(path) {
4182
+ return new SqliteLoopStorage(new Store(path));
4183
+ }
4184
+ export {
4185
+ createSqliteLoopStorage,
4186
+ SqliteLoopStorage
4187
+ };