@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/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // @bun
2
2
  // src/lib/store.ts
3
3
  import { Database } from "bun:sqlite";
4
- import { mkdirSync as mkdirSync2 } from "fs";
5
- import { dirname } from "path";
4
+ import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
5
+ import { tmpdir } from "os";
6
+ import { dirname, join as join3 } from "path";
6
7
 
7
8
  // src/lib/ids.ts
8
9
  import { randomBytes } from "crypto";
@@ -363,6 +364,8 @@ function validateTarget(value, label) {
363
364
  assertObject(value, label);
364
365
  if (value.type === "command") {
365
366
  assertString(value.command, `${label}.command`);
367
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
368
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
366
369
  if (value.shell !== true && /\s/.test(value.command.trim())) {
367
370
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
368
371
  }
@@ -374,6 +377,8 @@ function validateTarget(value, label) {
374
377
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
375
378
  if (!providers.includes(value.provider))
376
379
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
380
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
381
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
377
382
  if (value.authProfile !== undefined) {
378
383
  assertString(value.authProfile, `${label}.authProfile`);
379
384
  if (value.provider !== "codewith")
@@ -528,6 +533,52 @@ function workflowBodyFromJson(value, fallbackName) {
528
533
  });
529
534
  }
530
535
 
536
+ // src/lib/run-artifacts.ts
537
+ import { createHash } from "crypto";
538
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
539
+ import { basename, join as join2 } from "path";
540
+ function shortHash(value) {
541
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
542
+ }
543
+ function safeRunPathSlug(value, fallback) {
544
+ const raw = value?.trim() || fallback;
545
+ const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
546
+ return slug || fallback;
547
+ }
548
+ function workflowRunSubjectKey(kind, rawSubjectRef) {
549
+ const raw = rawSubjectRef?.trim() || "subject";
550
+ const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
551
+ const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
552
+ return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
553
+ ${raw}`)}`;
554
+ }
555
+ function workflowRunProjectSlug(projectKey) {
556
+ if (!projectKey?.trim())
557
+ return "global";
558
+ return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
559
+ }
560
+ function writeWorkflowRunManifest(args) {
561
+ const projectSlug = workflowRunProjectSlug(args.projectKey);
562
+ const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
563
+ const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
564
+ mkdirSync2(dir, { recursive: true, mode: 448 });
565
+ const manifestPath = join2(dir, "manifest.json");
566
+ writeFileSync(manifestPath, JSON.stringify({
567
+ version: 1,
568
+ workflowRunId: args.workflowRunId,
569
+ workflowId: args.workflowId,
570
+ workflowName: args.workflowName,
571
+ invocationId: args.invocationId,
572
+ workItemId: args.workItemId,
573
+ projectSlug,
574
+ subjectKey,
575
+ requiredReading: [],
576
+ createdAt: new Date().toISOString(),
577
+ ...args.payload
578
+ }, null, 2), { mode: 384 });
579
+ return manifestPath;
580
+ }
581
+
531
582
  // src/lib/store.ts
532
583
  function rowToLoop(row) {
533
584
  return {
@@ -597,8 +648,11 @@ function rowToWorkflowRun(row) {
597
648
  workflowName: row.workflow_name,
598
649
  loopId: row.loop_id ?? undefined,
599
650
  loopRunId: row.loop_run_id ?? undefined,
651
+ invocationId: row.invocation_id ?? undefined,
652
+ workItemId: row.work_item_id ?? undefined,
600
653
  scheduledFor: row.scheduled_for ?? undefined,
601
654
  idempotencyKey: row.idempotency_key ?? undefined,
655
+ manifestPath: row.manifest_path ?? undefined,
602
656
  status: row.status,
603
657
  startedAt: row.started_at ?? undefined,
604
658
  finishedAt: row.finished_at ?? undefined,
@@ -609,6 +663,44 @@ function rowToWorkflowRun(row) {
609
663
  updatedAt: row.updated_at
610
664
  };
611
665
  }
666
+ function rowToWorkflowInvocation(row) {
667
+ return {
668
+ id: row.id,
669
+ workflowId: row.workflow_id ?? undefined,
670
+ templateId: row.template_id ?? undefined,
671
+ sourceRef: JSON.parse(row.source_json),
672
+ subjectRef: JSON.parse(row.subject_json),
673
+ intent: row.intent,
674
+ scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
675
+ outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
676
+ createdAt: row.created_at,
677
+ updatedAt: row.updated_at
678
+ };
679
+ }
680
+ function rowToWorkflowWorkItem(row) {
681
+ return {
682
+ id: row.id,
683
+ routeKey: row.route_key,
684
+ idempotencyKey: row.idempotency_key,
685
+ invocationId: row.invocation_id,
686
+ sourceType: row.source_type,
687
+ sourceRef: row.source_ref,
688
+ subjectRef: row.subject_ref,
689
+ projectKey: row.project_key ?? undefined,
690
+ projectGroup: row.project_group ?? undefined,
691
+ priority: row.priority,
692
+ status: row.status,
693
+ attempts: row.attempts,
694
+ nextAttemptAt: row.next_attempt_at ?? undefined,
695
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
696
+ workflowId: row.workflow_id ?? undefined,
697
+ loopId: row.loop_id ?? undefined,
698
+ workflowRunId: row.workflow_run_id ?? undefined,
699
+ lastReason: row.last_reason ?? undefined,
700
+ createdAt: row.created_at,
701
+ updatedAt: row.updated_at
702
+ };
703
+ }
612
704
  function rowToWorkflowStepRun(row) {
613
705
  return {
614
706
  id: row.id,
@@ -722,13 +814,23 @@ function rowToLease(row) {
722
814
  updatedAt: row.updated_at
723
815
  };
724
816
  }
817
+ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
818
+ if (status === "succeeded")
819
+ return "succeeded";
820
+ if (["failed", "timed_out", "abandoned"].includes(status)) {
821
+ return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
822
+ }
823
+ return;
824
+ }
725
825
 
726
826
  class Store {
727
827
  db;
828
+ rootDir;
728
829
  constructor(path) {
729
830
  const file = path ?? dbPath();
730
831
  if (file !== ":memory:")
731
- mkdirSync2(dirname(file), { recursive: true, mode: 448 });
832
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
833
+ this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
732
834
  this.db = new Database(file);
733
835
  this.db.exec("PRAGMA busy_timeout = 5000;");
734
836
  this.db.exec("PRAGMA journal_mode = WAL;");
@@ -823,8 +925,11 @@ class Store {
823
925
  workflow_name TEXT NOT NULL,
824
926
  loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
825
927
  loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
928
+ invocation_id TEXT,
929
+ work_item_id TEXT,
826
930
  scheduled_for TEXT,
827
931
  idempotency_key TEXT,
932
+ manifest_path TEXT,
828
933
  status TEXT NOT NULL,
829
934
  started_at TEXT,
830
935
  finished_at TEXT,
@@ -839,8 +944,63 @@ class Store {
839
944
  WHERE idempotency_key IS NOT NULL;
840
945
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
841
946
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
947
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
948
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
842
949
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
843
950
 
951
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
952
+ id TEXT PRIMARY KEY,
953
+ workflow_id TEXT,
954
+ template_id TEXT,
955
+ source_kind TEXT NOT NULL,
956
+ source_id TEXT,
957
+ source_dedupe_key TEXT,
958
+ source_json TEXT NOT NULL,
959
+ subject_kind TEXT NOT NULL,
960
+ subject_id TEXT,
961
+ subject_path TEXT,
962
+ subject_url TEXT,
963
+ subject_json TEXT NOT NULL,
964
+ intent TEXT NOT NULL,
965
+ scope_json TEXT,
966
+ output_policy_json TEXT,
967
+ created_at TEXT NOT NULL,
968
+ updated_at TEXT NOT NULL
969
+ );
970
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
971
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
972
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
973
+ ON workflow_invocations(source_kind, source_dedupe_key)
974
+ WHERE source_dedupe_key IS NOT NULL;
975
+
976
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
977
+ id TEXT PRIMARY KEY,
978
+ route_key TEXT NOT NULL,
979
+ idempotency_key TEXT NOT NULL,
980
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
981
+ source_type TEXT NOT NULL,
982
+ source_ref TEXT NOT NULL,
983
+ subject_ref TEXT NOT NULL,
984
+ project_key TEXT,
985
+ project_group TEXT,
986
+ priority INTEGER NOT NULL,
987
+ status TEXT NOT NULL,
988
+ attempts INTEGER NOT NULL,
989
+ next_attempt_at TEXT,
990
+ lease_expires_at TEXT,
991
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
992
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
993
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
994
+ last_reason TEXT,
995
+ created_at TEXT NOT NULL,
996
+ updated_at TEXT NOT NULL,
997
+ UNIQUE(route_key, idempotency_key)
998
+ );
999
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
1000
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1001
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1002
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1003
+
844
1004
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
845
1005
  id TEXT PRIMARY KEY,
846
1006
  workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
@@ -953,12 +1113,16 @@ class Store {
953
1113
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
954
1114
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
955
1115
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1116
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1117
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1118
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
956
1119
  this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
957
1120
  this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
958
1121
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
959
1122
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
960
1123
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
961
1124
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
1125
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
962
1126
  }
963
1127
  addColumnIfMissing(table, column, definition) {
964
1128
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -1081,6 +1245,10 @@ class Store {
1081
1245
  $daemonLeaseId: opts.daemonLeaseId ?? null,
1082
1246
  $now: updated
1083
1247
  });
1248
+ if (patch.status && patch.status !== "active") {
1249
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1250
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1251
+ }
1084
1252
  const after = this.getLoop(id);
1085
1253
  if (!after)
1086
1254
  throw new Error(`loop not found after update: ${id}`);
@@ -1125,6 +1293,7 @@ class Store {
1125
1293
  $archivedFromStatus: loop.status,
1126
1294
  $updated: updated
1127
1295
  });
1296
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
1128
1297
  const archived = this.getLoop(loop.id);
1129
1298
  if (!archived)
1130
1299
  throw new Error(`loop not found after archive: ${loop.id}`);
@@ -1150,6 +1319,7 @@ class Store {
1150
1319
  }
1151
1320
  deleteLoop(idOrName) {
1152
1321
  const loop = this.requireLoop(idOrName);
1322
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
1153
1323
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
1154
1324
  return res.changes > 0;
1155
1325
  }
@@ -1208,6 +1378,185 @@ class Store {
1208
1378
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1209
1379
  return archived;
1210
1380
  }
1381
+ createWorkflowInvocation(input) {
1382
+ const now = nowIso();
1383
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1384
+ if (sourceDedupeKey) {
1385
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
1386
+ if (existing)
1387
+ return rowToWorkflowInvocation(existing);
1388
+ }
1389
+ const id = input.id ?? genId();
1390
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
1391
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
1392
+ output_policy_json, created_at, updated_at)
1393
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
1394
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
1395
+ $outputPolicyJson, $created, $updated)`).run({
1396
+ $id: id,
1397
+ $workflowId: input.workflowId ?? null,
1398
+ $templateId: input.templateId ?? null,
1399
+ $sourceKind: input.sourceRef.kind,
1400
+ $sourceId: input.sourceRef.id ?? null,
1401
+ $sourceDedupeKey: sourceDedupeKey ?? null,
1402
+ $sourceJson: JSON.stringify(input.sourceRef),
1403
+ $subjectKind: input.subjectRef.kind,
1404
+ $subjectId: input.subjectRef.id ?? null,
1405
+ $subjectPath: input.subjectRef.path ?? null,
1406
+ $subjectUrl: input.subjectRef.url ?? null,
1407
+ $subjectJson: JSON.stringify(input.subjectRef),
1408
+ $intent: input.intent,
1409
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1410
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1411
+ $created: now,
1412
+ $updated: now
1413
+ });
1414
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1415
+ if (!row)
1416
+ throw new Error(`workflow invocation not found after create: ${id}`);
1417
+ return rowToWorkflowInvocation(row);
1418
+ }
1419
+ getWorkflowInvocation(id) {
1420
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1421
+ return row ? rowToWorkflowInvocation(row) : undefined;
1422
+ }
1423
+ listWorkflowInvocations(opts = {}) {
1424
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
1425
+ return rows.map(rowToWorkflowInvocation);
1426
+ }
1427
+ upsertWorkflowWorkItem(input) {
1428
+ const now = nowIso();
1429
+ const id = genId();
1430
+ const status = input.status ?? "queued";
1431
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
1432
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
1433
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
1434
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
1435
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
1436
+ $lastReason, $created, $updated)
1437
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
1438
+ invocation_id=excluded.invocation_id,
1439
+ source_type=excluded.source_type,
1440
+ source_ref=excluded.source_ref,
1441
+ subject_ref=excluded.subject_ref,
1442
+ project_key=excluded.project_key,
1443
+ project_group=excluded.project_group,
1444
+ priority=excluded.priority,
1445
+ status=CASE
1446
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1447
+ THEN workflow_work_items.status
1448
+ ELSE excluded.status
1449
+ END,
1450
+ workflow_id=CASE
1451
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1452
+ ELSE NULL
1453
+ END,
1454
+ loop_id=CASE
1455
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1456
+ ELSE NULL
1457
+ END,
1458
+ workflow_run_id=CASE
1459
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1460
+ ELSE NULL
1461
+ END,
1462
+ lease_expires_at=CASE
1463
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1464
+ ELSE NULL
1465
+ END,
1466
+ next_attempt_at=excluded.next_attempt_at,
1467
+ last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1468
+ updated_at=excluded.updated_at`).run({
1469
+ $id: id,
1470
+ $routeKey: input.routeKey,
1471
+ $idempotencyKey: input.idempotencyKey,
1472
+ $invocationId: input.invocationId,
1473
+ $sourceType: input.sourceType,
1474
+ $sourceRef: input.sourceRef,
1475
+ $subjectRef: input.subjectRef,
1476
+ $projectKey: input.projectKey ?? null,
1477
+ $projectGroup: input.projectGroup ?? null,
1478
+ $priority: input.priority ?? 0,
1479
+ $status: status,
1480
+ $nextAttemptAt: input.nextAttemptAt ?? null,
1481
+ $lastReason: input.lastReason ?? null,
1482
+ $created: now,
1483
+ $updated: now
1484
+ });
1485
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
1486
+ if (!row)
1487
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
1488
+ return rowToWorkflowWorkItem(row);
1489
+ }
1490
+ getWorkflowWorkItem(id) {
1491
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
1492
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1493
+ }
1494
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
1495
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
1496
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1497
+ }
1498
+ listWorkflowWorkItems(opts = {}) {
1499
+ const limit = opts.limit ?? 100;
1500
+ let rows;
1501
+ if (opts.status && opts.routeKey) {
1502
+ 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);
1503
+ } else if (opts.status) {
1504
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
1505
+ } else if (opts.routeKey) {
1506
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
1507
+ } else {
1508
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
1509
+ }
1510
+ return rows.map(rowToWorkflowWorkItem);
1511
+ }
1512
+ countActiveWorkflowWorkItems(args = {}) {
1513
+ const active = ["admitted", "running"];
1514
+ const placeholders = active.map(() => "?").join(",");
1515
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
1516
+ 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;
1517
+ 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;
1518
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1519
+ }
1520
+ admitWorkflowWorkItem(id, patch) {
1521
+ const now = nowIso();
1522
+ const res = this.db.query(`UPDATE workflow_work_items
1523
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1524
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1525
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1526
+ $id: id,
1527
+ $workflowId: patch.workflowId,
1528
+ $loopId: patch.loopId,
1529
+ $reason: patch.reason ?? null,
1530
+ $updated: now
1531
+ });
1532
+ const item = this.getWorkflowWorkItem(id);
1533
+ if (!item)
1534
+ throw new Error(`workflow work item not found after admit: ${id}`);
1535
+ if (res.changes !== 1)
1536
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
1537
+ return item;
1538
+ }
1539
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
1540
+ const placeholders = statuses.map(() => "?").join(",");
1541
+ this.db.query(`UPDATE workflow_work_items
1542
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1543
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
1544
+ }
1545
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
1546
+ const placeholders = statuses.map(() => "?").join(",");
1547
+ this.db.query(`UPDATE workflow_work_items
1548
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1549
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
1550
+ }
1551
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
1552
+ const loop = this.getLoop(run.loopId);
1553
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
1554
+ if (!status)
1555
+ return;
1556
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
1557
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
1558
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
1559
+ }
1211
1560
  createGoal(input, opts = {}) {
1212
1561
  const now = nowIso();
1213
1562
  this.db.exec("BEGIN IMMEDIATE");
@@ -1481,6 +1830,10 @@ class Store {
1481
1830
  }
1482
1831
  createWorkflowRun(input) {
1483
1832
  const now = nowIso();
1833
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
1834
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
1835
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
1836
+ let manifestPath;
1484
1837
  if (input.idempotencyKey) {
1485
1838
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
1486
1839
  if (existing) {
@@ -1499,21 +1852,59 @@ class Store {
1499
1852
  }
1500
1853
  }
1501
1854
  const runId = genId();
1502
- this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
1503
- status, started_at, finished_at, duration_ms, error, created_at, updated_at)
1504
- VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
1505
- 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1855
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
1856
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
1857
+ manifestPath = invocation || workItem ? writeWorkflowRunManifest({
1858
+ loopsDataDir: this.rootDir,
1859
+ workflowRunId: runId,
1860
+ workflowId: input.workflow.id,
1861
+ workflowName: input.workflow.name,
1862
+ invocationId,
1863
+ workItemId,
1864
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
1865
+ subjectKind: invocation?.subjectRef.kind,
1866
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
1867
+ payload: {
1868
+ workflowInvocation: invocation,
1869
+ workflowWorkItem: workItem,
1870
+ loopId: input.loop?.id,
1871
+ loopRunId: input.loopRun?.id,
1872
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
1873
+ }
1874
+ }) : undefined;
1875
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
1876
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
1877
+ created_at, updated_at)
1878
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
1879
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1506
1880
  $id: runId,
1507
1881
  $workflowId: input.workflow.id,
1508
1882
  $workflowName: input.workflow.name,
1509
1883
  $loopId: input.loop?.id ?? null,
1510
1884
  $loopRunId: input.loopRun?.id ?? null,
1885
+ $invocationId: invocationId ?? null,
1886
+ $workItemId: workItemId ?? null,
1511
1887
  $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
1512
1888
  $idempotencyKey: input.idempotencyKey ?? null,
1889
+ $manifestPath: manifestPath ?? null,
1513
1890
  $started: now,
1514
1891
  $created: now,
1515
1892
  $updated: now
1516
1893
  });
1894
+ if (workItemId) {
1895
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
1896
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
1897
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
1898
+ $id: workItemId,
1899
+ $workflowRunId: runId,
1900
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
1901
+ $updated: now
1902
+ });
1903
+ if (workItemRes.changes !== 1) {
1904
+ const current = this.getWorkflowWorkItem(workItemId);
1905
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
1906
+ }
1907
+ }
1517
1908
  input.workflow.steps.forEach((step, sequence) => {
1518
1909
  const account = step.account ?? step.target.account;
1519
1910
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
@@ -1539,7 +1930,10 @@ class Store {
1539
1930
  workflowName: input.workflow.name,
1540
1931
  stepCount: input.workflow.steps.length,
1541
1932
  loopId: input.loop?.id,
1542
- loopRunId: input.loopRun?.id
1933
+ loopRunId: input.loopRun?.id,
1934
+ invocationId,
1935
+ workItemId,
1936
+ manifestPath
1543
1937
  }),
1544
1938
  $created: now
1545
1939
  });
@@ -1552,6 +1946,8 @@ class Store {
1552
1946
  try {
1553
1947
  this.db.exec("ROLLBACK");
1554
1948
  } catch {}
1949
+ if (manifestPath)
1950
+ rmSync(manifestPath, { force: true });
1555
1951
  throw error;
1556
1952
  }
1557
1953
  }
@@ -1764,6 +2160,10 @@ class Store {
1764
2160
  changed = res.changes === 1;
1765
2161
  if (changed)
1766
2162
  this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
2163
+ if (changed) {
2164
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2165
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2166
+ }
1767
2167
  this.db.exec("COMMIT");
1768
2168
  } catch (error) {
1769
2169
  try {
@@ -1788,6 +2188,7 @@ class Store {
1788
2188
  this.db.query(`UPDATE workflow_step_runs
1789
2189
  SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
1790
2190
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2191
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
1791
2192
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
1792
2193
  }
1793
2194
  this.db.exec("COMMIT");
@@ -2041,6 +2442,8 @@ class Store {
2041
2442
  throw new Error(`run not found after finalize: ${id}`);
2042
2443
  if (opts.claimedBy && res.changes !== 1)
2043
2444
  return run;
2445
+ if (res.changes === 1)
2446
+ this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2044
2447
  return run;
2045
2448
  }
2046
2449
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2134,6 +2537,14 @@ class Store {
2134
2537
  error: "parent loop run lease expired before completion",
2135
2538
  loopRunId: row.id
2136
2539
  });
