@hasna/loops 0.3.37 → 0.3.39

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,
@@ -841,8 +946,63 @@ class Store {
841
946
  WHERE idempotency_key IS NOT NULL;
842
947
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
843
948
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
949
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
950
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
844
951
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
845
952
 
953
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
954
+ id TEXT PRIMARY KEY,
955
+ workflow_id TEXT,
956
+ template_id TEXT,
957
+ source_kind TEXT NOT NULL,
958
+ source_id TEXT,
959
+ source_dedupe_key TEXT,
960
+ source_json TEXT NOT NULL,
961
+ subject_kind TEXT NOT NULL,
962
+ subject_id TEXT,
963
+ subject_path TEXT,
964
+ subject_url TEXT,
965
+ subject_json TEXT NOT NULL,
966
+ intent TEXT NOT NULL,
967
+ scope_json TEXT,
968
+ output_policy_json TEXT,
969
+ created_at TEXT NOT NULL,
970
+ updated_at TEXT NOT NULL
971
+ );
972
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
973
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
974
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
975
+ ON workflow_invocations(source_kind, source_dedupe_key)
976
+ WHERE source_dedupe_key IS NOT NULL;
977
+
978
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
979
+ id TEXT PRIMARY KEY,
980
+ route_key TEXT NOT NULL,
981
+ idempotency_key TEXT NOT NULL,
982
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
983
+ source_type TEXT NOT NULL,
984
+ source_ref TEXT NOT NULL,
985
+ subject_ref TEXT NOT NULL,
986
+ project_key TEXT,
987
+ project_group TEXT,
988
+ priority INTEGER NOT NULL,
989
+ status TEXT NOT NULL,
990
+ attempts INTEGER NOT NULL,
991
+ next_attempt_at TEXT,
992
+ lease_expires_at TEXT,
993
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
994
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
995
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
996
+ last_reason TEXT,
997
+ created_at TEXT NOT NULL,
998
+ updated_at TEXT NOT NULL,
999
+ UNIQUE(route_key, idempotency_key)
1000
+ );
1001
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
1002
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1003
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1004
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1005
+
846
1006
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
847
1007
  id TEXT PRIMARY KEY,
848
1008
  workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
@@ -955,12 +1115,16 @@ class Store {
955
1115
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
956
1116
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
957
1117
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1118
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1119
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1120
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
958
1121
  this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
959
1122
  this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
960
1123
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
961
1124
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
962
1125
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
963
1126
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
1127
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
964
1128
  }
965
1129
  addColumnIfMissing(table, column, definition) {
966
1130
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -1083,6 +1247,10 @@ class Store {
1083
1247
  $daemonLeaseId: opts.daemonLeaseId ?? null,
1084
1248
  $now: updated
1085
1249
  });
1250
+ if (patch.status && patch.status !== "active") {
1251
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1252
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1253
+ }
1086
1254
  const after = this.getLoop(id);
1087
1255
  if (!after)
1088
1256
  throw new Error(`loop not found after update: ${id}`);
@@ -1127,6 +1295,7 @@ class Store {
1127
1295
  $archivedFromStatus: loop.status,
1128
1296
  $updated: updated
1129
1297
  });
1298
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
1130
1299
  const archived = this.getLoop(loop.id);
1131
1300
  if (!archived)
1132
1301
  throw new Error(`loop not found after archive: ${loop.id}`);
@@ -1152,6 +1321,7 @@ class Store {
1152
1321
  }
1153
1322
  deleteLoop(idOrName) {
1154
1323
  const loop = this.requireLoop(idOrName);
1324
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
1155
1325
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
1156
1326
  return res.changes > 0;
1157
1327
  }
@@ -1210,6 +1380,185 @@ class Store {
1210
1380
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1211
1381
  return archived;
1212
1382
  }
1383
+ createWorkflowInvocation(input) {
1384
+ const now = nowIso();
1385
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1386
+ if (sourceDedupeKey) {
1387
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
1388
+ if (existing)
1389
+ return rowToWorkflowInvocation(existing);
1390
+ }
1391
+ const id = input.id ?? genId();
1392
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
1393
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
1394
+ output_policy_json, created_at, updated_at)
1395
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
1396
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
1397
+ $outputPolicyJson, $created, $updated)`).run({
1398
+ $id: id,
1399
+ $workflowId: input.workflowId ?? null,
1400
+ $templateId: input.templateId ?? null,
1401
+ $sourceKind: input.sourceRef.kind,
1402
+ $sourceId: input.sourceRef.id ?? null,
1403
+ $sourceDedupeKey: sourceDedupeKey ?? null,
1404
+ $sourceJson: JSON.stringify(input.sourceRef),
1405
+ $subjectKind: input.subjectRef.kind,
1406
+ $subjectId: input.subjectRef.id ?? null,
1407
+ $subjectPath: input.subjectRef.path ?? null,
1408
+ $subjectUrl: input.subjectRef.url ?? null,
1409
+ $subjectJson: JSON.stringify(input.subjectRef),
1410
+ $intent: input.intent,
1411
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1412
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1413
+ $created: now,
1414
+ $updated: now
1415
+ });
1416
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1417
+ if (!row)
1418
+ throw new Error(`workflow invocation not found after create: ${id}`);
1419
+ return rowToWorkflowInvocation(row);
1420
+ }
1421
+ getWorkflowInvocation(id) {
1422
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1423
+ return row ? rowToWorkflowInvocation(row) : undefined;
1424
+ }
1425
+ listWorkflowInvocations(opts = {}) {
1426
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
1427
+ return rows.map(rowToWorkflowInvocation);
1428
+ }
1429
+ upsertWorkflowWorkItem(input) {
1430
+ const now = nowIso();
1431
+ const id = genId();
1432
+ const status = input.status ?? "queued";
1433
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
1434
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
1435
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
1436
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
1437
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
1438
+ $lastReason, $created, $updated)
1439
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
1440
+ invocation_id=excluded.invocation_id,
1441
+ source_type=excluded.source_type,
1442
+ source_ref=excluded.source_ref,
1443
+ subject_ref=excluded.subject_ref,
1444
+ project_key=excluded.project_key,
1445
+ project_group=excluded.project_group,
1446
+ priority=excluded.priority,
1447
+ status=CASE
1448
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1449
+ THEN workflow_work_items.status
1450
+ ELSE excluded.status
1451
+ END,
1452
+ workflow_id=CASE
1453
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1454
+ ELSE NULL
1455
+ END,
1456
+ loop_id=CASE
1457
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1458
+ ELSE NULL
1459
+ END,
1460
+ workflow_run_id=CASE
1461
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1462
+ ELSE NULL
1463
+ END,
1464
+ lease_expires_at=CASE
1465
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1466
+ ELSE NULL
1467
+ END,
1468
+ next_attempt_at=excluded.next_attempt_at,
1469
+ last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1470
+ updated_at=excluded.updated_at`).run({
1471
+ $id: id,
1472
+ $routeKey: input.routeKey,
1473
+ $idempotencyKey: input.idempotencyKey,
1474
+ $invocationId: input.invocationId,
1475
+ $sourceType: input.sourceType,
1476
+ $sourceRef: input.sourceRef,
1477
+ $subjectRef: input.subjectRef,
1478
+ $projectKey: input.projectKey ?? null,
1479
+ $projectGroup: input.projectGroup ?? null,
1480
+ $priority: input.priority ?? 0,
1481
+ $status: status,
1482
+ $nextAttemptAt: input.nextAttemptAt ?? null,
1483
+ $lastReason: input.lastReason ?? null,
1484
+ $created: now,
1485
+ $updated: now
1486
+ });
1487
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
1488
+ if (!row)
1489
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
1490
+ return rowToWorkflowWorkItem(row);
1491
+ }
1492
+ getWorkflowWorkItem(id) {
1493
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
1494
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1495
+ }
1496
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
1497
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
1498
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1499
+ }
1500
+ listWorkflowWorkItems(opts = {}) {
1501
+ const limit = opts.limit ?? 100;
1502
+ let rows;
1503
+ if (opts.status && opts.routeKey) {
1504
+ 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);
1505
+ } else if (opts.status) {
1506
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
1507
+ } else if (opts.routeKey) {
1508
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
1509
+ } else {
1510
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
1511
+ }
1512
+ return rows.map(rowToWorkflowWorkItem);
1513
+ }
1514
+ countActiveWorkflowWorkItems(args = {}) {
1515
+ const active = ["admitted", "running"];
1516
+ const placeholders = active.map(() => "?").join(",");
1517
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
1518
+ 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;
1519
+ 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;
1520
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1521
+ }
1522
+ admitWorkflowWorkItem(id, patch) {
1523
+ const now = nowIso();
1524
+ const res = this.db.query(`UPDATE workflow_work_items
1525
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1526
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1527
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1528
+ $id: id,
1529
+ $workflowId: patch.workflowId,
1530
+ $loopId: patch.loopId,
1531
+ $reason: patch.reason ?? null,
1532
+ $updated: now
1533
+ });
1534
+ const item = this.getWorkflowWorkItem(id);
1535
+ if (!item)
1536
+ throw new Error(`workflow work item not found after admit: ${id}`);
1537
+ if (res.changes !== 1)
1538
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
1539
+ return item;
1540
+ }
1541
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
1542
+ const placeholders = statuses.map(() => "?").join(",");
1543
+ this.db.query(`UPDATE workflow_work_items
1544
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1545
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
1546
+ }
1547
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
1548
+ const placeholders = statuses.map(() => "?").join(",");
1549
+ this.db.query(`UPDATE workflow_work_items
1550
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1551
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
1552
+ }
1553
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
1554
+ const loop = this.getLoop(run.loopId);
1555
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
1556
+ if (!status)
1557
+ return;
1558
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
1559
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
1560
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
1561
+ }
1213
1562
  createGoal(input, opts = {}) {
1214
1563
  const now = nowIso();
1215
1564
  this.db.exec("BEGIN IMMEDIATE");
@@ -1483,6 +1832,10 @@ class Store {
1483
1832
  }
1484
1833
  createWorkflowRun(input) {
1485
1834
  const now = nowIso();
1835
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
1836
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
1837
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
1838
+ let manifestPath;
1486
1839
  if (input.idempotencyKey) {
1487
1840
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
1488
1841
  if (existing) {
@@ -1501,21 +1854,59 @@ class Store {
1501
1854
  }
1502
1855
  }
1503
1856
  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({
1857
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
1858
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
1859
+ manifestPath = invocation || workItem ? writeWorkflowRunManifest({
1860
+ loopsDataDir: this.rootDir,
1861
+ workflowRunId: runId,
1862
+ workflowId: input.workflow.id,
1863
+ workflowName: input.workflow.name,
1864
+ invocationId,
1865
+ workItemId,
1866
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
1867
+ subjectKind: invocation?.subjectRef.kind,
1868
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
1869
+ payload: {
1870
+ workflowInvocation: invocation,
1871
+ workflowWorkItem: workItem,
1872
+ loopId: input.loop?.id,
1873
+ loopRunId: input.loopRun?.id,
1874
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
1875
+ }
1876
+ }) : undefined;
1877
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
1878
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
1879
+ created_at, updated_at)
1880
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
1881
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1508
1882
  $id: runId,
1509
1883
  $workflowId: input.workflow.id,
1510
1884
  $workflowName: input.workflow.name,
1511
1885
  $loopId: input.loop?.id ?? null,
1512
1886
  $loopRunId: input.loopRun?.id ?? null,
1887
+ $invocationId: invocationId ?? null,
1888
+ $workItemId: workItemId ?? null,
1513
1889
  $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
1514
1890
  $idempotencyKey: input.idempotencyKey ?? null,
1891
+ $manifestPath: manifestPath ?? null,
1515
1892
  $started: now,
1516
1893
  $created: now,
1517
1894
  $updated: now
1518
1895
  });
1896
+ if (workItemId) {
1897
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
1898
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
1899
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
1900
+ $id: workItemId,
1901
+ $workflowRunId: runId,
1902
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
1903
+ $updated: now
1904
+ });
1905
+ if (workItemRes.changes !== 1) {
1906
+ const current = this.getWorkflowWorkItem(workItemId);
1907
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
1908
+ }
1909
+ }
1519
1910
  input.workflow.steps.forEach((step, sequence) => {
1520
1911
  const account = step.account ?? step.target.account;
1521
1912
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
@@ -1541,7 +1932,10 @@ class Store {
1541
1932
  workflowName: input.workflow.name,
1542
1933
  stepCount: input.workflow.steps.length,
1543
1934
  loopId: input.loop?.id,
1544
- loopRunId: input.loopRun?.id
1935
+ loopRunId: input.loopRun?.id,
1936
+ invocationId,
1937
+ workItemId,
1938
+ manifestPath
1545
1939
  }),
1546
1940
  $created: now
1547
1941
  });
@@ -1554,6 +1948,8 @@ class Store {
1554
1948
  try {
1555
1949
  this.db.exec("ROLLBACK");
1556
1950
  } catch {}
1951
+ if (manifestPath)
1952
+ rmSync(manifestPath, { force: true });
1557
1953
  throw error;
1558
1954
  }
1559
1955
  }
@@ -1766,6 +2162,10 @@ class Store {
1766
2162
  changed = res.changes === 1;
1767
2163
  if (changed)
1768
2164
  this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
2165
+ if (changed) {
2166
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2167
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2168
+ }
1769
2169
  this.db.exec("COMMIT");
1770
2170
  } catch (error) {
1771
2171
  try {
@@ -1790,6 +2190,7 @@ class Store {
1790
2190
  this.db.query(`UPDATE workflow_step_runs
1791
2191
  SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
1792
2192
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2193
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
1793
2194
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
1794
2195
  }
1795
2196
  this.db.exec("COMMIT");
@@ -2043,6 +2444,8 @@ class Store {
2043
2444
  throw new Error(`run not found after finalize: ${id}`);
2044
2445
  if (opts.claimedBy && res.changes !== 1)
2045
2446
  return run;
2447
+ if (res.changes === 1)
2448
+ this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2046
2449
  return run;
2047
2450
  }
2048
2451
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2136,6 +2539,14 @@ class Store {
2136
2539
  error: "parent loop run lease expired before completion",
2137
2540
  loopRunId: row.id
2138
2541
  });
