@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/cli/index.js CHANGED
@@ -3,8 +3,9 @@
3
3
 
4
4
  // src/lib/store.ts
5
5
  import { Database } from "bun:sqlite";
6
- import { mkdirSync as mkdirSync2 } from "fs";
7
- import { dirname } from "path";
6
+ import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { dirname, join as join3 } from "path";
8
9
 
9
10
  // src/lib/ids.ts
10
11
  import { randomBytes } from "crypto";
@@ -365,6 +366,8 @@ function validateTarget(value, label) {
365
366
  assertObject(value, label);
366
367
  if (value.type === "command") {
367
368
  assertString(value.command, `${label}.command`);
369
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
370
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
368
371
  if (value.shell !== true && /\s/.test(value.command.trim())) {
369
372
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
370
373
  }
@@ -376,6 +379,8 @@ function validateTarget(value, label) {
376
379
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
377
380
  if (!providers.includes(value.provider))
378
381
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
382
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
383
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
379
384
  if (value.authProfile !== undefined) {
380
385
  assertString(value.authProfile, `${label}.authProfile`);
381
386
  if (value.provider !== "codewith")
@@ -530,6 +535,52 @@ function workflowBodyFromJson(value, fallbackName) {
530
535
  });
531
536
  }
532
537
 
538
+ // src/lib/run-artifacts.ts
539
+ import { createHash } from "crypto";
540
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
541
+ import { basename, join as join2 } from "path";
542
+ function shortHash(value) {
543
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
544
+ }
545
+ function safeRunPathSlug(value, fallback) {
546
+ const raw = value?.trim() || fallback;
547
+ const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
548
+ return slug || fallback;
549
+ }
550
+ function workflowRunSubjectKey(kind, rawSubjectRef) {
551
+ const raw = rawSubjectRef?.trim() || "subject";
552
+ const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
553
+ const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
554
+ return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
555
+ ${raw}`)}`;
556
+ }
557
+ function workflowRunProjectSlug(projectKey) {
558
+ if (!projectKey?.trim())
559
+ return "global";
560
+ return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
561
+ }
562
+ function writeWorkflowRunManifest(args) {
563
+ const projectSlug = workflowRunProjectSlug(args.projectKey);
564
+ const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
565
+ const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
566
+ mkdirSync2(dir, { recursive: true, mode: 448 });
567
+ const manifestPath = join2(dir, "manifest.json");
568
+ writeFileSync(manifestPath, JSON.stringify({
569
+ version: 1,
570
+ workflowRunId: args.workflowRunId,
571
+ workflowId: args.workflowId,
572
+ workflowName: args.workflowName,
573
+ invocationId: args.invocationId,
574
+ workItemId: args.workItemId,
575
+ projectSlug,
576
+ subjectKey,
577
+ requiredReading: [],
578
+ createdAt: new Date().toISOString(),
579
+ ...args.payload
580
+ }, null, 2), { mode: 384 });
581
+ return manifestPath;
582
+ }
583
+
533
584
  // src/lib/store.ts
534
585
  function rowToLoop(row) {
535
586
  return {
@@ -599,8 +650,11 @@ function rowToWorkflowRun(row) {
599
650
  workflowName: row.workflow_name,
600
651
  loopId: row.loop_id ?? undefined,
601
652
  loopRunId: row.loop_run_id ?? undefined,
653
+ invocationId: row.invocation_id ?? undefined,
654
+ workItemId: row.work_item_id ?? undefined,
602
655
  scheduledFor: row.scheduled_for ?? undefined,
603
656
  idempotencyKey: row.idempotency_key ?? undefined,
657
+ manifestPath: row.manifest_path ?? undefined,
604
658
  status: row.status,
605
659
  startedAt: row.started_at ?? undefined,
606
660
  finishedAt: row.finished_at ?? undefined,
@@ -611,6 +665,44 @@ function rowToWorkflowRun(row) {
611
665
  updatedAt: row.updated_at
612
666
  };
613
667
  }
668
+ function rowToWorkflowInvocation(row) {
669
+ return {
670
+ id: row.id,
671
+ workflowId: row.workflow_id ?? undefined,
672
+ templateId: row.template_id ?? undefined,
673
+ sourceRef: JSON.parse(row.source_json),
674
+ subjectRef: JSON.parse(row.subject_json),
675
+ intent: row.intent,
676
+ scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
677
+ outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
678
+ createdAt: row.created_at,
679
+ updatedAt: row.updated_at
680
+ };
681
+ }
682
+ function rowToWorkflowWorkItem(row) {
683
+ return {
684
+ id: row.id,
685
+ routeKey: row.route_key,
686
+ idempotencyKey: row.idempotency_key,
687
+ invocationId: row.invocation_id,
688
+ sourceType: row.source_type,
689
+ sourceRef: row.source_ref,
690
+ subjectRef: row.subject_ref,
691
+ projectKey: row.project_key ?? undefined,
692
+ projectGroup: row.project_group ?? undefined,
693
+ priority: row.priority,
694
+ status: row.status,
695
+ attempts: row.attempts,
696
+ nextAttemptAt: row.next_attempt_at ?? undefined,
697
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
698
+ workflowId: row.workflow_id ?? undefined,
699
+ loopId: row.loop_id ?? undefined,
700
+ workflowRunId: row.workflow_run_id ?? undefined,
701
+ lastReason: row.last_reason ?? undefined,
702
+ createdAt: row.created_at,
703
+ updatedAt: row.updated_at
704
+ };
705
+ }
614
706
  function rowToWorkflowStepRun(row) {
615
707
  return {
616
708
  id: row.id,
@@ -724,13 +816,23 @@ function rowToLease(row) {
724
816
  updatedAt: row.updated_at
725
817
  };
726
818
  }
819
+ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
820
+ if (status === "succeeded")
821
+ return "succeeded";
822
+ if (["failed", "timed_out", "abandoned"].includes(status)) {
823
+ return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
824
+ }
825
+ return;
826
+ }
727
827
 
728
828
  class Store {
729
829
  db;
830
+ rootDir;
730
831
  constructor(path) {
731
832
  const file = path ?? dbPath();
732
833
  if (file !== ":memory:")
733
- mkdirSync2(dirname(file), { recursive: true, mode: 448 });
834
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
835
+ this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
734
836
  this.db = new Database(file);
735
837
  this.db.exec("PRAGMA busy_timeout = 5000;");
736
838
  this.db.exec("PRAGMA journal_mode = WAL;");
@@ -825,8 +927,11 @@ class Store {
825
927
  workflow_name TEXT NOT NULL,
826
928
  loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
827
929
  loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
930
+ invocation_id TEXT,
931
+ work_item_id TEXT,
828
932
  scheduled_for TEXT,
829
933
  idempotency_key TEXT,
934
+ manifest_path TEXT,
830
935
  status TEXT NOT NULL,
831
936
  started_at TEXT,
832
937
  finished_at TEXT,
@@ -843,6 +948,59 @@ class Store {
843
948
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
844
949
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
845
950
 
951
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
952
+ id TEXT PRIMARY KEY,
953
+ workflow_id TEXT,
954
+ template_id TEXT,
955
+ source_kind TEXT NOT NULL,
956
+ source_id TEXT,
957
+ source_dedupe_key TEXT,
958
+ source_json TEXT NOT NULL,
959
+ subject_kind TEXT NOT NULL,
960
+ subject_id TEXT,
961
+ subject_path TEXT,
962
+ subject_url TEXT,
963
+ subject_json TEXT NOT NULL,
964
+ intent TEXT NOT NULL,
965
+ scope_json TEXT,
966
+ output_policy_json TEXT,
967
+ created_at TEXT NOT NULL,
968
+ updated_at TEXT NOT NULL
969
+ );
970
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
971
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
972
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
973
+ ON workflow_invocations(source_kind, source_dedupe_key)
974
+ WHERE source_dedupe_key IS NOT NULL;
975
+
976
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
977
+ id TEXT PRIMARY KEY,
978
+ route_key TEXT NOT NULL,
979
+ idempotency_key TEXT NOT NULL,
980
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
981
+ source_type TEXT NOT NULL,
982
+ source_ref TEXT NOT NULL,
983
+ subject_ref TEXT NOT NULL,
984
+ project_key TEXT,
985
+ project_group TEXT,
986
+ priority INTEGER NOT NULL,
987
+ status TEXT NOT NULL,
988
+ attempts INTEGER NOT NULL,
989
+ next_attempt_at TEXT,
990
+ lease_expires_at TEXT,
991
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
992
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
993
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
994
+ last_reason TEXT,
995
+ created_at TEXT NOT NULL,
996
+ updated_at TEXT NOT NULL,
997
+ UNIQUE(route_key, idempotency_key)
998
+ );
999
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
1000
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1001
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1002
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1003
+
846
1004
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
847
1005
  id TEXT PRIMARY KEY,
848
1006
  workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
@@ -955,12 +1113,17 @@ class Store {
955
1113
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
956
1114
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
957
1115
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1116
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1117
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1118
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
958
1119
  this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
959
1120
  this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
1121
+ this.createWorkflowRunBackfillIndexes();
960
1122
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
961
1123
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
962
1124
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
963
1125
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
1126
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
964
1127
  }
965
1128
  addColumnIfMissing(table, column, definition) {
966
1129
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -968,6 +1131,12 @@ class Store {
968
1131
  return;
969
1132
  this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
970
1133
  }
1134
+ createWorkflowRunBackfillIndexes() {
1135
+ this.db.exec(`
1136
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
1137
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
1138
+ `);
1139
+ }
971
1140
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
972
1141
  if (!opts.daemonLeaseId)
973
1142
  return;
@@ -1083,6 +1252,10 @@ class Store {
1083
1252
  $daemonLeaseId: opts.daemonLeaseId ?? null,
1084
1253
  $now: updated
1085
1254
  });
1255
+ if (patch.status && patch.status !== "active") {
1256
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1257
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1258
+ }
1086
1259
  const after = this.getLoop(id);
1087
1260
  if (!after)
1088
1261
  throw new Error(`loop not found after update: ${id}`);
@@ -1127,6 +1300,7 @@ class Store {
1127
1300
  $archivedFromStatus: loop.status,
1128
1301
  $updated: updated
1129
1302
  });
1303
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
1130
1304
  const archived = this.getLoop(loop.id);
1131
1305
  if (!archived)
1132
1306
  throw new Error(`loop not found after archive: ${loop.id}`);
@@ -1152,6 +1326,7 @@ class Store {
1152
1326
  }
1153
1327
  deleteLoop(idOrName) {
1154
1328
  const loop = this.requireLoop(idOrName);
1329
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
1155
1330
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
1156
1331
  return res.changes > 0;
1157
1332
  }
@@ -1210,6 +1385,185 @@ class Store {
1210
1385
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1211
1386
  return archived;
1212
1387
  }
1388
+ createWorkflowInvocation(input) {
1389
+ const now = nowIso();
1390
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1391
+ if (sourceDedupeKey) {
1392
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
1393
+ if (existing)
1394
+ return rowToWorkflowInvocation(existing);
1395
+ }
1396
+ const id = input.id ?? genId();
1397
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
1398
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
1399
+ output_policy_json, created_at, updated_at)
1400
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
1401
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
1402
+ $outputPolicyJson, $created, $updated)`).run({
1403
+ $id: id,
1404
+ $workflowId: input.workflowId ?? null,
1405
+ $templateId: input.templateId ?? null,
1406
+ $sourceKind: input.sourceRef.kind,
1407
+ $sourceId: input.sourceRef.id ?? null,
1408
+ $sourceDedupeKey: sourceDedupeKey ?? null,
1409
+ $sourceJson: JSON.stringify(input.sourceRef),
1410
+ $subjectKind: input.subjectRef.kind,
1411
+ $subjectId: input.subjectRef.id ?? null,
1412
+ $subjectPath: input.subjectRef.path ?? null,
1413
+ $subjectUrl: input.subjectRef.url ?? null,
1414
+ $subjectJson: JSON.stringify(input.subjectRef),
1415
+ $intent: input.intent,
1416
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1417
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1418
+ $created: now,
1419
+ $updated: now
1420
+ });
1421
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1422
+ if (!row)
1423
+ throw new Error(`workflow invocation not found after create: ${id}`);
1424
+ return rowToWorkflowInvocation(row);
1425
+ }
1426
+ getWorkflowInvocation(id) {
1427
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1428
+ return row ? rowToWorkflowInvocation(row) : undefined;
1429
+ }
1430
+ listWorkflowInvocations(opts = {}) {
1431
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
1432
+ return rows.map(rowToWorkflowInvocation);
1433
+ }
1434
+ upsertWorkflowWorkItem(input) {
1435
+ const now = nowIso();
1436
+ const id = genId();
1437
+ const status = input.status ?? "queued";
1438
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
1439
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
1440
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
1441
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
1442
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
1443
+ $lastReason, $created, $updated)
1444
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
1445
+ invocation_id=excluded.invocation_id,
1446
+ source_type=excluded.source_type,
1447
+ source_ref=excluded.source_ref,
1448
+ subject_ref=excluded.subject_ref,
1449
+ project_key=excluded.project_key,
1450
+ project_group=excluded.project_group,
1451
+ priority=excluded.priority,
1452
+ status=CASE
1453
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1454
+ THEN workflow_work_items.status
1455
+ ELSE excluded.status
1456
+ END,
1457
+ workflow_id=CASE
1458
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1459
+ ELSE NULL
1460
+ END,
1461
+ loop_id=CASE
1462
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1463
+ ELSE NULL
1464
+ END,
1465
+ workflow_run_id=CASE
1466
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1467
+ ELSE NULL
1468
+ END,
1469
+ lease_expires_at=CASE
1470
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1471
+ ELSE NULL
1472
+ END,
1473
+ next_attempt_at=excluded.next_attempt_at,
1474
+ last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1475
+ updated_at=excluded.updated_at`).run({
1476
+ $id: id,
1477
+ $routeKey: input.routeKey,
1478
+ $idempotencyKey: input.idempotencyKey,
1479
+ $invocationId: input.invocationId,
1480
+ $sourceType: input.sourceType,
1481
+ $sourceRef: input.sourceRef,
1482
+ $subjectRef: input.subjectRef,
1483
+ $projectKey: input.projectKey ?? null,
1484
+ $projectGroup: input.projectGroup ?? null,
1485
+ $priority: input.priority ?? 0,
1486
+ $status: status,
1487
+ $nextAttemptAt: input.nextAttemptAt ?? null,
1488
+ $lastReason: input.lastReason ?? null,
1489
+ $created: now,
1490
+ $updated: now
1491
+ });
1492
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
1493
+ if (!row)
1494
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
1495
+ return rowToWorkflowWorkItem(row);
1496
+ }
1497
+ getWorkflowWorkItem(id) {
1498
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
1499
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1500
+ }
1501
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
1502
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
1503
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1504
+ }
1505
+ listWorkflowWorkItems(opts = {}) {
1506
+ const limit = opts.limit ?? 100;
1507
+ let rows;
1508
+ if (opts.status && opts.routeKey) {
1509
+ 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);
1510
+ } else if (opts.status) {
1511
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
1512
+ } else if (opts.routeKey) {
1513
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
1514
+ } else {
1515
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
1516
+ }
1517
+ return rows.map(rowToWorkflowWorkItem);
1518
+ }
1519
+ countActiveWorkflowWorkItems(args = {}) {
1520
+ const active = ["admitted", "running"];
1521
+ const placeholders = active.map(() => "?").join(",");
1522
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
1523
+ 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;
1524
+ 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;
1525
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1526
+ }
1527
+ admitWorkflowWorkItem(id, patch) {
1528
+ const now = nowIso();
1529
+ const res = this.db.query(`UPDATE workflow_work_items
1530
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1531
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1532
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1533
+ $id: id,
1534
+ $workflowId: patch.workflowId,
1535
+ $loopId: patch.loopId,
1536
+ $reason: patch.reason ?? null,
1537
+ $updated: now
1538
+ });
1539
+ const item = this.getWorkflowWorkItem(id);
1540
+ if (!item)
1541
+ throw new Error(`workflow work item not found after admit: ${id}`);
1542
+ if (res.changes !== 1)
1543
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
1544
+ return item;
1545
+ }
1546
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
1547
+ const placeholders = statuses.map(() => "?").join(",");
1548
+ this.db.query(`UPDATE workflow_work_items
1549
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1550
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
1551
+ }
1552
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
1553
+ const placeholders = statuses.map(() => "?").join(",");
1554
+ this.db.query(`UPDATE workflow_work_items
1555
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1556
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
1557
+ }
1558
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
1559
+ const loop = this.getLoop(run.loopId);
1560
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
1561
+ if (!status)
1562
+ return;
1563
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
1564
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
1565
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
1566
+ }
1213
1567
  createGoal(input, opts = {}) {
1214
1568
  const now = nowIso();
1215
1569
  this.db.exec("BEGIN IMMEDIATE");
@@ -1483,6 +1837,10 @@ class Store {
1483
1837
  }
1484
1838
  createWorkflowRun(input) {
1485
1839
  const now = nowIso();
1840
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
1841
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
1842
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
1843
+ let manifestPath;
1486
1844
  if (input.idempotencyKey) {
1487
1845
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
1488
1846
  if (existing) {
@@ -1501,21 +1859,59 @@ class Store {
1501
1859
  }
1502
1860
  }
1503
1861
  const runId = genId();
1504
- this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
1505
- status, started_at, finished_at, duration_ms, error, created_at, updated_at)
1506
- VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
1507
- 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1862
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
1863
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
1864
+ manifestPath = invocation || workItem ? writeWorkflowRunManifest({
1865
+ loopsDataDir: this.rootDir,
1866
+ workflowRunId: runId,
1867
+ workflowId: input.workflow.id,
1868
+ workflowName: input.workflow.name,
1869
+ invocationId,
1870
+ workItemId,
1871
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
1872
+ subjectKind: invocation?.subjectRef.kind,
1873
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
1874
+ payload: {
1875
+ workflowInvocation: invocation,
1876
+ workflowWorkItem: workItem,
1877
+ loopId: input.loop?.id,
1878
+ loopRunId: input.loopRun?.id,
1879
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
1880
+ }
1881
+ }) : undefined;
1882
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
1883
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
1884
+ created_at, updated_at)
1885
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
1886
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1508
1887
  $id: runId,
1509
1888
  $workflowId: input.workflow.id,
1510
1889
  $workflowName: input.workflow.name,
1511
1890
  $loopId: input.loop?.id ?? null,
1512
1891
  $loopRunId: input.loopRun?.id ?? null,
1892
+ $invocationId: invocationId ?? null,
1893
+ $workItemId: workItemId ?? null,
1513
1894
  $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
1514
1895
  $idempotencyKey: input.idempotencyKey ?? null,
1896
+ $manifestPath: manifestPath ?? null,
1515
1897
  $started: now,
1516
1898
  $created: now,
1517
1899
  $updated: now
1518
1900
  });
1901
+ if (workItemId) {
1902
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
1903
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
1904
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
1905
+ $id: workItemId,
1906
+ $workflowRunId: runId,
1907
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
1908
+ $updated: now
1909
+ });
1910
+ if (workItemRes.changes !== 1) {
1911
+ const current = this.getWorkflowWorkItem(workItemId);
1912
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
1913
+ }
1914
+ }
1519
1915
  input.workflow.steps.forEach((step, sequence) => {
1520
1916
  const account = step.account ?? step.target.account;
1521
1917
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
@@ -1541,7 +1937,10 @@ class Store {
1541
1937
  workflowName: input.workflow.name,
1542
1938
  stepCount: input.workflow.steps.length,
1543
1939
  loopId: input.loop?.id,
1544
- loopRunId: input.loopRun?.id
1940
+ loopRunId: input.loopRun?.id,
1941
+ invocationId,
1942
+ workItemId,
1943
+ manifestPath
1545
1944
  }),
1546
1945
  $created: now
1547
1946
  });
@@ -1554,6 +1953,8 @@ class Store {
1554
1953
  try {
1555
1954
  this.db.exec("ROLLBACK");
1556
1955
  } catch {}
1956
+ if (manifestPath)
1957
+ rmSync(manifestPath, { force: true });
1557
1958
  throw error;
1558
1959
  }
1559
1960
  }
@@ -1766,6 +2167,10 @@ class Store {
1766
2167
  changed = res.changes === 1;
1767
2168
  if (changed)
1768
2169
  this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
2170
+ if (changed) {
2171
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2172
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2173
+ }
1769
2174
  this.db.exec("COMMIT");
1770
2175
  } catch (error) {
1771
2176
  try {
@@ -1790,6 +2195,7 @@ class Store {
1790
2195
  this.db.query(`UPDATE workflow_step_runs
1791
2196
  SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
1792
2197
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2198
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
1793
2199
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
1794
2200
  }
1795
2201
  this.db.exec("COMMIT");
@@ -2043,6 +2449,8 @@ class Store {
2043
2449
  throw new Error(`run not found after finalize: ${id}`);
2044
2450
  if (opts.claimedBy && res.changes !== 1)
2045
2451
  return run;
2452
+ if (res.changes === 1)
2453
+ this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2046
2454
  return run;
2047
2455
  }
2048
2456
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2136,6 +2544,14 @@ class Store {
2136
2544
  error: "parent loop run lease expired before completion",
2137
2545
  loopRunId: row.id
2138
2546
  });