2540
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2541
+ }
2542
+ const loop = this.getLoop(row.loop_id);
2543
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
2544
+ if (itemStatus) {
2545
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2546
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2547
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2137
2548
  }
2138
2549
  this.db.exec("COMMIT");
2139
2550
  } catch (error) {
@@ -2346,7 +2757,7 @@ function resolveAccountEnv(account, toolHint, env) {
2346
2757
  // src/lib/env.ts
2347
2758
  import { accessSync, constants } from "fs";
2348
2759
  import { homedir as homedir2 } from "os";
2349
- import { delimiter, join as join2 } from "path";
2760
+ import { delimiter, join as join4 } from "path";
2350
2761
  function compactPathParts(parts) {
2351
2762
  const seen = new Set;
2352
2763
  const result = [];
@@ -2362,14 +2773,14 @@ function compactPathParts(parts) {
2362
2773
  function commonExecutableDirs(env = process.env) {
2363
2774
  const home = env.HOME || homedir2();
2364
2775
  return compactPathParts([
2365
- join2(home, ".local", "bin"),
2366
- join2(home, ".bun", "bin"),
2367
- join2(home, ".cargo", "bin"),
2368
- join2(home, ".npm-global", "bin"),
2369
- join2(home, "bin"),
2370
- env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
2776
+ join4(home, ".local", "bin"),
2777
+ join4(home, ".bun", "bin"),
2778
+ join4(home, ".cargo", "bin"),
2779
+ join4(home, ".npm-global", "bin"),
2780
+ join4(home, "bin"),
2781
+ env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
2371
2782
  env.PNPM_HOME,
2372
- env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2783
+ env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2373
2784
  "/opt/homebrew/bin",
2374
2785
  "/usr/local/bin",
2375
2786
  "/usr/bin",
@@ -2393,7 +2804,7 @@ function executableExists(command, env = process.env) {
2393
2804
  if (command.includes("/"))
2394
2805
  return isExecutable(command);
2395
2806
  for (const dir of (env.PATH ?? "").split(delimiter)) {
2396
- if (dir && isExecutable(join2(dir, command)))
2807
+ if (dir && isExecutable(join4(dir, command)))
2397
2808
  return true;
2398
2809
  }
2399
2810
  return false;
@@ -2751,6 +3162,7 @@ function commandSpec(target) {
2751
3162
  shell: commandTarget.shell,
2752
3163
  env: commandTarget.env,
2753
3164
  timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3165
+ idleTimeoutMs: commandTarget.idleTimeoutMs,
2754
3166
  account: commandTarget.account,
2755
3167
  accountTool: commandTarget.account?.tool
2756
3168
  };
@@ -2761,6 +3173,7 @@ function commandSpec(target) {
2761
3173
  args: agentArgs(agentTarget),
2762
3174
  cwd: agentTarget.cwd,
2763
3175
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3176
+ idleTimeoutMs: agentTarget.idleTimeoutMs,
2764
3177
  account: agentTarget.account,
2765
3178
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2766
3179
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
@@ -2908,6 +3321,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2908
3321
  let stdout = "";
2909
3322
  let stderr = "";
2910
3323
  let timedOut = false;
3324
+ let idleTimedOut = false;
2911
3325
  let exitCode;
2912
3326
  let error;
2913
3327
  let plan;
@@ -2946,18 +3360,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2946
3360
  if (opts.signal?.aborted)
2947
3361
  abortHandler();
2948
3362
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
2949
- child.stdout?.on("data", (chunk) => {
2950
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
2951
- });
2952
- child.stderr?.on("data", (chunk) => {
2953
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
2954
- });
2955
3363
  const timer = setTimeout(() => {
2956
3364
  timedOut = true;
2957
3365
  if (child.pid)
2958
3366
  killProcessGroup(child.pid);
2959
3367
  }, spec.timeoutMs);
2960
3368
  timer.unref();
3369
+ let idleTimer;
3370
+ const resetIdleTimer = () => {
3371
+ if (!spec.idleTimeoutMs)
3372
+ return;
3373
+ if (idleTimer)
3374
+ clearTimeout(idleTimer);
3375
+ idleTimer = setTimeout(() => {
3376
+ idleTimedOut = true;
3377
+ if (child.pid)
3378
+ killProcessGroup(child.pid);
3379
+ }, spec.idleTimeoutMs);
3380
+ idleTimer.unref();
3381
+ };
3382
+ resetIdleTimer();
3383
+ child.stdout?.on("data", (chunk) => {
3384
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3385
+ resetIdleTimer();
3386
+ });
3387
+ child.stderr?.on("data", (chunk) => {
3388
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3389
+ resetIdleTimer();
3390
+ });
2961
3391
  try {
2962
3392
  const [code, signal] = await once(child, "exit");
2963
3393
  if (typeof code === "number")
@@ -2968,17 +3398,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2968
3398
  error = err instanceof Error ? err.message : String(err);
2969
3399
  } finally {
2970
3400
  clearTimeout(timer);
3401
+ if (idleTimer)
3402
+ clearTimeout(idleTimer);
2971
3403
  opts.signal?.removeEventListener("abort", abortHandler);
2972
3404
  }
2973
3405
  const finishedAt = nowIso();
2974
3406
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2975
- if (timedOut) {
3407
+ if (timedOut || idleTimedOut) {
2976
3408
  return {
2977
3409
  status: "timed_out",
2978
3410
  exitCode,
2979
3411
  stdout,
2980
3412
  stderr,
2981
- error: `timed out after ${spec.timeoutMs}ms`,
3413
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
2982
3414
  pid: child.pid,
2983
3415
  startedAt,
2984
3416
  finishedAt,
@@ -3044,6 +3476,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3044
3476
  let stdout = "";
3045
3477
  let stderr = "";
3046
3478
  let timedOut = false;
3479
+ let idleTimedOut = false;
3047
3480
  let exitCode;
3048
3481
  let error;
3049
3482
  const env = executionEnv(spec, metadata, opts);
@@ -3106,18 +3539,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3106
3539
  if (opts.signal?.aborted)
3107
3540
  abortHandler();
3108
3541
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3109
- child.stdout?.on("data", (chunk) => {
3110
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
3111
- });
3112
- child.stderr?.on("data", (chunk) => {
3113
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
3114
- });
3115
3542
  const timer = setTimeout(() => {
3116
3543
  timedOut = true;
3117
3544
  if (child.pid)
3118
3545
  killProcessGroup(child.pid);
3119
3546
  }, spec.timeoutMs);
3120
3547
  timer.unref();
3548
+ let idleTimer;
3549
+ const resetIdleTimer = () => {
3550
+ if (!spec.idleTimeoutMs)
3551
+ return;
3552
+ if (idleTimer)
3553
+ clearTimeout(idleTimer);
3554
+ idleTimer = setTimeout(() => {
3555
+ idleTimedOut = true;
3556
+ if (child.pid)
3557
+ killProcessGroup(child.pid);
3558
+ }, spec.idleTimeoutMs);
3559
+ idleTimer.unref();
3560
+ };
3561
+ resetIdleTimer();
3562
+ child.stdout?.on("data", (chunk) => {
3563
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3564
+ resetIdleTimer();
3565
+ });
3566
+ child.stderr?.on("data", (chunk) => {
3567
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3568
+ resetIdleTimer();
3569
+ });
3121
3570
  try {
3122
3571
  const [code, signal] = await once(child, "exit");
3123
3572
  if (typeof code === "number")
@@ -3128,17 +3577,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3128
3577
  error = err instanceof Error ? err.message : String(err);
3129
3578
  } finally {
3130
3579
  clearTimeout(timer);
3580
+ if (idleTimer)
3581
+ clearTimeout(idleTimer);
3131
3582
  opts.signal?.removeEventListener("abort", abortHandler);
3132
3583
  }
3133
3584
  const finishedAt = nowIso();
3134
3585
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3135
- if (timedOut) {
3586
+ if (timedOut || idleTimedOut) {
3136
3587
  return {
3137
3588
  status: "timed_out",
3138
3589
  exitCode,
3139
3590
  stdout,
3140
3591
  stderr,
3141
- error: `timed out after ${spec.timeoutMs}ms`,
3592
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3142
3593
  pid: child.pid,
3143
3594
  startedAt,
3144
3595
  finishedAt,
@@ -4270,14 +4721,60 @@ class LoopsClient {
4270
4721
  function loops(opts = {}) {
4271
4722
  return new LoopsClient(opts);
4272
4723
  }
4724
+ function openAutomationsRuntimeBinding(overrides = {}) {
4725
+ const defaults = {
4726
+ integration: "open-automations",
4727
+ role: "runtime",
4728
+ handoff: "claim-queue",
4729
+ queueOwner: "open-automations",
4730
+ runtimeOwner: "open-loops",
4731
+ statusCommand: "automations status",
4732
+ claimCommand: "automations queue claim",
4733
+ completeCommand: "automations queue complete",
4734
+ failCommand: "automations queue fail",
4735
+ eventHandoff: {
4736
+ envelopeCommand: "automations webhooks event",
4737
+ handlerCommand: "loops events handle generic",
4738
+ pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json events handle generic",
4739
+ boundary: "Use only for explicit event-envelope workflow handoff. OpenAutomations still owns deterministic automation materialization and queue state; OpenLoops owns workflow invocation."
4740
+ },
4741
+ requiredEnvironment: ["HASNA_AUTOMATIONS_DIR"],
4742
+ guarantees: [
4743
+ "OpenAutomations owns automation specs, run materialization, queue state, DLQ, replay, idempotency, and approvals.",
4744
+ "OpenLoops may execute claimed actions through explicit command or SDK handoff only.",
4745
+ "OpenLoops may consume exported event envelopes only through explicit events handle commands.",
4746
+ "Workers must complete or fail actions by action id and runner id so OpenAutomations can enforce queue leases."
4747
+ ],
4748
+ nonGoals: [
4749
+ "OpenLoops must not become the OpenAutomations product surface.",
4750
+ "OpenLoops must not store automation specs or replace the OpenAutomations queue.",
4751
+ "OpenLoops must not infer automation trigger semantics from event transport alone."
4752
+ ]
4753
+ };
4754
+ return {
4755
+ ...defaults,
4756
+ ...overrides,
4757
+ eventHandoff: overrides.eventHandoff ?? defaults.eventHandoff,
4758
+ requiredEnvironment: overrides.requiredEnvironment ?? defaults.requiredEnvironment,
4759
+ guarantees: overrides.guarantees ?? defaults.guarantees,
4760
+ nonGoals: overrides.nonGoals ?? defaults.nonGoals
4761
+ };
4762
+ }
4273
4763
  // src/lib/templates.ts
4274
4764
  import { execFileSync } from "child_process";
4275
4765
  import { existsSync as existsSync2 } from "fs";
4276
4766
  import { homedir as homedir3 } from "os";
4277
- import { basename, isAbsolute, join as join3, relative, resolve } from "path";
4767
+ import { basename as basename2, isAbsolute, join as join5, relative, resolve } from "path";
4278
4768
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4279
4769
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4280
4770
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4771
+ var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
4772
+ var PR_REVIEW_TEMPLATE_ID = "pr-review";
4773
+ var SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
4774
+ var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
4775
+ var REPORT_ONLY_TEMPLATE_ID = "report-only";
4776
+ var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
4777
+ var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
4281
4778
  var TEMPLATE_SUMMARIES = [
4282
4779
  {
4283
4780
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4299,7 +4796,8 @@ var TEMPLATE_SUMMARIES = [
4299
4796
  { name: "model", description: "Provider model." },
4300
4797
  { name: "variant", description: "Provider reasoning/model effort variant." },
4301
4798
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4302
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
4799
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4800
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
4303
4801
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
4304
4802
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
4305
4803
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
@@ -4327,7 +4825,8 @@ var TEMPLATE_SUMMARIES = [
4327
4825
  { name: "model", description: "Provider model." },
4328
4826
  { name: "variant", description: "Provider reasoning/model effort variant." },
4329
4827
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4330
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
4828
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4829
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
4331
4830
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
4332
4831
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
4333
4832
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
@@ -4353,12 +4852,112 @@ var TEMPLATE_SUMMARIES = [
4353
4852
  { name: "model", description: "Provider model." },
4354
4853
  { name: "variant", description: "Provider reasoning/model effort variant." },
4355
4854
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4356
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
4855
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4856
+ { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
4357
4857
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
4358
4858
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
4359
4859
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
4360
4860
  { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
4361
4861
  ]
4862
+ },
4863
+ {
4864
+ id: TASK_LIFECYCLE_TEMPLATE_ID,
4865
+ name: "Task Lifecycle",
4866
+ description: "Run the standard task-created lifecycle: triage/dedupe, plan, worker execution, independent verification, and todos closure/follow-up evidence.",
4867
+ kind: "workflow",
4868
+ variables: [
4869
+ { name: "taskId", required: true, description: "Todos task id." },
4870
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4871
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4872
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
4873
+ { name: "provider", default: "codewith", description: "Agent provider." },
4874
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4875
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
4876
+ ]
4877
+ },
4878
+ {
4879
+ id: PR_REVIEW_TEMPLATE_ID,
4880
+ name: "PR Review",
4881
+ description: "Review and drive a pull request toward merge-ready state with a worker and fresh adversarial verifier.",
4882
+ kind: "workflow",
4883
+ variables: [
4884
+ { name: "prUrl", description: "Pull request URL." },
4885
+ { name: "prNumber", description: "Pull request number." },
4886
+ { name: "projectPath", required: true, description: "Repository working directory." },
4887
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4888
+ { name: "provider", default: "codewith", description: "Agent provider." },
4889
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4890
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
4891
+ ]
4892
+ },
4893
+ {
4894
+ id: SCHEDULED_AUDIT_TEMPLATE_ID,
4895
+ name: "Scheduled Audit",
4896
+ description: "Run a bounded scheduled audit, record evidence, create follow-up tasks for actionable findings, then verify the audit result.",
4897
+ kind: "workflow",
4898
+ variables: [
4899
+ { name: "objective", required: true, description: "Audit objective." },
4900
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4901
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4902
+ { name: "provider", default: "codewith", description: "Agent provider." },
4903
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4904
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
4905
+ ]
4906
+ },
4907
+ {
4908
+ id: KNOWLEDGE_REFRESH_TEMPLATE_ID,
4909
+ name: "Knowledge Refresh",
4910
+ description: "Review recent knowledge, improve structure/schema where needed, create deduped tasks for code changes, and verify the knowledge update.",
4911
+ kind: "workflow",
4912
+ variables: [
4913
+ { name: "scope", description: "Knowledge scope or label to refresh." },
4914
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4915
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4916
+ { name: "provider", default: "codewith", description: "Agent provider." },
4917
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4918
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
4919
+ ]
4920
+ },
4921
+ {
4922
+ id: REPORT_ONLY_TEMPLATE_ID,
4923
+ name: "Report Only",
4924
+ description: "Produce a bounded report without mutating repositories; verifier checks evidence, scope, and absence of unauthorized changes.",
4925
+ kind: "workflow",
4926
+ variables: [
4927
+ { name: "objective", required: true, description: "Report objective." },
4928
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4929
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4930
+ { name: "provider", default: "codewith", description: "Agent provider." },
4931
+ { name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
4932
+ { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
4933
+ ]
4934
+ },
4935
+ {
4936
+ id: INCIDENT_RESPONSE_TEMPLATE_ID,
4937
+ name: "Incident Response",
4938
+ description: "Triage an incident, gather bounded evidence, apply only allowed narrow mitigation, create follow-up tasks, and verify the response.",
4939
+ kind: "workflow",
4940
+ variables: [
4941
+ { name: "incidentId", description: "Incident or task id." },
4942
+ { name: "objective", required: true, description: "Incident response objective." },
4943
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4944
+ { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
4945
+ { name: "provider", default: "codewith", description: "Agent provider." },
4946
+ { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
4947
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
4948
+ ]
4949
+ },
4950
+ {
4951
+ id: DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID,
4952
+ name: "Deterministic Check Create Task",
4953
+ description: "Run a deterministic check command that writes compact evidence and upserts one deduped todos task when its expectation is not met.",
4954
+ kind: "workflow",
4955
+ variables: [
4956
+ { name: "checkCommand", required: true, description: "Shell command that performs the check and task upsert." },
4957
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4958
+ { name: "name", description: "Workflow name." },
4959
+ { name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
4960
+ ]
4362
4961
  }
4363
4962
  ];
4364
4963
  function compactJson(value) {
@@ -4420,7 +5019,7 @@ function defaultWorktreeRoot(root) {
4420
5019
  const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
4421
5020
  return isAbsolute(expanded) ? expanded : resolve(expanded);
4422
5021
  }
4423
- return join3(homedir3(), ".hasna", "loops", "worktrees");
5022
+ return join5(homedir3(), ".hasna", "loops", "worktrees");
4424
5023
  }
4425
5024
  function gitRootFor(path) {
4426
5025
  if (!existsSync2(path))
@@ -4521,11 +5120,11 @@ function worktreePlan(input, seed) {
4521
5120
  };
4522
5121
  }
4523
5122
  const root = defaultWorktreeRoot(input.worktreeRoot);
4524
- const repoSlug = slugSegment(basename(repoRoot), "repo");
5123
+ const repoSlug = slugSegment(basename2(repoRoot), "repo");
4525
5124
  const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
4526
- const worktreePath = join3(root, repoSlug, seedSlug);
5125
+ const worktreePath = join5(root, repoSlug, seedSlug);
4527
5126
  const relativeCwd = relative(repoRoot, originalCwd);
4528
- const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join3(worktreePath, relativeCwd) : worktreePath;
5127
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
4529
5128
  const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
4530
5129
  const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
4531
5130
  const prepareStep = {
@@ -4583,10 +5182,20 @@ function assertNativeAuthProfileSupport(input, provider) {
4583
5182
  return;
4584
5183
  throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
4585
5184
  }
5185
+ function failClosedSandbox(input, provider, sandbox) {
5186
+ if (!["codewith", "codex"].includes(provider))
5187
+ return;
5188
+ if (sandbox !== "danger-full-access")
5189
+ return;
5190
+ if (input.manualBreakGlass)
5191
+ return;
5192
+ 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");
5193
+ }
4586
5194
  function agentTarget(input, prompt, role, seed, plan) {
4587
5195
  const provider = input.provider ?? "codewith";
4588
5196
  assertNativeAuthProfileSupport(input, provider);
4589
- const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
5197
+ const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "workspace-write" : provider === "cursor" ? "enabled" : undefined);
5198
+ failClosedSandbox(input, provider, sandbox);
4590
5199
  return {
4591
5200
  type: "agent",
4592
5201
  provider,
@@ -4610,6 +5219,7 @@ function agentTarget(input, prompt, role, seed, plan) {
4610
5219
  branch: plan.branch,
4611
5220
  reason: plan.reason
4612
5221
  },
5222
+ allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
4613
5223
  routing: {
4614
5224
  projectPath: input.routeProjectPath ?? input.projectPath,
4615
5225
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
@@ -4699,7 +5309,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4699
5309
  name: "Verifier",
4700
5310
  description: "Adversarially verify worker output and update todos.",
4701
5311
  dependsOn: ["worker"],
4702
- target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
5312
+ target: {
5313
+ ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
5314
+ idleTimeoutMs: 10 * 60000
5315
+ },
4703
5316
  timeoutMs: 30 * 60000
4704
5317
  }
4705
5318
  ])
@@ -4777,7 +5390,10 @@ function renderEventWorkerVerifierWorkflow(input) {
4777
5390
  name: "Verifier",
4778
5391
  description: "Adversarially verify event handling.",
4779
5392
  dependsOn: ["worker"],
4780
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
5393
+ target: {
5394
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
5395
+ idleTimeoutMs: 10 * 60000
5396
+ },
4781
5397
  timeoutMs: 30 * 60000
4782
5398
  }
4783
5399
  ])
@@ -4828,13 +5444,142 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
4828
5444
  name: "Verifier",
4829
5445
  description: "Adversarially verify the bounded objective result.",
4830
5446
  dependsOn: ["worker"],
4831
- target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
5447
+ target: {
5448
+ ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
5449
+ idleTimeoutMs: 10 * 60000
5450
+ },
4832
5451
  timeoutMs: Math.min(timeoutMs, 30 * 60000)
4833
5452
  }
4834
5453
  ])
4835
5454
  };
4836
5455
  }
5456
+ function renderLifecycleBoundedTemplate(id, values) {
5457
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
5458
+ const common = {
5459
+ name: values.name,
5460
+ projectPath,
5461
+ routeProjectPath: values.routeProjectPath,
5462
+ projectGroup: values.projectGroup,
5463
+ provider: values.provider,
5464
+ authProfile: values.authProfile,
5465
+ authProfilePool: listVar(values.authProfilePool),
5466
+ workerAuthProfile: values.workerAuthProfile,
5467
+ verifierAuthProfile: values.verifierAuthProfile,
5468
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
5469
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
5470
+ model: values.model,
5471
+ variant: values.variant,
5472
+ agent: values.agent,
5473
+ permissionMode: values.permissionMode,
5474
+ sandbox: values.sandbox,
5475
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
5476
+ worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
5477
+ worktreeRoot: values.worktreeRoot,
5478
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
5479
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
5480
+ };
5481
+ if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
5482
+ const taskId = values.taskId ?? "";
5483
+ if (!taskId.trim())
5484
+ throw new Error("taskId is required");
5485
+ return renderBoundedAgentWorkerVerifierWorkflow({
5486
+ ...common,
5487
+ name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
5488
+ objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
5489
+ 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."
5490
+ });
5491
+ }
5492
+ if (id === PR_REVIEW_TEMPLATE_ID) {
5493
+ const pr = values.prUrl ?? values.prNumber ?? "";
5494
+ if (!pr.trim())
5495
+ throw new Error("prUrl or prNumber is required");
5496
+ return renderBoundedAgentWorkerVerifierWorkflow({
5497
+ ...common,
5498
+ name: values.name ?? `pr-review-${slugSegment(pr)}-worker-verifier`,
5499
+ objective: values.objective ?? `Review and drive PR ${pr} toward merge-ready state.`,
5500
+ 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."
5501
+ });
5502
+ }
5503
+ if (id === SCHEDULED_AUDIT_TEMPLATE_ID) {
5504
+ const objective = values.objective ?? "";
5505
+ if (!objective.trim())
5506
+ throw new Error("objective is required");
5507
+ return renderBoundedAgentWorkerVerifierWorkflow({
5508
+ ...common,
5509
+ name: values.name ?? `scheduled-audit-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
5510
+ objective,
5511
+ 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."
5512
+ });
5513
+ }
5514
+ if (id === KNOWLEDGE_REFRESH_TEMPLATE_ID) {
5515
+ const scope = values.scope ?? values.label ?? "recent knowledge";
5516
+ return renderBoundedAgentWorkerVerifierWorkflow({
5517
+ ...common,
5518
+ name: values.name ?? `knowledge-refresh-${slugSegment(scope)}-worker-verifier`,
5519
+ objective: values.objective ?? `Refresh and verify ${scope}.`,
5520
+ 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."
5521
+ });
5522
+ }
5523
+ if (id === REPORT_ONLY_TEMPLATE_ID) {
5524
+ const objective = values.objective ?? "";
5525
+ if (!objective.trim())
5526
+ throw new Error("objective is required");
5527
+ return renderBoundedAgentWorkerVerifierWorkflow({
5528
+ ...common,
5529
+ name: values.name ?? `report-only-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
5530
+ objective,
5531
+ 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."
5532
+ });
5533
+ }
5534
+ if (id === INCIDENT_RESPONSE_TEMPLATE_ID) {
5535
+ const objective = values.objective ?? "";
5536
+ if (!objective.trim())
5537
+ throw new Error("objective is required");
5538
+ const incident = values.incidentId ?? values.taskId ?? "incident";
5539
+ return renderBoundedAgentWorkerVerifierWorkflow({
5540
+ ...common,
5541
+ name: values.name ?? `incident-response-${slugSegment(incident)}-worker-verifier`,
5542
+ objective,
5543
+ 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."
5544
+ });
5545
+ }
5546
+ return;
5547
+ }
5548
+ function renderDeterministicCheckCreateTaskWorkflow(values) {
5549
+ const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
5550
+ const checkCommand = values.checkCommand ?? "";
5551
+ if (!checkCommand.trim())
5552
+ throw new Error("checkCommand is required");
5553
+ const seed = `${projectPath}:${checkCommand}`;
5554
+ return {
5555
+ name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
5556
+ description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
5557
+ version: 1,
5558
+ steps: [
5559
+ {
5560
+ id: "check",
5561
+ name: "Check",
5562
+ description: "Run the deterministic check/task-upsert command.",
5563
+ target: {
5564
+ type: "command",
5565
+ command: "bash",
5566
+ args: ["-lc", checkCommand],
5567
+ cwd: projectPath,
5568
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
5569
+ idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
5570
+ },
5571
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
5572
+ }
5573
+ ]
5574
+ };
5575
+ }
4837
5576
  function renderLoopTemplate(id, values) {
5577
+ if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
5578
+ return renderDeterministicCheckCreateTaskWorkflow(values);
5579
+ }
5580
+ const lifecycle = renderLifecycleBoundedTemplate(id, values);
5581
+ if (lifecycle)
5582
+ return lifecycle;
4838
5583
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
4839
5584
  return renderTodosTaskWorkerVerifierWorkflow({
4840
5585
  taskId: values.taskId ?? "",
@@ -4855,6 +5600,7 @@ function renderLoopTemplate(id, values) {
4855
5600
  agent: values.agent,
4856
5601
  permissionMode: values.permissionMode,
4857
5602
  sandbox: values.sandbox,
5603
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
4858
5604
  worktreeMode: values.worktreeMode,
4859
5605
  worktreeRoot: values.worktreeRoot,
4860
5606
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -4885,6 +5631,7 @@ function renderLoopTemplate(id, values) {
4885
5631
  agent: values.agent,
4886
5632
  permissionMode: values.permissionMode,
4887
5633
  sandbox: values.sandbox,
5634
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
4888
5635
  worktreeMode: values.worktreeMode,
4889
5636
  worktreeRoot: values.worktreeRoot,
4890
5637
  worktreeBranchPrefix: values.worktreeBranchPrefix
@@ -4910,6 +5657,7 @@ function renderLoopTemplate(id, values) {
4910
5657
  agent: values.agent,
4911
5658
  permissionMode: values.permissionMode,
4912
5659
  sandbox: values.sandbox,
5660
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
4913
5661
  worktreeMode: values.worktreeMode,
4914
5662
  worktreeRoot: values.worktreeRoot,
4915
5663
  worktreeBranchPrefix: values.worktreeBranchPrefix,
@@ -4922,6 +5670,16 @@ function listVar(value) {
4922
5670
  const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
4923
5671
  return values?.length ? values : undefined;
4924
5672
  }
5673
+ function booleanVar(value) {
5674
+ if (value === undefined)
5675
+ return;
5676
+ const normalized = value.trim().toLowerCase();
5677
+ if (["1", "true", "yes", "on"].includes(normalized))
5678
+ return true;
5679
+ if (["0", "false", "no", "off", ""].includes(normalized))
5680
+ return false;
5681
+ throw new Error(`expected boolean value, got ${value}`);
5682
+ }
4925
5683
  function accountPoolVar(value, tool) {
4926
5684
  return listVar(value)?.map((profile) => ({ profile, tool }));
4927
5685
  }
@@ -4930,7 +5688,7 @@ import { spawnSync as spawnSync3 } from "child_process";
4930
5688
  import { accessSync as accessSync2, constants as constants2 } from "fs";
4931
5689
 
4932
5690
  // src/daemon/control.ts
4933
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
5691
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4934
5692
  import { hostname } from "os";
4935
5693
  import { dirname as dirname2 } from "path";
4936
5694
 
@@ -4968,11 +5726,11 @@ function readPid(path = pidFilePath()) {
4968
5726
  }
4969
5727
  }
4970
5728
  function writePid(pid = process.pid, path = pidFilePath()) {
4971
- mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
4972
- writeFileSync(path, String(pid));
5729
+ mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
5730
+ writeFileSync2(path, String(pid));
4973
5731
  }
4974
5732
  function removePid(path = pidFilePath()) {
4975
- rmSync(path, { force: true });
5733
+ rmSync2(path, { force: true });
4976
5734
  }
4977
5735
  function isAlive(pid) {
4978
5736
  try {
@@ -5163,7 +5921,7 @@ function runDoctor(store) {
5163
5921
  };
5164
5922
  }
5165
5923
  // src/lib/health.ts
5166
- import { createHash } from "crypto";
5924
+ import { createHash as createHash2 } from "crypto";
5167
5925
 
5168
5926
  // src/lib/format.ts
5169
5927
  var TEXT_OUTPUT_LIMIT = 32 * 1024;
@@ -5249,6 +6007,12 @@ function publicWorkflow(workflow) {
5249
6007
  function publicWorkflowRun(run) {
5250
6008
  return { ...run, error: redact(run.error) };
5251
6009
  }
6010
+ function publicWorkflowInvocation(invocation) {
6011
+ return redactSensitivePayload(invocation);
6012
+ }
6013
+ function publicWorkflowWorkItem(item) {
6014
+ return { ...item, lastReason: redact(item.lastReason, 240) };
6015
+ }
5252
6016
  function publicWorkflowStepRun(run, showOutput = false) {
5253
6017
  return {
5254
6018
  ...run,
@@ -5306,7 +6070,7 @@ function searchableText(run) {
5306
6070
  `).toLowerCase();
5307
6071
  }
5308
6072
  function stableFingerprint(parts) {
5309
- return createHash("sha256").update(parts.join(`
6073
+ return createHash2("sha256").update(parts.join(`
5310
6074
  `)).digest("hex").slice(0, 16);
5311
6075
  }
5312
6076
  function stableFailureFingerprint(run, classification) {
@@ -5493,7 +6257,7 @@ function buildHealthReport(store, opts = {}) {
5493
6257
  };
5494
6258
  }
5495
6259
  // src/lib/hygiene.ts
5496
- import { basename as basename2 } from "path";
6260
+ import { basename as basename3 } from "path";
5497
6261
  var PROVIDER_TOKENS = new Set([
5498
6262
  "codewith",
5499
6263
  "claude",
@@ -5514,7 +6278,7 @@ function repoSlugFromCwd(cwd) {
5514
6278
  return "";
5515
6279
  if (cwd.includes("/.hasna/loops/"))
5516
6280
  return "";
5517
- return slugify(basename2(cwd));
6281
+ return slugify(basename3(cwd));
5518
6282
  }
5519
6283
  function scopeForLoop(loop) {
5520
6284
  const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
@@ -5747,6 +6511,7 @@ export {
5747
6511
  preflightTarget,
5748
6512
  parseDuration,
5749
6513
  parseCron,
6514
+ openAutomationsRuntimeBinding,
5750
6515
  nextCronRun,
5751
6516
  loops,
5752
6517
  listOpenMachines,