@hasna/loops 0.3.38 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/store.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // @bun
2
2
  // src/lib/store.ts
3
3
  import { Database } from "bun:sqlite";
4
- import { mkdirSync as mkdirSync2 } from "fs";
5
- import { dirname } from "path";
4
+ import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
5
+ import { tmpdir } from "os";
6
+ import { dirname, join as join3 } from "path";
6
7
 
7
8
  // src/lib/ids.ts
8
9
  import { randomBytes } from "crypto";
@@ -363,6 +364,8 @@ function validateTarget(value, label) {
363
364
  assertObject(value, label);
364
365
  if (value.type === "command") {
365
366
  assertString(value.command, `${label}.command`);
367
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
368
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
366
369
  if (value.shell !== true && /\s/.test(value.command.trim())) {
367
370
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
368
371
  }
@@ -374,6 +377,8 @@ function validateTarget(value, label) {
374
377
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
375
378
  if (!providers.includes(value.provider))
376
379
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
380
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
381
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
377
382
  if (value.authProfile !== undefined) {
378
383
  assertString(value.authProfile, `${label}.authProfile`);
379
384
  if (value.provider !== "codewith")
@@ -528,6 +533,52 @@ function workflowBodyFromJson(value, fallbackName) {
528
533
  });
529
534
  }
530
535
 
536
+ // src/lib/run-artifacts.ts
537
+ import { createHash } from "crypto";
538
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
539
+ import { basename, join as join2 } from "path";
540
+ function shortHash(value) {
541
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
542
+ }
543
+ function safeRunPathSlug(value, fallback) {
544
+ const raw = value?.trim() || fallback;
545
+ const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
546
+ return slug || fallback;
547
+ }
548
+ function workflowRunSubjectKey(kind, rawSubjectRef) {
549
+ const raw = rawSubjectRef?.trim() || "subject";
550
+ const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
551
+ const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
552
+ return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
553
+ ${raw}`)}`;
554
+ }
555
+ function workflowRunProjectSlug(projectKey) {
556
+ if (!projectKey?.trim())
557
+ return "global";
558
+ return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
559
+ }
560
+ function writeWorkflowRunManifest(args) {
561
+ const projectSlug = workflowRunProjectSlug(args.projectKey);
562
+ const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
563
+ const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
564
+ mkdirSync2(dir, { recursive: true, mode: 448 });
565
+ const manifestPath = join2(dir, "manifest.json");
566
+ writeFileSync(manifestPath, JSON.stringify({
567
+ version: 1,
568
+ workflowRunId: args.workflowRunId,
569
+ workflowId: args.workflowId,
570
+ workflowName: args.workflowName,
571
+ invocationId: args.invocationId,
572
+ workItemId: args.workItemId,
573
+ projectSlug,
574
+ subjectKey,
575
+ requiredReading: [],
576
+ createdAt: new Date().toISOString(),
577
+ ...args.payload
578
+ }, null, 2), { mode: 384 });
579
+ return manifestPath;
580
+ }
581
+
531
582
  // src/lib/store.ts
532
583
  function rowToLoop(row) {
533
584
  return {
@@ -597,8 +648,11 @@ function rowToWorkflowRun(row) {
597
648
  workflowName: row.workflow_name,
598
649
  loopId: row.loop_id ?? undefined,
599
650
  loopRunId: row.loop_run_id ?? undefined,
651
+ invocationId: row.invocation_id ?? undefined,
652
+ workItemId: row.work_item_id ?? undefined,
600
653
  scheduledFor: row.scheduled_for ?? undefined,
601
654
  idempotencyKey: row.idempotency_key ?? undefined,
655
+ manifestPath: row.manifest_path ?? undefined,
602
656
  status: row.status,
603
657
  startedAt: row.started_at ?? undefined,
604
658
  finishedAt: row.finished_at ?? undefined,
@@ -609,6 +663,44 @@ function rowToWorkflowRun(row) {
609
663
  updatedAt: row.updated_at
610
664
  };
611
665
  }
666
+ function rowToWorkflowInvocation(row) {
667
+ return {
668
+ id: row.id,
669
+ workflowId: row.workflow_id ?? undefined,
670
+ templateId: row.template_id ?? undefined,
671
+ sourceRef: JSON.parse(row.source_json),
672
+ subjectRef: JSON.parse(row.subject_json),
673
+ intent: row.intent,
674
+ scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
675
+ outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
676
+ createdAt: row.created_at,
677
+ updatedAt: row.updated_at
678
+ };
679
+ }
680
+ function rowToWorkflowWorkItem(row) {
681
+ return {
682
+ id: row.id,
683
+ routeKey: row.route_key,
684
+ idempotencyKey: row.idempotency_key,
685
+ invocationId: row.invocation_id,
686
+ sourceType: row.source_type,
687
+ sourceRef: row.source_ref,
688
+ subjectRef: row.subject_ref,
689
+ projectKey: row.project_key ?? undefined,
690
+ projectGroup: row.project_group ?? undefined,
691
+ priority: row.priority,
692
+ status: row.status,
693
+ attempts: row.attempts,
694
+ nextAttemptAt: row.next_attempt_at ?? undefined,
695
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
696
+ workflowId: row.workflow_id ?? undefined,
697
+ loopId: row.loop_id ?? undefined,
698
+ workflowRunId: row.workflow_run_id ?? undefined,
699
+ lastReason: row.last_reason ?? undefined,
700
+ createdAt: row.created_at,
701
+ updatedAt: row.updated_at
702
+ };
703
+ }
612
704
  function rowToWorkflowStepRun(row) {
613
705
  return {
614
706
  id: row.id,
@@ -722,13 +814,23 @@ function rowToLease(row) {
722
814
  updatedAt: row.updated_at
723
815
  };
724
816
  }
817
+ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
818
+ if (status === "succeeded")
819
+ return "succeeded";
820
+ if (["failed", "timed_out", "abandoned"].includes(status)) {
821
+ return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
822
+ }
823
+ return;
824
+ }
725
825
 
726
826
  class Store {
727
827
  db;
828
+ rootDir;
728
829
  constructor(path) {
729
830
  const file = path ?? dbPath();
730
831
  if (file !== ":memory:")
731
- mkdirSync2(dirname(file), { recursive: true, mode: 448 });
832
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
833
+ this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
732
834
  this.db = new Database(file);
733
835
  this.db.exec("PRAGMA busy_timeout = 5000;");
734
836
  this.db.exec("PRAGMA journal_mode = WAL;");
@@ -823,8 +925,11 @@ class Store {
823
925
  workflow_name TEXT NOT NULL,
824
926
  loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
825
927
  loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
928
+ invocation_id TEXT,
929
+ work_item_id TEXT,
826
930
  scheduled_for TEXT,
827
931
  idempotency_key TEXT,
932
+ manifest_path TEXT,
828
933
  status TEXT NOT NULL,
829
934
  started_at TEXT,
830
935
  finished_at TEXT,
@@ -841,6 +946,59 @@ class Store {
841
946
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
842
947
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
843
948
 
949
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
950
+ id TEXT PRIMARY KEY,
951
+ workflow_id TEXT,
952
+ template_id TEXT,
953
+ source_kind TEXT NOT NULL,
954
+ source_id TEXT,
955
+ source_dedupe_key TEXT,
956
+ source_json TEXT NOT NULL,
957
+ subject_kind TEXT NOT NULL,
958
+ subject_id TEXT,
959
+ subject_path TEXT,
960
+ subject_url TEXT,
961
+ subject_json TEXT NOT NULL,
962
+ intent TEXT NOT NULL,
963
+ scope_json TEXT,
964
+ output_policy_json TEXT,
965
+ created_at TEXT NOT NULL,
966
+ updated_at TEXT NOT NULL
967
+ );
968
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
969
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
970
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
971
+ ON workflow_invocations(source_kind, source_dedupe_key)
972
+ WHERE source_dedupe_key IS NOT NULL;
973
+
974
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
975
+ id TEXT PRIMARY KEY,
976
+ route_key TEXT NOT NULL,
977
+ idempotency_key TEXT NOT NULL,
978
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
979
+ source_type TEXT NOT NULL,
980
+ source_ref TEXT NOT NULL,
981
+ subject_ref TEXT NOT NULL,
982
+ project_key TEXT,
983
+ project_group TEXT,
984
+ priority INTEGER NOT NULL,
985
+ status TEXT NOT NULL,
986
+ attempts INTEGER NOT NULL,
987
+ next_attempt_at TEXT,
988
+ lease_expires_at TEXT,
989
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
990
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
991
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
992
+ last_reason TEXT,
993
+ created_at TEXT NOT NULL,
994
+ updated_at TEXT NOT NULL,
995
+ UNIQUE(route_key, idempotency_key)
996
+ );
997
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
998
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
999
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1000
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1001
+
844
1002
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
845
1003
  id TEXT PRIMARY KEY,
846
1004
  workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
@@ -953,12 +1111,17 @@ class Store {
953
1111
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
954
1112
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
955
1113
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1114
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1115
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1116
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
956
1117
  this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
957
1118
  this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
1119
+ this.createWorkflowRunBackfillIndexes();
958
1120
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
959
1121
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
960
1122
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
961
1123
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
1124
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
962
1125
  }
963
1126
  addColumnIfMissing(table, column, definition) {
964
1127
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -966,6 +1129,12 @@ class Store {
966
1129
  return;
967
1130
  this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
968
1131
  }
1132
+ createWorkflowRunBackfillIndexes() {
1133
+ this.db.exec(`
1134
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
1135
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
1136
+ `);
1137
+ }
969
1138
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
970
1139
  if (!opts.daemonLeaseId)
971
1140
  return;
@@ -1081,6 +1250,10 @@ class Store {
1081
1250
  $daemonLeaseId: opts.daemonLeaseId ?? null,
1082
1251
  $now: updated
1083
1252
  });
1253
+ if (patch.status && patch.status !== "active") {
1254
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1255
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1256
+ }
1084
1257
  const after = this.getLoop(id);
1085
1258
  if (!after)
1086
1259
  throw new Error(`loop not found after update: ${id}`);
@@ -1125,6 +1298,7 @@ class Store {
1125
1298
  $archivedFromStatus: loop.status,
1126
1299
  $updated: updated
1127
1300
  });
1301
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
1128
1302
  const archived = this.getLoop(loop.id);
1129
1303
  if (!archived)
1130
1304
  throw new Error(`loop not found after archive: ${loop.id}`);
@@ -1150,6 +1324,7 @@ class Store {
1150
1324
  }
1151
1325
  deleteLoop(idOrName) {
1152
1326
  const loop = this.requireLoop(idOrName);
1327
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
1153
1328
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
1154
1329
  return res.changes > 0;
1155
1330
  }
@@ -1208,6 +1383,185 @@ class Store {
1208
1383
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1209
1384
  return archived;
1210
1385
  }
1386
+ createWorkflowInvocation(input) {
1387
+ const now = nowIso();
1388
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1389
+ if (sourceDedupeKey) {
1390
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
1391
+ if (existing)
1392
+ return rowToWorkflowInvocation(existing);
1393
+ }
1394
+ const id = input.id ?? genId();
1395
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
1396
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
1397
+ output_policy_json, created_at, updated_at)
1398
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
1399
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
1400
+ $outputPolicyJson, $created, $updated)`).run({
1401
+ $id: id,
1402
+ $workflowId: input.workflowId ?? null,
1403
+ $templateId: input.templateId ?? null,
1404
+ $sourceKind: input.sourceRef.kind,
1405
+ $sourceId: input.sourceRef.id ?? null,
1406
+ $sourceDedupeKey: sourceDedupeKey ?? null,
1407
+ $sourceJson: JSON.stringify(input.sourceRef),
1408
+ $subjectKind: input.subjectRef.kind,
1409
+ $subjectId: input.subjectRef.id ?? null,
1410
+ $subjectPath: input.subjectRef.path ?? null,
1411
+ $subjectUrl: input.subjectRef.url ?? null,
1412
+ $subjectJson: JSON.stringify(input.subjectRef),
1413
+ $intent: input.intent,
1414
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1415
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1416
+ $created: now,
1417
+ $updated: now
1418
+ });
1419
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1420
+ if (!row)
1421
+ throw new Error(`workflow invocation not found after create: ${id}`);
1422
+ return rowToWorkflowInvocation(row);
1423
+ }
1424
+ getWorkflowInvocation(id) {
1425
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1426
+ return row ? rowToWorkflowInvocation(row) : undefined;
1427
+ }
1428
+ listWorkflowInvocations(opts = {}) {
1429
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
1430
+ return rows.map(rowToWorkflowInvocation);
1431
+ }
1432
+ upsertWorkflowWorkItem(input) {
1433
+ const now = nowIso();
1434
+ const id = genId();
1435
+ const status = input.status ?? "queued";
1436
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
1437
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
1438
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
1439
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
1440
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
1441
+ $lastReason, $created, $updated)
1442
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
1443
+ invocation_id=excluded.invocation_id,
1444
+ source_type=excluded.source_type,
1445
+ source_ref=excluded.source_ref,
1446
+ subject_ref=excluded.subject_ref,
1447
+ project_key=excluded.project_key,
1448
+ project_group=excluded.project_group,
1449
+ priority=excluded.priority,
1450
+ status=CASE
1451
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1452
+ THEN workflow_work_items.status
1453
+ ELSE excluded.status
1454
+ END,
1455
+ workflow_id=CASE
1456
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1457
+ ELSE NULL
1458
+ END,
1459
+ loop_id=CASE
1460
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1461
+ ELSE NULL
1462
+ END,
1463
+ workflow_run_id=CASE
1464
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1465
+ ELSE NULL
1466
+ END,
1467
+ lease_expires_at=CASE
1468
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1469
+ ELSE NULL
1470
+ END,
1471
+ next_attempt_at=excluded.next_attempt_at,
1472
+ last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1473
+ updated_at=excluded.updated_at`).run({
1474
+ $id: id,
1475
+ $routeKey: input.routeKey,
1476
+ $idempotencyKey: input.idempotencyKey,
1477
+ $invocationId: input.invocationId,
1478
+ $sourceType: input.sourceType,
1479
+ $sourceRef: input.sourceRef,
1480
+ $subjectRef: input.subjectRef,
1481
+ $projectKey: input.projectKey ?? null,
1482
+ $projectGroup: input.projectGroup ?? null,
1483
+ $priority: input.priority ?? 0,
1484
+ $status: status,
1485
+ $nextAttemptAt: input.nextAttemptAt ?? null,
1486
+ $lastReason: input.lastReason ?? null,
1487
+ $created: now,
1488
+ $updated: now
1489
+ });
1490
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
1491
+ if (!row)
1492
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
1493
+ return rowToWorkflowWorkItem(row);
1494
+ }
1495
+ getWorkflowWorkItem(id) {
1496
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
1497
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1498
+ }
1499
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
1500
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
1501
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1502
+ }
1503
+ listWorkflowWorkItems(opts = {}) {
1504
+ const limit = opts.limit ?? 100;
1505
+ let rows;
1506
+ if (opts.status && opts.routeKey) {
1507
+ 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);
1508
+ } else if (opts.status) {
1509
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
1510
+ } else if (opts.routeKey) {
1511
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
1512
+ } else {
1513
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
1514
+ }
1515
+ return rows.map(rowToWorkflowWorkItem);
1516
+ }
1517
+ countActiveWorkflowWorkItems(args = {}) {
1518
+ const active = ["admitted", "running"];
1519
+ const placeholders = active.map(() => "?").join(",");
1520
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
1521
+ 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;
1522
+ 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;
1523
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1524
+ }
1525
+ admitWorkflowWorkItem(id, patch) {
1526
+ const now = nowIso();
1527
+ const res = this.db.query(`UPDATE workflow_work_items
1528
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1529
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1530
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1531
+ $id: id,
1532
+ $workflowId: patch.workflowId,
1533
+ $loopId: patch.loopId,
1534
+ $reason: patch.reason ?? null,
1535
+ $updated: now
1536
+ });
1537
+ const item = this.getWorkflowWorkItem(id);
1538
+ if (!item)
1539
+ throw new Error(`workflow work item not found after admit: ${id}`);
1540
+ if (res.changes !== 1)
1541
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
1542
+ return item;
1543
+ }
1544
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
1545
+ const placeholders = statuses.map(() => "?").join(",");
1546
+ this.db.query(`UPDATE workflow_work_items
1547
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1548
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
1549
+ }
1550
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
1551
+ const placeholders = statuses.map(() => "?").join(",");
1552
+ this.db.query(`UPDATE workflow_work_items
1553
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1554
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
1555
+ }
1556
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
1557
+ const loop = this.getLoop(run.loopId);
1558
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
1559
+ if (!status)
1560
+ return;
1561
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
1562
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
1563
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
1564
+ }
1211
1565
  createGoal(input, opts = {}) {
1212
1566
  const now = nowIso();
1213
1567
  this.db.exec("BEGIN IMMEDIATE");
@@ -1481,6 +1835,10 @@ class Store {
1481
1835
  }
1482
1836
  createWorkflowRun(input) {
1483
1837
  const now = nowIso();
1838
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
1839
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
1840
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
1841
+ let manifestPath;
1484
1842
  if (input.idempotencyKey) {
1485
1843
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
1486
1844
  if (existing) {
@@ -1499,21 +1857,59 @@ class Store {
1499
1857
  }
1500
1858
  }
1501
1859
  const runId = genId();
1502
- this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
1503
- status, started_at, finished_at, duration_ms, error, created_at, updated_at)
1504
- VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
1505
- 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1860
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
1861
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
1862
+ manifestPath = invocation || workItem ? writeWorkflowRunManifest({
1863
+ loopsDataDir: this.rootDir,
1864
+ workflowRunId: runId,
1865
+ workflowId: input.workflow.id,
1866
+ workflowName: input.workflow.name,
1867
+ invocationId,
1868
+ workItemId,
1869
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
1870
+ subjectKind: invocation?.subjectRef.kind,
1871
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
1872
+ payload: {
1873
+ workflowInvocation: invocation,
1874
+ workflowWorkItem: workItem,
1875
+ loopId: input.loop?.id,
1876
+ loopRunId: input.loopRun?.id,
1877
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
1878
+ }
1879
+ }) : undefined;
1880
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
1881
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
1882
+ created_at, updated_at)
1883
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
1884
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1506
1885
  $id: runId,
1507
1886
  $workflowId: input.workflow.id,
1508
1887
  $workflowName: input.workflow.name,
1509
1888
  $loopId: input.loop?.id ?? null,
1510
1889
  $loopRunId: input.loopRun?.id ?? null,
1890
+ $invocationId: invocationId ?? null,
1891
+ $workItemId: workItemId ?? null,
1511
1892
  $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
1512
1893
  $idempotencyKey: input.idempotencyKey ?? null,
1894
+ $manifestPath: manifestPath ?? null,
1513
1895
  $started: now,
1514
1896
  $created: now,
1515
1897
  $updated: now
1516
1898
  });
1899
+ if (workItemId) {
1900
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
1901
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
1902
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
1903
+ $id: workItemId,
1904
+ $workflowRunId: runId,
1905
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
1906
+ $updated: now
1907
+ });
1908
+ if (workItemRes.changes !== 1) {
1909
+ const current = this.getWorkflowWorkItem(workItemId);
1910
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
1911
+ }
1912
+ }
1517
1913
  input.workflow.steps.forEach((step, sequence) => {
1518
1914
  const account = step.account ?? step.target.account;
1519
1915
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
@@ -1539,7 +1935,10 @@ class Store {
1539
1935
  workflowName: input.workflow.name,
1540
1936
  stepCount: input.workflow.steps.length,
1541
1937
  loopId: input.loop?.id,
1542
- loopRunId: input.loopRun?.id
1938
+ loopRunId: input.loopRun?.id,
1939
+ invocationId,
1940
+ workItemId,
1941
+ manifestPath
1543
1942
  }),
1544
1943
  $created: now
1545
1944
  });
@@ -1552,6 +1951,8 @@ class Store {
1552
1951
  try {
1553
1952
  this.db.exec("ROLLBACK");
1554
1953
  } catch {}
1954
+ if (manifestPath)
1955
+ rmSync(manifestPath, { force: true });
1555
1956
  throw error;
1556
1957
  }
1557
1958
  }
@@ -1764,6 +2165,10 @@ class Store {
1764
2165
  changed = res.changes === 1;
1765
2166
  if (changed)
1766
2167
  this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
2168
+ if (changed) {
2169
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2170
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2171
+ }
1767
2172
  this.db.exec("COMMIT");
1768
2173
  } catch (error) {
1769
2174
  try {
@@ -1788,6 +2193,7 @@ class Store {
1788
2193
  this.db.query(`UPDATE workflow_step_runs
1789
2194
  SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
1790
2195
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2196
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
1791
2197
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
1792
2198
  }
1793
2199
  this.db.exec("COMMIT");
@@ -2041,6 +2447,8 @@ class Store {
2041
2447
  throw new Error(`run not found after finalize: ${id}`);
2042
2448
  if (opts.claimedBy && res.changes !== 1)
2043
2449
  return run;
2450
+ if (res.changes === 1)
2451
+ this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2044
2452
  return run;
2045
2453
  }
2046
2454
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2134,6 +2542,14 @@ class Store {
2134
2542
  error: "parent loop run lease expired before completion",
2135
2543
  loopRunId: row.id
2136
2544
  });