2547
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2548
+ }
2549
+ const loop = this.getLoop(row.loop_id);
2550
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
2551
+ if (itemStatus) {
2552
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2553
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2554
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2139
2555
  }
2140
2556
  this.db.exec("COMMIT");
2141
2557
  } catch (error) {
@@ -2244,11 +2660,11 @@ class Store {
2244
2660
  }
2245
2661
 
2246
2662
  // src/cli/index.ts
2247
- import { createHash as createHash2, randomUUID } from "crypto";
2248
- import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, openSync as openSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2663
+ import { createHash as createHash3, randomUUID } from "crypto";
2664
+ import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
2249
2665
  import { spawnSync as spawnSync5 } from "child_process";
2250
- import { join as join4, resolve as resolve2 } from "path";
2251
- import { tmpdir } from "os";
2666
+ import { join as join6, resolve as resolve2 } from "path";
2667
+ import { tmpdir as tmpdir2 } from "os";
2252
2668
  import { Database as Database2 } from "bun:sqlite";
2253
2669
  import { Command } from "commander";
2254
2670
 
@@ -2336,6 +2752,12 @@ function publicWorkflow(workflow) {
2336
2752
  function publicWorkflowRun(run) {
2337
2753
  return { ...run, error: redact(run.error) };
2338
2754
  }
2755
+ function publicWorkflowInvocation(invocation) {
2756
+ return redactSensitivePayload(invocation);
2757
+ }
2758
+ function publicWorkflowWorkItem(item) {
2759
+ return { ...item, lastReason: redact(item.lastReason, 240) };
2760
+ }
2339
2761
  function publicWorkflowStepRun(run, showOutput = false) {
2340
2762
  return {
2341
2763
  ...run,
@@ -2466,7 +2888,7 @@ function resolveAccountEnv(account, toolHint, env) {
2466
2888
  // src/lib/env.ts
2467
2889
  import { accessSync, constants } from "fs";
2468
2890
  import { homedir as homedir2 } from "os";
2469
- import { delimiter, join as join2 } from "path";
2891
+ import { delimiter, join as join4 } from "path";
2470
2892
  function compactPathParts(parts) {
2471
2893
  const seen = new Set;
2472
2894
  const result = [];
@@ -2482,14 +2904,14 @@ function compactPathParts(parts) {
2482
2904
  function commonExecutableDirs(env = process.env) {
2483
2905
  const home = env.HOME || homedir2();
2484
2906
  return compactPathParts([
2485
- join2(home, ".local", "bin"),
2486
- join2(home, ".bun", "bin"),
2487
- join2(home, ".cargo", "bin"),
2488
- join2(home, ".npm-global", "bin"),
2489
- join2(home, "bin"),
2490
- env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
2907
+ join4(home, ".local", "bin"),
2908
+ join4(home, ".bun", "bin"),
2909
+ join4(home, ".cargo", "bin"),
2910
+ join4(home, ".npm-global", "bin"),
2911
+ join4(home, "bin"),
2912
+ env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
2491
2913
  env.PNPM_HOME,
2492
- env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2914
+ env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2493
2915
  "/opt/homebrew/bin",
2494
2916
  "/usr/local/bin",
2495
2917
  "/usr/bin",
@@ -2513,7 +2935,7 @@ function executableExists(command, env = process.env) {
2513
2935
  if (command.includes("/"))
2514
2936
  return isExecutable(command);
2515
2937
  for (const dir of (env.PATH ?? "").split(delimiter)) {
2516
- if (dir && isExecutable(join2(dir, command)))
2938
+ if (dir && isExecutable(join4(dir, command)))
2517
2939
  return true;
2518
2940
  }
2519
2941
  return false;
@@ -2871,6 +3293,7 @@ function commandSpec(target) {
2871
3293
  shell: commandTarget.shell,
2872
3294
  env: commandTarget.env,
2873
3295
  timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3296
+ idleTimeoutMs: commandTarget.idleTimeoutMs,
2874
3297
  account: commandTarget.account,
2875
3298
  accountTool: commandTarget.account?.tool
2876
3299
  };
@@ -2881,6 +3304,7 @@ function commandSpec(target) {
2881
3304
  args: agentArgs(agentTarget),
2882
3305
  cwd: agentTarget.cwd,
2883
3306
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3307
+ idleTimeoutMs: agentTarget.idleTimeoutMs,
2884
3308
  account: agentTarget.account,
2885
3309
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2886
3310
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
@@ -3028,6 +3452,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3028
3452
  let stdout = "";
3029
3453
  let stderr = "";
3030
3454
  let timedOut = false;
3455
+ let idleTimedOut = false;
3031
3456
  let exitCode;
3032
3457
  let error;
3033
3458
  let plan;
@@ -3066,18 +3491,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3066
3491
  if (opts.signal?.aborted)
3067
3492
  abortHandler();
3068
3493
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3069
- child.stdout?.on("data", (chunk) => {
3070
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
3071
- });
3072
- child.stderr?.on("data", (chunk) => {
3073
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
3074
- });
3075
3494
  const timer = setTimeout(() => {
3076
3495
  timedOut = true;
3077
3496
  if (child.pid)
3078
3497
  killProcessGroup(child.pid);
3079
3498
  }, spec.timeoutMs);
3080
3499
  timer.unref();
3500
+ let idleTimer;
3501
+ const resetIdleTimer = () => {
3502
+ if (!spec.idleTimeoutMs)
3503
+ return;
3504
+ if (idleTimer)
3505
+ clearTimeout(idleTimer);
3506
+ idleTimer = setTimeout(() => {
3507
+ idleTimedOut = true;
3508
+ if (child.pid)
3509
+ killProcessGroup(child.pid);
3510
+ }, spec.idleTimeoutMs);
3511
+ idleTimer.unref();
3512
+ };
3513
+ resetIdleTimer();
3514
+ child.stdout?.on("data", (chunk) => {
3515
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3516
+ resetIdleTimer();
3517
+ });
3518
+ child.stderr?.on("data", (chunk) => {
3519
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3520
+ resetIdleTimer();
3521
+ });
3081
3522
  try {
3082
3523
  const [code, signal] = await once(child, "exit");
3083
3524
  if (typeof code === "number")
@@ -3088,17 +3529,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3088
3529
  error = err instanceof Error ? err.message : String(err);
3089
3530
  } finally {
3090
3531
  clearTimeout(timer);
3532
+ if (idleTimer)
3533
+ clearTimeout(idleTimer);
3091
3534
  opts.signal?.removeEventListener("abort", abortHandler);
3092
3535
  }
3093
3536
  const finishedAt = nowIso();
3094
3537
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3095
- if (timedOut) {
3538
+ if (timedOut || idleTimedOut) {
3096
3539
  return {
3097
3540
  status: "timed_out",
3098
3541
  exitCode,
3099
3542
  stdout,
3100
3543
  stderr,
3101
- error: `timed out after ${spec.timeoutMs}ms`,
3544
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3102
3545
  pid: child.pid,
3103
3546
  startedAt,
3104
3547
  finishedAt,
@@ -3164,6 +3607,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3164
3607
  let stdout = "";
3165
3608
  let stderr = "";
3166
3609
  let timedOut = false;
3610
+ let idleTimedOut = false;
3167
3611
  let exitCode;
3168
3612
  let error;
3169
3613
  const env = executionEnv(spec, metadata, opts);
@@ -3226,18 +3670,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3226
3670
  if (opts.signal?.aborted)
3227
3671
  abortHandler();
3228
3672
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3229
- child.stdout?.on("data", (chunk) => {
3230
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
3231
- });
3232
- child.stderr?.on("data", (chunk) => {
3233
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
3234
- });
3235
3673
  const timer = setTimeout(() => {
3236
3674
  timedOut = true;
3237
3675
  if (child.pid)
3238
3676
  killProcessGroup(child.pid);
3239
3677
  }, spec.timeoutMs);
3240
3678
  timer.unref();
3679
+ let idleTimer;
3680
+ const resetIdleTimer = () => {
3681
+ if (!spec.idleTimeoutMs)
3682
+ return;
3683
+ if (idleTimer)
3684
+ clearTimeout(idleTimer);
3685
+ idleTimer = setTimeout(() => {
3686
+ idleTimedOut = true;
3687
+ if (child.pid)
3688
+ killProcessGroup(child.pid);
3689
+ }, spec.idleTimeoutMs);
3690
+ idleTimer.unref();
3691
+ };
3692
+ resetIdleTimer();
3693
+ child.stdout?.on("data", (chunk) => {
3694
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3695
+ resetIdleTimer();
3696
+ });
3697
+ child.stderr?.on("data", (chunk) => {
3698
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3699
+ resetIdleTimer();
3700
+ });
3241
3701
  try {
3242
3702
  const [code, signal] = await once(child, "exit");
3243
3703
  if (typeof code === "number")
@@ -3248,17 +3708,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3248
3708
  error = err instanceof Error ? err.message : String(err);
3249
3709
  } finally {
3250
3710
  clearTimeout(timer);
3711
+ if (idleTimer)
3712
+ clearTimeout(idleTimer);
3251
3713
  opts.signal?.removeEventListener("abort", abortHandler);
3252
3714
  }
3253
3715
  const finishedAt = nowIso();
3254
3716
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3255
- if (timedOut) {
3717
+ if (timedOut || idleTimedOut) {
3256
3718
  return {
3257
3719
  status: "timed_out",
3258
3720
  exitCode,
3259
3721
  stdout,
3260
3722
  stderr,
3261
- error: `timed out after ${spec.timeoutMs}ms`,
3723
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3262
3724
  pid: child.pid,
3263
3725
  startedAt,
3264
3726
  finishedAt,
@@ -4300,7 +4762,7 @@ async function tick(deps) {
4300
4762
  }
4301
4763
 
4302
4764
  // src/daemon/control.ts
4303
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
4765
+ import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4304
4766
  import { hostname } from "os";
4305
4767
  import { dirname as dirname2 } from "path";
4306
4768
 
@@ -4338,11 +4800,11 @@ function readPid(path = pidFilePath()) {
4338
4800
  }
4339
4801
  }
4340
4802
  function writePid(pid = process.pid, path = pidFilePath()) {
4341
- mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
4342
- writeFileSync(path, String(pid));
4803
+ mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
4804
+ writeFileSync2(path, String(pid));
4343
4805
  }
4344
4806
  function removePid(path = pidFilePath()) {
4345
- rmSync(path, { force: true });
4807
+ rmSync2(path, { force: true });
4346
4808
  }
4347
4809
  function isAlive(pid) {
4348
4810
  try {
@@ -4601,7 +5063,7 @@ async function startDaemon(opts) {
4601
5063
  }
4602
5064
 
4603
5065
  // src/daemon/install.ts
4604
- import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
5066
+ import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
4605
5067
  import { spawnSync as spawnSync3 } from "child_process";
4606
5068
  import { dirname as dirname3 } from "path";
4607
5069
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
@@ -4609,8 +5071,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
4609
5071
  const pathEnv = normalizeExecutionPath(process.env);
4610
5072
  if (process.platform === "linux") {
4611
5073
  const path = systemdServicePath();
4612
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4613
- writeFileSync2(path, `[Unit]
5074
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
5075
+ writeFileSync3(path, `[Unit]
4614
5076
  Description=Hasna OpenLoops daemon
4615
5077
  After=default.target
4616
5078
 
@@ -4636,8 +5098,8 @@ WantedBy=default.target
4636
5098
  }
4637
5099
  if (process.platform === "darwin") {
4638
5100
  const path = launchdPlistPath();
4639
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4640
- writeFileSync2(path, `<?xml version="1.0" encoding="UTF-8"?>
5101
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
5102
+ writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
4641
5103
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4642
5104
  <plist version="1.0">
4643
5105
  <dict>
@@ -4796,7 +5258,7 @@ function runDoctor(store) {
4796
5258
  }
4797
5259
 
4798
5260
  // src/lib/health.ts
4799
- import { createHash } from "crypto";
5261
+ import { createHash as createHash2 } from "crypto";
4800
5262
  var EVIDENCE_CHARS = 2000;
4801
5263
  var FINGERPRINT_EVIDENCE_CHARS = 120;
4802
5264
  var CLASSIFICATIONS = [
@@ -4828,7 +5290,7 @@ function searchableText(run) {
4828
5290
  `).toLowerCase();
4829
5291
  }
4830
5292
  function stableFingerprint(parts) {
4831
- return createHash("sha256").update(parts.join(`
5293
+ return createHash2("sha256").update(parts.join(`
4832
5294
  `)).digest("hex").slice(0, 16);
4833
5295
  }
4834
5296
  function stableFailureFingerprint(run, classification) {
@@ -5016,7 +5478,7 @@ function buildHealthReport(store, opts = {}) {
5016
5478
  }
5017
5479
 
5018
5480
  // src/lib/hygiene.ts
5019
- import { basename } from "path";
5481
+ import { basename as basename2 } from "path";
5020
5482
  var PROVIDER_TOKENS = new Set([
5021
5483
  "codewith",
5022
5484
  "claude",
@@ -5037,7 +5499,7 @@ function repoSlugFromCwd(cwd) {
5037
5499
  return "";
5038
5500
  if (cwd.includes("/.hasna/loops/"))
5039
5501
  return "";
5040
- return slugify(basename(cwd));
5502
+ return slugify(basename2(cwd));
5041
5503
  }
5042
5504
  function scopeForLoop(loop) {
5043
5505
  const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
@@ -5254,7 +5716,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5254
5716
  // package.json
5255
5717
  var package_default = {
5256
5718
  name: "@hasna/loops",
5257
- version: "0.3.38",
5719
+ version: "0.3.40",
5258
5720
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5259
5721
  type: "module",
5260
5722
  main: "dist/index.js",
@@ -5346,10 +5808,17 @@ function packageVersion() {
5346
5808
  import { execFileSync } from "child_process";
5347
5809
  import { existsSync as existsSync3 } from "fs";
5348
5810
  import { homedir as homedir3 } from "os";
5349
- import { basename as basename2, isAbsolute, join as join3, relative, resolve } from "path";
5811
+ import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
5350
5812
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
5351
5813
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5352
5814
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
5815
+ var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
5816
+ var PR_REVIEW_TEMPLATE_ID = "pr-review";
5817
+ var SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
5818
+ var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
5819
+ var REPORT_ONLY_TEMPLATE_ID = "report-only";
5820
+ var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
5821
+ var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
5353
5822
  var TEMPLATE_SUMMARIES = [
5354
5823
  {
5355
5824
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -5371,7 +5840,8 @@ var TEMPLATE_SUMMARIES = [
5371
5840
  { name: "model", description: "Provider model." },
5372
5841
  { name: "variant", description: "Provider reasoning/model effort variant." },
5373
5842
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5374
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5843
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5844
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5375
5845
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5376
5846
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5377
5847
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
@@ -5399,7 +5869,8 @@ var TEMPLATE_SUMMARIES = [
5399
5869
  { name: "model", description: "Provider model." },
5400
5870
  { name: "variant", description: "Provider reasoning/model effort variant." },
5401
5871
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5402
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5872
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5873
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5403
5874
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5404
5875
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5405
5876
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
@@ -5425,12 +5896,112 @@ var TEMPLATE_SUMMARIES = [
5425
5896
  { name: "model", description: "Provider model." },
5426
5897
  { name: "variant", description: "Provider reasoning/model effort variant." },
5427
5898
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5428
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5899
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5900
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5429
5901
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5430
5902
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5431
5903
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
5432
5904
  { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5433
5905
  ]
5906
+ },
5907
+ {
5908
+ id: TASK_LIFECYCLE_TEMPLATE_ID,
5909
+ name: "Task Lifecycle",
5910
+ description: "Run the standard task-created lifecycle: triage/dedupe, plan, worker execution, independent verification, and todos closure/follow-up evidence.",
5911
+ kind: "workflow",
5912
+ variables: [
5913
+ { name: "taskId", required: true, description: "Todos task id." },
5914
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5915
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5916
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
5917
+ { name: "provider", default: "codewith", description: "Agent provider." },
5918
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5919
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5920
+ ]
5921
+ },
5922
+ {
5923
+ id: PR_REVIEW_TEMPLATE_ID,
5924
+ name: "PR Review",
5925
+ description: "Review and drive a pull request toward merge-ready state with a worker and fresh adversarial verifier.",
5926
+ kind: "workflow",
5927
+ variables: [
5928
+ { name: "prUrl", description: "Pull request URL." },
5929
+ { name: "prNumber", description: "Pull request number." },
5930
+ { name: "projectPath", required: true, description: "Repository working directory." },
5931
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5932
+ { name: "provider", default: "codewith", description: "Agent provider." },
5933
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5934
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5935
+ ]
5936
+ },
5937
+ {
5938
+ id: SCHEDULED_AUDIT_TEMPLATE_ID,
5939
+ name: "Scheduled Audit",
5940
+ description: "Run a bounded scheduled audit, record evidence, create follow-up tasks for actionable findings, then verify the audit result.",
5941
+ kind: "workflow",
5942
+ variables: [
5943
+ { name: "objective", required: true, description: "Audit objective." },
5944
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5945
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5946
+ { name: "provider", default: "codewith", description: "Agent provider." },
5947
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5948
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5949
+ ]
5950
+ },
5951
+ {
5952
+ id: KNOWLEDGE_REFRESH_TEMPLATE_ID,
5953
+ name: "Knowledge Refresh",
5954
+ description: "Review recent knowledge, improve structure/schema where needed, create deduped tasks for code changes, and verify the knowledge update.",
5955
+ kind: "workflow",
5956
+ variables: [
5957
+ { name: "scope", description: "Knowledge scope or label to refresh." },
5958
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5959
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5960
+ { name: "provider", default: "codewith", description: "Agent provider." },
5961
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5962
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5963
+ ]
5964
+ },
5965
+ {
5966
+ id: REPORT_ONLY_TEMPLATE_ID,
5967
+ name: "Report Only",
5968
+ description: "Produce a bounded report without mutating repositories; verifier checks evidence, scope, and absence of unauthorized changes.",
5969
+ kind: "workflow",
5970
+ variables: [
5971
+ { name: "objective", required: true, description: "Report objective." },
5972
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5973
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5974
+ { name: "provider", default: "codewith", description: "Agent provider." },
5975
+ { name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
5976
+ { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
5977
+ ]
5978
+ },
5979
+ {
5980
+ id: INCIDENT_RESPONSE_TEMPLATE_ID,
5981
+ name: "Incident Response",
5982
+ description: "Triage an incident, gather bounded evidence, apply only allowed narrow mitigation, create follow-up tasks, and verify the response.",
5983
+ kind: "workflow",
5984
+ variables: [
5985
+ { name: "incidentId", description: "Incident or task id." },
5986
+ { name: "objective", required: true, description: "Incident response objective." },
5987
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5988
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5989
+ { name: "provider", default: "codewith", description: "Agent provider." },
5990
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5991
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5992
+ ]
5993
+ },
5994
+ {
5995
+ id: DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID,
5996
+ name: "Deterministic Check Create Task",
5997
+ description: "Run a deterministic check command that writes compact evidence and upserts one deduped todos task when its expectation is not met.",
5998
+ kind: "workflow",
5999
+ variables: [
6000
+ { name: "checkCommand", required: true, description: "Shell command that performs the check and task upsert." },
6001
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
6002
+ { name: "name", description: "Workflow name." },
6003
+ { name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
6004
+ ]
5434
6005
  }
5435
6006
  ];
5436
6007
  function compactJson(value) {
@@ -5492,7 +6063,7 @@ function defaultWorktreeRoot(root) {
5492
6063
  const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
5493
6064
  return isAbsolute(expanded) ? expanded : resolve(expanded);
5494
6065
  }
5495
- return join3(homedir3(), ".hasna", "loops", "worktrees");
6066
+ return join5(homedir3(), ".hasna", "loops", "worktrees");
5496
6067
  }
5497
6068
  function gitRootFor(path) {
5498
6069
  if (!existsSync3(path))
@@ -5593,11 +6164,11 @@ function worktreePlan(input, seed) {
5593
6164
  };
5594
6165
  }
5595
6166
  const root = defaultWorktreeRoot(input.worktreeRoot);
5596
- const repoSlug = slugSegment(basename2(repoRoot), "repo");
6167
+ const repoSlug = slugSegment(basename3(repoRoot), "repo");
5597
6168
  const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
5598
- const worktreePath = join3(root, repoSlug, seedSlug);
6169
+ const worktreePath = join5(root, repoSlug, seedSlug);
5599
6170
  const relativeCwd = relative(repoRoot, originalCwd);
5600
- const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join3(worktreePath, relativeCwd) : worktreePath;
6171
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5601
6172
  const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
5602
6173
  const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
5603
6174
  const prepareStep = {
@@ -5655,10 +6226,20 @@ function assertNativeAuthProfileSupport(input, provider) {
5655
6226
  return;
5656
6227
  throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
5657
6228
  }
6229
+ function failClosedSandbox(input, provider, sandbox) {
6230
+ if (!["codewith", "codex"].includes(provider))
6231
+ return;
6232
+ if (sandbox !== "danger-full-access")
6233
+ return;
6234
+ if (input.manualBreakGlass)
6235
+ return;
6236
+ throw new Error("danger-full-access is manual break-glass only for generated worker/verifier workflows; use sandbox=workspace-write or set manualBreakGlass=true with explicit operator approval");
6237
+ }
5658
6238
  function agentTarget(input, prompt, role, seed, plan) {
5659
6239
  const provider = input.provider ?? "codewith";
5660
6240
  assertNativeAuthProfileSupport(input, provider);
5661
- const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
6241
+ const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "workspace-write" : provider === "cursor" ? "enabled" : undefined);
6242
+ failClosedSandbox(input, provider, sandbox);
5662
6243
  return {
5663
6244
  type: "agent",
5664
6245
  provider,
@@ -5682,6 +6263,7 @@ function agentTarget(input, prompt, role, seed, plan) {
5682
6263
  branch: plan.branch,
5683
6264
  reason: plan.reason
5684
6265
  },
6266
+ allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
5685
6267
  routing: {
5686
6268
  projectPath: input.routeProjectPath ?? input.projectPath,
5687
6269
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
@@ -5771,7 +6353,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5771
6353
  name: "Verifier",
5772
6354
  description: "Adversarially verify worker output and update todos.",
5773
6355
  dependsOn: ["worker"],
5774
- target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6356
+ target: {
6357
+ ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6358
+ idleTimeoutMs: 10 * 60000
6359
+ },
5775
6360
  timeoutMs: 30 * 60000
5776
6361
  }
5777
6362
  ])
@@ -5849,7 +6434,10 @@ function renderEventWorkerVerifierWorkflow(input) {
5849
6434
  name: "Verifier",
5850
6435
  description: "Adversarially verify event handling.",
5851
6436
  dependsOn: ["worker"],
5852
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6437
+ target: {
6438
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6439
+ idleTimeoutMs: 10 * 60000
6440
+ },
5853
6441
  timeoutMs: 30 * 60000
5854
6442
  }
5855
6443
  ])
@@ -5900,13 +6488,142 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5900
6488
  name: "Verifier",
5901
6489
  description: "Adversarially verify the bounded objective result.",
5902
6490
  dependsOn: ["worker"],
5903
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6491
+ target: {
6492
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6493
+ idleTimeoutMs: 10 * 60000
6494
+ },
5904
6495
  timeoutMs: Math.min(timeoutMs, 30 * 60000)
5905
6496
  }
5906
6497
  ])
5907
6498
  };
5908
6499
  }
6500
+ function renderLifecycleBoundedTemplate(id, values) {
6501
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
6502
+ const common = {
6503
+ name: values.name,
6504
+ projectPath,
6505
+ routeProjectPath: values.routeProjectPath,
6506
+ projectGroup: values.projectGroup,
6507
+ provider: values.provider,
6508
+ authProfile: values.authProfile,
6509
+ authProfilePool: listVar(values.authProfilePool),
6510
+ workerAuthProfile: values.workerAuthProfile,
6511
+ verifierAuthProfile: values.verifierAuthProfile,
6512
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
6513
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
6514
+ model: values.model,
6515
+ variant: values.variant,
6516
+ agent: values.agent,
6517
+ permissionMode: values.permissionMode,
6518
+ sandbox: values.sandbox,
6519
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
6520
+ worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
6521
+ worktreeRoot: values.worktreeRoot,
6522
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
6523
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
6524
+ };
6525
+ if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
6526
+ const taskId = values.taskId ?? "";
6527
+ if (!taskId.trim())
6528
+ throw new Error("taskId is required");
6529
+ return renderBoundedAgentWorkerVerifierWorkflow({
6530
+ ...common,
6531
+ name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
6532
+ objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
6533
+ prompt: values.prompt ?? "Triage and dedupe the task, verify it is eligible for loop execution, create or update a concise plan artifact/comment, execute only the allowed scope, validate, record evidence, and let the verifier decide final task state. Add follow-up tasks instead of broadening scope."
6534
+ });
6535
+ }
6536
+ if (id === PR_REVIEW_TEMPLATE_ID) {
6537
+ const pr = values.prUrl ?? values.prNumber ?? "";
6538
+ if (!pr.trim())
6539
+ throw new Error("prUrl or prNumber is required");
6540
+ return renderBoundedAgentWorkerVerifierWorkflow({
6541
+ ...common,
6542
+ name: values.name ?? `pr-review-${slugSegment(pr)}-worker-verifier`,
6543
+ objective: values.objective ?? `Review and drive PR ${pr} toward merge-ready state.`,
6544
+ prompt: values.prompt ?? "Inspect PR state, checks, conflicts, branch freshness, review requirements, and repo policy. Apply only owned logical fixes in the isolated worktree, validate, update the PR/task with evidence, and do not merge unless policy/checks make it clearly safe."
6545
+ });
6546
+ }
6547
+ if (id === SCHEDULED_AUDIT_TEMPLATE_ID) {
6548
+ const objective = values.objective ?? "";
6549
+ if (!objective.trim())
6550
+ throw new Error("objective is required");
6551
+ return renderBoundedAgentWorkerVerifierWorkflow({
6552
+ ...common,
6553
+ name: values.name ?? `scheduled-audit-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
6554
+ objective,
6555
+ prompt: values.prompt ?? "Run the bounded audit, write compact evidence, create deduped todos tasks for actionable issues, and avoid implementation unless the task explicitly allows it."
6556
+ });
6557
+ }
6558
+ if (id === KNOWLEDGE_REFRESH_TEMPLATE_ID) {
6559
+ const scope = values.scope ?? values.label ?? "recent knowledge";
6560
+ return renderBoundedAgentWorkerVerifierWorkflow({
6561
+ ...common,
6562
+ name: values.name ?? `knowledge-refresh-${slugSegment(scope)}-worker-verifier`,
6563
+ objective: values.objective ?? `Refresh and verify ${scope}.`,
6564
+ prompt: values.prompt ?? "Inspect recent knowledge records, improve structure/schema where appropriate, avoid duplicates, create tasks for code changes instead of doing unrelated implementation, and record verification evidence."
6565
+ });
6566
+ }
6567
+ if (id === REPORT_ONLY_TEMPLATE_ID) {
6568
+ const objective = values.objective ?? "";
6569
+ if (!objective.trim())
6570
+ throw new Error("objective is required");
6571
+ return renderBoundedAgentWorkerVerifierWorkflow({
6572
+ ...common,
6573
+ name: values.name ?? `report-only-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
6574
+ objective,
6575
+ prompt: values.prompt ?? "Produce a report only. Do not mutate repositories, tasks, secrets, databases, or external systems except for writing the requested report/evidence artifact."
6576
+ });
6577
+ }
6578
+ if (id === INCIDENT_RESPONSE_TEMPLATE_ID) {
6579
+ const objective = values.objective ?? "";
6580
+ if (!objective.trim())
6581
+ throw new Error("objective is required");
6582
+ const incident = values.incidentId ?? values.taskId ?? "incident";
6583
+ return renderBoundedAgentWorkerVerifierWorkflow({
6584
+ ...common,
6585
+ name: values.name ?? `incident-response-${slugSegment(incident)}-worker-verifier`,
6586
+ objective,
6587
+ prompt: values.prompt ?? "Triage first, gather bounded evidence, mitigate only narrow allowed issues, preserve data/history/secrets, create follow-up tasks for larger fixes, and require verifier confirmation before closure."
6588
+ });
6589
+ }
6590
+ return;
6591
+ }
6592
+ function renderDeterministicCheckCreateTaskWorkflow(values) {
6593
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
6594
+ const checkCommand = values.checkCommand ?? "";
6595
+ if (!checkCommand.trim())
6596
+ throw new Error("checkCommand is required");
6597
+ const seed = `${projectPath}:${checkCommand}`;
6598
+ return {
6599
+ name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
6600
+ description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
6601
+ version: 1,
6602
+ steps: [
6603
+ {
6604
+ id: "check",
6605
+ name: "Check",
6606
+ description: "Run the deterministic check/task-upsert command.",
6607
+ target: {
6608
+ type: "command",
6609
+ command: "bash",
6610
+ args: ["-lc", checkCommand],
6611
+ cwd: projectPath,
6612
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
6613
+ idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
6614
+ },
6615
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
6616
+ }
6617
+ ]
6618
+ };
6619
+ }
5909
6620
  function renderLoopTemplate(id, values) {
6621
+ if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
6622
+ return renderDeterministicCheckCreateTaskWorkflow(values);
6623
+ }
6624
+ const lifecycle = renderLifecycleBoundedTemplate(id, values);
6625
+ if (lifecycle)
6626
+ return lifecycle;
5910
6627
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
5911
6628
  return renderTodosTaskWorkerVerifierWorkflow({
5912
6629
  taskId: values.taskId ?? "",
@@ -5927,6 +6644,7 @@ function renderLoopTemplate(id, values) {
5927
6644
  agent: values.agent,
5928
6645
  permissionMode: values.permissionMode,
5929
6646
  sandbox: values.sandbox,
6647
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5930
6648
  worktreeMode: values.worktreeMode,
5931
6649
  worktreeRoot: values.worktreeRoot,
5932
6650
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -5957,6 +6675,7 @@ function renderLoopTemplate(id, values) {
5957
6675
  agent: values.agent,
5958
6676
  permissionMode: values.permissionMode,
5959
6677
  sandbox: values.sandbox,
6678
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5960
6679
  worktreeMode: values.worktreeMode,
5961
6680
  worktreeRoot: values.worktreeRoot,
5962
6681
  worktreeBranchPrefix: values.worktreeBranchPrefix
@@ -5982,6 +6701,7 @@ function renderLoopTemplate(id, values) {
5982
6701
  agent: values.agent,
5983
6702
  permissionMode: values.permissionMode,
5984
6703
  sandbox: values.sandbox,
6704
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5985
6705
  worktreeMode: values.worktreeMode,
5986
6706
  worktreeRoot: values.worktreeRoot,
5987
6707
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -5994,6 +6714,16 @@ function listVar(value) {
5994
6714
  const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5995
6715
  return values?.length ? values : undefined;
5996
6716
  }
6717
+ function booleanVar(value) {
6718
+ if (value === undefined)
6719
+ return;
6720
+ const normalized = value.trim().toLowerCase();
6721
+ if (["1", "true", "yes", "on"].includes(normalized))
6722
+ return true;
6723
+ if (["0", "false", "no", "off", ""].includes(normalized))
6724
+ return false;
6725
+ throw new Error(`expected boolean value, got ${value}`);
6726
+ }
5997
6727
  function accountPoolVar(value, tool) {
5998
6728
  return listVar(value)?.map((profile) => ({ profile, tool }));
5999
6729
  }
@@ -6224,8 +6954,8 @@ function runLocalCommand(command, args, opts = {}) {
6224
6954
  };
6225
6955
  }
6226
6956
  function runLocalCommandWithStdoutFile(command, args, opts = {}) {
6227
- const tempDir = mkdtempSync(join4(tmpdir(), "loops-command-output-"));
6228
- const stdoutPath = join4(tempDir, "stdout");
6957
+ const tempDir = mkdtempSync2(join6(tmpdir2(), "loops-command-output-"));
6958
+ const stdoutPath = join6(tempDir, "stdout");
6229
6959
  const stdoutFd = openSync2(stdoutPath, "w");
6230
6960
  let result;
6231
6961
  try {
@@ -6249,7 +6979,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
6249
6979
  error: result.error ? String(result.error.message || result.error) : ""
6250
6980
  };
6251
6981
  } finally {
6252
- rmSync2(tempDir, { recursive: true, force: true });
6982
+ rmSync3(tempDir, { recursive: true, force: true });
6253
6983
  }
6254
6984
  }
6255
6985
  function ensureTodosTaskList(project, slug, name, description) {
@@ -6265,23 +6995,23 @@ function ensureTodosTaskList(project, slug, name, description) {
6265
6995
  }
6266
6996
  function backupLoopsDatabase(reason) {
6267
6997
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
6268
- const backupDir = join4(dataDir(), "backups");
6269
- mkdirSync5(backupDir, { recursive: true, mode: 448 });
6270
- const backupPath = join4(backupDir, `loops.db.bak-${reason}-${stamp}`);
6998
+ const backupDir = join6(dataDir(), "backups");
6999
+ mkdirSync6(backupDir, { recursive: true, mode: 448 });
7000
+ const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
6271
7001
  const db = new Database2(dbPath(), { readonly: true });
6272
7002
  try {
6273
- writeFileSync3(backupPath, db.serialize(), { mode: 384 });
7003
+ writeFileSync4(backupPath, db.serialize(), { mode: 384 });
6274
7004
  } finally {
6275
7005
  db.close();
6276
7006
  }
6277
7007
  return backupPath;
6278
7008
  }
6279
7009
  function stableHash(parts) {
6280
- return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
7010
+ return createHash3("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
6281
7011
  `)).digest("hex").slice(0, 16);
6282
7012
  }
6283
7013
  function routeCursorsPath() {
6284
- return join4(dataDir(), "route-cursors.json");
7014
+ return join6(dataDir(), "route-cursors.json");
6285
7015
  }
6286
7016
  function readRouteCursors() {
6287
7017
  const path = routeCursorsPath();
@@ -6299,15 +7029,15 @@ function writeRouteCursor(key, lastFingerprint) {
6299
7029
  return;
6300
7030
  const cursors = readRouteCursors();
6301
7031
  cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
6302
- writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
7032
+ writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
6303
7033
  }
6304
7034
  function writeRouteEvidence(kind, value, evidenceDir) {
6305
7035
  if (!evidenceDir)
6306
7036
  return;
6307
- mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
7037
+ mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
6308
7038
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
6309
- const evidencePath = join4(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
6310
- writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
7039
+ const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
7040
+ writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
6311
7041
  return evidencePath;
6312
7042
  }
6313
7043
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
@@ -6426,7 +7156,7 @@ function slugSegment2(value, fallback = "event") {
6426
7156
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
6427
7157
  }
6428
7158
  function stableSuffix(value) {
6429
- return createHash2("sha256").update(value).digest("hex").slice(0, 12);
7159
+ return createHash3("sha256").update(value).digest("hex").slice(0, 12);
6430
7160
  }
6431
7161
  function taskEventField(data, keys) {
6432
7162
  for (const key of keys) {
@@ -6561,6 +7291,33 @@ function taskRouteEligibility(data, metadata) {
6561
7291
  }
6562
7292
  return { eligible: true, tags };
6563
7293
  }
7294
+ function generatedRouteSandboxPreflight(workflow) {
7295
+ const checks = [];
7296
+ for (const step of workflow.steps) {
7297
+ if (step.target.type !== "agent")
7298
+ continue;
7299
+ const target = step.target;
7300
+ const worktreeEnabled = Boolean(target.worktree?.enabled);
7301
+ if (target.sandbox === "danger-full-access") {
7302
+ const manual = target.allowlist?.commands?.includes("manual-break-glass");
7303
+ if (!manual) {
7304
+ throw new Error(`route step ${step.id} uses danger-full-access without manual break-glass evidence`);
7305
+ }
7306
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "manual-break-glass" });
7307
+ continue;
7308
+ }
7309
+ if (["codewith", "codex"].includes(target.provider) && (target.sandbox === "workspace-write" || target.sandbox === "read-only") || target.provider === "cursor" && target.sandbox === "enabled") {
7310
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "provider-native-sandbox" });
7311
+ continue;
7312
+ }
7313
+ if (worktreeEnabled) {
7314
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "isolated-worktree" });
7315
+ continue;
7316
+ }
7317
+ throw new Error(`route step ${step.id} has no verified unattended isolation; use provider sandbox workspace-write/read-only/enabled, worktreeMode=required, or explicit manual break-glass`);
7318
+ }
7319
+ return checks;
7320
+ }
6564
7321
  function routeThrottleLimitsFromOpts(opts) {
6565
7322
  return {
6566
7323
  maxActive: positiveInteger(opts.maxActive, "--max-active"),
@@ -6594,46 +7351,10 @@ function normalizeRoutePath(value) {
6594
7351
  function routeProjectGroup(optsGroup, data, metadata) {
6595
7352
  return optsGroup?.trim() || taskEventField(data, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]) || taskEventField(metadata, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]);
6596
7353
  }
6597
- function firstWorkflowRouting(workflow) {
6598
- for (const step of workflow.steps) {
6599
- if (step.target.type !== "agent")
6600
- continue;
6601
- const target = step.target;
6602
- const projectPath = normalizeRoutePath(target.routing?.projectPath ?? target.worktree?.originalCwd ?? target.cwd);
6603
- const projectGroup = target.routing?.projectGroup?.trim();
6604
- if (projectPath || projectGroup) {
6605
- return {
6606
- ...projectPath ? { projectPath } : {},
6607
- ...projectGroup ? { projectGroup } : {}
6608
- };
6609
- }
6610
- }
6611
- return;
6612
- }
6613
- function activeWorkflowLoopRoutes(store) {
6614
- const values = [];
6615
- for (const loop of store.listLoops({ status: "active", limit: 1e4 })) {
6616
- if (loop.target.type !== "workflow")
6617
- continue;
6618
- const workflow = store.getWorkflow(loop.target.workflowId);
6619
- if (!workflow || workflow.status !== "active")
6620
- continue;
6621
- const routing = firstWorkflowRouting(workflow);
6622
- if (routing)
6623
- values.push({ loop, routing });
6624
- }
6625
- return values;
6626
- }
6627
7354
  function routeThrottleDecision(store, args) {
6628
7355
  const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
6629
7356
  const projectGroup = args.projectGroup?.trim() || undefined;
6630
- const active = activeWorkflowLoopRoutes(store);
6631
- const counts = {
6632
- global: active.length,
6633
- project: active.filter((entry) => normalizeRoutePath(entry.routing.projectPath) === projectPath).length
6634
- };
6635
- if (projectGroup)
6636
- counts.projectGroup = active.filter((entry) => entry.routing.projectGroup?.trim() === projectGroup).length;
7357
+ const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
6637
7358
  const base = {
6638
7359
  projectPath,
6639
7360
  ...projectGroup ? { projectGroup } : {},
@@ -6666,12 +7387,8 @@ function routeThrottleDryRunPreview(args) {
6666
7387
  limits: args.limits
6667
7388
  };
6668
7389
  }
6669
- function findLoopByTaskIdempotency(store, idempotencyKey) {
6670
- const marker = `idempotency=${idempotencyKey}`;
6671
- return store.listLoops({ includeArchived: true, limit: 1e5 }).find((loop) => loop.description?.includes(marker));
6672
- }
6673
- async function readEventEnvelopeFromStdin() {
6674
- const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
7390
+ async function readEventEnvelopeInput(opts = {}) {
7391
+ const raw = opts.eventJson ?? (opts.eventFile ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
6675
7392
  const event = JSON.parse(raw);
6676
7393
  if (!event || typeof event !== "object" || Array.isArray(event))
6677
7394
  throw new Error("event JSON must be an object");
@@ -6683,6 +7400,9 @@ async function readEventEnvelopeFromStdin() {
6683
7400
  throw new Error("event.source is required");
6684
7401
  return event;
6685
7402
  }
7403
+ async function readEventEnvelopeFromStdin() {
7404
+ return readEventEnvelopeInput();
7405
+ }
6686
7406
  function routeTodosTaskEvent(event, opts) {
6687
7407
  const data = eventData(event);
6688
7408
  const metadata = eventMetadata(event);
@@ -6717,24 +7437,27 @@ function routeTodosTaskEvent(event, opts) {
6717
7437
  const namePrefix = opts.namePrefix ?? "event:todos-task";
6718
7438
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
6719
7439
  const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
6720
- const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
6721
7440
  if (!opts.dryRun) {
6722
7441
  const store2 = new Store;
6723
7442
  try {
6724
- const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName) ?? findLoopByTaskIdempotency(store2, idempotencyKey);
6725
- if (existingLoop) {
6726
- const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
7443
+ const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
7444
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7445
+ const existingLoop = store2.getLoop(existingItem.loopId);
7446
+ const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
7447
+ const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
6727
7448
  return {
6728
7449
  kind: "deduped",
6729
7450
  value: {
6730
7451
  deduped: true,
6731
7452
  idempotencyKey,
6732
- dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
7453
+ dedupedBy: "work-item",
6733
7454
  event,
7455
+ invocation: existingInvocation ? publicWorkflowInvocation(existingInvocation) : undefined,
7456
+ workItem: publicWorkflowWorkItem(existingItem),
6734
7457
  workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
6735
- loop: publicLoop(existingLoop)
7458
+ loop: existingLoop ? publicLoop(existingLoop) : undefined
6736
7459
  },
6737
- human: `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
7460
+ human: `deduped existing work item ${existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
6738
7461
  };
6739
7462
  }
6740
7463
  } finally {
@@ -6768,6 +7491,7 @@ function routeTodosTaskEvent(event, opts) {
6768
7491
  agent: opts.agent,
6769
7492
  permissionMode,
6770
7493
  sandbox,
7494
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
6771
7495
  worktreeMode: opts.worktreeMode ?? "auto",
6772
7496
  worktreeRoot: opts.worktreeRoot,
6773
7497
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
@@ -6776,11 +7500,53 @@ function routeTodosTaskEvent(event, opts) {
6776
7500
  });
6777
7501
  workflowBody.name = workflowName;
6778
7502
  workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7503
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7504
+ const invocationInput = {
7505
+ templateId: "todos-task-worker-verifier",
7506
+ sourceRef: {
7507
+ kind: "event",
7508
+ id: event.id,
7509
+ dedupeKey: idempotencyKey,
7510
+ raw: { type: event.type, source: event.source, subject: event.subject }
7511
+ },
7512
+ subjectRef: {
7513
+ kind: "task",
7514
+ id: taskId,
7515
+ path: routeProjectPath,
7516
+ raw: { title: taskTitle, description: taskDescription }
7517
+ },
7518
+ intent: "route",
7519
+ scope: {
7520
+ projectPath: routeProjectPath,
7521
+ projectGroup,
7522
+ worktreePolicy: opts.worktreeMode ?? "auto",
7523
+ permissions: permissionMode,
7524
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7525
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
7526
+ concurrencyGroup: projectGroup ?? routeProjectPath
7527
+ },
7528
+ outputPolicy: {
7529
+ report: "always",
7530
+ createTask: "on_failure"
7531
+ }
7532
+ };
7533
+ const workItemInput = {
7534
+ routeKey: "todos-task",
7535
+ idempotencyKey,
7536
+ invocationId: "<created-invocation-id>",
7537
+ sourceType: event.type,
7538
+ sourceRef: event.id,
7539
+ subjectRef: taskId,
7540
+ projectKey: routeProjectPath,
7541
+ projectGroup,
7542
+ priority: 0,
7543
+ status: "queued"
7544
+ };
6779
7545
  const loopInput = {
6780
7546
  name: loopName,
6781
7547
  description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
6782
7548
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
6783
- target: { type: "workflow", workflowId: "<created-workflow-id>" },
7549
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
6784
7550
  overlap: "skip",
6785
7551
  maxAttempts: 1,
6786
7552
  retryDelayMs: 60000,
@@ -6795,7 +7561,7 @@ function routeTodosTaskEvent(event, opts) {
6795
7561
  }, {}) : undefined;
6796
7562
  return {
6797
7563
  kind: "created",
6798
- value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
7564
+ value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
6799
7565
  human: `dry-run ${loopName}`
6800
7566
  };
6801
7567
  }
@@ -6803,27 +7569,44 @@ function routeTodosTaskEvent(event, opts) {
6803
7569
  try {
6804
7570
  const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
6805
7571
  const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
7572
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
6806
7573
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
6807
7574
  name: workflowBody.name,
6808
7575
  type: "todos-task-event-workflow",
6809
7576
  event: event.id
6810
7577
  }, {}) : undefined;
6811
7578
  const outcome = store.writeTransaction(() => {
6812
- const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName) ?? findLoopByTaskIdempotency(store, idempotencyKey);
6813
- if (existingLoop) {
6814
- const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
6815
- return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
7579
+ const invocation = store.createWorkflowInvocation(invocationInput);
7580
+ const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
7581
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7582
+ const existingLoop = store.getLoop(existingItem.loopId);
7583
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
7584
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
6816
7585
  }
6817
7586
  const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7587
+ const workItem = store.upsertWorkflowWorkItem({
7588
+ ...workItemInput,
7589
+ invocationId: invocation.id,
7590
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
7591
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
7592
+ });
6818
7593
  if (throttle && !throttle.allowed)
6819
- return { kind: "throttled", throttle };
7594
+ return { kind: "throttled", invocation, workItem, throttle };
6820
7595
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
6821
7596
  const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
6822
7597
  const loop = store.createLoop({
6823
7598
  ...loopInput,
6824
- target: { type: "workflow", workflowId: workflow.id }
7599
+ target: {
7600
+ type: "workflow",
7601
+ workflowId: workflow.id,
7602
+ input: {
7603
+ workflowInvocationId: invocation.id,
7604
+ workflowWorkItemId: workItem.id
7605
+ }
7606
+ }
6825
7607
  });
6826
- return { kind: "created", workflow, loop, throttle };
7608
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
7609
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
6827
7610
  });
6828
7611
  if (outcome.kind === "deduped") {
6829
7612
  return {
@@ -6831,12 +7614,14 @@ function routeTodosTaskEvent(event, opts) {
6831
7614
  value: {
6832
7615
  deduped: true,
6833
7616
  idempotencyKey,
6834
- dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
7617
+ dedupedBy: "work-item",
6835
7618
  event,
7619
+ invocation: publicWorkflowInvocation(outcome.invocation),
7620
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
6836
7621
  workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
6837
- loop: publicLoop(outcome.existingLoop)
7622
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
6838
7623
  },
6839
- human: `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
7624
+ human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
6840
7625
  };
6841
7626
  }
6842
7627
  if (outcome.kind === "throttled") {
@@ -6848,6 +7633,8 @@ function routeTodosTaskEvent(event, opts) {
6848
7633
  reason: outcome.throttle.reason,
6849
7634
  idempotencyKey,
6850
7635
  event,
7636
+ invocation: publicWorkflowInvocation(outcome.invocation),
7637
+ workItem: publicWorkflowWorkItem(outcome.workItem),
6851
7638
  throttle: outcome.throttle,
6852
7639
  workflow: workflowBody,
6853
7640
  loop: loopInput
@@ -6861,9 +7648,12 @@ function routeTodosTaskEvent(event, opts) {
6861
7648
  deduped: false,
6862
7649
  idempotencyKey,
6863
7650
  event,
7651
+ invocation: publicWorkflowInvocation(outcome.invocation),
7652
+ workItem: publicWorkflowWorkItem(outcome.workItem),
6864
7653
  workflow: publicWorkflow(outcome.workflow),
6865
7654
  loop: publicLoop(outcome.loop),
6866
7655
  throttle: outcome.throttle,
7656
+ sandboxPreflight,
6867
7657
  preflight
6868
7658
  },
6869
7659
  human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
@@ -6872,14 +7662,226 @@ function routeTodosTaskEvent(event, opts) {
6872
7662
  store.close();
6873
7663
  }
6874
7664
  }
6875
- function taskField(task, keys) {
6876
- for (const key of keys) {
6877
- const value = stringField(task[key]);
6878
- if (value)
6879
- return value;
6880
- }
6881
- return;
6882
- }
7665
+ function routeGenericEvent(event, opts) {
7666
+ const data = eventData(event);
7667
+ const metadata = eventMetadata(event);
7668
+ const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
7669
+ const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
7670
+ const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
7671
+ const throttleLimits = routeThrottleLimitsFromOpts(opts);
7672
+ const eventSuffix = event.id.slice(0, 8);
7673
+ const source = slugSegment2(event.source, "source");
7674
+ const type = slugSegment2(event.type, "type");
7675
+ const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
7676
+ const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
7677
+ const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
7678
+ const provider = opts.provider ?? "codewith";
7679
+ if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
7680
+ throw new Error("unsupported provider");
7681
+ const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7682
+ const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7683
+ const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7684
+ const workflowBody = renderEventWorkerVerifierWorkflow({
7685
+ eventId: event.id,
7686
+ eventType: event.type,
7687
+ eventSource: event.source,
7688
+ eventSubject: stringField(event.subject),
7689
+ eventMessage: stringField(event.message),
7690
+ eventJson: JSON.stringify(event),
7691
+ projectPath,
7692
+ routeProjectPath,
7693
+ projectGroup,
7694
+ provider,
7695
+ authProfile,
7696
+ authProfilePool: splitList(opts.authProfilePool),
7697
+ workerAuthProfile: opts.workerAuthProfile,
7698
+ verifierAuthProfile: opts.verifierAuthProfile,
7699
+ account: accountFromOpts(opts),
7700
+ accountPool: accountPoolFromOpts(opts),
7701
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
7702
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
7703
+ model: opts.model,
7704
+ variant: opts.variant,
7705
+ agent: opts.agent,
7706
+ permissionMode,
7707
+ sandbox,
7708
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7709
+ worktreeMode: opts.worktreeMode ?? "auto",
7710
+ worktreeRoot: opts.worktreeRoot,
7711
+ worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops"
7712
+ });
7713
+ workflowBody.name = workflowName;
7714
+ workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7715
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7716
+ const invocationInput = {
7717
+ templateId: "event-worker-verifier",
7718
+ sourceRef: {
7719
+ kind: "event",
7720
+ id: event.id,
7721
+ dedupeKey: idempotencyKey,
7722
+ raw: { source: event.source, type: event.type }
7723
+ },
7724
+ subjectRef: {
7725
+ kind: "event",
7726
+ id: stringField(event.subject) ?? event.id,
7727
+ path: routeProjectPath,
7728
+ raw: { message: stringField(event.message) }
7729
+ },
7730
+ intent: "route",
7731
+ scope: {
7732
+ projectPath: routeProjectPath,
7733
+ projectGroup,
7734
+ worktreePolicy: opts.worktreeMode ?? "auto",
7735
+ permissions: permissionMode,
7736
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7737
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
7738
+ concurrencyGroup: projectGroup ?? routeProjectPath
7739
+ },
7740
+ outputPolicy: {
7741
+ report: "always",
7742
+ createTask: "on_failure"
7743
+ }
7744
+ };
7745
+ const workItemInput = {
7746
+ routeKey: "generic-event",
7747
+ idempotencyKey,
7748
+ invocationId: "<created-invocation-id>",
7749
+ sourceType: event.type,
7750
+ sourceRef: event.id,
7751
+ subjectRef: stringField(event.subject) ?? event.id,
7752
+ projectKey: routeProjectPath,
7753
+ projectGroup,
7754
+ priority: 0,
7755
+ status: "queued"
7756
+ };
7757
+ const loopInput = {
7758
+ name: loopName,
7759
+ description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
7760
+ schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
7761
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
7762
+ overlap: "skip",
7763
+ maxAttempts: 1,
7764
+ retryDelayMs: 60000,
7765
+ leaseMs: 90 * 60000
7766
+ };
7767
+ if (opts.dryRun) {
7768
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7769
+ const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
7770
+ name: workflowBody.name,
7771
+ type: "generic-event-workflow",
7772
+ event: event.id
7773
+ }, {}) : undefined;
7774
+ return {
7775
+ kind: "created",
7776
+ value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
7777
+ human: `dry-run ${loopName}`
7778
+ };
7779
+ }
7780
+ const store = new Store;
7781
+ try {
7782
+ const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
7783
+ const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
7784
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
7785
+ const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
7786
+ name: workflowBody.name,
7787
+ type: "generic-event-workflow",
7788
+ event: event.id
7789
+ }, {}) : undefined;
7790
+ const outcome = store.writeTransaction(() => {
7791
+ const invocation = store.createWorkflowInvocation(invocationInput);
7792
+ const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
7793
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7794
+ const existingLoop = store.getLoop(existingItem.loopId);
7795
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
7796
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
7797
+ }
7798
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7799
+ const workItem = store.upsertWorkflowWorkItem({
7800
+ ...workItemInput,
7801
+ invocationId: invocation.id,
7802
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
7803
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
7804
+ });
7805
+ if (throttle && !throttle.allowed)
7806
+ return { kind: "throttled", invocation, workItem, throttle };
7807
+ const existingWorkflow = store.findWorkflowByName(workflowBody.name);
7808
+ const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
7809
+ const loop = store.createLoop({
7810
+ ...loopInput,
7811
+ target: {
7812
+ type: "workflow",
7813
+ workflowId: workflow.id,
7814
+ input: {
7815
+ workflowInvocationId: invocation.id,
7816
+ workflowWorkItemId: workItem.id
7817
+ }
7818
+ }
7819
+ });
7820
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
7821
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
7822
+ });
7823
+ if (outcome.kind === "deduped") {
7824
+ return {
7825
+ kind: "deduped",
7826
+ value: {
7827
+ deduped: true,
7828
+ idempotencyKey,
7829
+ dedupedBy: "work-item",
7830
+ event,
7831
+ invocation: publicWorkflowInvocation(outcome.invocation),
7832
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
7833
+ workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
7834
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
7835
+ },
7836
+ human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
7837
+ };
7838
+ }
7839
+ if (outcome.kind === "throttled") {
7840
+ return {
7841
+ kind: "throttled",
7842
+ value: {
7843
+ skipped: true,
7844
+ queuedAtSource: true,
7845
+ reason: outcome.throttle.reason,
7846
+ idempotencyKey,
7847
+ event,
7848
+ invocation: publicWorkflowInvocation(outcome.invocation),
7849
+ workItem: publicWorkflowWorkItem(outcome.workItem),
7850
+ throttle: outcome.throttle,
7851
+ workflow: workflowBody,
7852
+ loop: loopInput
7853
+ },
7854
+ human: `skipped event ${event.id}: ${outcome.throttle.reason}`
7855
+ };
7856
+ }
7857
+ return {
7858
+ kind: "created",
7859
+ value: {
7860
+ deduped: false,
7861
+ idempotencyKey,
7862
+ event,
7863
+ invocation: publicWorkflowInvocation(outcome.invocation),
7864
+ workItem: publicWorkflowWorkItem(outcome.workItem),
7865
+ workflow: publicWorkflow(outcome.workflow),
7866
+ loop: publicLoop(outcome.loop),
7867
+ throttle: outcome.throttle,
7868
+ sandboxPreflight,
7869
+ preflight
7870
+ },
7871
+ human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
7872
+ };
7873
+ } finally {
7874
+ store.close();
7875
+ }
7876
+ }
7877
+ function taskField(task, keys) {
7878
+ for (const key of keys) {
7879
+ const value = stringField(task[key]);
7880
+ if (value)
7881
+ return value;
7882
+ }
7883
+ return;
7884
+ }
6883
7885
  function taskListId(task) {
6884
7886
  return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
6885
7887
  }
@@ -7119,9 +8121,155 @@ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <na
7119
8121
  });
7120
8122
  var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
7121
8123
  var templates = program.command("templates").alias("template").description("render and store reusable loop/workflow templates");
8124
+ var routes = program.command("routes").alias("route").description("inspect workflow invocation/admission routes");
7122
8125
  var events = program.command("events").description("handle Hasna event envelopes from stdin or command transport");
7123
8126
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
7124
8127
  var goal = program.command("goal").description("inspect goal runs");
8128
+ function addRouteEventOptions(command) {
8129
+ return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
8130
+ }
8131
+ function addTodosDrainOptions(command) {
8132
+ return command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
8133
+ }
8134
+ function routeEventByKind(kind, event, opts) {
8135
+ if (kind === "todos-task")
8136
+ return routeTodosTaskEvent(event, opts);
8137
+ if (kind === "generic")
8138
+ return routeGenericEvent(event, opts);
8139
+ throw new Error("route kind must be todos-task or generic");
8140
+ }
8141
+ function routeDrainArgs(opts) {
8142
+ const args = ["events", "drain", "todos-task"];
8143
+ const add = (flag, value) => {
8144
+ if (value !== undefined && value !== false && value !== "")
8145
+ args.push(flag, String(value));
8146
+ };
8147
+ const addBool = (flag, value) => {
8148
+ if (value === true)
8149
+ args.push(flag);
8150
+ };
8151
+ add("--todos-project", opts.todosProject);
8152
+ add("--todos-project-id", opts.todosProjectId);
8153
+ add("--task-list", opts.taskList);
8154
+ add("--project-path-prefix", opts.projectPathPrefix);
8155
+ add("--tags", opts.tags ?? opts.tag);
8156
+ add("--limit", opts.limit);
8157
+ add("--scan-limit", opts.scanLimit);
8158
+ add("--max-dispatch", opts.maxDispatch);
8159
+ add("--evidence-dir", opts.evidenceDir);
8160
+ addBool("--compact", opts.compact);
8161
+ add("--provider", opts.provider);
8162
+ add("--auth-profile", opts.authProfile);
8163
+ add("--auth-profile-pool", opts.authProfilePool);
8164
+ add("--worker-auth-profile", opts.workerAuthProfile);
8165
+ add("--verifier-auth-profile", opts.verifierAuthProfile);
8166
+ add("--account", opts.account);
8167
+ add("--account-pool", opts.accountPool);
8168
+ add("--worker-account", opts.workerAccount);
8169
+ add("--verifier-account", opts.verifierAccount);
8170
+ add("--account-tool", opts.accountTool);
8171
+ add("--model", opts.model);
8172
+ add("--variant", opts.variant);
8173
+ add("--agent", opts.agent);
8174
+ add("--permission-mode", opts.permissionMode);
8175
+ add("--sandbox", opts.sandbox);
8176
+ addBool("--manual-break-glass", opts.manualBreakGlass);
8177
+ add("--project-path", opts.projectPath);
8178
+ add("--project-group", opts.projectGroup);
8179
+ add("--max-active", opts.maxActive);
8180
+ add("--max-active-per-project", opts.maxActivePerProject);
8181
+ add("--max-active-per-project-group", opts.maxActivePerProjectGroup);
8182
+ add("--worktree-mode", opts.worktreeMode);
8183
+ add("--worktree-root", opts.worktreeRoot);
8184
+ add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
8185
+ add("--name-prefix", opts.namePrefix);
8186
+ addBool("--preflight", opts.preflight);
8187
+ return args;
8188
+ }
8189
+ function drainTodosTaskRoutes(opts) {
8190
+ const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
8191
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
8192
+ const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
8193
+ const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
8194
+ const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
8195
+ const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
8196
+ const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
8197
+ const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
8198
+ const ready = loadReadyTodosTasks(opts, scanLimit);
8199
+ const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
8200
+ projectId: opts.todosProjectId,
8201
+ taskListId: taskListFilter,
8202
+ projectPathPrefix: opts.projectPathPrefix,
8203
+ tags: requiredTags
8204
+ }));
8205
+ const candidates = filteredCandidates.slice(0, candidateLimit);
8206
+ const results = [];
8207
+ let created = 0;
8208
+ for (const task of candidates) {
8209
+ if (created >= maxDispatch)
8210
+ break;
8211
+ const event = taskDrainEvent(task);
8212
+ const result = routeTodosTaskEvent(event, opts);
8213
+ results.push(result);
8214
+ if (result.kind === "created" && !opts.dryRun)
8215
+ created += 1;
8216
+ if (result.kind === "created" && opts.dryRun)
8217
+ created += 1;
8218
+ }
8219
+ const report = {
8220
+ drainedAt: new Date().toISOString(),
8221
+ todosProject,
8222
+ todosProjectId: opts.todosProjectId,
8223
+ taskList: opts.taskList,
8224
+ taskListId: taskListFilter,
8225
+ projectPathPrefix: opts.projectPathPrefix,
8226
+ tags: requiredTags,
8227
+ limit: candidateLimit,
8228
+ scanLimit,
8229
+ filtersApplied: hasPostFilters,
8230
+ scanned: ready.length,
8231
+ candidates: candidates.length,
8232
+ filteredCandidates: filteredCandidates.length,
8233
+ scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
8234
+ considered: results.length,
8235
+ created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
8236
+ deduped: results.filter((result) => result.kind === "deduped").length,
8237
+ throttled: results.filter((result) => result.kind === "throttled").length,
8238
+ skipped: results.filter((result) => result.kind === "skipped").length,
8239
+ maxDispatch,
8240
+ source: "todos ready",
8241
+ dryRun: Boolean(opts.dryRun),
8242
+ results: results.map((result) => ({ kind: result.kind, ...result.value }))
8243
+ };
8244
+ const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
8245
+ const output = opts.compact ? {
8246
+ drainedAt: report.drainedAt,
8247
+ todosProject: report.todosProject,
8248
+ todosProjectId: report.todosProjectId,
8249
+ taskList: report.taskList,
8250
+ taskListId: report.taskListId,
8251
+ projectPathPrefix: report.projectPathPrefix,
8252
+ tags: report.tags,
8253
+ limit: report.limit,
8254
+ scanLimit: report.scanLimit,
8255
+ filtersApplied: report.filtersApplied,
8256
+ scanned: report.scanned,
8257
+ candidates: report.candidates,
8258
+ filteredCandidates: report.filteredCandidates,
8259
+ scanExhausted: report.scanExhausted,
8260
+ considered: report.considered,
8261
+ created: report.created,
8262
+ deduped: report.deduped,
8263
+ throttled: report.throttled,
8264
+ skipped: report.skipped,
8265
+ maxDispatch: report.maxDispatch,
8266
+ source: report.source,
8267
+ dryRun: report.dryRun,
8268
+ evidencePath,
8269
+ results: results.map(compactDrainResult)
8270
+ } : { ...report, evidencePath };
8271
+ print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8272
+ }
7125
8273
  templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
7126
8274
  const values = listLoopTemplates();
7127
8275
  if (isJson())
@@ -7152,14 +8300,102 @@ templates.command("create-workflow <id>").description("render and store a templa
7152
8300
  store.close();
7153
8301
  }
7154
8302
  });
8303
+ routes.command("list").description("list admission work items").option("--status <status>", "filter by work item status").option("--route-key <key>", "filter by route key").option("--limit <n>", "maximum rows", "50").action((opts) => {
8304
+ const store = new Store;
8305
+ try {
8306
+ const items = store.listWorkflowWorkItems({
8307
+ status: opts.status,
8308
+ routeKey: opts.routeKey,
8309
+ limit: positiveInteger(opts.limit, "--limit") ?? 50
8310
+ });
8311
+ if (isJson())
8312
+ print(items.map(publicWorkflowWorkItem));
8313
+ else {
8314
+ for (const item of items) {
8315
+ console.log(`${item.id} ${item.status.padEnd(10)} ${item.routeKey} ${item.subjectRef} ${item.loopId ?? "-"}`);
8316
+ }
8317
+ }
8318
+ } finally {
8319
+ store.close();
8320
+ }
8321
+ });
8322
+ routes.command("show <id>").description("show one admission work item").action((id) => {
8323
+ const store = new Store;
8324
+ try {
8325
+ const item = store.getWorkflowWorkItem(id);
8326
+ if (!item)
8327
+ throw new Error(`route work item not found: ${id}`);
8328
+ const invocation = store.getWorkflowInvocation(item.invocationId);
8329
+ const workflow = item.workflowId ? store.getWorkflow(item.workflowId) : undefined;
8330
+ const loop = item.loopId ? store.getLoop(item.loopId) : undefined;
8331
+ print({
8332
+ item: publicWorkflowWorkItem(item),
8333
+ invocation: invocation ? publicWorkflowInvocation(invocation) : undefined,
8334
+ workflow: workflow ? publicWorkflow(workflow) : undefined,
8335
+ loop: loop ? publicLoop(loop) : undefined
8336
+ }, `${item.id} ${item.status} ${item.routeKey} ${item.subjectRef}`);
8337
+ } finally {
8338
+ store.close();
8339
+ }
8340
+ });
8341
+ routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
8342
+ const store = new Store;
8343
+ try {
8344
+ const invocations = store.listWorkflowInvocations({ limit: positiveInteger(opts.limit, "--limit") ?? 50 });
8345
+ if (isJson())
8346
+ print(invocations.map(publicWorkflowInvocation));
8347
+ else {
8348
+ for (const invocation of invocations) {
8349
+ console.log(`${invocation.id} ${invocation.intent.padEnd(8)} ${invocation.sourceRef.kind}:${invocation.sourceRef.id ?? "-"} -> ${invocation.subjectRef.kind}:${invocation.subjectRef.id ?? invocation.subjectRef.path ?? "-"}`);
8350
+ }
8351
+ }
8352
+ } finally {
8353
+ store.close();
8354
+ }
8355
+ });
8356
+ addRouteEventOptions(routes.command("preview <kind>").description("preview a route-created workflow invocation without storing it")).action(async (kind, opts) => {
8357
+ const event = await readEventEnvelopeInput(opts);
8358
+ const result = routeEventByKind(kind, event, { ...opts, dryRun: true });
8359
+ print(result.value, result.human);
8360
+ });
8361
+ addRouteEventOptions(routes.command("create <kind>").description("create a route workflow invocation and admit it when capacity allows")).action(async (kind, opts) => {
8362
+ const event = await readEventEnvelopeInput(opts);
8363
+ const result = routeEventByKind(kind, event, { ...opts, dryRun: false });
8364
+ print(result.value, result.human);
8365
+ });
8366
+ addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable source queue into bounded route workflow loops")).action((kind, opts) => {
8367
+ if (kind !== "todos-task")
8368
+ throw new Error("route drain currently supports kind todos-task");
8369
+ drainTodosTaskRoutes(opts);
8370
+ });
8371
+ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
8372
+ if (kind !== "todos-task")
8373
+ throw new Error("route schedule currently supports kind todos-task");
8374
+ const store = new Store;
8375
+ try {
8376
+ const target = {
8377
+ type: "command",
8378
+ command: "loops",
8379
+ args: ["--json", ...routeDrainArgs({ ...opts, compact: opts.compact ?? true })],
8380
+ timeoutMs: parseDuration("20m"),
8381
+ preflight: runtimePreflightFromOpts(opts)
8382
+ };
8383
+ const input = baseCreateInput(name, opts, target);
8384
+ const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "route-drain", kind }, { loopName: name }, { machine: input.machine }) : undefined;
8385
+ const loop = store.createLoop(input);
8386
+ printCreatedLoop(loop, `created route drain loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
8387
+ } finally {
8388
+ store.close();
8389
+ }
8390
+ });
7155
8391
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
7156
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
8392
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
7157
8393
  const event = await readEventEnvelopeFromStdin();
7158
8394
  const result = routeTodosTaskEvent(event, opts);
7159
8395
  print(result.value, result.human);
7160
8396
  });
7161
8397
  var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
7162
- eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
8398
+ eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
7163
8399
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
7164
8400
  const todosProject = opts.todosProject ?? defaultLoopsProject();
7165
8401
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
@@ -7243,7 +8479,7 @@ eventsDrain.command("todos-task").description("drain ready todos tasks into boun
7243
8479
  } : { ...report, evidencePath };
7244
8480
  print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
7245
8481
  });
7246
- eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
8482
+ eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
7247
8483
  const event = await readEventEnvelopeFromStdin();
7248
8484
  const data = eventData(event);
7249
8485
  const metadata = eventMetadata(event);
@@ -7256,19 +8492,7 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7256
8492
  const type = slugSegment2(event.type, "type");
7257
8493
  const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
7258
8494
  const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
7259
- if (!opts.dryRun) {
7260
- const store2 = new Store;
7261
- try {
7262
- const existingLoop = store2.findLoopByName(loopName);
7263
- if (existingLoop) {
7264
- const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
7265
- print({ deduped: true, event, workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
7266
- return;
7267
- }
7268
- } finally {
7269
- store2.close();
7270
- }
7271
- }
8495
+ const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
7272
8496
  const provider = opts.provider;
7273
8497
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
7274
8498
  throw new Error("unsupported provider");
@@ -7299,17 +8523,60 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7299
8523
  agent: opts.agent,
7300
8524
  permissionMode,
7301
8525
  sandbox,
8526
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7302
8527
  worktreeMode: opts.worktreeMode,
7303
8528
  worktreeRoot: opts.worktreeRoot,
7304
8529
  worktreeBranchPrefix: opts.worktreeBranchPrefix
7305
8530
  });
7306
8531
  workflowBody.name = workflowName;
7307
8532
  workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
8533
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
8534
+ const invocationInput = {
8535
+ templateId: "event-worker-verifier",
8536
+ sourceRef: {
8537
+ kind: "event",
8538
+ id: event.id,
8539
+ dedupeKey: idempotencyKey,
8540
+ raw: { source: event.source, type: event.type }
8541
+ },
8542
+ subjectRef: {
8543
+ kind: "event",
8544
+ id: stringField(event.subject) ?? event.id,
8545
+ path: routeProjectPath,
8546
+ raw: { message: stringField(event.message) }
8547
+ },
8548
+ intent: "route",
8549
+ scope: {
8550
+ projectPath: routeProjectPath,
8551
+ projectGroup,
8552
+ worktreePolicy: opts.worktreeMode ?? "auto",
8553
+ permissions: permissionMode,
8554
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
8555
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
8556
+ concurrencyGroup: projectGroup ?? routeProjectPath
8557
+ },
8558
+ outputPolicy: {
8559
+ report: "always",
8560
+ createTask: "on_failure"
8561
+ }
8562
+ };
8563
+ const workItemInput = {
8564
+ routeKey: "generic-event",
8565
+ idempotencyKey,
8566
+ invocationId: "<created-invocation-id>",
8567
+ sourceType: event.type,
8568
+ sourceRef: event.id,
8569
+ subjectRef: stringField(event.subject) ?? event.id,
8570
+ projectKey: routeProjectPath,
8571
+ projectGroup,
8572
+ priority: 0,
8573
+ status: "queued"
8574
+ };
7308
8575
  const loopInput = {
7309
8576
  name: loopName,
7310
- description: `Run ${workflowBody.name} once for event ${event.id}`,
8577
+ description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
7311
8578
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
7312
- target: { type: "workflow", workflowId: "<created-workflow-id>" },
8579
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
7313
8580
  overlap: "skip",
7314
8581
  maxAttempts: 1,
7315
8582
  retryDelayMs: 60000,
@@ -7322,44 +8589,92 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7322
8589
  type: "generic-event-workflow",
7323
8590
  event: event.id
7324
8591
  }, {}) : undefined;
7325
- print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
8592
+ print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
7326
8593
  return;
7327
8594
  }
7328
8595
  const store = new Store;
7329
8596
  try {
7330
8597
  const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
7331
8598
  const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
8599
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
7332
8600
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
7333
8601
  name: workflowBody.name,
7334
8602
  type: "generic-event-workflow",
7335
8603
  event: event.id
7336
8604
  }, {}) : undefined;
7337
8605
  const outcome = store.writeTransaction(() => {
7338
- const existingLoop = store.findLoopByName(loopName);
7339
- if (existingLoop) {
7340
- const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
7341
- return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
8606
+ const invocation = store.createWorkflowInvocation(invocationInput);
8607
+ const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
8608
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8609
+ const existingLoop = store.getLoop(existingItem.loopId);
8610
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8611
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
7342
8612
  }
7343
8613
  const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8614
+ const workItem = store.upsertWorkflowWorkItem({
8615
+ ...workItemInput,
8616
+ invocationId: invocation.id,
8617
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
8618
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8619
+ });
7344
8620
  if (throttle && !throttle.allowed)
7345
- return { kind: "throttled", throttle };
8621
+ return { kind: "throttled", invocation, workItem, throttle };
7346
8622
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
7347
8623
  const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
7348
8624
  const loop = store.createLoop({
7349
8625
  ...loopInput,
7350
- target: { type: "workflow", workflowId: workflow.id }
8626
+ target: {
8627
+ type: "workflow",
8628
+ workflowId: workflow.id,
8629
+ input: {
8630
+ workflowInvocationId: invocation.id,
8631
+ workflowWorkItemId: workItem.id
8632
+ }
8633
+ }
7351
8634
  });
7352
- return { kind: "created", workflow, loop, throttle };
8635
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
8636
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
7353
8637
  });
7354
8638
  if (outcome.kind === "deduped") {
7355
- print({ deduped: true, event, workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined, loop: publicLoop(outcome.existingLoop) }, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name})`);
8639
+ print({
8640
+ deduped: true,
8641
+ idempotencyKey,
8642
+ dedupedBy: "work-item",
8643
+ event,
8644
+ invocation: publicWorkflowInvocation(outcome.invocation),
8645
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
8646
+ workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
8647
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
8648
+ }, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
7356
8649
  return;
7357
8650
  }
7358
8651
  if (outcome.kind === "throttled") {
7359
- print({ skipped: true, queuedAtSource: true, reason: outcome.throttle.reason, event, throttle: outcome.throttle, workflow: workflowBody, loop: loopInput }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
8652
+ print({
8653
+ skipped: true,
8654
+ queuedAtSource: true,
8655
+ reason: outcome.throttle.reason,
8656
+ idempotencyKey,
8657
+ event,
8658
+ invocation: publicWorkflowInvocation(outcome.invocation),
8659
+ workItem: publicWorkflowWorkItem(outcome.workItem),
8660
+ throttle: outcome.throttle,
8661
+ workflow: workflowBody,
8662
+ loop: loopInput
8663
+ }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
7360
8664
  return;
7361
8665
  }
7362
- print({ deduped: false, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name}`);
8666
+ print({
8667
+ deduped: false,
8668
+ idempotencyKey,
8669
+ event,
8670
+ invocation: publicWorkflowInvocation(outcome.invocation),
8671
+ workItem: publicWorkflowWorkItem(outcome.workItem),
8672
+ workflow: publicWorkflow(outcome.workflow),
8673
+ loop: publicLoop(outcome.loop),
8674
+ throttle: outcome.throttle,
8675
+ sandboxPreflight,
8676
+ preflight
8677
+ }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
7363
8678
  } finally {
7364
8679
  store.close();
7365
8680
  }