2542
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2543
+ }
2544
+ const loop = this.getLoop(row.loop_id);
2545
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
2546
+ if (itemStatus) {
2547
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2548
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2549
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2139
2550
  }
2140
2551
  this.db.exec("COMMIT");
2141
2552
  } catch (error) {
@@ -2244,11 +2655,11 @@ class Store {
2244
2655
  }
2245
2656
 
2246
2657
  // 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";
2658
+ import { createHash as createHash3, randomUUID } from "crypto";
2659
+ 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
2660
  import { spawnSync as spawnSync5 } from "child_process";
2250
- import { join as join4, resolve as resolve2 } from "path";
2251
- import { tmpdir } from "os";
2661
+ import { join as join6, resolve as resolve2 } from "path";
2662
+ import { tmpdir as tmpdir2 } from "os";
2252
2663
  import { Database as Database2 } from "bun:sqlite";
2253
2664
  import { Command } from "commander";
2254
2665
 
@@ -2336,6 +2747,12 @@ function publicWorkflow(workflow) {
2336
2747
  function publicWorkflowRun(run) {
2337
2748
  return { ...run, error: redact(run.error) };
2338
2749
  }
2750
+ function publicWorkflowInvocation(invocation) {
2751
+ return redactSensitivePayload(invocation);
2752
+ }
2753
+ function publicWorkflowWorkItem(item) {
2754
+ return { ...item, lastReason: redact(item.lastReason, 240) };
2755
+ }
2339
2756
  function publicWorkflowStepRun(run, showOutput = false) {
2340
2757
  return {
2341
2758
  ...run,
@@ -2466,7 +2883,7 @@ function resolveAccountEnv(account, toolHint, env) {
2466
2883
  // src/lib/env.ts
2467
2884
  import { accessSync, constants } from "fs";
2468
2885
  import { homedir as homedir2 } from "os";
2469
- import { delimiter, join as join2 } from "path";
2886
+ import { delimiter, join as join4 } from "path";
2470
2887
  function compactPathParts(parts) {
2471
2888
  const seen = new Set;
2472
2889
  const result = [];
@@ -2482,14 +2899,14 @@ function compactPathParts(parts) {
2482
2899
  function commonExecutableDirs(env = process.env) {
2483
2900
  const home = env.HOME || homedir2();
2484
2901
  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,
2902
+ join4(home, ".local", "bin"),
2903
+ join4(home, ".bun", "bin"),
2904
+ join4(home, ".cargo", "bin"),
2905
+ join4(home, ".npm-global", "bin"),
2906
+ join4(home, "bin"),
2907
+ env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
2491
2908
  env.PNPM_HOME,
2492
- env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2909
+ env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2493
2910
  "/opt/homebrew/bin",
2494
2911
  "/usr/local/bin",
2495
2912
  "/usr/bin",
@@ -2513,7 +2930,7 @@ function executableExists(command, env = process.env) {
2513
2930
  if (command.includes("/"))
2514
2931
  return isExecutable(command);
2515
2932
  for (const dir of (env.PATH ?? "").split(delimiter)) {
2516
- if (dir && isExecutable(join2(dir, command)))
2933
+ if (dir && isExecutable(join4(dir, command)))
2517
2934
  return true;
2518
2935
  }
2519
2936
  return false;
@@ -2871,6 +3288,7 @@ function commandSpec(target) {
2871
3288
  shell: commandTarget.shell,
2872
3289
  env: commandTarget.env,
2873
3290
  timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3291
+ idleTimeoutMs: commandTarget.idleTimeoutMs,
2874
3292
  account: commandTarget.account,
2875
3293
  accountTool: commandTarget.account?.tool
2876
3294
  };
@@ -2881,6 +3299,7 @@ function commandSpec(target) {
2881
3299
  args: agentArgs(agentTarget),
2882
3300
  cwd: agentTarget.cwd,
2883
3301
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3302
+ idleTimeoutMs: agentTarget.idleTimeoutMs,
2884
3303
  account: agentTarget.account,
2885
3304
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2886
3305
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
@@ -3028,6 +3447,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3028
3447
  let stdout = "";
3029
3448
  let stderr = "";
3030
3449
  let timedOut = false;
3450
+ let idleTimedOut = false;
3031
3451
  let exitCode;
3032
3452
  let error;
3033
3453
  let plan;
@@ -3066,18 +3486,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3066
3486
  if (opts.signal?.aborted)
3067
3487
  abortHandler();
3068
3488
  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
3489
  const timer = setTimeout(() => {
3076
3490
  timedOut = true;
3077
3491
  if (child.pid)
3078
3492
  killProcessGroup(child.pid);
3079
3493
  }, spec.timeoutMs);
3080
3494
  timer.unref();
3495
+ let idleTimer;
3496
+ const resetIdleTimer = () => {
3497
+ if (!spec.idleTimeoutMs)
3498
+ return;
3499
+ if (idleTimer)
3500
+ clearTimeout(idleTimer);
3501
+ idleTimer = setTimeout(() => {
3502
+ idleTimedOut = true;
3503
+ if (child.pid)
3504
+ killProcessGroup(child.pid);
3505
+ }, spec.idleTimeoutMs);
3506
+ idleTimer.unref();
3507
+ };
3508
+ resetIdleTimer();
3509
+ child.stdout?.on("data", (chunk) => {
3510
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3511
+ resetIdleTimer();
3512
+ });
3513
+ child.stderr?.on("data", (chunk) => {
3514
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3515
+ resetIdleTimer();
3516
+ });
3081
3517
  try {
3082
3518
  const [code, signal] = await once(child, "exit");
3083
3519
  if (typeof code === "number")
@@ -3088,17 +3524,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3088
3524
  error = err instanceof Error ? err.message : String(err);
3089
3525
  } finally {
3090
3526
  clearTimeout(timer);
3527
+ if (idleTimer)
3528
+ clearTimeout(idleTimer);
3091
3529
  opts.signal?.removeEventListener("abort", abortHandler);
3092
3530
  }
3093
3531
  const finishedAt = nowIso();
3094
3532
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3095
- if (timedOut) {
3533
+ if (timedOut || idleTimedOut) {
3096
3534
  return {
3097
3535
  status: "timed_out",
3098
3536
  exitCode,
3099
3537
  stdout,
3100
3538
  stderr,
3101
- error: `timed out after ${spec.timeoutMs}ms`,
3539
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3102
3540
  pid: child.pid,
3103
3541
  startedAt,
3104
3542
  finishedAt,
@@ -3164,6 +3602,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3164
3602
  let stdout = "";
3165
3603
  let stderr = "";
3166
3604
  let timedOut = false;
3605
+ let idleTimedOut = false;
3167
3606
  let exitCode;
3168
3607
  let error;
3169
3608
  const env = executionEnv(spec, metadata, opts);
@@ -3226,18 +3665,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3226
3665
  if (opts.signal?.aborted)
3227
3666
  abortHandler();
3228
3667
  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
3668
  const timer = setTimeout(() => {
3236
3669
  timedOut = true;
3237
3670
  if (child.pid)
3238
3671
  killProcessGroup(child.pid);
3239
3672
  }, spec.timeoutMs);
3240
3673
  timer.unref();
3674
+ let idleTimer;
3675
+ const resetIdleTimer = () => {
3676
+ if (!spec.idleTimeoutMs)
3677
+ return;
3678
+ if (idleTimer)
3679
+ clearTimeout(idleTimer);
3680
+ idleTimer = setTimeout(() => {
3681
+ idleTimedOut = true;
3682
+ if (child.pid)
3683
+ killProcessGroup(child.pid);
3684
+ }, spec.idleTimeoutMs);
3685
+ idleTimer.unref();
3686
+ };
3687
+ resetIdleTimer();
3688
+ child.stdout?.on("data", (chunk) => {
3689
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3690
+ resetIdleTimer();
3691
+ });
3692
+ child.stderr?.on("data", (chunk) => {
3693
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3694
+ resetIdleTimer();
3695
+ });
3241
3696
  try {
3242
3697
  const [code, signal] = await once(child, "exit");
3243
3698
  if (typeof code === "number")
@@ -3248,17 +3703,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3248
3703
  error = err instanceof Error ? err.message : String(err);
3249
3704
  } finally {
3250
3705
  clearTimeout(timer);
3706
+ if (idleTimer)
3707
+ clearTimeout(idleTimer);
3251
3708
  opts.signal?.removeEventListener("abort", abortHandler);
3252
3709
  }
3253
3710
  const finishedAt = nowIso();
3254
3711
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3255
- if (timedOut) {
3712
+ if (timedOut || idleTimedOut) {
3256
3713
  return {
3257
3714
  status: "timed_out",
3258
3715
  exitCode,
3259
3716
  stdout,
3260
3717
  stderr,
3261
- error: `timed out after ${spec.timeoutMs}ms`,
3718
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3262
3719
  pid: child.pid,
3263
3720
  startedAt,
3264
3721
  finishedAt,
@@ -4300,7 +4757,7 @@ async function tick(deps) {
4300
4757
  }
4301
4758
 
4302
4759
  // src/daemon/control.ts
4303
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
4760
+ import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4304
4761
  import { hostname } from "os";
4305
4762
  import { dirname as dirname2 } from "path";
4306
4763
 
@@ -4338,11 +4795,11 @@ function readPid(path = pidFilePath()) {
4338
4795
  }
4339
4796
  }
4340
4797
  function writePid(pid = process.pid, path = pidFilePath()) {
4341
- mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
4342
- writeFileSync(path, String(pid));
4798
+ mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
4799
+ writeFileSync2(path, String(pid));
4343
4800
  }
4344
4801
  function removePid(path = pidFilePath()) {
4345
- rmSync(path, { force: true });
4802
+ rmSync2(path, { force: true });
4346
4803
  }
4347
4804
  function isAlive(pid) {
4348
4805
  try {
@@ -4601,7 +5058,7 @@ async function startDaemon(opts) {
4601
5058
  }
4602
5059
 
4603
5060
  // src/daemon/install.ts
4604
- import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
5061
+ import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
4605
5062
  import { spawnSync as spawnSync3 } from "child_process";
4606
5063
  import { dirname as dirname3 } from "path";
4607
5064
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
@@ -4609,8 +5066,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
4609
5066
  const pathEnv = normalizeExecutionPath(process.env);
4610
5067
  if (process.platform === "linux") {
4611
5068
  const path = systemdServicePath();
4612
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4613
- writeFileSync2(path, `[Unit]
5069
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
5070
+ writeFileSync3(path, `[Unit]
4614
5071
  Description=Hasna OpenLoops daemon
4615
5072
  After=default.target
4616
5073
 
@@ -4636,8 +5093,8 @@ WantedBy=default.target
4636
5093
  }
4637
5094
  if (process.platform === "darwin") {
4638
5095
  const path = launchdPlistPath();
4639
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4640
- writeFileSync2(path, `<?xml version="1.0" encoding="UTF-8"?>
5096
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
5097
+ writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
4641
5098
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4642
5099
  <plist version="1.0">
4643
5100
  <dict>
@@ -4796,7 +5253,7 @@ function runDoctor(store) {
4796
5253
  }
4797
5254
 
4798
5255
  // src/lib/health.ts
4799
- import { createHash } from "crypto";
5256
+ import { createHash as createHash2 } from "crypto";
4800
5257
  var EVIDENCE_CHARS = 2000;
4801
5258
  var FINGERPRINT_EVIDENCE_CHARS = 120;
4802
5259
  var CLASSIFICATIONS = [
@@ -4828,7 +5285,7 @@ function searchableText(run) {
4828
5285
  `).toLowerCase();
4829
5286
  }
4830
5287
  function stableFingerprint(parts) {
4831
- return createHash("sha256").update(parts.join(`
5288
+ return createHash2("sha256").update(parts.join(`
4832
5289
  `)).digest("hex").slice(0, 16);
4833
5290
  }
4834
5291
  function stableFailureFingerprint(run, classification) {
@@ -5016,7 +5473,7 @@ function buildHealthReport(store, opts = {}) {
5016
5473
  }
5017
5474
 
5018
5475
  // src/lib/hygiene.ts
5019
- import { basename } from "path";
5476
+ import { basename as basename2 } from "path";
5020
5477
  var PROVIDER_TOKENS = new Set([
5021
5478
  "codewith",
5022
5479
  "claude",
@@ -5037,7 +5494,7 @@ function repoSlugFromCwd(cwd) {
5037
5494
  return "";
5038
5495
  if (cwd.includes("/.hasna/loops/"))
5039
5496
  return "";
5040
- return slugify(basename(cwd));
5497
+ return slugify(basename2(cwd));
5041
5498
  }
5042
5499
  function scopeForLoop(loop) {
5043
5500
  const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
@@ -5254,7 +5711,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5254
5711
  // package.json
5255
5712
  var package_default = {
5256
5713
  name: "@hasna/loops",
5257
- version: "0.3.37",
5714
+ version: "0.3.39",
5258
5715
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5259
5716
  type: "module",
5260
5717
  main: "dist/index.js",
@@ -5346,10 +5803,17 @@ function packageVersion() {
5346
5803
  import { execFileSync } from "child_process";
5347
5804
  import { existsSync as existsSync3 } from "fs";
5348
5805
  import { homedir as homedir3 } from "os";
5349
- import { basename as basename2, isAbsolute, join as join3, relative, resolve } from "path";
5806
+ import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
5350
5807
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
5351
5808
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5352
5809
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
5810
+ var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
5811
+ var PR_REVIEW_TEMPLATE_ID = "pr-review";
5812
+ var SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
5813
+ var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
5814
+ var REPORT_ONLY_TEMPLATE_ID = "report-only";
5815
+ var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
5816
+ var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
5353
5817
  var TEMPLATE_SUMMARIES = [
5354
5818
  {
5355
5819
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -5371,7 +5835,8 @@ var TEMPLATE_SUMMARIES = [
5371
5835
  { name: "model", description: "Provider model." },
5372
5836
  { name: "variant", description: "Provider reasoning/model effort variant." },
5373
5837
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5374
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5838
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5839
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5375
5840
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5376
5841
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5377
5842
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
@@ -5399,7 +5864,8 @@ var TEMPLATE_SUMMARIES = [
5399
5864
  { name: "model", description: "Provider model." },
5400
5865
  { name: "variant", description: "Provider reasoning/model effort variant." },
5401
5866
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5402
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5867
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5868
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5403
5869
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5404
5870
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5405
5871
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
@@ -5425,12 +5891,112 @@ var TEMPLATE_SUMMARIES = [
5425
5891
  { name: "model", description: "Provider model." },
5426
5892
  { name: "variant", description: "Provider reasoning/model effort variant." },
5427
5893
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5428
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5894
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5895
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5429
5896
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5430
5897
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5431
5898
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
5432
5899
  { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5433
5900
  ]
5901
+ },
5902
+ {
5903
+ id: TASK_LIFECYCLE_TEMPLATE_ID,
5904
+ name: "Task Lifecycle",
5905
+ description: "Run the standard task-created lifecycle: triage/dedupe, plan, worker execution, independent verification, and todos closure/follow-up evidence.",
5906
+ kind: "workflow",
5907
+ variables: [
5908
+ { name: "taskId", required: true, description: "Todos task id." },
5909
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5910
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5911
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
5912
+ { name: "provider", default: "codewith", description: "Agent provider." },
5913
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5914
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5915
+ ]
5916
+ },
5917
+ {
5918
+ id: PR_REVIEW_TEMPLATE_ID,
5919
+ name: "PR Review",
5920
+ description: "Review and drive a pull request toward merge-ready state with a worker and fresh adversarial verifier.",
5921
+ kind: "workflow",
5922
+ variables: [
5923
+ { name: "prUrl", description: "Pull request URL." },
5924
+ { name: "prNumber", description: "Pull request number." },
5925
+ { name: "projectPath", required: true, description: "Repository working directory." },
5926
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5927
+ { name: "provider", default: "codewith", description: "Agent provider." },
5928
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5929
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5930
+ ]
5931
+ },
5932
+ {
5933
+ id: SCHEDULED_AUDIT_TEMPLATE_ID,
5934
+ name: "Scheduled Audit",
5935
+ description: "Run a bounded scheduled audit, record evidence, create follow-up tasks for actionable findings, then verify the audit result.",
5936
+ kind: "workflow",
5937
+ variables: [
5938
+ { name: "objective", required: true, description: "Audit objective." },
5939
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5940
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5941
+ { name: "provider", default: "codewith", description: "Agent provider." },
5942
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5943
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5944
+ ]
5945
+ },
5946
+ {
5947
+ id: KNOWLEDGE_REFRESH_TEMPLATE_ID,
5948
+ name: "Knowledge Refresh",
5949
+ description: "Review recent knowledge, improve structure/schema where needed, create deduped tasks for code changes, and verify the knowledge update.",
5950
+ kind: "workflow",
5951
+ variables: [
5952
+ { name: "scope", description: "Knowledge scope or label to refresh." },
5953
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5954
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5955
+ { name: "provider", default: "codewith", description: "Agent provider." },
5956
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5957
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5958
+ ]
5959
+ },
5960
+ {
5961
+ id: REPORT_ONLY_TEMPLATE_ID,
5962
+ name: "Report Only",
5963
+ description: "Produce a bounded report without mutating repositories; verifier checks evidence, scope, and absence of unauthorized changes.",
5964
+ kind: "workflow",
5965
+ variables: [
5966
+ { name: "objective", required: true, description: "Report objective." },
5967
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5968
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5969
+ { name: "provider", default: "codewith", description: "Agent provider." },
5970
+ { name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
5971
+ { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
5972
+ ]
5973
+ },
5974
+ {
5975
+ id: INCIDENT_RESPONSE_TEMPLATE_ID,
5976
+ name: "Incident Response",
5977
+ description: "Triage an incident, gather bounded evidence, apply only allowed narrow mitigation, create follow-up tasks, and verify the response.",
5978
+ kind: "workflow",
5979
+ variables: [
5980
+ { name: "incidentId", description: "Incident or task id." },
5981
+ { name: "objective", required: true, description: "Incident response objective." },
5982
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5983
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5984
+ { name: "provider", default: "codewith", description: "Agent provider." },
5985
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5986
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5987
+ ]
5988
+ },
5989
+ {
5990
+ id: DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID,
5991
+ name: "Deterministic Check Create Task",
5992
+ description: "Run a deterministic check command that writes compact evidence and upserts one deduped todos task when its expectation is not met.",
5993
+ kind: "workflow",
5994
+ variables: [
5995
+ { name: "checkCommand", required: true, description: "Shell command that performs the check and task upsert." },
5996
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5997
+ { name: "name", description: "Workflow name." },
5998
+ { name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
5999
+ ]
5434
6000
  }
5435
6001
  ];
5436
6002
  function compactJson(value) {
@@ -5492,7 +6058,7 @@ function defaultWorktreeRoot(root) {
5492
6058
  const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
5493
6059
  return isAbsolute(expanded) ? expanded : resolve(expanded);
5494
6060
  }
5495
- return join3(homedir3(), ".hasna", "loops", "worktrees");
6061
+ return join5(homedir3(), ".hasna", "loops", "worktrees");
5496
6062
  }
5497
6063
  function gitRootFor(path) {
5498
6064
  if (!existsSync3(path))
@@ -5593,11 +6159,11 @@ function worktreePlan(input, seed) {
5593
6159
  };
5594
6160
  }
5595
6161
  const root = defaultWorktreeRoot(input.worktreeRoot);
5596
- const repoSlug = slugSegment(basename2(repoRoot), "repo");
6162
+ const repoSlug = slugSegment(basename3(repoRoot), "repo");
5597
6163
  const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
5598
- const worktreePath = join3(root, repoSlug, seedSlug);
6164
+ const worktreePath = join5(root, repoSlug, seedSlug);
5599
6165
  const relativeCwd = relative(repoRoot, originalCwd);
5600
- const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join3(worktreePath, relativeCwd) : worktreePath;
6166
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5601
6167
  const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
5602
6168
  const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
5603
6169
  const prepareStep = {
@@ -5655,10 +6221,20 @@ function assertNativeAuthProfileSupport(input, provider) {
5655
6221
  return;
5656
6222
  throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
5657
6223
  }
6224
+ function failClosedSandbox(input, provider, sandbox) {
6225
+ if (!["codewith", "codex"].includes(provider))
6226
+ return;
6227
+ if (sandbox !== "danger-full-access")
6228
+ return;
6229
+ if (input.manualBreakGlass)
6230
+ return;
6231
+ 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");
6232
+ }
5658
6233
  function agentTarget(input, prompt, role, seed, plan) {
5659
6234
  const provider = input.provider ?? "codewith";
5660
6235
  assertNativeAuthProfileSupport(input, provider);
5661
- const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
6236
+ const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "workspace-write" : provider === "cursor" ? "enabled" : undefined);
6237
+ failClosedSandbox(input, provider, sandbox);
5662
6238
  return {
5663
6239
  type: "agent",
5664
6240
  provider,
@@ -5682,6 +6258,7 @@ function agentTarget(input, prompt, role, seed, plan) {
5682
6258
  branch: plan.branch,
5683
6259
  reason: plan.reason
5684
6260
  },
6261
+ allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
5685
6262
  routing: {
5686
6263
  projectPath: input.routeProjectPath ?? input.projectPath,
5687
6264
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
@@ -5771,7 +6348,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5771
6348
  name: "Verifier",
5772
6349
  description: "Adversarially verify worker output and update todos.",
5773
6350
  dependsOn: ["worker"],
5774
- target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6351
+ target: {
6352
+ ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6353
+ idleTimeoutMs: 10 * 60000
6354
+ },
5775
6355
  timeoutMs: 30 * 60000
5776
6356
  }
5777
6357
  ])
@@ -5849,7 +6429,10 @@ function renderEventWorkerVerifierWorkflow(input) {
5849
6429
  name: "Verifier",
5850
6430
  description: "Adversarially verify event handling.",
5851
6431
  dependsOn: ["worker"],
5852
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6432
+ target: {
6433
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6434
+ idleTimeoutMs: 10 * 60000
6435
+ },
5853
6436
  timeoutMs: 30 * 60000
5854
6437
  }
5855
6438
  ])
@@ -5900,13 +6483,142 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5900
6483
  name: "Verifier",
5901
6484
  description: "Adversarially verify the bounded objective result.",
5902
6485
  dependsOn: ["worker"],
5903
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6486
+ target: {
6487
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6488
+ idleTimeoutMs: 10 * 60000
6489
+ },
5904
6490
  timeoutMs: Math.min(timeoutMs, 30 * 60000)
5905
6491
  }
5906
6492
  ])
5907
6493
  };
5908
6494
  }
6495
+ function renderLifecycleBoundedTemplate(id, values) {
6496
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
6497
+ const common = {
6498
+ name: values.name,
6499
+ projectPath,
6500
+ routeProjectPath: values.routeProjectPath,
6501
+ projectGroup: values.projectGroup,
6502
+ provider: values.provider,
6503
+ authProfile: values.authProfile,
6504
+ authProfilePool: listVar(values.authProfilePool),
6505
+ workerAuthProfile: values.workerAuthProfile,
6506
+ verifierAuthProfile: values.verifierAuthProfile,
6507
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
6508
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
6509
+ model: values.model,
6510
+ variant: values.variant,
6511
+ agent: values.agent,
6512
+ permissionMode: values.permissionMode,
6513
+ sandbox: values.sandbox,
6514
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
6515
+ worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
6516
+ worktreeRoot: values.worktreeRoot,
6517
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
6518
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
6519
+ };
6520
+ if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
6521
+ const taskId = values.taskId ?? "";
6522
+ if (!taskId.trim())
6523
+ throw new Error("taskId is required");
6524
+ return renderBoundedAgentWorkerVerifierWorkflow({
6525
+ ...common,
6526
+ name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
6527
+ objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
6528
+ 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."
6529
+ });
6530
+ }
6531
+ if (id === PR_REVIEW_TEMPLATE_ID) {
6532
+ const pr = values.prUrl ?? values.prNumber ?? "";
6533
+ if (!pr.trim())
6534
+ throw new Error("prUrl or prNumber is required");
6535
+ return renderBoundedAgentWorkerVerifierWorkflow({
6536
+ ...common,
6537
+ name: values.name ?? `pr-review-${slugSegment(pr)}-worker-verifier`,
6538
+ objective: values.objective ?? `Review and drive PR ${pr} toward merge-ready state.`,
6539
+ 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."
6540
+ });
6541
+ }
6542
+ if (id === SCHEDULED_AUDIT_TEMPLATE_ID) {
6543
+ const objective = values.objective ?? "";
6544
+ if (!objective.trim())
6545
+ throw new Error("objective is required");
6546
+ return renderBoundedAgentWorkerVerifierWorkflow({
6547
+ ...common,
6548
+ name: values.name ?? `scheduled-audit-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
6549
+ objective,
6550
+ 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."
6551
+ });
6552
+ }
6553
+ if (id === KNOWLEDGE_REFRESH_TEMPLATE_ID) {
6554
+ const scope = values.scope ?? values.label ?? "recent knowledge";
6555
+ return renderBoundedAgentWorkerVerifierWorkflow({
6556
+ ...common,
6557
+ name: values.name ?? `knowledge-refresh-${slugSegment(scope)}-worker-verifier`,
6558
+ objective: values.objective ?? `Refresh and verify ${scope}.`,
6559
+ 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."
6560
+ });
6561
+ }
6562
+ if (id === REPORT_ONLY_TEMPLATE_ID) {
6563
+ const objective = values.objective ?? "";
6564
+ if (!objective.trim())
6565
+ throw new Error("objective is required");
6566
+ return renderBoundedAgentWorkerVerifierWorkflow({
6567
+ ...common,
6568
+ name: values.name ?? `report-only-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
6569
+ objective,
6570
+ 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."
6571
+ });
6572
+ }
6573
+ if (id === INCIDENT_RESPONSE_TEMPLATE_ID) {
6574
+ const objective = values.objective ?? "";
6575
+ if (!objective.trim())
6576
+ throw new Error("objective is required");
6577
+ const incident = values.incidentId ?? values.taskId ?? "incident";
6578
+ return renderBoundedAgentWorkerVerifierWorkflow({
6579
+ ...common,
6580
+ name: values.name ?? `incident-response-${slugSegment(incident)}-worker-verifier`,
6581
+ objective,
6582
+ 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."
6583
+ });
6584
+ }
6585
+ return;
6586
+ }
6587
+ function renderDeterministicCheckCreateTaskWorkflow(values) {
6588
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
6589
+ const checkCommand = values.checkCommand ?? "";
6590
+ if (!checkCommand.trim())
6591
+ throw new Error("checkCommand is required");
6592
+ const seed = `${projectPath}:${checkCommand}`;
6593
+ return {
6594
+ name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
6595
+ description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
6596
+ version: 1,
6597
+ steps: [
6598
+ {
6599
+ id: "check",
6600
+ name: "Check",
6601
+ description: "Run the deterministic check/task-upsert command.",
6602
+ target: {
6603
+ type: "command",
6604
+ command: "bash",
6605
+ args: ["-lc", checkCommand],
6606
+ cwd: projectPath,
6607
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
6608
+ idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
6609
+ },
6610
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
6611
+ }
6612
+ ]
6613
+ };
6614
+ }
5909
6615
  function renderLoopTemplate(id, values) {
6616
+ if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
6617
+ return renderDeterministicCheckCreateTaskWorkflow(values);
6618
+ }
6619
+ const lifecycle = renderLifecycleBoundedTemplate(id, values);
6620
+ if (lifecycle)
6621
+ return lifecycle;
5910
6622
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
5911
6623
  return renderTodosTaskWorkerVerifierWorkflow({
5912
6624
  taskId: values.taskId ?? "",
@@ -5927,6 +6639,7 @@ function renderLoopTemplate(id, values) {
5927
6639
  agent: values.agent,
5928
6640
  permissionMode: values.permissionMode,
5929
6641
  sandbox: values.sandbox,
6642
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5930
6643
  worktreeMode: values.worktreeMode,
5931
6644
  worktreeRoot: values.worktreeRoot,
5932
6645
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -5957,6 +6670,7 @@ function renderLoopTemplate(id, values) {
5957
6670
  agent: values.agent,
5958
6671
  permissionMode: values.permissionMode,
5959
6672
  sandbox: values.sandbox,
6673
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5960
6674
  worktreeMode: values.worktreeMode,
5961
6675
  worktreeRoot: values.worktreeRoot,
5962
6676
  worktreeBranchPrefix: values.worktreeBranchPrefix
@@ -5982,6 +6696,7 @@ function renderLoopTemplate(id, values) {
5982
6696
  agent: values.agent,
5983
6697
  permissionMode: values.permissionMode,
5984
6698
  sandbox: values.sandbox,
6699
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5985
6700
  worktreeMode: values.worktreeMode,
5986
6701
  worktreeRoot: values.worktreeRoot,
5987
6702
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -5994,6 +6709,16 @@ function listVar(value) {
5994
6709
  const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5995
6710
  return values?.length ? values : undefined;
5996
6711
  }
6712
+ function booleanVar(value) {
6713
+ if (value === undefined)
6714
+ return;
6715
+ const normalized = value.trim().toLowerCase();
6716
+ if (["1", "true", "yes", "on"].includes(normalized))
6717
+ return true;
6718
+ if (["0", "false", "no", "off", ""].includes(normalized))
6719
+ return false;
6720
+ throw new Error(`expected boolean value, got ${value}`);
6721
+ }
5997
6722
  function accountPoolVar(value, tool) {
5998
6723
  return listVar(value)?.map((profile) => ({ profile, tool }));
5999
6724
  }
@@ -6224,8 +6949,8 @@ function runLocalCommand(command, args, opts = {}) {
6224
6949
  };
6225
6950
  }
6226
6951
  function runLocalCommandWithStdoutFile(command, args, opts = {}) {
6227
- const tempDir = mkdtempSync(join4(tmpdir(), "loops-command-output-"));
6228
- const stdoutPath = join4(tempDir, "stdout");
6952
+ const tempDir = mkdtempSync2(join6(tmpdir2(), "loops-command-output-"));
6953
+ const stdoutPath = join6(tempDir, "stdout");
6229
6954
  const stdoutFd = openSync2(stdoutPath, "w");
6230
6955
  let result;
6231
6956
  try {
@@ -6249,7 +6974,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
6249
6974
  error: result.error ? String(result.error.message || result.error) : ""
6250
6975
  };
6251
6976
  } finally {
6252
- rmSync2(tempDir, { recursive: true, force: true });
6977
+ rmSync3(tempDir, { recursive: true, force: true });
6253
6978
  }
6254
6979
  }
6255
6980
  function ensureTodosTaskList(project, slug, name, description) {
@@ -6265,23 +6990,23 @@ function ensureTodosTaskList(project, slug, name, description) {
6265
6990
  }
6266
6991
  function backupLoopsDatabase(reason) {
6267
6992
  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}`);
6993
+ const backupDir = join6(dataDir(), "backups");
6994
+ mkdirSync6(backupDir, { recursive: true, mode: 448 });
6995
+ const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
6271
6996
  const db = new Database2(dbPath(), { readonly: true });
6272
6997
  try {
6273
- writeFileSync3(backupPath, db.serialize(), { mode: 384 });
6998
+ writeFileSync4(backupPath, db.serialize(), { mode: 384 });
6274
6999
  } finally {
6275
7000
  db.close();
6276
7001
  }
6277
7002
  return backupPath;
6278
7003
  }
6279
7004
  function stableHash(parts) {
6280
- return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
7005
+ return createHash3("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
6281
7006
  `)).digest("hex").slice(0, 16);
6282
7007
  }
6283
7008
  function routeCursorsPath() {
6284
- return join4(dataDir(), "route-cursors.json");
7009
+ return join6(dataDir(), "route-cursors.json");
6285
7010
  }
6286
7011
  function readRouteCursors() {
6287
7012
  const path = routeCursorsPath();
@@ -6299,15 +7024,15 @@ function writeRouteCursor(key, lastFingerprint) {
6299
7024
  return;
6300
7025
  const cursors = readRouteCursors();
6301
7026
  cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
6302
- writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
7027
+ writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
6303
7028
  }
6304
7029
  function writeRouteEvidence(kind, value, evidenceDir) {
6305
7030
  if (!evidenceDir)
6306
7031
  return;
6307
- mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
7032
+ mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
6308
7033
  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" });
7034
+ const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
7035
+ writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
6311
7036
  return evidencePath;
6312
7037
  }
6313
7038
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
@@ -6426,7 +7151,7 @@ function slugSegment2(value, fallback = "event") {
6426
7151
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
6427
7152
  }
6428
7153
  function stableSuffix(value) {
6429
- return createHash2("sha256").update(value).digest("hex").slice(0, 12);
7154
+ return createHash3("sha256").update(value).digest("hex").slice(0, 12);
6430
7155
  }
6431
7156
  function taskEventField(data, keys) {
6432
7157
  for (const key of keys) {
@@ -6561,6 +7286,33 @@ function taskRouteEligibility(data, metadata) {
6561
7286
  }
6562
7287
  return { eligible: true, tags };
6563
7288
  }
7289
+ function generatedRouteSandboxPreflight(workflow) {
7290
+ const checks = [];
7291
+ for (const step of workflow.steps) {
7292
+ if (step.target.type !== "agent")
7293
+ continue;
7294
+ const target = step.target;
7295
+ const worktreeEnabled = Boolean(target.worktree?.enabled);
7296
+ if (target.sandbox === "danger-full-access") {
7297
+ const manual = target.allowlist?.commands?.includes("manual-break-glass");
7298
+ if (!manual) {
7299
+ throw new Error(`route step ${step.id} uses danger-full-access without manual break-glass evidence`);
7300
+ }
7301
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "manual-break-glass" });
7302
+ continue;
7303
+ }
7304
+ if (["codewith", "codex"].includes(target.provider) && (target.sandbox === "workspace-write" || target.sandbox === "read-only") || target.provider === "cursor" && target.sandbox === "enabled") {
7305
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "provider-native-sandbox" });
7306
+ continue;
7307
+ }
7308
+ if (worktreeEnabled) {
7309
+ checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "isolated-worktree" });
7310
+ continue;
7311
+ }
7312
+ 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`);
7313
+ }
7314
+ return checks;
7315
+ }
6564
7316
  function routeThrottleLimitsFromOpts(opts) {
6565
7317
  return {
6566
7318
  maxActive: positiveInteger(opts.maxActive, "--max-active"),
@@ -6594,46 +7346,10 @@ function normalizeRoutePath(value) {
6594
7346
  function routeProjectGroup(optsGroup, data, metadata) {
6595
7347
  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
7348
  }
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
7349
  function routeThrottleDecision(store, args) {
6628
7350
  const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
6629
7351
  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;
7352
+ const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
6637
7353
  const base = {
6638
7354
  projectPath,
6639
7355
  ...projectGroup ? { projectGroup } : {},
@@ -6666,12 +7382,8 @@ function routeThrottleDryRunPreview(args) {
6666
7382
  limits: args.limits
6667
7383
  };
6668
7384
  }
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();
7385
+ async function readEventEnvelopeInput(opts = {}) {
7386
+ const raw = opts.eventJson ?? (opts.eventFile ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
6675
7387
  const event = JSON.parse(raw);
6676
7388
  if (!event || typeof event !== "object" || Array.isArray(event))
6677
7389
  throw new Error("event JSON must be an object");
@@ -6683,6 +7395,9 @@ async function readEventEnvelopeFromStdin() {
6683
7395
  throw new Error("event.source is required");
6684
7396
  return event;
6685
7397
  }
7398
+ async function readEventEnvelopeFromStdin() {
7399
+ return readEventEnvelopeInput();
7400
+ }
6686
7401
  function routeTodosTaskEvent(event, opts) {
6687
7402
  const data = eventData(event);
6688
7403
  const metadata = eventMetadata(event);
@@ -6717,24 +7432,27 @@ function routeTodosTaskEvent(event, opts) {
6717
7432
  const namePrefix = opts.namePrefix ?? "event:todos-task";
6718
7433
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
6719
7434
  const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
6720
- const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
6721
7435
  if (!opts.dryRun) {
6722
7436
  const store2 = new Store;
6723
7437
  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;
7438
+ const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
7439
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7440
+ const existingLoop = store2.getLoop(existingItem.loopId);
7441
+ const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
7442
+ const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
6727
7443
  return {
6728
7444
  kind: "deduped",
6729
7445
  value: {
6730
7446
  deduped: true,
6731
7447
  idempotencyKey,
6732
- dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
7448
+ dedupedBy: "work-item",
6733
7449
  event,
7450
+ invocation: existingInvocation ? publicWorkflowInvocation(existingInvocation) : undefined,
7451
+ workItem: publicWorkflowWorkItem(existingItem),
6734
7452
  workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
6735
- loop: publicLoop(existingLoop)
7453
+ loop: existingLoop ? publicLoop(existingLoop) : undefined
6736
7454
  },
6737
- human: `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
7455
+ human: `deduped existing work item ${existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
6738
7456
  };
6739
7457
  }
6740
7458
  } finally {
@@ -6768,6 +7486,7 @@ function routeTodosTaskEvent(event, opts) {
6768
7486
  agent: opts.agent,
6769
7487
  permissionMode,
6770
7488
  sandbox,
7489
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
6771
7490
  worktreeMode: opts.worktreeMode ?? "auto",
6772
7491
  worktreeRoot: opts.worktreeRoot,
6773
7492
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
@@ -6776,11 +7495,53 @@ function routeTodosTaskEvent(event, opts) {
6776
7495
  });
6777
7496
  workflowBody.name = workflowName;
6778
7497
  workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7498
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7499
+ const invocationInput = {
7500
+ templateId: "todos-task-worker-verifier",
7501
+ sourceRef: {
7502
+ kind: "event",
7503
+ id: event.id,
7504
+ dedupeKey: idempotencyKey,
7505
+ raw: { type: event.type, source: event.source, subject: event.subject }
7506
+ },
7507
+ subjectRef: {
7508
+ kind: "task",
7509
+ id: taskId,
7510
+ path: routeProjectPath,
7511
+ raw: { title: taskTitle, description: taskDescription }
7512
+ },
7513
+ intent: "route",
7514
+ scope: {
7515
+ projectPath: routeProjectPath,
7516
+ projectGroup,
7517
+ worktreePolicy: opts.worktreeMode ?? "auto",
7518
+ permissions: permissionMode,
7519
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7520
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
7521
+ concurrencyGroup: projectGroup ?? routeProjectPath
7522
+ },
7523
+ outputPolicy: {
7524
+ report: "always",
7525
+ createTask: "on_failure"
7526
+ }
7527
+ };
7528
+ const workItemInput = {
7529
+ routeKey: "todos-task",
7530
+ idempotencyKey,
7531
+ invocationId: "<created-invocation-id>",
7532
+ sourceType: event.type,
7533
+ sourceRef: event.id,
7534
+ subjectRef: taskId,
7535
+ projectKey: routeProjectPath,
7536
+ projectGroup,
7537
+ priority: 0,
7538
+ status: "queued"
7539
+ };
6779
7540
  const loopInput = {
6780
7541
  name: loopName,
6781
7542
  description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
6782
7543
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
6783
- target: { type: "workflow", workflowId: "<created-workflow-id>" },
7544
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
6784
7545
  overlap: "skip",
6785
7546
  maxAttempts: 1,
6786
7547
  retryDelayMs: 60000,
@@ -6795,7 +7556,7 @@ function routeTodosTaskEvent(event, opts) {
6795
7556
  }, {}) : undefined;
6796
7557
  return {
6797
7558
  kind: "created",
6798
- value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
7559
+ value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
6799
7560
  human: `dry-run ${loopName}`
6800
7561
  };
6801
7562
  }
@@ -6803,27 +7564,44 @@ function routeTodosTaskEvent(event, opts) {
6803
7564
  try {
6804
7565
  const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
6805
7566
  const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
7567
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
6806
7568
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
6807
7569
  name: workflowBody.name,
6808
7570
  type: "todos-task-event-workflow",
6809
7571
  event: event.id
6810
7572
  }, {}) : undefined;
6811
7573
  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 };
7574
+ const invocation = store.createWorkflowInvocation(invocationInput);
7575
+ const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
7576
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7577
+ const existingLoop = store.getLoop(existingItem.loopId);
7578
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
7579
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
6816
7580
  }
6817
7581
  const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7582
+ const workItem = store.upsertWorkflowWorkItem({
7583
+ ...workItemInput,
7584
+ invocationId: invocation.id,
7585
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
7586
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
7587
+ });
6818
7588
  if (throttle && !throttle.allowed)
6819
- return { kind: "throttled", throttle };
7589
+ return { kind: "throttled", invocation, workItem, throttle };
6820
7590
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
6821
7591
  const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
6822
7592
  const loop = store.createLoop({
6823
7593
  ...loopInput,
6824
- target: { type: "workflow", workflowId: workflow.id }
7594
+ target: {
7595
+ type: "workflow",
7596
+ workflowId: workflow.id,
7597
+ input: {
7598
+ workflowInvocationId: invocation.id,
7599
+ workflowWorkItemId: workItem.id
7600
+ }
7601
+ }
6825
7602
  });
6826
- return { kind: "created", workflow, loop, throttle };
7603
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
7604
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
6827
7605
  });
6828
7606
  if (outcome.kind === "deduped") {
6829
7607
  return {
@@ -6831,12 +7609,14 @@ function routeTodosTaskEvent(event, opts) {
6831
7609
  value: {
6832
7610
  deduped: true,
6833
7611
  idempotencyKey,
6834
- dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
7612
+ dedupedBy: "work-item",
6835
7613
  event,
7614
+ invocation: publicWorkflowInvocation(outcome.invocation),
7615
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
6836
7616
  workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
6837
- loop: publicLoop(outcome.existingLoop)
7617
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
6838
7618
  },
6839
- human: `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
7619
+ human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
6840
7620
  };
6841
7621
  }
6842
7622
  if (outcome.kind === "throttled") {
@@ -6848,6 +7628,8 @@ function routeTodosTaskEvent(event, opts) {
6848
7628
  reason: outcome.throttle.reason,
6849
7629
  idempotencyKey,
6850
7630
  event,
7631
+ invocation: publicWorkflowInvocation(outcome.invocation),
7632
+ workItem: publicWorkflowWorkItem(outcome.workItem),
6851
7633
  throttle: outcome.throttle,
6852
7634
  workflow: workflowBody,
6853
7635
  loop: loopInput
@@ -6861,9 +7643,12 @@ function routeTodosTaskEvent(event, opts) {
6861
7643
  deduped: false,
6862
7644
  idempotencyKey,
6863
7645
  event,
7646
+ invocation: publicWorkflowInvocation(outcome.invocation),
7647
+ workItem: publicWorkflowWorkItem(outcome.workItem),
6864
7648
  workflow: publicWorkflow(outcome.workflow),
6865
7649
  loop: publicLoop(outcome.loop),
6866
7650
  throttle: outcome.throttle,
7651
+ sandboxPreflight,
6867
7652
  preflight
6868
7653
  },
6869
7654
  human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
@@ -6872,17 +7657,229 @@ function routeTodosTaskEvent(event, opts) {
6872
7657
  store.close();
6873
7658
  }
6874
7659
  }
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
- }
6883
- function taskListId(task) {
6884
- return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
6885
- }
7660
+ function routeGenericEvent(event, opts) {
7661
+ const data = eventData(event);
7662
+ const metadata = eventMetadata(event);
7663
+ 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();
7664
+ const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
7665
+ const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
7666
+ const throttleLimits = routeThrottleLimitsFromOpts(opts);
7667
+ const eventSuffix = event.id.slice(0, 8);
7668
+ const source = slugSegment2(event.source, "source");
7669
+ const type = slugSegment2(event.type, "type");
7670
+ const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
7671
+ const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
7672
+ const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
7673
+ const provider = opts.provider ?? "codewith";
7674
+ if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
7675
+ throw new Error("unsupported provider");
7676
+ const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7677
+ const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7678
+ const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7679
+ const workflowBody = renderEventWorkerVerifierWorkflow({
7680
+ eventId: event.id,
7681
+ eventType: event.type,
7682
+ eventSource: event.source,
7683
+ eventSubject: stringField(event.subject),
7684
+ eventMessage: stringField(event.message),
7685
+ eventJson: JSON.stringify(event),
7686
+ projectPath,
7687
+ routeProjectPath,
7688
+ projectGroup,
7689
+ provider,
7690
+ authProfile,
7691
+ authProfilePool: splitList(opts.authProfilePool),
7692
+ workerAuthProfile: opts.workerAuthProfile,
7693
+ verifierAuthProfile: opts.verifierAuthProfile,
7694
+ account: accountFromOpts(opts),
7695
+ accountPool: accountPoolFromOpts(opts),
7696
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
7697
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
7698
+ model: opts.model,
7699
+ variant: opts.variant,
7700
+ agent: opts.agent,
7701
+ permissionMode,
7702
+ sandbox,
7703
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7704
+ worktreeMode: opts.worktreeMode ?? "auto",
7705
+ worktreeRoot: opts.worktreeRoot,
7706
+ worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops"
7707
+ });
7708
+ workflowBody.name = workflowName;
7709
+ workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7710
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7711
+ const invocationInput = {
7712
+ templateId: "event-worker-verifier",
7713
+ sourceRef: {
7714
+ kind: "event",
7715
+ id: event.id,
7716
+ dedupeKey: idempotencyKey,
7717
+ raw: { source: event.source, type: event.type }
7718
+ },
7719
+ subjectRef: {
7720
+ kind: "event",
7721
+ id: stringField(event.subject) ?? event.id,
7722
+ path: routeProjectPath,
7723
+ raw: { message: stringField(event.message) }
7724
+ },
7725
+ intent: "route",
7726
+ scope: {
7727
+ projectPath: routeProjectPath,
7728
+ projectGroup,
7729
+ worktreePolicy: opts.worktreeMode ?? "auto",
7730
+ permissions: permissionMode,
7731
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7732
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
7733
+ concurrencyGroup: projectGroup ?? routeProjectPath
7734
+ },
7735
+ outputPolicy: {
7736
+ report: "always",
7737
+ createTask: "on_failure"
7738
+ }
7739
+ };
7740
+ const workItemInput = {
7741
+ routeKey: "generic-event",
7742
+ idempotencyKey,
7743
+ invocationId: "<created-invocation-id>",
7744
+ sourceType: event.type,
7745
+ sourceRef: event.id,
7746
+ subjectRef: stringField(event.subject) ?? event.id,
7747
+ projectKey: routeProjectPath,
7748
+ projectGroup,
7749
+ priority: 0,
7750
+ status: "queued"
7751
+ };
7752
+ const loopInput = {
7753
+ name: loopName,
7754
+ description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
7755
+ schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
7756
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
7757
+ overlap: "skip",
7758
+ maxAttempts: 1,
7759
+ retryDelayMs: 60000,
7760
+ leaseMs: 90 * 60000
7761
+ };
7762
+ if (opts.dryRun) {
7763
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7764
+ const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
7765
+ name: workflowBody.name,
7766
+ type: "generic-event-workflow",
7767
+ event: event.id
7768
+ }, {}) : undefined;
7769
+ return {
7770
+ kind: "created",
7771
+ value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
7772
+ human: `dry-run ${loopName}`
7773
+ };
7774
+ }
7775
+ const store = new Store;
7776
+ try {
7777
+ const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
7778
+ const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
7779
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
7780
+ const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
7781
+ name: workflowBody.name,
7782
+ type: "generic-event-workflow",
7783
+ event: event.id
7784
+ }, {}) : undefined;
7785
+ const outcome = store.writeTransaction(() => {
7786
+ const invocation = store.createWorkflowInvocation(invocationInput);
7787
+ const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
7788
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
7789
+ const existingLoop = store.getLoop(existingItem.loopId);
7790
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
7791
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
7792
+ }
7793
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7794
+ const workItem = store.upsertWorkflowWorkItem({
7795
+ ...workItemInput,
7796
+ invocationId: invocation.id,
7797
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
7798
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
7799
+ });
7800
+ if (throttle && !throttle.allowed)
7801
+ return { kind: "throttled", invocation, workItem, throttle };
7802
+ const existingWorkflow = store.findWorkflowByName(workflowBody.name);
7803
+ const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
7804
+ const loop = store.createLoop({
7805
+ ...loopInput,
7806
+ target: {
7807
+ type: "workflow",
7808
+ workflowId: workflow.id,
7809
+ input: {
7810
+ workflowInvocationId: invocation.id,
7811
+ workflowWorkItemId: workItem.id
7812
+ }
7813
+ }
7814
+ });
7815
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
7816
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
7817
+ });
7818
+ if (outcome.kind === "deduped") {
7819
+ return {
7820
+ kind: "deduped",
7821
+ value: {
7822
+ deduped: true,
7823
+ idempotencyKey,
7824
+ dedupedBy: "work-item",
7825
+ event,
7826
+ invocation: publicWorkflowInvocation(outcome.invocation),
7827
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
7828
+ workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
7829
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
7830
+ },
7831
+ human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
7832
+ };
7833
+ }
7834
+ if (outcome.kind === "throttled") {
7835
+ return {
7836
+ kind: "throttled",
7837
+ value: {
7838
+ skipped: true,
7839
+ queuedAtSource: true,
7840
+ reason: outcome.throttle.reason,
7841
+ idempotencyKey,
7842
+ event,
7843
+ invocation: publicWorkflowInvocation(outcome.invocation),
7844
+ workItem: publicWorkflowWorkItem(outcome.workItem),
7845
+ throttle: outcome.throttle,
7846
+ workflow: workflowBody,
7847
+ loop: loopInput
7848
+ },
7849
+ human: `skipped event ${event.id}: ${outcome.throttle.reason}`
7850
+ };
7851
+ }
7852
+ return {
7853
+ kind: "created",
7854
+ value: {
7855
+ deduped: false,
7856
+ idempotencyKey,
7857
+ event,
7858
+ invocation: publicWorkflowInvocation(outcome.invocation),
7859
+ workItem: publicWorkflowWorkItem(outcome.workItem),
7860
+ workflow: publicWorkflow(outcome.workflow),
7861
+ loop: publicLoop(outcome.loop),
7862
+ throttle: outcome.throttle,
7863
+ sandboxPreflight,
7864
+ preflight
7865
+ },
7866
+ human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
7867
+ };
7868
+ } finally {
7869
+ store.close();
7870
+ }
7871
+ }
7872
+ function taskField(task, keys) {
7873
+ for (const key of keys) {
7874
+ const value = stringField(task[key]);
7875
+ if (value)
7876
+ return value;
7877
+ }
7878
+ return;
7879
+ }
7880
+ function taskListId(task) {
7881
+ return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
7882
+ }
6886
7883
  function taskProjectId(task) {
6887
7884
  return taskField(task, ["project_id", "projectId"]);
6888
7885
  }
@@ -7119,9 +8116,155 @@ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <na
7119
8116
  });
7120
8117
  var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
7121
8118
  var templates = program.command("templates").alias("template").description("render and store reusable loop/workflow templates");
8119
+ var routes = program.command("routes").alias("route").description("inspect workflow invocation/admission routes");
7122
8120
  var events = program.command("events").description("handle Hasna event envelopes from stdin or command transport");
7123
8121
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
7124
8122
  var goal = program.command("goal").description("inspect goal runs");
8123
+ function addRouteEventOptions(command) {
8124
+ 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");
8125
+ }
8126
+ function addTodosDrainOptions(command) {
8127
+ 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");
8128
+ }
8129
+ function routeEventByKind(kind, event, opts) {
8130
+ if (kind === "todos-task")
8131
+ return routeTodosTaskEvent(event, opts);
8132
+ if (kind === "generic")
8133
+ return routeGenericEvent(event, opts);
8134
+ throw new Error("route kind must be todos-task or generic");
8135
+ }
8136
+ function routeDrainArgs(opts) {
8137
+ const args = ["events", "drain", "todos-task"];
8138
+ const add = (flag, value) => {
8139
+ if (value !== undefined && value !== false && value !== "")
8140
+ args.push(flag, String(value));
8141
+ };
8142
+ const addBool = (flag, value) => {
8143
+ if (value === true)
8144
+ args.push(flag);
8145
+ };
8146
+ add("--todos-project", opts.todosProject);
8147
+ add("--todos-project-id", opts.todosProjectId);
8148
+ add("--task-list", opts.taskList);
8149
+ add("--project-path-prefix", opts.projectPathPrefix);
8150
+ add("--tags", opts.tags ?? opts.tag);
8151
+ add("--limit", opts.limit);
8152
+ add("--scan-limit", opts.scanLimit);
8153
+ add("--max-dispatch", opts.maxDispatch);
8154
+ add("--evidence-dir", opts.evidenceDir);
8155
+ addBool("--compact", opts.compact);
8156
+ add("--provider", opts.provider);
8157
+ add("--auth-profile", opts.authProfile);
8158
+ add("--auth-profile-pool", opts.authProfilePool);
8159
+ add("--worker-auth-profile", opts.workerAuthProfile);
8160
+ add("--verifier-auth-profile", opts.verifierAuthProfile);
8161
+ add("--account", opts.account);
8162
+ add("--account-pool", opts.accountPool);
8163
+ add("--worker-account", opts.workerAccount);
8164
+ add("--verifier-account", opts.verifierAccount);
8165
+ add("--account-tool", opts.accountTool);
8166
+ add("--model", opts.model);
8167
+ add("--variant", opts.variant);
8168
+ add("--agent", opts.agent);
8169
+ add("--permission-mode", opts.permissionMode);
8170
+ add("--sandbox", opts.sandbox);
8171
+ addBool("--manual-break-glass", opts.manualBreakGlass);
8172
+ add("--project-path", opts.projectPath);
8173
+ add("--project-group", opts.projectGroup);
8174
+ add("--max-active", opts.maxActive);
8175
+ add("--max-active-per-project", opts.maxActivePerProject);
8176
+ add("--max-active-per-project-group", opts.maxActivePerProjectGroup);
8177
+ add("--worktree-mode", opts.worktreeMode);
8178
+ add("--worktree-root", opts.worktreeRoot);
8179
+ add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
8180
+ add("--name-prefix", opts.namePrefix);
8181
+ addBool("--preflight", opts.preflight);
8182
+ return args;
8183
+ }
8184
+ function drainTodosTaskRoutes(opts) {
8185
+ const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
8186
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
8187
+ const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
8188
+ const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
8189
+ const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
8190
+ const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
8191
+ const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
8192
+ const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
8193
+ const ready = loadReadyTodosTasks(opts, scanLimit);
8194
+ const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
8195
+ projectId: opts.todosProjectId,
8196
+ taskListId: taskListFilter,
8197
+ projectPathPrefix: opts.projectPathPrefix,
8198
+ tags: requiredTags
8199
+ }));
8200
+ const candidates = filteredCandidates.slice(0, candidateLimit);
8201
+ const results = [];
8202
+ let created = 0;
8203
+ for (const task of candidates) {
8204
+ if (created >= maxDispatch)
8205
+ break;
8206
+ const event = taskDrainEvent(task);
8207
+ const result = routeTodosTaskEvent(event, opts);
8208
+ results.push(result);
8209
+ if (result.kind === "created" && !opts.dryRun)
8210
+ created += 1;
8211
+ if (result.kind === "created" && opts.dryRun)
8212
+ created += 1;
8213
+ }
8214
+ const report = {
8215
+ drainedAt: new Date().toISOString(),
8216
+ todosProject,
8217
+ todosProjectId: opts.todosProjectId,
8218
+ taskList: opts.taskList,
8219
+ taskListId: taskListFilter,
8220
+ projectPathPrefix: opts.projectPathPrefix,
8221
+ tags: requiredTags,
8222
+ limit: candidateLimit,
8223
+ scanLimit,
8224
+ filtersApplied: hasPostFilters,
8225
+ scanned: ready.length,
8226
+ candidates: candidates.length,
8227
+ filteredCandidates: filteredCandidates.length,
8228
+ scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
8229
+ considered: results.length,
8230
+ created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
8231
+ deduped: results.filter((result) => result.kind === "deduped").length,
8232
+ throttled: results.filter((result) => result.kind === "throttled").length,
8233
+ skipped: results.filter((result) => result.kind === "skipped").length,
8234
+ maxDispatch,
8235
+ source: "todos ready",
8236
+ dryRun: Boolean(opts.dryRun),
8237
+ results: results.map((result) => ({ kind: result.kind, ...result.value }))
8238
+ };
8239
+ const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
8240
+ const output = opts.compact ? {
8241
+ drainedAt: report.drainedAt,
8242
+ todosProject: report.todosProject,
8243
+ todosProjectId: report.todosProjectId,
8244
+ taskList: report.taskList,
8245
+ taskListId: report.taskListId,
8246
+ projectPathPrefix: report.projectPathPrefix,
8247
+ tags: report.tags,
8248
+ limit: report.limit,
8249
+ scanLimit: report.scanLimit,
8250
+ filtersApplied: report.filtersApplied,
8251
+ scanned: report.scanned,
8252
+ candidates: report.candidates,
8253
+ filteredCandidates: report.filteredCandidates,
8254
+ scanExhausted: report.scanExhausted,
8255
+ considered: report.considered,
8256
+ created: report.created,
8257
+ deduped: report.deduped,
8258
+ throttled: report.throttled,
8259
+ skipped: report.skipped,
8260
+ maxDispatch: report.maxDispatch,
8261
+ source: report.source,
8262
+ dryRun: report.dryRun,
8263
+ evidencePath,
8264
+ results: results.map(compactDrainResult)
8265
+ } : { ...report, evidencePath };
8266
+ print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8267
+ }
7125
8268
  templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
7126
8269
  const values = listLoopTemplates();
7127
8270
  if (isJson())
@@ -7152,14 +8295,102 @@ templates.command("create-workflow <id>").description("render and store a templa
7152
8295
  store.close();
7153
8296
  }
7154
8297
  });
8298
+ 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) => {
8299
+ const store = new Store;
8300
+ try {
8301
+ const items = store.listWorkflowWorkItems({
8302
+ status: opts.status,
8303
+ routeKey: opts.routeKey,
8304
+ limit: positiveInteger(opts.limit, "--limit") ?? 50
8305
+ });
8306
+ if (isJson())
8307
+ print(items.map(publicWorkflowWorkItem));
8308
+ else {
8309
+ for (const item of items) {
8310
+ console.log(`${item.id} ${item.status.padEnd(10)} ${item.routeKey} ${item.subjectRef} ${item.loopId ?? "-"}`);
8311
+ }
8312
+ }
8313
+ } finally {
8314
+ store.close();
8315
+ }
8316
+ });
8317
+ routes.command("show <id>").description("show one admission work item").action((id) => {
8318
+ const store = new Store;
8319
+ try {
8320
+ const item = store.getWorkflowWorkItem(id);
8321
+ if (!item)
8322
+ throw new Error(`route work item not found: ${id}`);
8323
+ const invocation = store.getWorkflowInvocation(item.invocationId);
8324
+ const workflow = item.workflowId ? store.getWorkflow(item.workflowId) : undefined;
8325
+ const loop = item.loopId ? store.getLoop(item.loopId) : undefined;
8326
+ print({
8327
+ item: publicWorkflowWorkItem(item),
8328
+ invocation: invocation ? publicWorkflowInvocation(invocation) : undefined,
8329
+ workflow: workflow ? publicWorkflow(workflow) : undefined,
8330
+ loop: loop ? publicLoop(loop) : undefined
8331
+ }, `${item.id} ${item.status} ${item.routeKey} ${item.subjectRef}`);
8332
+ } finally {
8333
+ store.close();
8334
+ }
8335
+ });
8336
+ routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
8337
+ const store = new Store;
8338
+ try {
8339
+ const invocations = store.listWorkflowInvocations({ limit: positiveInteger(opts.limit, "--limit") ?? 50 });
8340
+ if (isJson())
8341
+ print(invocations.map(publicWorkflowInvocation));
8342
+ else {
8343
+ for (const invocation of invocations) {
8344
+ console.log(`${invocation.id} ${invocation.intent.padEnd(8)} ${invocation.sourceRef.kind}:${invocation.sourceRef.id ?? "-"} -> ${invocation.subjectRef.kind}:${invocation.subjectRef.id ?? invocation.subjectRef.path ?? "-"}`);
8345
+ }
8346
+ }
8347
+ } finally {
8348
+ store.close();
8349
+ }
8350
+ });
8351
+ addRouteEventOptions(routes.command("preview <kind>").description("preview a route-created workflow invocation without storing it")).action(async (kind, opts) => {
8352
+ const event = await readEventEnvelopeInput(opts);
8353
+ const result = routeEventByKind(kind, event, { ...opts, dryRun: true });
8354
+ print(result.value, result.human);
8355
+ });
8356
+ addRouteEventOptions(routes.command("create <kind>").description("create a route workflow invocation and admit it when capacity allows")).action(async (kind, opts) => {
8357
+ const event = await readEventEnvelopeInput(opts);
8358
+ const result = routeEventByKind(kind, event, { ...opts, dryRun: false });
8359
+ print(result.value, result.human);
8360
+ });
8361
+ addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable source queue into bounded route workflow loops")).action((kind, opts) => {
8362
+ if (kind !== "todos-task")
8363
+ throw new Error("route drain currently supports kind todos-task");
8364
+ drainTodosTaskRoutes(opts);
8365
+ });
8366
+ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
8367
+ if (kind !== "todos-task")
8368
+ throw new Error("route schedule currently supports kind todos-task");
8369
+ const store = new Store;
8370
+ try {
8371
+ const target = {
8372
+ type: "command",
8373
+ command: "loops",
8374
+ args: ["--json", ...routeDrainArgs({ ...opts, compact: opts.compact ?? true })],
8375
+ timeoutMs: parseDuration("20m"),
8376
+ preflight: runtimePreflightFromOpts(opts)
8377
+ };
8378
+ const input = baseCreateInput(name, opts, target);
8379
+ const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "route-drain", kind }, { loopName: name }, { machine: input.machine }) : undefined;
8380
+ const loop = store.createLoop(input);
8381
+ printCreatedLoop(loop, `created route drain loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
8382
+ } finally {
8383
+ store.close();
8384
+ }
8385
+ });
7155
8386
  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) => {
8387
+ 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
8388
  const event = await readEventEnvelopeFromStdin();
7158
8389
  const result = routeTodosTaskEvent(event, opts);
7159
8390
  print(result.value, result.human);
7160
8391
  });
7161
8392
  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) => {
8393
+ 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
8394
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
7164
8395
  const todosProject = opts.todosProject ?? defaultLoopsProject();
7165
8396
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
@@ -7243,7 +8474,7 @@ eventsDrain.command("todos-task").description("drain ready todos tasks into boun
7243
8474
  } : { ...report, evidencePath };
7244
8475
  print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
7245
8476
  });
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) => {
8477
+ 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
8478
  const event = await readEventEnvelopeFromStdin();
7248
8479
  const data = eventData(event);
7249
8480
  const metadata = eventMetadata(event);
@@ -7256,19 +8487,7 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7256
8487
  const type = slugSegment2(event.type, "type");
7257
8488
  const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
7258
8489
  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
- }
8490
+ const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
7272
8491
  const provider = opts.provider;
7273
8492
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
7274
8493
  throw new Error("unsupported provider");
@@ -7299,17 +8518,60 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7299
8518
  agent: opts.agent,
7300
8519
  permissionMode,
7301
8520
  sandbox,
8521
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
7302
8522
  worktreeMode: opts.worktreeMode,
7303
8523
  worktreeRoot: opts.worktreeRoot,
7304
8524
  worktreeBranchPrefix: opts.worktreeBranchPrefix
7305
8525
  });
7306
8526
  workflowBody.name = workflowName;
7307
8527
  workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
8528
+ const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
8529
+ const invocationInput = {
8530
+ templateId: "event-worker-verifier",
8531
+ sourceRef: {
8532
+ kind: "event",
8533
+ id: event.id,
8534
+ dedupeKey: idempotencyKey,
8535
+ raw: { source: event.source, type: event.type }
8536
+ },
8537
+ subjectRef: {
8538
+ kind: "event",
8539
+ id: stringField(event.subject) ?? event.id,
8540
+ path: routeProjectPath,
8541
+ raw: { message: stringField(event.message) }
8542
+ },
8543
+ intent: "route",
8544
+ scope: {
8545
+ projectPath: routeProjectPath,
8546
+ projectGroup,
8547
+ worktreePolicy: opts.worktreeMode ?? "auto",
8548
+ permissions: permissionMode,
8549
+ manualBreakGlass: Boolean(opts.manualBreakGlass),
8550
+ accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
8551
+ concurrencyGroup: projectGroup ?? routeProjectPath
8552
+ },
8553
+ outputPolicy: {
8554
+ report: "always",
8555
+ createTask: "on_failure"
8556
+ }
8557
+ };
8558
+ const workItemInput = {
8559
+ routeKey: "generic-event",
8560
+ idempotencyKey,
8561
+ invocationId: "<created-invocation-id>",
8562
+ sourceType: event.type,
8563
+ sourceRef: event.id,
8564
+ subjectRef: stringField(event.subject) ?? event.id,
8565
+ projectKey: routeProjectPath,
8566
+ projectGroup,
8567
+ priority: 0,
8568
+ status: "queued"
8569
+ };
7308
8570
  const loopInput = {
7309
8571
  name: loopName,
7310
- description: `Run ${workflowBody.name} once for event ${event.id}`,
8572
+ description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
7311
8573
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
7312
- target: { type: "workflow", workflowId: "<created-workflow-id>" },
8574
+ target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
7313
8575
  overlap: "skip",
7314
8576
  maxAttempts: 1,
7315
8577
  retryDelayMs: 60000,
@@ -7322,44 +8584,92 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
7322
8584
  type: "generic-event-workflow",
7323
8585
  event: event.id
7324
8586
  }, {}) : undefined;
7325
- print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
8587
+ print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
7326
8588
  return;
7327
8589
  }
7328
8590
  const store = new Store;
7329
8591
  try {
7330
8592
  const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
7331
8593
  const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
8594
+ generatedRouteSandboxPreflight(workflowPreflightSpec);
7332
8595
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
7333
8596
  name: workflowBody.name,
7334
8597
  type: "generic-event-workflow",
7335
8598
  event: event.id
7336
8599
  }, {}) : undefined;
7337
8600
  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 };
8601
+ const invocation = store.createWorkflowInvocation(invocationInput);
8602
+ const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
8603
+ if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8604
+ const existingLoop = store.getLoop(existingItem.loopId);
8605
+ const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8606
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
7342
8607
  }
7343
8608
  const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8609
+ const workItem = store.upsertWorkflowWorkItem({
8610
+ ...workItemInput,
8611
+ invocationId: invocation.id,
8612
+ status: throttle && !throttle.allowed ? "deferred" : "queued",
8613
+ lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8614
+ });
7344
8615
  if (throttle && !throttle.allowed)
7345
- return { kind: "throttled", throttle };
8616
+ return { kind: "throttled", invocation, workItem, throttle };
7346
8617
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
7347
8618
  const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
7348
8619
  const loop = store.createLoop({
7349
8620
  ...loopInput,
7350
- target: { type: "workflow", workflowId: workflow.id }
8621
+ target: {
8622
+ type: "workflow",
8623
+ workflowId: workflow.id,
8624
+ input: {
8625
+ workflowInvocationId: invocation.id,
8626
+ workflowWorkItemId: workItem.id
8627
+ }
8628
+ }
7351
8629
  });
7352
- return { kind: "created", workflow, loop, throttle };
8630
+ const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
8631
+ return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
7353
8632
  });
7354
8633
  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})`);
8634
+ print({
8635
+ deduped: true,
8636
+ idempotencyKey,
8637
+ dedupedBy: "work-item",
8638
+ event,
8639
+ invocation: publicWorkflowInvocation(outcome.invocation),
8640
+ workItem: publicWorkflowWorkItem(outcome.existingItem),
8641
+ workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
8642
+ loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
8643
+ }, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
7356
8644
  return;
7357
8645
  }
7358
8646
  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}`);
8647
+ print({
8648
+ skipped: true,
8649
+ queuedAtSource: true,
8650
+ reason: outcome.throttle.reason,
8651
+ idempotencyKey,
8652
+ event,
8653
+ invocation: publicWorkflowInvocation(outcome.invocation),
8654
+ workItem: publicWorkflowWorkItem(outcome.workItem),
8655
+ throttle: outcome.throttle,
8656
+ workflow: workflowBody,
8657
+ loop: loopInput
8658
+ }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
7360
8659
  return;
7361
8660
  }
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}`);
8661
+ print({
8662
+ deduped: false,
8663
+ idempotencyKey,
8664
+ event,
8665
+ invocation: publicWorkflowInvocation(outcome.invocation),
8666
+ workItem: publicWorkflowWorkItem(outcome.workItem),
8667
+ workflow: publicWorkflow(outcome.workflow),
8668
+ loop: publicLoop(outcome.loop),
8669
+ throttle: outcome.throttle,
8670
+ sandboxPreflight,
8671
+ preflight
8672
+ }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
7363
8673
  } finally {
7364
8674
  store.close();
7365
8675
  }