2545
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2546
+ }
2547
+ const loop = this.getLoop(row.loop_id);
2548
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
2549
+ if (itemStatus) {
2550
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2551
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2552
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2137
2553
  }
2138
2554
  this.db.exec("COMMIT");
2139
2555
  } catch (error) {
@@ -2,6 +2,13 @@ import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, Agen
2
2
  export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
3
3
  export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4
4
  export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
5
+ export declare const TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
6
+ export declare const PR_REVIEW_TEMPLATE_ID = "pr-review";
7
+ export declare const SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
8
+ export declare const KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
9
+ export declare const REPORT_ONLY_TEMPLATE_ID = "report-only";
10
+ export declare const INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
11
+ export declare const DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
5
12
  export interface TodosTaskWorkflowTemplateInput {
6
13
  taskId: string;
7
14
  taskTitle?: string;
@@ -23,6 +30,7 @@ export interface TodosTaskWorkflowTemplateInput {
23
30
  agent?: string;
24
31
  permissionMode?: AgentPermissionMode;
25
32
  sandbox?: AgentSandbox;
33
+ manualBreakGlass?: boolean;
26
34
  worktreeMode?: AgentWorktreeMode;
27
35
  worktreeRoot?: string;
28
36
  worktreeBranchPrefix?: string;
@@ -53,6 +61,7 @@ export interface EventWorkflowTemplateInput {
53
61
  agent?: string;
54
62
  permissionMode?: AgentPermissionMode;
55
63
  sandbox?: AgentSandbox;
64
+ manualBreakGlass?: boolean;
56
65
  worktreeMode?: AgentWorktreeMode;
57
66
  worktreeRoot?: string;
58
67
  worktreeBranchPrefix?: string;
@@ -78,6 +87,7 @@ export interface BoundedAgentWorkflowTemplateInput {
78
87
  agent?: string;
79
88
  permissionMode?: AgentPermissionMode;
80
89
  sandbox?: AgentSandbox;
90
+ manualBreakGlass?: boolean;
81
91
  worktreeMode?: AgentWorktreeMode;
82
92
  worktreeRoot?: string;
83
93
  worktreeBranchPrefix?: string;
@@ -1,4 +1,4 @@
1
- import type { CreateLoopInput, Goal, GoalRun, Loop, LoopRun } from "../types.js";
1
+ import type { CreateLoopInput, Goal, GoalRun, Loop, LoopRun, OpenAutomationsRuntimeBinding } from "../types.js";
2
2
  import { tick } from "../lib/scheduler.js";
3
3
  import { Store } from "../lib/store.js";
4
4
  export { runGoal } from "../lib/goal/runner.js";
@@ -30,3 +30,4 @@ export declare class LoopsClient {
30
30
  close(): void;
31
31
  }
32
32
  export declare function loops(opts?: LoopsClientOptions): LoopsClient;
33
+ export declare function openAutomationsRuntimeBinding(overrides?: Partial<OpenAutomationsRuntimeBinding>): OpenAutomationsRuntimeBinding;