@hasna/loops 0.4.14 → 0.4.23

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +119 -29
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +825 -7
  5. package/dist/cli/index.js +5423 -2619
  6. package/dist/daemon/index.js +478 -84
  7. package/dist/generated/storage-kit/index.d.ts +1 -1
  8. package/dist/index.js +2521 -226
  9. package/dist/lib/cloud/mode.d.ts +17 -0
  10. package/dist/lib/cloud/resolve.d.ts +16 -0
  11. package/dist/lib/cloud/storage.d.ts +82 -0
  12. package/dist/lib/cloud/transport.d.ts +148 -0
  13. package/dist/lib/format.d.ts +2 -1
  14. package/dist/lib/migration.d.ts +40 -0
  15. package/dist/lib/mode.js +3 -3
  16. package/dist/lib/route/index.d.ts +2 -0
  17. package/dist/lib/route/policies.d.ts +51 -0
  18. package/dist/lib/route/provider-admission.d.ts +37 -0
  19. package/dist/lib/route/throttle.d.ts +4 -0
  20. package/dist/lib/route/types.d.ts +8 -0
  21. package/dist/lib/run-receipts.d.ts +14 -0
  22. package/dist/lib/storage/contract.d.ts +7 -1
  23. package/dist/lib/storage/index.js +710 -31
  24. package/dist/lib/storage/postgres-loop-storage.d.ts +6 -0
  25. package/dist/lib/storage/postgres-schema.js +29 -0
  26. package/dist/lib/storage/postgres.js +29 -0
  27. package/dist/lib/storage/sqlite.d.ts +6 -0
  28. package/dist/lib/storage/sqlite.js +412 -18
  29. package/dist/lib/store/index.d.ts +268 -0
  30. package/dist/lib/store.d.ts +48 -1
  31. package/dist/lib/store.js +396 -18
  32. package/dist/lib/template-kit.d.ts +25 -0
  33. package/dist/lib/templates.d.ts +16 -1
  34. package/dist/mcp/http.d.ts +16 -0
  35. package/dist/mcp/index.js +1658 -168
  36. package/dist/runner/index.js +76 -9
  37. package/dist/sdk/http.d.ts +67 -0
  38. package/dist/sdk/http.js +21 -0
  39. package/dist/sdk/index.d.ts +50 -17
  40. package/dist/sdk/index.js +1509 -132
  41. package/dist/serve/index.js +1598 -122
  42. package/dist/types.d.ts +49 -0
  43. package/docs/AUTOMATION_RUNTIME_DESIGN.md +442 -1
  44. package/docs/CUTOVER-RUNBOOK.md +73 -56
  45. package/docs/DEPLOYMENT_MODES.md +35 -18
  46. package/docs/RUNTIME_BOUNDARY.md +203 -0
  47. package/docs/USAGE.md +145 -27
  48. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1202,6 +1202,175 @@ function discardWorkflowRunManifest(staged) {
1202
1202
  } catch {}
1203
1203
  }
1204
1204
 
1205
+ // src/lib/run-receipts.ts
1206
+ import { createHash as createHash2 } from "crypto";
1207
+ import { hostname } from "os";
1208
+
1209
+ // src/lib/run-envelope.ts
1210
+ var ENVELOPE_EXCERPT_CHARS = 2048;
1211
+ var BLOCKED_STEP_ERROR_PREFIX = "blocked:";
1212
+ function boundedExcerpt(text, limit = ENVELOPE_EXCERPT_CHARS) {
1213
+ if (!text)
1214
+ return;
1215
+ const scrubbed = scrubSecrets(text);
1216
+ if (scrubbed.length <= limit * 2)
1217
+ return scrubbed;
1218
+ const omitted = scrubbed.length - limit * 2;
1219
+ return `${scrubbed.slice(0, limit)}
1220
+ [... ${omitted} chars omitted ...]
1221
+ ${scrubbed.slice(-limit)}`;
1222
+ }
1223
+ function summarizeOutput(stdout, stderr) {
1224
+ return {
1225
+ stdoutBytes: stdout ? Buffer.byteLength(stdout, "utf8") : 0,
1226
+ stderrBytes: stderr ? Buffer.byteLength(stderr, "utf8") : 0,
1227
+ stdoutExcerpt: boundedExcerpt(stdout),
1228
+ stderrExcerpt: boundedExcerpt(stderr)
1229
+ };
1230
+ }
1231
+ function summarizeExecutorResult(result) {
1232
+ return {
1233
+ ...summarizeOutput(result.stdout, result.stderr),
1234
+ status: result.status,
1235
+ exitCode: result.exitCode,
1236
+ durationMs: result.durationMs,
1237
+ error: boundedExcerpt(result.error)
1238
+ };
1239
+ }
1240
+ function isBlockedStepRun(step) {
1241
+ return step?.status === "skipped" && (step.error?.startsWith(BLOCKED_STEP_ERROR_PREFIX) ?? false);
1242
+ }
1243
+ function summarizeWorkflowStepRun(step) {
1244
+ return {
1245
+ ...summarizeOutput(step.stdout, step.stderr),
1246
+ stepId: step.stepId,
1247
+ status: step.status,
1248
+ exitCode: step.exitCode,
1249
+ durationMs: step.durationMs,
1250
+ error: boundedExcerpt(step.error),
1251
+ blocked: isBlockedStepRun(step)
1252
+ };
1253
+ }
1254
+ function workflowRunEnvelope(run, steps) {
1255
+ return JSON.stringify({ workflowRun: run, steps: steps.map(summarizeWorkflowStepRun) }, null, 2);
1256
+ }
1257
+
1258
+ // src/lib/run-receipts.ts
1259
+ var RUN_RECEIPT_SUMMARY_TEXT_CHARS = 4096;
1260
+ var RUN_RECEIPT_MAX_IDS = 100;
1261
+ var RUN_RECEIPT_MAX_EVIDENCE_PATHS = 100;
1262
+ var RUN_RECEIPT_MAX_PATH_CHARS = 1024;
1263
+ function canonicalJson(value) {
1264
+ if (Array.isArray(value))
1265
+ return `[${value.map(canonicalJson).join(",")}]`;
1266
+ if (value && typeof value === "object") {
1267
+ return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
1268
+ }
1269
+ return JSON.stringify(value);
1270
+ }
1271
+ function digestReceipt(receipt) {
1272
+ return `sha256:${createHash2("sha256").update(canonicalJson(receipt)).digest("hex")}`;
1273
+ }
1274
+ function isoOrNull(value, label) {
1275
+ if (value === undefined || value === null || value === "")
1276
+ return null;
1277
+ const date = new Date(value);
1278
+ if (Number.isNaN(date.getTime()))
1279
+ throw new Error(`${label} must be a valid ISO date/time`);
1280
+ return date.toISOString();
1281
+ }
1282
+ function nonEmpty(value, label) {
1283
+ const trimmed = value?.trim();
1284
+ if (!trimmed)
1285
+ throw new Error(`${label} must be non-empty`);
1286
+ return trimmed;
1287
+ }
1288
+ function normalizedStringArray(values, opts) {
1289
+ const out = [];
1290
+ const seen = new Set;
1291
+ for (const raw of values ?? []) {
1292
+ const value = String(raw).trim();
1293
+ if (!value || seen.has(value))
1294
+ continue;
1295
+ seen.add(value);
1296
+ out.push(opts.itemMax ? value.slice(0, opts.itemMax) : value);
1297
+ if (out.length >= opts.max)
1298
+ break;
1299
+ }
1300
+ if ((values?.length ?? 0) > opts.max) {
1301
+ out.push(`[truncated ${values.length - opts.max} ${opts.label}]`);
1302
+ }
1303
+ return out;
1304
+ }
1305
+ function receiptMachine(input, opts) {
1306
+ if (typeof input.machine === "string")
1307
+ return nonEmpty(input.machine, "machine");
1308
+ if (input.machine && typeof input.machine === "object")
1309
+ return input.machine;
1310
+ if (opts.loop?.machine)
1311
+ return { ...opts.loop.machine };
1312
+ if (typeof opts.defaultMachine === "string")
1313
+ return nonEmpty(opts.defaultMachine, "machine");
1314
+ if (opts.defaultMachine && typeof opts.defaultMachine === "object")
1315
+ return opts.defaultMachine;
1316
+ return hostname();
1317
+ }
1318
+ function normalizeSummary(input, run) {
1319
+ const stdout = input.stdout ?? run?.stdout;
1320
+ const stderr = input.stderr ?? run?.stderr;
1321
+ const output = summarizeOutput(stdout, stderr);
1322
+ const summaryInput = input.summary;
1323
+ const provided = typeof summaryInput === "object" && summaryInput !== null ? summaryInput : {};
1324
+ const text = typeof summaryInput === "string" ? boundedExcerpt(summaryInput, Math.floor(RUN_RECEIPT_SUMMARY_TEXT_CHARS / 2)) : typeof provided.text === "string" ? boundedExcerpt(provided.text, Math.floor(RUN_RECEIPT_SUMMARY_TEXT_CHARS / 2)) : undefined;
1325
+ return {
1326
+ text,
1327
+ stdout_bytes: Number.isFinite(provided.stdout_bytes) ? Number(provided.stdout_bytes) : output.stdoutBytes,
1328
+ stderr_bytes: Number.isFinite(provided.stderr_bytes) ? Number(provided.stderr_bytes) : output.stderrBytes,
1329
+ stdout_excerpt: typeof provided.stdout_excerpt === "string" ? boundedExcerpt(provided.stdout_excerpt) : output.stdoutExcerpt,
1330
+ stderr_excerpt: typeof provided.stderr_excerpt === "string" ? boundedExcerpt(provided.stderr_excerpt) : output.stderrExcerpt,
1331
+ error: typeof provided.error === "string" ? boundedExcerpt(provided.error) : boundedExcerpt(input.error ?? run?.error),
1332
+ duration_ms: Number.isFinite(provided.duration_ms) ? Number(provided.duration_ms) : input.duration_ms ?? run?.durationMs
1333
+ };
1334
+ }
1335
+ function normalizeRunReceipt(input, opts = {}) {
1336
+ const now = (opts.now ?? new Date).toISOString();
1337
+ const run = opts.run;
1338
+ const loopId = input.loop_id ?? run?.loopId ?? opts.loop?.id;
1339
+ const normalized = {
1340
+ loop_id: nonEmpty(loopId, "loop_id"),
1341
+ run_id: nonEmpty(input.run_id ?? run?.id, "run_id"),
1342
+ machine: receiptMachine(input, opts),
1343
+ repo: nonEmpty(input.repo ?? opts.defaultRepo ?? targetRepo(opts.loop) ?? process.cwd(), "repo"),
1344
+ task_ids: normalizedStringArray(input.task_ids, { max: RUN_RECEIPT_MAX_IDS, label: "task_ids" }),
1345
+ knowledge_ids: normalizedStringArray(input.knowledge_ids, { max: RUN_RECEIPT_MAX_IDS, label: "knowledge_ids" }),
1346
+ started_at: isoOrNull(input.started_at ?? run?.startedAt, "started_at"),
1347
+ finished_at: isoOrNull(input.finished_at ?? run?.finishedAt, "finished_at"),
1348
+ status: nonEmpty(input.status ?? run?.status, "status"),
1349
+ exit_code: input.exit_code === undefined ? run?.exitCode ?? null : input.exit_code,
1350
+ summary: normalizeSummary(input, run),
1351
+ evidence_paths: normalizedStringArray(input.evidence_paths, {
1352
+ max: RUN_RECEIPT_MAX_EVIDENCE_PATHS,
1353
+ label: "evidence_paths",
1354
+ itemMax: RUN_RECEIPT_MAX_PATH_CHARS
1355
+ })
1356
+ };
1357
+ return {
1358
+ ...normalized,
1359
+ digest_id: input.digest_id?.trim() || digestReceipt(normalized),
1360
+ created_at: opts.existing?.created_at ?? now,
1361
+ updated_at: now
1362
+ };
1363
+ }
1364
+ function targetRepo(loop) {
1365
+ if (!loop)
1366
+ return;
1367
+ if (loop.target.type === "command")
1368
+ return loop.target.cwd;
1369
+ if (loop.target.type === "agent")
1370
+ return loop.target.cwd;
1371
+ return;
1372
+ }
1373
+
1205
1374
  // src/lib/route/todos-cli.ts
1206
1375
  import { closeSync, mkdtempSync, openSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
1207
1376
  import { spawnSync as spawnSync2 } from "child_process";
@@ -1273,6 +1442,9 @@ function publicRun(run, showOutput = false, opts = {}) {
1273
1442
  error: opts.redactError ? redact(run.error) : run.error
1274
1443
  };
1275
1444
  }
1445
+ function publicRunReceipt(receipt) {
1446
+ return { ...receipt };
1447
+ }
1276
1448
  function publicExecutorResult(result, showOutput = false) {
1277
1449
  return {
1278
1450
  ...result,
@@ -1396,7 +1568,7 @@ function todosMutationSummary(result) {
1396
1568
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1397
1569
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1398
1570
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1399
- var SCHEMA_USER_VERSION = 7;
1571
+ var SCHEMA_USER_VERSION = 8;
1400
1572
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1401
1573
  var PRUNE_BATCH_SIZE = 400;
1402
1574
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1452,6 +1624,25 @@ function rowToRun(row) {
1452
1624
  updatedAt: row.updated_at
1453
1625
  };
1454
1626
  }
1627
+ function rowToRunReceipt(row) {
1628
+ return {
1629
+ loop_id: row.loop_id,
1630
+ run_id: row.run_id,
1631
+ machine: JSON.parse(row.machine_json),
1632
+ repo: row.repo,
1633
+ task_ids: JSON.parse(row.task_ids_json),
1634
+ knowledge_ids: JSON.parse(row.knowledge_ids_json),
1635
+ digest_id: row.digest_id,
1636
+ started_at: row.started_at,
1637
+ finished_at: row.finished_at,
1638
+ status: row.status,
1639
+ exit_code: row.exit_code,
1640
+ summary: JSON.parse(row.summary_json),
1641
+ evidence_paths: JSON.parse(row.evidence_paths_json),
1642
+ created_at: row.created_at,
1643
+ updated_at: row.updated_at
1644
+ };
1645
+ }
1455
1646
  function latestRunTime(row) {
1456
1647
  return row.finished_at ?? row.started_at ?? row.created_at;
1457
1648
  }
@@ -1515,6 +1706,7 @@ function rowToWorkflowWorkItem(row) {
1515
1706
  subjectRef: row.subject_ref,
1516
1707
  projectKey: row.project_key ?? undefined,
1517
1708
  projectGroup: row.project_group ?? undefined,
1709
+ machineId: row.machine_id ?? undefined,
1518
1710
  routeScope: row.route_scope ?? undefined,
1519
1711
  priority: row.priority,
1520
1712
  status: row.status,
@@ -1672,6 +1864,7 @@ function scrubbedOrNull(value) {
1672
1864
  return value == null ? null : scrubSecrets(value);
1673
1865
  }
1674
1866
  var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
1867
+ var MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS = 64 * 1024;
1675
1868
  function clampPersistedRunOutput(value) {
1676
1869
  if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
1677
1870
  return value;
@@ -1686,6 +1879,42 @@ ${tail}`;
1686
1879
  function persistedRunOutput(value) {
1687
1880
  return clampPersistedRunOutput(scrubbedOrNull(value));
1688
1881
  }
1882
+ function clampTextToChars(value, maxChars, reason) {
1883
+ if (value.length <= maxChars)
1884
+ return value;
1885
+ const marker = `
1886
+ ...[truncated by ${reason}]...
1887
+ `;
1888
+ const budget = Math.max(0, maxChars - marker.length);
1889
+ const headLength = Math.ceil(budget / 2);
1890
+ const tailLength = Math.floor(budget / 2);
1891
+ return `${value.slice(0, headLength)}${marker}${value.slice(value.length - tailLength)}`;
1892
+ }
1893
+ function boundedWorkflowEventPayloadJson(scrubbedJson) {
1894
+ if (scrubbedJson.length <= MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS)
1895
+ return scrubbedJson;
1896
+ const base = {
1897
+ truncated: true,
1898
+ originalChars: scrubbedJson.length,
1899
+ maxChars: MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS,
1900
+ preview: ""
1901
+ };
1902
+ const baseChars = JSON.stringify(base).length;
1903
+ let previewBudget = Math.max(0, MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS - baseChars - 64);
1904
+ while (true) {
1905
+ const preview = clampTextToChars(scrubbedJson, previewBudget, "loops workflow-event payload retention");
1906
+ const bounded = JSON.stringify({ ...base, preview });
1907
+ if (bounded.length <= MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS || previewBudget === 0)
1908
+ return bounded;
1909
+ previewBudget = Math.max(0, previewBudget - (bounded.length - MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS) - 64);
1910
+ }
1911
+ }
1912
+ function persistedWorkflowEventPayload(payload) {
1913
+ if (payload == null)
1914
+ return null;
1915
+ const scrubbed = scrubSecretsDeep(payload);
1916
+ return boundedWorkflowEventPayloadJson(scrubSecrets(JSON.stringify(scrubbed)));
1917
+ }
1689
1918
  function chmodIfExists(path, mode) {
1690
1919
  try {
1691
1920
  if (existsSync(path))
@@ -1803,6 +2032,17 @@ class Store {
1803
2032
  this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
1804
2033
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
1805
2034
  }
2035
+ },
2036
+ {
2037
+ id: "0009_run_receipts",
2038
+ apply: () => this.createRunReceiptsSchema()
2039
+ },
2040
+ {
2041
+ id: "0010_work_item_machine_id",
2042
+ apply: () => {
2043
+ this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
2044
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
2045
+ }
1806
2046
  }
1807
2047
  ];
1808
2048
  }
@@ -1864,6 +2104,28 @@ class Store {
1864
2104
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1865
2105
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1866
2106
 
2107
+ CREATE TABLE IF NOT EXISTS run_receipts (
2108
+ run_id TEXT PRIMARY KEY,
2109
+ loop_id TEXT NOT NULL,
2110
+ machine_json TEXT NOT NULL,
2111
+ repo TEXT NOT NULL,
2112
+ task_ids_json TEXT NOT NULL,
2113
+ knowledge_ids_json TEXT NOT NULL,
2114
+ digest_id TEXT NOT NULL,
2115
+ started_at TEXT,
2116
+ finished_at TEXT,
2117
+ status TEXT NOT NULL,
2118
+ exit_code INTEGER,
2119
+ summary_json TEXT NOT NULL,
2120
+ evidence_paths_json TEXT NOT NULL,
2121
+ created_at TEXT NOT NULL,
2122
+ updated_at TEXT NOT NULL
2123
+ );
2124
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_loop ON run_receipts(loop_id, created_at DESC);
2125
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_repo ON run_receipts(repo, created_at DESC);
2126
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_digest ON run_receipts(digest_id);
2127
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_status ON run_receipts(status, created_at DESC);
2128
+
1867
2129
  CREATE TABLE IF NOT EXISTS daemon_lease (
1868
2130
  id TEXT PRIMARY KEY,
1869
2131
  pid INTEGER NOT NULL,
@@ -1950,6 +2212,7 @@ class Store {
1950
2212
  subject_ref TEXT NOT NULL,
1951
2213
  project_key TEXT,
1952
2214
  project_group TEXT,
2215
+ machine_id TEXT,
1953
2216
  route_scope TEXT,
1954
2217
  priority INTEGER NOT NULL,
1955
2218
  status TEXT NOT NULL,
@@ -1968,10 +2231,10 @@ class Store {
1968
2231
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1969
2232
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1970
2233
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1971
- -- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
1972
- -- migration 0008_work_item_route_scope, never here: this baseline DDL
2234
+ -- New-column indexes (route_scope, machine_id, etc.) are created ONLY by
2235
+ -- their additive migrations, never here: this baseline DDL
1973
2236
  -- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
1974
- -- database the CREATE TABLE above is a no-op, so an index on route_scope
2237
+ -- database the CREATE TABLE above is a no-op, so an index on a new column
1975
2238
  -- here would execute before the column exists and crash the open
1976
2239
  -- ("no such column: route_scope"). New columns may be folded into the
1977
2240
  -- CREATE TABLE (fresh-db only); their indexes must live in the migration.
@@ -2082,6 +2345,31 @@ class Store {
2082
2345
  CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
2083
2346
  `);
2084
2347
  }
2348
+ createRunReceiptsSchema() {
2349
+ this.db.exec(`
2350
+ CREATE TABLE IF NOT EXISTS run_receipts (
2351
+ run_id TEXT PRIMARY KEY,
2352
+ loop_id TEXT NOT NULL,
2353
+ machine_json TEXT NOT NULL,
2354
+ repo TEXT NOT NULL,
2355
+ task_ids_json TEXT NOT NULL,
2356
+ knowledge_ids_json TEXT NOT NULL,
2357
+ digest_id TEXT NOT NULL,
2358
+ started_at TEXT,
2359
+ finished_at TEXT,
2360
+ status TEXT NOT NULL,
2361
+ exit_code INTEGER,
2362
+ summary_json TEXT NOT NULL,
2363
+ evidence_paths_json TEXT NOT NULL,
2364
+ created_at TEXT NOT NULL,
2365
+ updated_at TEXT NOT NULL
2366
+ );
2367
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_loop ON run_receipts(loop_id, created_at DESC);
2368
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_repo ON run_receipts(repo, created_at DESC);
2369
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_digest ON run_receipts(digest_id);
2370
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_status ON run_receipts(status, created_at DESC);
2371
+ `);
2372
+ }
2085
2373
  addColumnIfMissing(table, column, definition) {
2086
2374
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
2087
2375
  if (columns.some((c) => c.name === column))
@@ -2193,19 +2481,24 @@ class Store {
2193
2481
  }
2194
2482
  listLoops(opts = {}) {
2195
2483
  const limit = opts.limit ?? 200;
2484
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
2485
+ if (opts.name != null) {
2486
+ const rows2 = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.name, limit, offset);
2487
+ return this.withLatestRunSummaries(rows2.map(rowToLoop));
2488
+ }
2196
2489
  let rows;
2197
2490
  if (opts.status && opts.archived) {
2198
- rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
2491
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
2199
2492
  } else if (opts.status && opts.includeArchived) {
2200
- rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
2493
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
2201
2494
  } else if (opts.status) {
2202
- rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
2495
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
2203
2496
  } else if (opts.archived) {
2204
- rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
2497
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ? OFFSET ?").all(limit, offset);
2205
2498
  } else if (opts.includeArchived) {
2206
- rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
2499
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ? OFFSET ?").all(limit, offset);
2207
2500
  } else {
2208
- rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
2501
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ? OFFSET ?").all(limit, offset);
2209
2502
  }
2210
2503
  return this.withLatestRunSummaries(rows.map(rowToLoop));
2211
2504
  }
@@ -2883,10 +3176,10 @@ class Store {
2883
3176
  const id = genId();
2884
3177
  const status = input.status ?? "queued";
2885
3178
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2886
- subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
3179
+ subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
2887
3180
  workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2888
3181
  VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2889
- $projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
3182
+ $projectKey, $projectGroup, $machineId, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2890
3183
  $lastReason, $created, $updated)
2891
3184
  ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2892
3185
  invocation_id=excluded.invocation_id,
@@ -2895,6 +3188,10 @@ class Store {
2895
3188
  subject_ref=excluded.subject_ref,
2896
3189
  project_key=excluded.project_key,
2897
3190
  project_group=excluded.project_group,
3191
+ machine_id=CASE
3192
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.machine_id
3193
+ ELSE excluded.machine_id
3194
+ END,
2898
3195
  route_scope=excluded.route_scope,
2899
3196
  priority=excluded.priority,
2900
3197
  status=CASE
@@ -2937,6 +3234,7 @@ class Store {
2937
3234
  $subjectRef: input.subjectRef,
2938
3235
  $projectKey: input.projectKey ?? null,
2939
3236
  $projectGroup: input.projectGroup ?? null,
3237
+ $machineId: input.machineId ?? null,
2940
3238
  $routeScope: input.routeScope ?? null,
2941
3239
  $priority: input.priority ?? 0,
2942
3240
  $status: status,
@@ -3446,7 +3744,7 @@ class Store {
3446
3744
  VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
3447
3745
  $id: genId(),
3448
3746
  $workflowRunId: runId,
3449
- $payload: JSON.stringify({
3747
+ $payload: persistedWorkflowEventPayload({
3450
3748
  workflowId: input.workflow.id,
3451
3749
  workflowName: input.workflow.name,
3452
3750
  stepCount: input.workflow.steps.length,
@@ -3777,7 +4075,7 @@ class Store {
3777
4075
  $sequence: sequence,
3778
4076
  $eventType: eventType,
3779
4077
  $stepId: stepId ?? null,
3780
- $payload: payload ? JSON.stringify(payload) : null,
4078
+ $payload: persistedWorkflowEventPayload(payload),
3781
4079
  $created: now
3782
4080
  });
3783
4081
  const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
@@ -4100,18 +4398,93 @@ class Store {
4100
4398
  }
4101
4399
  listRuns(opts = {}) {
4102
4400
  const limit = opts.limit ?? 100;
4401
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
4103
4402
  let rows;
4104
4403
  if (opts.loopId && opts.status) {
4105
- rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, opts.status, limit);
4404
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.loopId, opts.status, limit, offset);
4106
4405
  } else if (opts.loopId) {
4107
- rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, limit);
4406
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.loopId, limit, offset);
4108
4407
  } else if (opts.status) {
4109
- rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit);
4408
+ rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
4110
4409
  } else {
4111
- rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ?").all(limit);
4410
+ rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ? OFFSET ?").all(limit, offset);
4112
4411
  }
4113
4412
  return rows.map(rowToRun);
4114
4413
  }
4414
+ writeRunReceipt(input, opts = {}) {
4415
+ const inputRunId = typeof input.run_id === "string" && input.run_id.trim() ? input.run_id : undefined;
4416
+ const existing = inputRunId ? this.getRunReceipt(inputRunId) : undefined;
4417
+ const run = inputRunId ? this.getRun(inputRunId) : undefined;
4418
+ const loop = input.loop_id ? this.getLoop(input.loop_id) : run ? this.getLoop(run.loopId) : undefined;
4419
+ const receipt = normalizeRunReceipt(input, { now: opts.now, run, loop, existing });
4420
+ this.db.query(`INSERT INTO run_receipts (run_id, loop_id, machine_json, repo, task_ids_json, knowledge_ids_json, digest_id,
4421
+ started_at, finished_at, status, exit_code, summary_json, evidence_paths_json, created_at, updated_at)
4422
+ VALUES ($runId, $loopId, $machineJson, $repo, $taskIdsJson, $knowledgeIdsJson, $digestId,
4423
+ $startedAt, $finishedAt, $status, $exitCode, $summaryJson, $evidencePathsJson, $createdAt, $updatedAt)
4424
+ ON CONFLICT(run_id) DO UPDATE SET
4425
+ loop_id=excluded.loop_id,
4426
+ machine_json=excluded.machine_json,
4427
+ repo=excluded.repo,
4428
+ task_ids_json=excluded.task_ids_json,
4429
+ knowledge_ids_json=excluded.knowledge_ids_json,
4430
+ digest_id=excluded.digest_id,
4431
+ started_at=excluded.started_at,
4432
+ finished_at=excluded.finished_at,
4433
+ status=excluded.status,
4434
+ exit_code=excluded.exit_code,
4435
+ summary_json=excluded.summary_json,
4436
+ evidence_paths_json=excluded.evidence_paths_json,
4437
+ updated_at=excluded.updated_at`).run({
4438
+ $runId: receipt.run_id,
4439
+ $loopId: receipt.loop_id,
4440
+ $machineJson: JSON.stringify(receipt.machine),
4441
+ $repo: receipt.repo,
4442
+ $taskIdsJson: JSON.stringify(receipt.task_ids),
4443
+ $knowledgeIdsJson: JSON.stringify(receipt.knowledge_ids),
4444
+ $digestId: receipt.digest_id,
4445
+ $startedAt: receipt.started_at,
4446
+ $finishedAt: receipt.finished_at,
4447
+ $status: receipt.status,
4448
+ $exitCode: receipt.exit_code,
4449
+ $summaryJson: JSON.stringify(receipt.summary),
4450
+ $evidencePathsJson: JSON.stringify(receipt.evidence_paths),
4451
+ $createdAt: receipt.created_at,
4452
+ $updatedAt: receipt.updated_at
4453
+ });
4454
+ return this.getRunReceipt(receipt.run_id) ?? receipt;
4455
+ }
4456
+ getRunReceipt(runId) {
4457
+ const row = this.db.query("SELECT * FROM run_receipts WHERE run_id = ?").get(runId);
4458
+ return row ? rowToRunReceipt(row) : undefined;
4459
+ }
4460
+ listRunReceipts(opts = {}) {
4461
+ const limit = opts.limit ?? 100;
4462
+ const filters = [];
4463
+ const params = [];
4464
+ if (opts.loopId) {
4465
+ filters.push("loop_id = ?");
4466
+ params.push(opts.loopId);
4467
+ }
4468
+ if (opts.repo) {
4469
+ filters.push("repo = ?");
4470
+ params.push(opts.repo);
4471
+ }
4472
+ if (opts.status) {
4473
+ filters.push("status = ?");
4474
+ params.push(opts.status);
4475
+ }
4476
+ if (opts.taskId) {
4477
+ filters.push("EXISTS (SELECT 1 FROM json_each(run_receipts.task_ids_json) WHERE value = ?)");
4478
+ params.push(opts.taskId);
4479
+ }
4480
+ if (opts.knowledgeId) {
4481
+ filters.push("EXISTS (SELECT 1 FROM json_each(run_receipts.knowledge_ids_json) WHERE value = ?)");
4482
+ params.push(opts.knowledgeId);
4483
+ }
4484
+ const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
4485
+ const rows = this.db.query(`SELECT * FROM run_receipts ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit);
4486
+ return rows.map(rowToRunReceipt);
4487
+ }
4115
4488
  deferLiveExpiredRun(id, now, opts = {}) {
4116
4489
  const updated = now.toISOString();
4117
4490
  const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
@@ -4270,6 +4643,9 @@ class Store {
4270
4643
  const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
4271
4644
  return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
4272
4645
  }
4646
+ exportMigrationRunPage(opts) {
4647
+ return this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?").all(opts.limit, opts.offset).map(rowToRun);
4648
+ }
4273
4649
  countTable(table) {
4274
4650
  const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
4275
4651
  return row?.count ?? 0;
@@ -4594,7 +4970,7 @@ class Store {
4594
4970
  // package.json
4595
4971
  var package_default = {
4596
4972
  name: "@hasna/loops",
4597
- version: "0.4.14",
4973
+ version: "0.4.23",
4598
4974
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4599
4975
  type: "module",
4600
4976
  main: "dist/index.js",
@@ -4704,7 +5080,6 @@ var package_default = {
4704
5080
  bun: ">=1.0.0"
4705
5081
  },
4706
5082
  dependencies: {
4707
- "@hasna/contracts": "^0.4.1",
4708
5083
  "@hasna/events": "^0.1.9",
4709
5084
  "@modelcontextprotocol/sdk": "^1.29.0",
4710
5085
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -4714,7 +5089,8 @@ var package_default = {
4714
5089
  zod: "4.4.3"
4715
5090
  },
4716
5091
  optionalDependencies: {
4717
- "@hasna/machines": "0.0.49"
5092
+ "@hasna/machines": "0.0.49",
5093
+ "@hasna/contracts": "^0.4.2"
4718
5094
  },
4719
5095
  devDependencies: {
4720
5096
  "@types/bun": "latest",
@@ -4927,12 +5303,12 @@ function deploymentStatusLine(status) {
4927
5303
  }
4928
5304
 
4929
5305
  // src/lib/storage/postgres-schema.ts
4930
- import { createHash as createHash2 } from "crypto";
5306
+ import { createHash as createHash3 } from "crypto";
4931
5307
  var POSTGRES_MIGRATION_LEDGER_TABLE = "open_loops_schema_migrations";
4932
5308
  function checksumStorageSql(sql) {
4933
5309
  const normalized = sql.trim().replace(/\r\n/g, `
4934
5310
  `);
4935
- return `sha256:${createHash2("sha256").update(normalized).digest("hex")}`;
5311
+ return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
4936
5312
  }
4937
5313
  function migration(id, sql) {
4938
5314
  return Object.freeze({ id, sql: sql.trim(), checksum: checksumStorageSql(sql) });
@@ -5250,6 +5626,35 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, creat
5250
5626
  migration("0004_work_item_route_scope", `
5251
5627
  ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
5252
5628
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
5629
+ `),
5630
+ migration("0005_run_receipts", `
5631
+ CREATE TABLE IF NOT EXISTS run_receipts (
5632
+ run_id TEXT PRIMARY KEY,
5633
+ loop_id TEXT NOT NULL,
5634
+ machine_json JSONB NOT NULL,
5635
+ repo TEXT NOT NULL,
5636
+ task_ids_json JSONB NOT NULL,
5637
+ knowledge_ids_json JSONB NOT NULL,
5638
+ digest_id TEXT NOT NULL,
5639
+ started_at TIMESTAMPTZ,
5640
+ finished_at TIMESTAMPTZ,
5641
+ status TEXT NOT NULL,
5642
+ exit_code INTEGER,
5643
+ summary_json JSONB NOT NULL,
5644
+ evidence_paths_json JSONB NOT NULL,
5645
+ created_at TIMESTAMPTZ NOT NULL,
5646
+ updated_at TIMESTAMPTZ NOT NULL
5647
+ );
5648
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_loop ON run_receipts(loop_id, created_at DESC);
5649
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_repo ON run_receipts(repo, created_at DESC);
5650
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_digest ON run_receipts(digest_id);
5651
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_status ON run_receipts(status, created_at DESC);
5652
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_task_ids ON run_receipts USING GIN (task_ids_json);
5653
+ CREATE INDEX IF NOT EXISTS idx_run_receipts_knowledge_ids ON run_receipts USING GIN (knowledge_ids_json);
5654
+ `),
5655
+ migration("0006_work_item_machine_id", `
5656
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS machine_id TEXT;
5657
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status);
5253
5658
  `)
5254
5659
  ]);
5255
5660
 
@@ -5352,12 +5757,108 @@ function isMissingLedgerError(error) {
5352
5757
  // src/mcp/index.ts
5353
5758
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5354
5759
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5760
+
5761
+ // src/mcp/http.ts
5762
+ import { createServer } from "http";
5763
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5764
+ var MCP_HTTP_SERVICE_NAME = "loops";
5765
+ var DEFAULT_MCP_HTTP_PORT = 8890;
5766
+ function isStdioMode(argv = process.argv, env = process.env) {
5767
+ return argv.includes("--stdio") || env.MCP_STDIO === "1";
5768
+ }
5769
+ function resolveMcpHttpPort(argv = process.argv, env = process.env) {
5770
+ const portIdx = argv.indexOf("--port");
5771
+ if (portIdx !== -1 && argv[portIdx + 1]) {
5772
+ return parsePort(argv[portIdx + 1], "--port");
5773
+ }
5774
+ if (env.MCP_HTTP_PORT) {
5775
+ return parsePort(env.MCP_HTTP_PORT, "MCP_HTTP_PORT");
5776
+ }
5777
+ return DEFAULT_MCP_HTTP_PORT;
5778
+ }
5779
+ function parsePort(raw, source) {
5780
+ const parsed = Number(raw);
5781
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
5782
+ throw new Error(`Invalid ${source} value "${raw}". Expected 0-65535.`);
5783
+ }
5784
+ return parsed;
5785
+ }
5786
+ async function readJsonBody(req) {
5787
+ const chunks = [];
5788
+ for await (const chunk of req) {
5789
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5790
+ }
5791
+ const text = Buffer.concat(chunks).toString("utf8");
5792
+ if (!text)
5793
+ return;
5794
+ return JSON.parse(text);
5795
+ }
5796
+ async function startMcpHttpServer(buildServer, options) {
5797
+ const host = options?.host ?? "127.0.0.1";
5798
+ const requestedPort = options?.port ?? resolveMcpHttpPort();
5799
+ const serviceName = options?.serviceName ?? MCP_HTTP_SERVICE_NAME;
5800
+ const httpServer = createServer(async (req, res) => {
5801
+ try {
5802
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
5803
+ if (req.method === "GET" && url.pathname === "/health") {
5804
+ res.writeHead(200, { "Content-Type": "application/json" });
5805
+ res.end(JSON.stringify({ status: "ok", name: serviceName }));
5806
+ return;
5807
+ }
5808
+ if (url.pathname !== "/mcp") {
5809
+ res.writeHead(404, { "Content-Type": "text/plain" });
5810
+ res.end("Not Found");
5811
+ return;
5812
+ }
5813
+ const server = buildServer();
5814
+ const transport = new StreamableHTTPServerTransport({
5815
+ sessionIdGenerator: undefined
5816
+ });
5817
+ await server.connect(transport);
5818
+ let parsedBody;
5819
+ if (req.method === "POST") {
5820
+ parsedBody = await readJsonBody(req);
5821
+ }
5822
+ await transport.handleRequest(req, res, parsedBody);
5823
+ res.on("close", () => {
5824
+ transport.close();
5825
+ server.close();
5826
+ });
5827
+ } catch (error) {
5828
+ console.error(`[${serviceName}-mcp] HTTP error:`, error);
5829
+ if (!res.headersSent) {
5830
+ res.writeHead(500, { "Content-Type": "application/json" });
5831
+ res.end(JSON.stringify({
5832
+ jsonrpc: "2.0",
5833
+ error: { code: -32603, message: "Internal server error" },
5834
+ id: null
5835
+ }));
5836
+ }
5837
+ }
5838
+ });
5839
+ await new Promise((resolve2, reject) => {
5840
+ httpServer.once("error", reject);
5841
+ httpServer.listen(requestedPort, host, () => resolve2());
5842
+ });
5843
+ const addr = httpServer.address();
5844
+ const port = typeof addr === "object" && addr ? addr.port : requestedPort;
5845
+ console.error(`[${serviceName}-mcp] Streamable HTTP listening on http://${host}:${port}/mcp`);
5846
+ return {
5847
+ port,
5848
+ host,
5849
+ close: () => new Promise((resolve2, reject) => {
5850
+ httpServer.close((err) => err ? reject(err) : resolve2());
5851
+ })
5852
+ };
5853
+ }
5854
+
5855
+ // src/mcp/index.ts
5355
5856
  import { z as z2 } from "zod/v3";
5356
5857
 
5357
5858
  // src/daemon/control.ts
5358
5859
  import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
5359
5860
  import { spawnSync as spawnSync3 } from "child_process";
5360
- import { hostname } from "os";
5861
+ import { hostname as hostname2 } from "os";
5361
5862
  import { dirname as dirname3, join as join5 } from "path";
5362
5863
 
5363
5864
  // src/daemon/loop.ts
@@ -5510,7 +6011,7 @@ function daemonStatus(store, path = pidFilePath()) {
5510
6011
  return {
5511
6012
  ...isDaemonRunning(path),
5512
6013
  lease: store.getDaemonLease(),
5513
- host: hostname(),
6014
+ host: hostname2(),
5514
6015
  loops: {
5515
6016
  total: store.countLoops(),
5516
6017
  active: store.countLoops("active"),
@@ -5867,6 +6368,7 @@ var TRANSPORT_ENV_KEYS = new Set([
5867
6368
  "LOGNAME",
5868
6369
  "PATH",
5869
6370
  "SHELL",
6371
+ "SHLVL",
5870
6372
  "SSH_AGENT_PID",
5871
6373
  "SSH_AUTH_SOCK",
5872
6374
  "TERM",
@@ -6088,6 +6590,7 @@ function composeExecutionEnv(spec, metadata, opts, accountEnv) {
6088
6590
  Object.assign(env, spec.env ?? {});
6089
6591
  Object.assign(env, allowlistEnv(spec.allowlist));
6090
6592
  env.PATH = normalizeExecutionPath(env);
6593
+ env.SHLVL ||= "1";
6091
6594
  Object.assign(env, metadataEnv(metadata));
6092
6595
  return env;
6093
6596
  }
@@ -6159,7 +6662,7 @@ function remoteWorktreePrepareLines(worktree) {
6159
6662
  return [
6160
6663
  "__openloops_prepare_worktree() {",
6161
6664
  ` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
6162
- " local top expected_common actual_common current",
6665
+ " local top expected_common actual_common current status recovered",
6163
6666
  ' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
6164
6667
  ' if [ -e "$path" ]; then',
6165
6668
  ' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
@@ -6168,7 +6671,19 @@ function remoteWorktreePrepareLines(worktree) {
6168
6671
  ' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
6169
6672
  ' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
6170
6673
  ' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
6171
- ' if [ "$current" != "$branch" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch" >&2; return 1; fi',
6674
+ ' if [ "$current" != "$branch" ]; then',
6675
+ ' status="$(git -C "$path" status --porcelain=v1 --untracked-files=all 2>/dev/null)" || { echo "existing worktree $path is on branch ${current:-unknown}, expected $branch, and cleanliness could not be checked; inspect or remove the stale worktree" >&2; return 1; }',
6676
+ ' if [ -n "$status" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch, and has local changes; commit/stash them or remove the stale worktree before retrying" >&2; return 1; fi',
6677
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6678
+ ' git -C "$path" checkout "$branch" 1>&2 || { echo "existing worktree $path is clean but could not switch from branch ${current:-unknown} to expected $branch; remove/prune the stale worktree or free the branch before retrying" >&2; return 1; }',
6679
+ ' elif [ -z "$current" ]; then',
6680
+ ' git -C "$path" checkout -b "$branch" 1>&2 || { echo "existing worktree $path is clean but could not recreate expected branch $branch at the current detached HEAD; remove/prune the stale worktree before retrying" >&2; return 1; }',
6681
+ " else",
6682
+ ' echo "existing worktree $path is on branch $current, expected $branch, but expected branch does not exist; remove/prune the stale worktree or recreate the expected branch before retrying" >&2; return 1',
6683
+ " fi",
6684
+ ' recovered="$(git -C "$path" branch --show-current 2>/dev/null)"',
6685
+ ' if [ "$recovered" != "$branch" ]; then echo "existing worktree $path branch recovery ended on ${recovered:-unknown}, expected $branch; remove/prune the stale worktree before retrying" >&2; return 1; fi',
6686
+ " fi",
6172
6687
  " return 0",
6173
6688
  " fi",
6174
6689
  ' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
@@ -6249,8 +6764,21 @@ function transportEnv(opts) {
6249
6764
  env[key] = value;
6250
6765
  }
6251
6766
  env.PATH = normalizeExecutionPath(env);
6767
+ env.SHLVL ||= "1";
6252
6768
  return env;
6253
6769
  }
6770
+ function remotePreflightFailureMessage(machineId, detail) {
6771
+ const normalized = detail.trim();
6772
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .*key in .*known_hosts/i.test(normalized)) {
6773
+ return [
6774
+ `remote preflight failed on ${machineId}: SSH host key verification failed.`,
6775
+ `Verify ${machineId}'s host identity and repair SSH known_hosts/trust material outside OpenLoops;`,
6776
+ "OpenLoops will not disable host-key checking or modify known_hosts automatically.",
6777
+ normalized ? `Transport detail: ${normalized}` : ""
6778
+ ].filter(Boolean).join(" ");
6779
+ }
6780
+ return `remote preflight failed on ${machineId}${normalized ? `: ${normalized}` : ""}`;
6781
+ }
6254
6782
  function assertCodewithProfileListed(profile, result) {
6255
6783
  const profiles = codewithProfileSetFromResult(result);
6256
6784
  if (!profiles.has(profile)) {
@@ -6302,6 +6830,9 @@ async function preflightNativeAuthProfile(spec, env) {
6302
6830
  const tableResult = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
6303
6831
  assertCodewithProfileListed(spec.nativeAuthProfile.profile, tableResult);
6304
6832
  }
6833
+ function spawnDetail(result) {
6834
+ return (result.stderr || result.stdout || result.error || "").toString().trim();
6835
+ }
6305
6836
  function resolvedDirEquals(left, right) {
6306
6837
  try {
6307
6838
  return realpathSync(left) === realpathSync(right);
@@ -6351,8 +6882,45 @@ async function ensureLocalWorktree(worktree, env) {
6351
6882
  }
6352
6883
  const current = await git(["-C", path, "branch", "--show-current"]);
6353
6884
  const actualBranch = current.stdout.trim();
6354
- if (current.error || (current.status ?? 1) !== 0 || actualBranch !== branch) {
6355
- return { error: `existing worktree ${path} is on branch ${actualBranch || "unknown"}, expected ${branch}` };
6885
+ if (current.error || (current.status ?? 1) !== 0) {
6886
+ return { error: `existing worktree ${path} branch could not be inspected${spawnDetail(current) ? `: ${spawnDetail(current)}` : ""}` };
6887
+ }
6888
+ if (actualBranch !== branch) {
6889
+ const actualLabel = actualBranch || "unknown";
6890
+ const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
6891
+ if (status.error || (status.status ?? 1) !== 0) {
6892
+ const detail = spawnDetail(status);
6893
+ return {
6894
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and cleanliness could not be checked; inspect or remove the stale worktree${detail ? `: ${detail}` : ""}`
6895
+ };
6896
+ }
6897
+ if (status.stdout.trim()) {
6898
+ return {
6899
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and has local changes; commit/stash them or remove the stale worktree before retrying`
6900
+ };
6901
+ }
6902
+ const hasBranch2 = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6903
+ const checkout = hasBranch2.status === 0 ? await git(["-C", path, "checkout", branch]) : actualBranch ? {
6904
+ status: 1,
6905
+ stdout: "",
6906
+ stderr: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, but expected branch does not exist; remove/prune the stale worktree or recreate the expected branch before retrying`,
6907
+ signal: null,
6908
+ timedOut: false
6909
+ } : await git(["-C", path, "checkout", "-b", branch]);
6910
+ if (checkout.error || (checkout.status ?? 1) !== 0) {
6911
+ const detail = spawnDetail(checkout);
6912
+ return {
6913
+ error: `existing worktree ${path} is clean but could not recover branch ${branch} from ${actualLabel}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
6914
+ };
6915
+ }
6916
+ const recovered = await git(["-C", path, "branch", "--show-current"]);
6917
+ if (recovered.error || (recovered.status ?? 1) !== 0 || recovered.stdout.trim() !== branch) {
6918
+ const recoveredBranch = recovered.stdout.trim() || "unknown";
6919
+ const detail = spawnDetail(recovered);
6920
+ return {
6921
+ error: `existing worktree ${path} branch recovery ended on ${recoveredBranch}, expected ${branch}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
6922
+ };
6923
+ }
6356
6924
  }
6357
6925
  return { cwd: worktree.cwd };
6358
6926
  }
@@ -6368,7 +6936,7 @@ async function ensureLocalWorktree(worktree, env) {
6368
6936
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6369
6937
  const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6370
6938
  if (add.error || (add.status ?? 1) !== 0) {
6371
- const detail = (add.stderr || add.stdout || add.error || "").toString().trim();
6939
+ const detail = spawnDetail(add);
6372
6940
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
6373
6941
  }
6374
6942
  return { cwd: worktree.cwd };
@@ -6414,7 +6982,7 @@ function preflightRemoteSpec(spec, machine, metadata, opts) {
6414
6982
  throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
6415
6983
  if ((result.status ?? 1) !== 0) {
6416
6984
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
6417
- throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
6985
+ throw new Error(remotePreflightFailureMessage(machine.id, detail));
6418
6986
  }
6419
6987
  }
6420
6988
  async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
@@ -6674,7 +7242,7 @@ async function executeLoop(loop, run, opts = {}) {
6674
7242
  }
6675
7243
 
6676
7244
  // src/lib/health.ts
6677
- import { createHash as createHash3 } from "crypto";
7245
+ import { createHash as createHash4 } from "crypto";
6678
7246
  import { existsSync as existsSync4, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
6679
7247
  import { join as join7 } from "path";
6680
7248
  var EVIDENCE_CHARS = 2000;
@@ -6736,7 +7304,7 @@ function tagsFromValue(value) {
6736
7304
  return [];
6737
7305
  }
6738
7306
  function stableFingerprint(parts) {
6739
- return createHash3("sha256").update(parts.join(`
7307
+ return createHash4("sha256").update(parts.join(`
6740
7308
  `)).digest("hex").slice(0, 16);
6741
7309
  }
6742
7310
  function stableScanFingerprint(parts) {
@@ -7715,55 +8283,6 @@ ${evidence.join(`
7715
8283
  import { generateObject } from "ai";
7716
8284
  import { z } from "zod";
7717
8285
 
7718
- // src/lib/run-envelope.ts
7719
- var ENVELOPE_EXCERPT_CHARS = 2048;
7720
- var BLOCKED_STEP_ERROR_PREFIX = "blocked:";
7721
- function boundedExcerpt(text, limit = ENVELOPE_EXCERPT_CHARS) {
7722
- if (!text)
7723
- return;
7724
- const scrubbed = scrubSecrets(text);
7725
- if (scrubbed.length <= limit * 2)
7726
- return scrubbed;
7727
- const omitted = scrubbed.length - limit * 2;
7728
- return `${scrubbed.slice(0, limit)}
7729
- [... ${omitted} chars omitted ...]
7730
- ${scrubbed.slice(-limit)}`;
7731
- }
7732
- function summarizeOutput(stdout, stderr) {
7733
- return {
7734
- stdoutBytes: stdout ? Buffer.byteLength(stdout, "utf8") : 0,
7735
- stderrBytes: stderr ? Buffer.byteLength(stderr, "utf8") : 0,
7736
- stdoutExcerpt: boundedExcerpt(stdout),
7737
- stderrExcerpt: boundedExcerpt(stderr)
7738
- };
7739
- }
7740
- function summarizeExecutorResult(result) {
7741
- return {
7742
- ...summarizeOutput(result.stdout, result.stderr),
7743
- status: result.status,
7744
- exitCode: result.exitCode,
7745
- durationMs: result.durationMs,
7746
- error: boundedExcerpt(result.error)
7747
- };
7748
- }
7749
- function isBlockedStepRun(step) {
7750
- return step?.status === "skipped" && (step.error?.startsWith(BLOCKED_STEP_ERROR_PREFIX) ?? false);
7751
- }
7752
- function summarizeWorkflowStepRun(step) {
7753
- return {
7754
- ...summarizeOutput(step.stdout, step.stderr),
7755
- stepId: step.stepId,
7756
- status: step.status,
7757
- exitCode: step.exitCode,
7758
- durationMs: step.durationMs,
7759
- error: boundedExcerpt(step.error),
7760
- blocked: isBlockedStepRun(step)
7761
- };
7762
- }
7763
- function workflowRunEnvelope(run, steps) {
7764
- return JSON.stringify({ workflowRun: run, steps: steps.map(summarizeWorkflowStepRun) }, null, 2);
7765
- }
7766
-
7767
8286
  // src/lib/goal/model-factory.ts
7768
8287
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
7769
8288
  var DEFAULT_GOAL_MODEL = "openai/gpt-4o-mini";
@@ -9003,21 +9522,933 @@ async function tick(deps) {
9003
9522
  return { claimed, completed, skipped, recovered, expired };
9004
9523
  }
9005
9524
 
9006
- // src/mcp/index.ts
9007
- var LOOP_STATUSES = ["active", "paused", "stopped", "expired"];
9008
- var LOOP_STATUS_FILTERS = [...LOOP_STATUSES, "all"];
9009
- var RUN_STATUSES = ["running", "succeeded", "failed", "timed_out", "abandoned", "skipped"];
9010
- var WORKFLOW_STATUSES = ["active", "archived"];
9011
- var WORKFLOW_STATUS_FILTERS = [...WORKFLOW_STATUSES, "all"];
9012
- var CATCH_UP_POLICIES = ["none", "latest", "all"];
9013
- var OVERLAP_POLICIES = ["skip", "allow"];
9014
- var INTERVAL_ANCHORS = ["fixed_rate", "fixed_delay"];
9015
- var MAX_LIMIT = 500;
9525
+ // src/lib/cloud/mode.ts
9526
+ var DEPRECATED_STORAGE_MODE_ALIASES = ["remote", "hybrid", "self_hosted"];
9527
+ function normalizeStorageMode(value) {
9528
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
9529
+ if (normalized === "local")
9530
+ return { mode: "local", deprecatedAlias: null };
9531
+ if (normalized === "cloud")
9532
+ return { mode: "cloud", deprecatedAlias: null };
9533
+ if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
9534
+ return { mode: "cloud", deprecatedAlias: normalized };
9535
+ }
9536
+ throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
9537
+ }
9538
+ function envToken(name) {
9539
+ return name.toUpperCase().replace(/-/g, "_");
9540
+ }
9541
+
9542
+ // src/lib/cloud/transport.ts
9543
+ function defaultCloudBaseUrl(name) {
9544
+ return `https://${name}.hasna.xyz`;
9545
+ }
9546
+ function clientTransportEnvKeys(name) {
9547
+ const token = envToken(name);
9548
+ return {
9549
+ modeKeys: [
9550
+ `HASNA_${token}_STORAGE_MODE`,
9551
+ `HASNA_${token}_MODE`,
9552
+ `${token}_STORAGE_MODE`,
9553
+ `${token}_MODE`
9554
+ ],
9555
+ apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
9556
+ apiKeyKeys: [
9557
+ `HASNA_${token}_API_KEY`,
9558
+ `${token}_API_KEY`,
9559
+ `HASNA_${token}_API_TOKEN`,
9560
+ `${token}_API_TOKEN`
9561
+ ]
9562
+ };
9563
+ }
9564
+ function firstEnv(env, keys) {
9565
+ for (const key of keys) {
9566
+ const value = env[key]?.trim();
9567
+ if (value)
9568
+ return { key, value };
9569
+ }
9570
+ return null;
9571
+ }
9572
+ function toV1BaseUrl(apiUrl) {
9573
+ const url = new URL(apiUrl);
9574
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
9575
+ throw new Error("API URL must use http or https.");
9576
+ }
9577
+ let path = url.pathname.replace(/\/+$/, "");
9578
+ if (path.endsWith("/v1"))
9579
+ path = path.slice(0, -"/v1".length);
9580
+ url.pathname = `${path}/v1`;
9581
+ url.search = "";
9582
+ url.hash = "";
9583
+ return url.toString().replace(/\/+$/, "");
9584
+ }
9585
+ function resolveClientTransport(name, env = process.env) {
9586
+ const keys = clientTransportEnvKeys(name);
9587
+ const modeHit = firstEnv(env, keys.modeKeys);
9588
+ const urlHit = firstEnv(env, keys.apiUrlKeys);
9589
+ const keyHit = firstEnv(env, keys.apiKeyKeys);
9590
+ let mode = "local";
9591
+ let deprecatedAlias = null;
9592
+ let modeSource = "default";
9593
+ const warnings = [];
9594
+ if (modeHit) {
9595
+ const normalized = normalizeStorageMode(modeHit.value);
9596
+ mode = normalized.mode;
9597
+ deprecatedAlias = normalized.deprecatedAlias;
9598
+ modeSource = modeHit.key;
9599
+ if (deprecatedAlias) {
9600
+ warnings.push(`Deprecated mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Prefer ${keys.modeKeys[0]}=cloud.`);
9601
+ }
9602
+ }
9603
+ if (mode === "local") {
9604
+ return {
9605
+ transport: "local",
9606
+ mode,
9607
+ deprecatedAlias,
9608
+ modeSource,
9609
+ baseUrl: null,
9610
+ apiUrlSource: null,
9611
+ apiKeyPresent: Boolean(keyHit),
9612
+ apiKeySource: keyHit ? keyHit.key : null,
9613
+ misconfigured: false,
9614
+ warning: warnings.length > 0 ? warnings.join(" ") : null
9615
+ };
9616
+ }
9617
+ if (!keyHit) {
9618
+ warnings.push(`${modeSource}=cloud but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to cloud; using local store. Set ${keys.apiKeyKeys[0]} to enable the cloud client.`);
9619
+ return {
9620
+ transport: "local",
9621
+ mode,
9622
+ deprecatedAlias,
9623
+ modeSource,
9624
+ baseUrl: null,
9625
+ apiUrlSource: null,
9626
+ apiKeyPresent: false,
9627
+ apiKeySource: null,
9628
+ misconfigured: true,
9629
+ warning: warnings.join(" ")
9630
+ };
9631
+ }
9632
+ const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name);
9633
+ const apiUrlSource = urlHit ? urlHit.key : "default";
9634
+ let baseUrl;
9635
+ try {
9636
+ baseUrl = toV1BaseUrl(rawUrl);
9637
+ } catch (error) {
9638
+ const message = error instanceof Error ? error.message : String(error);
9639
+ warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
9640
+ return {
9641
+ transport: "local",
9642
+ mode,
9643
+ deprecatedAlias,
9644
+ modeSource,
9645
+ baseUrl: null,
9646
+ apiUrlSource: null,
9647
+ apiKeyPresent: true,
9648
+ apiKeySource: keyHit.key,
9649
+ misconfigured: true,
9650
+ warning: warnings.join(" ")
9651
+ };
9652
+ }
9653
+ return {
9654
+ transport: "cloud-http",
9655
+ mode,
9656
+ deprecatedAlias,
9657
+ modeSource,
9658
+ baseUrl,
9659
+ apiUrlSource,
9660
+ apiKeyPresent: true,
9661
+ apiKeySource: keyHit.key,
9662
+ misconfigured: false,
9663
+ warning: warnings.length > 0 ? warnings.join(" ") : null
9664
+ };
9665
+ }
9666
+
9667
+ class HasnaHttpError extends Error {
9668
+ status;
9669
+ method;
9670
+ path;
9671
+ body;
9672
+ constructor(method, path, status, body) {
9673
+ super(`Hasna cloud request failed: ${method} ${path} -> ${status}`);
9674
+ this.name = "HasnaHttpError";
9675
+ this.status = status;
9676
+ this.method = method;
9677
+ this.path = path;
9678
+ this.body = body;
9679
+ }
9680
+ }
9681
+ var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
9682
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
9683
+ function appendQuery(path, query) {
9684
+ if (!query)
9685
+ return path;
9686
+ const params = query instanceof URLSearchParams ? query : new URLSearchParams;
9687
+ if (!(query instanceof URLSearchParams)) {
9688
+ for (const [key, value] of Object.entries(query)) {
9689
+ if (value === null || value === undefined)
9690
+ continue;
9691
+ if (Array.isArray(value)) {
9692
+ for (const v of value)
9693
+ params.append(key, String(v));
9694
+ } else {
9695
+ params.append(key, String(value));
9696
+ }
9697
+ }
9698
+ }
9699
+ const qs = params.toString();
9700
+ if (!qs)
9701
+ return path;
9702
+ return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
9703
+ }
9704
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
9705
+ function createHasnaHttpTransport(options) {
9706
+ const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
9707
+ const base = options.baseUrl.replace(/\/+$/, "");
9708
+ const timeoutMs = options.timeoutMs ?? 30000;
9709
+ const sleep = options.sleepImpl ?? defaultSleep;
9710
+ const defaultRetry = options.retry;
9711
+ function resolveRetry(callRetry) {
9712
+ const chosen = callRetry !== undefined ? callRetry : defaultRetry;
9713
+ if (chosen === false)
9714
+ return null;
9715
+ const r = chosen ?? {};
9716
+ return {
9717
+ retries: r.retries ?? 2,
9718
+ baseDelayMs: r.baseDelayMs ?? 200,
9719
+ maxDelayMs: r.maxDelayMs ?? 2000,
9720
+ retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
9721
+ };
9722
+ }
9723
+ async function once2(method, rel, url, body, opts) {
9724
+ const headers = {
9725
+ "x-api-key": options.apiKey,
9726
+ Authorization: `Bearer ${options.apiKey}`,
9727
+ Accept: "application/json",
9728
+ ...options.headers ?? {},
9729
+ ...opts.headers ?? {}
9730
+ };
9731
+ if (opts.idempotencyKey)
9732
+ headers["Idempotency-Key"] = opts.idempotencyKey;
9733
+ const init = { method, headers };
9734
+ if (body !== undefined) {
9735
+ headers["Content-Type"] = "application/json";
9736
+ init.body = JSON.stringify(body);
9737
+ }
9738
+ const controller = new AbortController;
9739
+ const onAbort = () => controller.abort();
9740
+ if (opts.signal) {
9741
+ if (opts.signal.aborted)
9742
+ controller.abort();
9743
+ else
9744
+ opts.signal.addEventListener("abort", onAbort, { once: true });
9745
+ }
9746
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
9747
+ init.signal = controller.signal;
9748
+ let response;
9749
+ try {
9750
+ response = await fetchImpl(url, init);
9751
+ } catch (error) {
9752
+ const err = error instanceof Error ? error : new Error(String(error));
9753
+ if (opts.signal?.aborted)
9754
+ return { ok: false, retryable: false, error: err };
9755
+ return { ok: false, retryable: true, error: err };
9756
+ } finally {
9757
+ clearTimeout(timer);
9758
+ if (opts.signal)
9759
+ opts.signal.removeEventListener("abort", onAbort);
9760
+ }
9761
+ const text = await response.text();
9762
+ let parsed = undefined;
9763
+ if (text.length > 0) {
9764
+ try {
9765
+ parsed = JSON.parse(text);
9766
+ } catch {
9767
+ parsed = text;
9768
+ }
9769
+ }
9770
+ if (!response.ok) {
9771
+ const retry = resolveRetry(opts.retry);
9772
+ const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
9773
+ return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
9774
+ }
9775
+ return { ok: true, value: parsed };
9776
+ }
9777
+ async function request(method, path, body, opts = {}) {
9778
+ const upper = method.toUpperCase();
9779
+ const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
9780
+ const url = `${base}${rel}`;
9781
+ const retry = resolveRetry(opts.retry);
9782
+ const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
9783
+ const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
9784
+ let last = null;
9785
+ for (let attempt = 1;attempt <= maxAttempts; attempt++) {
9786
+ const result = await once2(upper, rel, url, body, opts);
9787
+ if (result.ok)
9788
+ return result.value;
9789
+ last = result;
9790
+ const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
9791
+ if (!canRetry)
9792
+ break;
9793
+ const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
9794
+ const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
9795
+ await sleep(backoff + jitter);
9796
+ }
9797
+ throw last.error;
9798
+ }
9799
+ return {
9800
+ baseUrl: base,
9801
+ request,
9802
+ get: (path, opts) => request("GET", path, undefined, opts),
9803
+ post: (path, body, opts) => request("POST", path, body, opts),
9804
+ put: (path, body, opts) => request("PUT", path, body, opts),
9805
+ patch: (path, body, opts) => request("PATCH", path, body, opts),
9806
+ del: (path, body, opts) => request("DELETE", path, body, opts)
9807
+ };
9808
+ }
9809
+ function createClientTransport(name, env = process.env, overrides) {
9810
+ const resolution = resolveClientTransport(name, env);
9811
+ if (resolution.misconfigured) {
9812
+ throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for cloud mode.`);
9813
+ }
9814
+ if (resolution.transport === "local" || !resolution.baseUrl) {
9815
+ return { transport: "local", client: null, resolution };
9816
+ }
9817
+ const keys = clientTransportEnvKeys(name);
9818
+ const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
9819
+ if (!apiKey) {
9820
+ throw new Error(`Client for '${name}' resolved to cloud-http without an API key.`);
9821
+ }
9822
+ return {
9823
+ transport: "cloud-http",
9824
+ client: createHasnaHttpTransport({
9825
+ name,
9826
+ baseUrl: resolution.baseUrl,
9827
+ apiKey,
9828
+ ...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
9829
+ ...overrides?.headers ? { headers: overrides.headers } : {},
9830
+ ...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
9831
+ ...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
9832
+ ...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
9833
+ }),
9834
+ resolution
9835
+ };
9836
+ }
9837
+
9838
+ // src/lib/cloud/storage.ts
9839
+ function resourcePath(resource) {
9840
+ const trimmed = resource.replace(/^\/+|\/+$/g, "");
9841
+ if (!trimmed)
9842
+ throw new Error("resource must be a non-empty path segment");
9843
+ return `/${trimmed}`;
9844
+ }
9845
+ function entityPath(resource, id) {
9846
+ if (id === undefined || id === null || `${id}`.length === 0) {
9847
+ throw new Error("id must be a non-empty string");
9848
+ }
9849
+ return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
9850
+ }
9851
+ function newIdempotencyKey() {
9852
+ const g = globalThis;
9853
+ if (g.crypto?.randomUUID)
9854
+ return g.crypto.randomUUID();
9855
+ return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
9856
+ }
9857
+ function extractItems(raw) {
9858
+ if (Array.isArray(raw))
9859
+ return raw;
9860
+ if (raw && typeof raw === "object") {
9861
+ const obj = raw;
9862
+ for (const key of ["items", "data", "results", "rows", "records"]) {
9863
+ if (Array.isArray(obj[key]))
9864
+ return obj[key];
9865
+ }
9866
+ }
9867
+ return [];
9868
+ }
9869
+ function extractTotal(raw) {
9870
+ if (raw && typeof raw === "object") {
9871
+ const obj = raw;
9872
+ for (const key of ["total", "count", "totalCount", "total_count"]) {
9873
+ if (typeof obj[key] === "number")
9874
+ return obj[key];
9875
+ }
9876
+ }
9877
+ return null;
9878
+ }
9879
+ function extractCursor(raw) {
9880
+ if (raw && typeof raw === "object") {
9881
+ const obj = raw;
9882
+ for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
9883
+ if (typeof obj[key] === "string")
9884
+ return obj[key];
9885
+ }
9886
+ }
9887
+ return null;
9888
+ }
9889
+ function createHasnaStorageClient(name, transport) {
9890
+ return {
9891
+ name,
9892
+ baseUrl: transport.baseUrl,
9893
+ transport,
9894
+ async list(resource, options = {}) {
9895
+ const raw = await transport.get(resourcePath(resource), options);
9896
+ return {
9897
+ items: extractItems(raw),
9898
+ total: extractTotal(raw),
9899
+ cursor: extractCursor(raw),
9900
+ raw
9901
+ };
9902
+ },
9903
+ async get(resource, id, options = {}) {
9904
+ try {
9905
+ return await transport.get(entityPath(resource, id), options);
9906
+ } catch (error) {
9907
+ if (error instanceof HasnaHttpError && error.status === 404)
9908
+ return null;
9909
+ throw error;
9910
+ }
9911
+ },
9912
+ async create(resource, body, options = {}) {
9913
+ const { idempotencyKey, ...rest } = options;
9914
+ return transport.post(resourcePath(resource), body, {
9915
+ ...rest,
9916
+ idempotencyKey: idempotencyKey ?? newIdempotencyKey()
9917
+ });
9918
+ },
9919
+ async update(resource, id, patch, options = {}) {
9920
+ const { method = "PATCH", idempotencyKey, ...rest } = options;
9921
+ const call = method === "PUT" ? transport.put : transport.patch;
9922
+ return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
9923
+ },
9924
+ async delete(resource, id, options = {}) {
9925
+ try {
9926
+ await transport.del(entityPath(resource, id), undefined, options);
9927
+ } catch (error) {
9928
+ if (error instanceof HasnaHttpError && error.status === 404)
9929
+ return;
9930
+ throw error;
9931
+ }
9932
+ }
9933
+ };
9934
+ }
9935
+
9936
+ // src/lib/cloud/resolve.ts
9937
+ function firstValue(env, keys) {
9938
+ for (const key of keys) {
9939
+ const value = env[key]?.trim();
9940
+ if (value)
9941
+ return value;
9942
+ }
9943
+ return;
9944
+ }
9945
+ function resolveCloudStorage(name, env = process.env) {
9946
+ const token = envToken(name);
9947
+ const modeKeys = [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`];
9948
+ const apiUrlKeys = [`HASNA_${token}_API_URL`, `${token}_API_URL`];
9949
+ const apiKeyKeys = [
9950
+ `HASNA_${token}_API_KEY`,
9951
+ `${token}_API_KEY`,
9952
+ `HASNA_${token}_API_TOKEN`,
9953
+ `${token}_API_TOKEN`
9954
+ ];
9955
+ const explicitMode = firstValue(env, modeKeys);
9956
+ if (explicitMode && normalizeStorageMode(explicitMode).mode === "local") {
9957
+ return { transport: "local", client: null };
9958
+ }
9959
+ const apiUrl = firstValue(env, apiUrlKeys);
9960
+ const apiKey = firstValue(env, apiKeyKeys);
9961
+ if (!apiUrl || !apiKey) {
9962
+ return { transport: "local", client: null };
9963
+ }
9964
+ const cloudEnv = { ...env, [`HASNA_${token}_STORAGE_MODE`]: "self_hosted" };
9965
+ const wired = createClientTransport(name, cloudEnv);
9966
+ if (wired.transport !== "cloud-http") {
9967
+ return { transport: "local", client: null };
9968
+ }
9969
+ return {
9970
+ transport: "cloud-http",
9971
+ client: createHasnaStorageClient(name, wired.client),
9972
+ baseUrl: wired.client.baseUrl
9973
+ };
9974
+ }
9975
+
9976
+ // src/lib/store/index.ts
9977
+ class CloudUnsupportedError extends Error {
9978
+ constructor(operation) {
9979
+ super(`operation not supported over the hosted OpenLoops API: ${operation}. ` + `Run it on a machine using local storage, or unset HASNA_LOOPS_API_URL/HASNA_LOOPS_API_KEY.`);
9980
+ this.name = "CloudUnsupportedError";
9981
+ }
9982
+ }
9983
+
9984
+ class LocalStore {
9985
+ transport = "local";
9986
+ store;
9987
+ constructor(store = new Store) {
9988
+ this.store = store;
9989
+ }
9990
+ get raw() {
9991
+ return this.store;
9992
+ }
9993
+ async close() {
9994
+ this.store.close();
9995
+ }
9996
+ async createLoop(input, from) {
9997
+ return from ? this.store.createLoop(input, from) : this.store.createLoop(input);
9998
+ }
9999
+ async getLoop(id) {
10000
+ return this.store.getLoop(id);
10001
+ }
10002
+ async findLoopByName(name) {
10003
+ return this.store.findLoopByName(name);
10004
+ }
10005
+ async requireLoop(idOrName) {
10006
+ return this.store.requireLoop(idOrName);
10007
+ }
10008
+ async requireUniqueLoop(idOrName) {
10009
+ return this.store.requireUniqueLoop(idOrName);
10010
+ }
10011
+ async listLoops(opts = {}) {
10012
+ return this.store.listLoops(opts);
10013
+ }
10014
+ async countLoops(status, opts = {}) {
10015
+ return this.store.countLoops(status, opts);
10016
+ }
10017
+ async updateLoop(id, patch) {
10018
+ return this.store.updateLoop(id, patch);
10019
+ }
10020
+ async renameLoop(id, name) {
10021
+ return this.store.renameLoop(id, name);
10022
+ }
10023
+ async archiveLoop(idOrName) {
10024
+ return this.store.archiveLoop(idOrName);
10025
+ }
10026
+ async unarchiveLoop(idOrName) {
10027
+ return this.store.unarchiveLoop(idOrName);
10028
+ }
10029
+ async deleteLoop(idOrName) {
10030
+ return this.store.deleteLoop(idOrName);
10031
+ }
10032
+ async createWorkflow(input) {
10033
+ return this.store.createWorkflow(input);
10034
+ }
10035
+ async getWorkflow(id) {
10036
+ return this.store.getWorkflow(id);
10037
+ }
10038
+ async findWorkflowByName(name) {
10039
+ return this.store.findWorkflowByName(name);
10040
+ }
10041
+ async requireWorkflow(idOrName) {
10042
+ return this.store.requireWorkflow(idOrName);
10043
+ }
10044
+ async listWorkflows(opts = {}) {
10045
+ return this.store.listWorkflows(opts);
10046
+ }
10047
+ async countWorkflows(opts = {}) {
10048
+ return this.store.countWorkflows(opts);
10049
+ }
10050
+ async archiveWorkflow(idOrName) {
10051
+ return this.store.archiveWorkflow(idOrName);
10052
+ }
10053
+ async getWorkflowRun(id) {
10054
+ return this.store.getWorkflowRun(id);
10055
+ }
10056
+ async requireWorkflowRun(id) {
10057
+ return this.store.requireWorkflowRun(id);
10058
+ }
10059
+ async listWorkflowRuns(opts = {}) {
10060
+ return this.store.listWorkflowRuns(opts);
10061
+ }
10062
+ async listWorkflowStepRuns(workflowRunId) {
10063
+ return this.store.listWorkflowStepRuns(workflowRunId);
10064
+ }
10065
+ async listWorkflowEvents(workflowRunId, limit) {
10066
+ return limit === undefined ? this.store.listWorkflowEvents(workflowRunId) : this.store.listWorkflowEvents(workflowRunId, limit);
10067
+ }
10068
+ async recoverWorkflowRun(workflowRunId, reason) {
10069
+ return reason === undefined ? this.store.recoverWorkflowRun(workflowRunId) : this.store.recoverWorkflowRun(workflowRunId, reason);
10070
+ }
10071
+ async cancelWorkflowRun(workflowRunId, reason) {
10072
+ return reason === undefined ? this.store.cancelWorkflowRun(workflowRunId) : this.store.cancelWorkflowRun(workflowRunId, reason);
10073
+ }
10074
+ async listWorkflowWorkItems(opts = {}) {
10075
+ return this.store.listWorkflowWorkItems(opts);
10076
+ }
10077
+ async getWorkflowWorkItem(id) {
10078
+ return this.store.getWorkflowWorkItem(id);
10079
+ }
10080
+ async requeueWorkflowWorkItem(id, patch = {}) {
10081
+ return this.store.requeueWorkflowWorkItem(id, patch);
10082
+ }
10083
+ async listWorkflowInvocations(opts = {}) {
10084
+ return this.store.listWorkflowInvocations(opts);
10085
+ }
10086
+ async getWorkflowInvocation(id) {
10087
+ return this.store.getWorkflowInvocation(id);
10088
+ }
10089
+ async getGoal(id) {
10090
+ return this.store.getGoal(id);
10091
+ }
10092
+ async findGoalByLoop(idOrName) {
10093
+ return this.store.findGoalByLoop(idOrName);
10094
+ }
10095
+ async findGoalByRunId(id) {
10096
+ return this.store.findGoalByRunId(id);
10097
+ }
10098
+ async listGoals(opts = {}) {
10099
+ return this.store.listGoals(opts);
10100
+ }
10101
+ async listGoalPlanNodes(goalIdOrPlanId) {
10102
+ return this.store.listGoalPlanNodes(goalIdOrPlanId);
10103
+ }
10104
+ async listGoalRuns(opts = {}) {
10105
+ return this.store.listGoalRuns(opts);
10106
+ }
10107
+ async listRuns(opts = {}) {
10108
+ return this.store.listRuns(opts);
10109
+ }
10110
+ async getRun(id) {
10111
+ return this.store.getRun(id);
10112
+ }
10113
+ async writeRunReceipt(input) {
10114
+ return this.store.writeRunReceipt(input);
10115
+ }
10116
+ async getRunReceipt(runId) {
10117
+ return this.store.getRunReceipt(runId);
10118
+ }
10119
+ async listRunReceipts(opts = {}) {
10120
+ return this.store.listRunReceipts(opts);
10121
+ }
10122
+ async pruneHistory(opts) {
10123
+ return this.store.pruneHistory(opts);
10124
+ }
10125
+ }
10126
+ function clean(query) {
10127
+ const out = {};
10128
+ for (const [key, value] of Object.entries(query)) {
10129
+ if (value !== undefined && value !== null && value !== "")
10130
+ out[key] = value;
10131
+ }
10132
+ return out;
10133
+ }
10134
+ function pickArray(raw, key, fallback = []) {
10135
+ if (raw && typeof raw === "object" && Array.isArray(raw[key])) {
10136
+ return raw[key];
10137
+ }
10138
+ return fallback;
10139
+ }
10140
+ function pickObject(raw, key) {
10141
+ if (raw && typeof raw === "object") {
10142
+ const value = raw[key];
10143
+ return value == null ? undefined : value;
10144
+ }
10145
+ return raw == null ? undefined : raw;
10146
+ }
10147
+
10148
+ class ApiStore {
10149
+ client;
10150
+ baseUrl;
10151
+ transport = "cloud-http";
10152
+ constructor(client, baseUrl) {
10153
+ this.client = client;
10154
+ this.baseUrl = baseUrl;
10155
+ }
10156
+ get t() {
10157
+ return this.client.transport;
10158
+ }
10159
+ async close() {}
10160
+ async createLoop(input) {
10161
+ return pickObject(await this.t.post("/loops", input), "loop");
10162
+ }
10163
+ async getLoop(id) {
10164
+ try {
10165
+ return pickObject(await this.t.get(`/loops/${encodeURIComponent(id)}`), "loop");
10166
+ } catch {
10167
+ return;
10168
+ }
10169
+ }
10170
+ async findLoopByName(name) {
10171
+ const matches = (await this.listLoops({ name, limit: 1000 })).filter((loop) => loop.name === name);
10172
+ return matches[0];
10173
+ }
10174
+ async requireLoop(idOrName) {
10175
+ const loop = await this.resolveLoop(idOrName);
10176
+ if (!loop)
10177
+ throw new LoopNotFoundError(idOrName);
10178
+ return loop;
10179
+ }
10180
+ async requireUniqueLoop(idOrName) {
10181
+ const byId = await this.getLoop(idOrName);
10182
+ if (byId)
10183
+ return byId;
10184
+ const matches = (await this.listLoops({ name: idOrName, limit: 1000 })).filter((loop) => loop.name === idOrName);
10185
+ if (matches.length === 0)
10186
+ throw new LoopNotFoundError(idOrName);
10187
+ if (matches.length === 1)
10188
+ return matches[0];
10189
+ const active = matches.filter((loop) => !loop.archivedAt);
10190
+ if (active.length !== 1)
10191
+ throw new AmbiguousNameError(idOrName);
10192
+ return active[0];
10193
+ }
10194
+ async resolveLoop(idOrName) {
10195
+ const byId = await this.getLoop(idOrName);
10196
+ if (byId)
10197
+ return byId;
10198
+ const matches = (await this.listLoops({ name: idOrName, limit: 1000 })).filter((loop) => loop.name === idOrName);
10199
+ if (matches.length === 0)
10200
+ return;
10201
+ if (matches.length === 1)
10202
+ return matches[0];
10203
+ const active = matches.filter((loop) => !loop.archivedAt);
10204
+ if (active.length === 1)
10205
+ return active[0];
10206
+ throw new AmbiguousNameError(idOrName);
10207
+ }
10208
+ async listLoops(opts = {}) {
10209
+ const raw = await this.t.get("/loops", { query: clean({ ...opts }) });
10210
+ return pickArray(raw, "loops");
10211
+ }
10212
+ async countLoops(status, opts = {}) {
10213
+ const raw = await this.t.get("/loops/count", { query: clean({ status, ...opts }) });
10214
+ return Number(pickObject(raw, "count") ?? 0);
10215
+ }
10216
+ async updateLoop(id, patch) {
10217
+ return pickObject(await this.t.patch(`/loops/${encodeURIComponent(id)}`, patch), "loop");
10218
+ }
10219
+ async renameLoop(id, name) {
10220
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(id)}/rename`, { name }), "loop");
10221
+ }
10222
+ async archiveLoop(idOrName) {
10223
+ const loop = await this.requireLoop(idOrName);
10224
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(loop.id)}/archive`), "loop");
10225
+ }
10226
+ async unarchiveLoop(idOrName) {
10227
+ const loop = await this.requireLoop(idOrName);
10228
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(loop.id)}/unarchive`), "loop");
10229
+ }
10230
+ async deleteLoop(idOrName) {
10231
+ const loop = await this.resolveLoop(idOrName);
10232
+ if (!loop)
10233
+ return false;
10234
+ const raw = await this.t.request("DELETE", `/loops/${encodeURIComponent(loop.id)}`);
10235
+ return Boolean(pickObject(raw, "deleted") ?? true);
10236
+ }
10237
+ async createWorkflow(input) {
10238
+ return pickObject(await this.t.post("/workflows", input), "workflow");
10239
+ }
10240
+ async getWorkflow(id) {
10241
+ try {
10242
+ return pickObject(await this.t.get(`/workflows/${encodeURIComponent(id)}`), "workflow");
10243
+ } catch {
10244
+ return;
10245
+ }
10246
+ }
10247
+ async findWorkflowByName(name) {
10248
+ const matches = (await this.listWorkflows({ limit: 1000 })).filter((wf) => wf.name === name);
10249
+ return matches[0];
10250
+ }
10251
+ async requireWorkflow(idOrName) {
10252
+ const byId = await this.getWorkflow(idOrName);
10253
+ if (byId)
10254
+ return byId;
10255
+ const byName = await this.findWorkflowByName(idOrName);
10256
+ if (byName)
10257
+ return byName;
10258
+ throw new Error(`workflow not found: ${idOrName}`);
10259
+ }
10260
+ async listWorkflows(opts = {}) {
10261
+ const raw = await this.t.get("/workflows", { query: clean({ ...opts }) });
10262
+ return pickArray(raw, "workflows");
10263
+ }
10264
+ async countWorkflows(opts = {}) {
10265
+ const raw = await this.t.get("/workflows/count", { query: clean({ ...opts }) });
10266
+ return Number(pickObject(raw, "count") ?? 0);
10267
+ }
10268
+ async archiveWorkflow(idOrName) {
10269
+ const wf = await this.requireWorkflow(idOrName);
10270
+ return pickObject(await this.t.post(`/workflows/${encodeURIComponent(wf.id)}/archive`), "workflow");
10271
+ }
10272
+ async getWorkflowRun(id) {
10273
+ try {
10274
+ return pickObject(await this.t.get(`/workflow-runs/${encodeURIComponent(id)}`), "workflowRun");
10275
+ } catch {
10276
+ return;
10277
+ }
10278
+ }
10279
+ async requireWorkflowRun(id) {
10280
+ const run = await this.getWorkflowRun(id);
10281
+ if (!run)
10282
+ throw new Error(`workflow run not found: ${id}`);
10283
+ return run;
10284
+ }
10285
+ async listWorkflowRuns(opts = {}) {
10286
+ const raw = await this.t.get("/workflow-runs", { query: clean({ ...opts }) });
10287
+ return pickArray(raw, "workflowRuns");
10288
+ }
10289
+ async listWorkflowStepRuns(workflowRunId) {
10290
+ const raw = await this.t.get(`/workflow-runs/${encodeURIComponent(workflowRunId)}/steps`);
10291
+ return pickArray(raw, "steps");
10292
+ }
10293
+ async listWorkflowEvents(workflowRunId, limit) {
10294
+ const raw = await this.t.get(`/workflow-runs/${encodeURIComponent(workflowRunId)}/events`, { query: clean({ limit }) });
10295
+ return pickArray(raw, "events");
10296
+ }
10297
+ async recoverWorkflowRun(workflowRunId, reason) {
10298
+ const raw = await this.t.post(`/workflow-runs/${encodeURIComponent(workflowRunId)}/recover`, { reason });
10299
+ return {
10300
+ run: pickObject(raw, "workflowRun"),
10301
+ recoveredSteps: pickArray(raw, "recoveredSteps")
10302
+ };
10303
+ }
10304
+ async cancelWorkflowRun(_workflowRunId, _reason) {
10305
+ throw new CloudUnsupportedError("workflows cancel");
10306
+ }
10307
+ async listWorkflowWorkItems(opts = {}) {
10308
+ const raw = await this.t.get("/work-items", { query: clean({ ...opts }) });
10309
+ return pickArray(raw, "workItems");
10310
+ }
10311
+ async getWorkflowWorkItem(id) {
10312
+ try {
10313
+ return pickObject(await this.t.get(`/work-items/${encodeURIComponent(id)}`), "workItem");
10314
+ } catch {
10315
+ return;
10316
+ }
10317
+ }
10318
+ async requeueWorkflowWorkItem(_id, _patch = {}) {
10319
+ throw new CloudUnsupportedError("routes requeue");
10320
+ }
10321
+ async listWorkflowInvocations(opts = {}) {
10322
+ const raw = await this.t.get("/invocations", { query: clean({ ...opts }) });
10323
+ return pickArray(raw, "invocations");
10324
+ }
10325
+ async getWorkflowInvocation(id) {
10326
+ try {
10327
+ return pickObject(await this.t.get(`/invocations/${encodeURIComponent(id)}`), "invocation");
10328
+ } catch {
10329
+ return;
10330
+ }
10331
+ }
10332
+ async getGoal(id) {
10333
+ try {
10334
+ return pickObject(await this.t.get(`/goals/${encodeURIComponent(id)}`), "goal");
10335
+ } catch {
10336
+ return;
10337
+ }
10338
+ }
10339
+ async findGoalByLoop(idOrName) {
10340
+ try {
10341
+ return pickObject(await this.t.get("/goals", { query: clean({ loop: idOrName }) }), "goal");
10342
+ } catch {
10343
+ return;
10344
+ }
10345
+ }
10346
+ async findGoalByRunId(id) {
10347
+ try {
10348
+ return pickObject(await this.t.get("/goals", { query: clean({ runId: id }) }), "goal");
10349
+ } catch {
10350
+ return;
10351
+ }
10352
+ }
10353
+ async listGoals(opts = {}) {
10354
+ const raw = await this.t.get("/goals", { query: clean({ ...opts }) });
10355
+ return pickArray(raw, "goals");
10356
+ }
10357
+ async listGoalPlanNodes(goalIdOrPlanId) {
10358
+ const raw = await this.t.get(`/goals/${encodeURIComponent(goalIdOrPlanId)}/plan-nodes`);
10359
+ return pickArray(raw, "nodes");
10360
+ }
10361
+ async listGoalRuns(opts = {}) {
10362
+ const raw = await this.t.get("/goal-runs", { query: clean({ ...opts }) });
10363
+ return pickArray(raw, "goalRuns");
10364
+ }
10365
+ async listRuns(opts = {}) {
10366
+ const raw = await this.t.get("/runs", { query: clean({ ...opts, showOutput: true }) });
10367
+ return pickArray(raw, "runs");
10368
+ }
10369
+ async getRun(id) {
10370
+ try {
10371
+ return pickObject(await this.t.get(`/runs/${encodeURIComponent(id)}`, { query: { showOutput: true } }), "run");
10372
+ } catch {
10373
+ return;
10374
+ }
10375
+ }
10376
+ async writeRunReceipt(input) {
10377
+ return pickObject(await this.t.post("/receipts", input), "receipt");
10378
+ }
10379
+ async getRunReceipt(runId) {
10380
+ try {
10381
+ return pickObject(await this.t.get(`/receipts/${encodeURIComponent(runId)}`), "receipt");
10382
+ } catch {
10383
+ return;
10384
+ }
10385
+ }
10386
+ async listRunReceipts(opts = {}) {
10387
+ const raw = await this.t.get("/receipts", { query: clean({ ...opts }) });
10388
+ return pickArray(raw, "receipts");
10389
+ }
10390
+ async pruneHistory(opts) {
10391
+ const raw = await this.t.post("/history/prune", {
10392
+ maxAgeDays: opts.maxAgeDays,
10393
+ keepPerLoop: opts.keepPerLoop,
10394
+ dryRun: opts.dryRun
10395
+ });
10396
+ return pickObject(raw, "history");
10397
+ }
10398
+ }
10399
+ function getStore(env = process.env) {
10400
+ const resolution = resolveCloudStorage("loops", env);
10401
+ if (resolution.transport === "cloud-http")
10402
+ return new ApiStore(resolution.client, resolution.baseUrl);
10403
+ return new LocalStore;
10404
+ }
10405
+ function isCloudStore(env = process.env) {
10406
+ return resolveCloudStorage("loops", env).transport === "cloud-http";
10407
+ }
10408
+
10409
+ // src/mcp/index.ts
10410
+ var LOOP_STATUSES = ["active", "paused", "stopped", "expired"];
10411
+ var LOOP_STATUS_FILTERS = [...LOOP_STATUSES, "all"];
10412
+ var RUN_STATUSES = ["running", "succeeded", "failed", "timed_out", "abandoned", "skipped"];
10413
+ var WORKFLOW_STATUSES = ["active", "archived"];
10414
+ var WORKFLOW_STATUS_FILTERS = [...WORKFLOW_STATUSES, "all"];
10415
+ var CATCH_UP_POLICIES = ["none", "latest", "all"];
10416
+ var OVERLAP_POLICIES = ["skip", "allow"];
10417
+ var INTERVAL_ANCHORS = ["fixed_rate", "fixed_delay"];
10418
+ var MAX_LIMIT = 500;
9016
10419
  var MUTATION_ENV = "LOOPS_MCP_ALLOW_MUTATIONS";
9017
10420
  var loopIdOrNameSchema = z2.string().min(1).describe("Loop id or exact loop name. Names resolve on exact match only; ambiguous names require the id.");
9018
10421
  var workflowIdOrNameSchema = z2.string().min(1).describe("Workflow id or exact workflow name.");
9019
10422
  var showOutputSchema = z2.boolean().optional().describe("Include raw stdout/stderr (default false: only redacted lengths are returned).");
9020
10423
  var limitSchema = z2.number().int().min(1).max(MAX_LIMIT).optional().describe(`Maximum entries to return (1-${MAX_LIMIT}).`);
10424
+ var runReceiptSummarySchema = z2.object({
10425
+ text: z2.string().optional().describe("Short human summary. It is scrubbed and bounded before storage."),
10426
+ stdout_bytes: z2.number().int().min(0).optional().describe("Original stdout byte count."),
10427
+ stderr_bytes: z2.number().int().min(0).optional().describe("Original stderr byte count."),
10428
+ stdout_excerpt: z2.string().optional().describe("Bounded stdout excerpt. Raw unbounded stdout is never required."),
10429
+ stderr_excerpt: z2.string().optional().describe("Bounded stderr excerpt. Raw unbounded stderr is never required."),
10430
+ error: z2.string().optional().describe("Bounded error excerpt."),
10431
+ duration_ms: z2.number().int().min(0).optional().describe("Run duration in milliseconds.")
10432
+ });
10433
+ var runReceiptInputSchema = {
10434
+ loop_id: z2.string().min(1).optional().describe("OpenLoops loop id. Optional when run_id references an existing loop run."),
10435
+ run_id: z2.string().min(1).describe("Scheduler-neutral run id. Existing values are updated idempotently."),
10436
+ machine: z2.union([z2.string().min(1), z2.record(z2.string(), z2.unknown())]).optional().describe("Machine id/name or machine metadata object."),
10437
+ repo: z2.string().min(1).optional().describe("Repository path or owner/repo string. Defaults from the loop target cwd when possible."),
10438
+ task_ids: z2.array(z2.string().min(1)).optional().describe("Task ids associated with this run."),
10439
+ knowledge_ids: z2.array(z2.string().min(1)).optional().describe("Knowledge record ids associated with this run."),
10440
+ digest_id: z2.string().min(1).optional().describe("Stable digest id. Computed from normalized receipt content when omitted."),
10441
+ started_at: z2.string().nullable().optional().describe("Run start timestamp."),
10442
+ finished_at: z2.string().nullable().optional().describe("Run finish timestamp."),
10443
+ status: z2.string().min(1).optional().describe("Run status."),
10444
+ exit_code: z2.number().int().nullable().optional().describe("Process exit code."),
10445
+ summary: z2.union([z2.string(), runReceiptSummarySchema]).nullable().optional().describe("Bounded structured summary; may be a string shorthand."),
10446
+ evidence_paths: z2.array(z2.string().min(1)).optional().describe("Bounded paths to durable evidence artifacts."),
10447
+ stdout: z2.string().optional().describe("Optional raw stdout to summarize and bound before storage."),
10448
+ stderr: z2.string().optional().describe("Optional raw stderr to summarize and bound before storage."),
10449
+ error: z2.string().optional().describe("Optional raw error text to summarize and bound before storage."),
10450
+ duration_ms: z2.number().int().min(0).optional().describe("Run duration in milliseconds.")
10451
+ };
9021
10452
  var optionalTimeoutSchema = z2.number().int().positive().nullable().optional().describe("Per-run timeout in milliseconds; null disables the timeout.");
9022
10453
  var catchUpSchema = z2.enum(CATCH_UP_POLICIES).optional().describe("Missed-slot policy after downtime: none skips them, latest replays the newest slot, all replays every slot.");
9023
10454
  var overlapSchema = z2.enum(OVERLAP_POLICIES).optional().describe("Behavior when a slot comes due while a previous run is active: skip records a skipped run, allow runs concurrently.");
@@ -9087,6 +10518,17 @@ function errorResult(error) {
9087
10518
  };
9088
10519
  }
9089
10520
  async function withStore(fn) {
10521
+ const store = getStore();
10522
+ try {
10523
+ return await fn(store);
10524
+ } finally {
10525
+ await store.close();
10526
+ }
10527
+ }
10528
+ async function withLocalStore(operation, fn) {
10529
+ if (isCloudStore()) {
10530
+ throw new Error(`'${operation}' inspects this machine's local OpenLoops runtime and is not available while flipped to the hosted OpenLoops API. ` + `Unset HASNA_LOOPS_API_URL/HASNA_LOOPS_API_KEY (or set HASNA_LOOPS_STORAGE_MODE=local) to run it here.`);
10531
+ }
9090
10532
  const store = new Store;
9091
10533
  try {
9092
10534
  return await fn(store);
@@ -9100,7 +10542,7 @@ function requireMutationsEnabled() {
9100
10542
  throw new Error(`MCP mutation tools require ${MUTATION_ENV}=true`);
9101
10543
  }
9102
10544
  }
9103
- function nonEmpty(value, label) {
10545
+ function nonEmpty2(value, label) {
9104
10546
  const trimmed = value.trim();
9105
10547
  if (!trimmed)
9106
10548
  throw new Error(`${label} must be non-empty`);
@@ -9118,7 +10560,7 @@ function normalizeSchedule(input) {
9118
10560
  if (input.type === "interval")
9119
10561
  return { type: "interval", everyMs: input.everyMs, anchor: input.anchor ?? "fixed_rate" };
9120
10562
  if (input.type === "cron")
9121
- return { type: "cron", expression: nonEmpty(input.expression, "schedule.expression") };
10563
+ return { type: "cron", expression: nonEmpty2(input.expression, "schedule.expression") };
9122
10564
  return { type: "dynamic", minIntervalMs: input.minIntervalMs };
9123
10565
  }
9124
10566
  function durationLabel(ms) {
@@ -9161,7 +10603,7 @@ function defaultLoopDescription(name, schedule, target) {
9161
10603
  ].join(" ");
9162
10604
  }
9163
10605
  function commonCreateInput(input) {
9164
- const name = nonEmpty(input.name, "name");
10606
+ const name = nonEmpty2(input.name, "name");
9165
10607
  const schedule = normalizeSchedule(input.schedule);
9166
10608
  return {
9167
10609
  name,
@@ -9217,13 +10659,13 @@ var TOOL_REGISTRATIONS = [
9217
10659
  includeArchived: z2.boolean().optional().describe("Include archived loops alongside live ones (default false)."),
9218
10660
  archivedOnly: z2.boolean().optional().describe("Return only archived loops (default false).")
9219
10661
  },
9220
- handler: ({ status, limit, includeArchived, archivedOnly }) => withStore((store) => ({
9221
- loops: store.listLoops({
10662
+ handler: ({ status, limit, includeArchived, archivedOnly }) => withStore(async (store) => ({
10663
+ loops: (await store.listLoops({
9222
10664
  status: filteredLoopStatus(status),
9223
10665
  limit,
9224
10666
  includeArchived: includeArchived ?? false,
9225
10667
  archived: archivedOnly ?? false
9226
- }).map(publicLoop)
10668
+ })).map(publicLoop)
9227
10669
  }))
9228
10670
  },
9229
10671
  {
@@ -9236,9 +10678,9 @@ var TOOL_REGISTRATIONS = [
9236
10678
  includeLatestRun: z2.boolean().optional().describe("Include the most recent run record (default false)."),
9237
10679
  showOutput: showOutputSchema
9238
10680
  },
9239
- handler: ({ idOrName, includeLatestRun, showOutput }) => withStore((store) => {
9240
- const loop = store.requireLoop(idOrName);
9241
- const latestRun = includeLatestRun ? store.listRuns({ loopId: loop.id, limit: 1 })[0] : undefined;
10681
+ handler: ({ idOrName, includeLatestRun, showOutput }) => withStore(async (store) => {
10682
+ const loop = await store.requireLoop(idOrName);
10683
+ const latestRun = includeLatestRun ? (await store.listRuns({ loopId: loop.id, limit: 1 }))[0] : undefined;
9242
10684
  return {
9243
10685
  loop: publicLoop(loop),
9244
10686
  latestRun: latestRun ? publicRun(latestRun, showOutput ?? false) : undefined
@@ -9246,34 +10688,78 @@ var TOOL_REGISTRATIONS = [
9246
10688
  })
9247
10689
  },
9248
10690
  {
9249
- name: "loops_runs",
9250
- aliases: ["loop_runs"],
9251
- description: "List loop runs with optional loop/status filtering.",
10691
+ name: "loops_runs",
10692
+ aliases: ["loop_runs"],
10693
+ description: "List loop runs with optional loop/status filtering.",
10694
+ readOnly: true,
10695
+ annotations: READ_ONLY_ANNOTATIONS,
10696
+ inputSchema: {
10697
+ idOrName: loopIdOrNameSchema.optional(),
10698
+ status: z2.enum(RUN_STATUSES).optional().describe("Filter by run status."),
10699
+ limit: limitSchema,
10700
+ showOutput: showOutputSchema
10701
+ },
10702
+ handler: ({ idOrName, status, limit, showOutput }) => withStore(async (store) => {
10703
+ const loop = idOrName ? await store.requireLoop(idOrName) : undefined;
10704
+ const runs = (await store.listRuns({
10705
+ loopId: loop?.id,
10706
+ status,
10707
+ limit
10708
+ })).map((run) => publicRun(run, showOutput ?? false));
10709
+ return { loop: loop ? publicLoop(loop) : undefined, runs };
10710
+ })
10711
+ },
10712
+ {
10713
+ name: "loops_receipts_list",
10714
+ description: "List scheduler-neutral run receipts with bounded summaries and evidence paths.",
10715
+ readOnly: true,
10716
+ annotations: READ_ONLY_ANNOTATIONS,
10717
+ inputSchema: {
10718
+ loop_id: z2.string().min(1).optional().describe("Filter by loop_id."),
10719
+ repo: z2.string().min(1).optional().describe("Filter by repo."),
10720
+ task_id: z2.string().min(1).optional().describe("Filter by task id."),
10721
+ knowledge_id: z2.string().min(1).optional().describe("Filter by knowledge id."),
10722
+ status: z2.string().min(1).optional().describe("Filter by receipt status."),
10723
+ limit: limitSchema
10724
+ },
10725
+ handler: ({ loop_id, repo, task_id, knowledge_id, status, limit }) => withStore(async (store) => ({
10726
+ receipts: (await store.listRunReceipts({ loopId: loop_id, repo, taskId: task_id, knowledgeId: knowledge_id, status, limit })).map(publicRunReceipt)
10727
+ }))
10728
+ },
10729
+ {
10730
+ name: "loops_receipt_read",
10731
+ description: "Read one scheduler-neutral run receipt by run id.",
9252
10732
  readOnly: true,
9253
10733
  annotations: READ_ONLY_ANNOTATIONS,
9254
10734
  inputSchema: {
9255
- idOrName: loopIdOrNameSchema.optional(),
9256
- status: z2.enum(RUN_STATUSES).optional().describe("Filter by run status."),
9257
- limit: limitSchema,
9258
- showOutput: showOutputSchema
10735
+ run_id: z2.string().min(1).describe("Run id.")
9259
10736
  },
9260
- handler: ({ idOrName, status, limit, showOutput }) => withStore((store) => {
9261
- const loop = idOrName ? store.requireLoop(idOrName) : undefined;
9262
- const runs = store.listRuns({
9263
- loopId: loop?.id,
9264
- status,
9265
- limit
9266
- }).map((run) => publicRun(run, showOutput ?? false));
9267
- return { loop: loop ? publicLoop(loop) : undefined, runs };
10737
+ handler: ({ run_id }) => withStore(async (store) => {
10738
+ const receipt = await store.getRunReceipt(run_id);
10739
+ if (!receipt)
10740
+ throw new Error(`run receipt not found: ${run_id}`);
10741
+ return { receipt: publicRunReceipt(receipt) };
9268
10742
  })
9269
10743
  },
10744
+ {
10745
+ name: "loops_receipt_write",
10746
+ description: `Write a scheduler-neutral run receipt. Requires ${MUTATION_ENV}=true on the MCP server process.`,
10747
+ readOnly: false,
10748
+ guarded: true,
10749
+ annotations: mutationAnnotations({ idempotent: true }),
10750
+ inputSchema: runReceiptInputSchema,
10751
+ handler: (input) => {
10752
+ requireMutationsEnabled();
10753
+ return withStore(async (store) => ({ receipt: publicRunReceipt(await store.writeRunReceipt(input)) }));
10754
+ }
10755
+ },
9270
10756
  {
9271
10757
  name: "loops_doctor",
9272
10758
  description: "Run OpenLoops runtime diagnostics.",
9273
10759
  readOnly: true,
9274
10760
  annotations: READ_ONLY_ANNOTATIONS,
9275
10761
  inputSchema: {},
9276
- handler: () => withStore((store) => runDoctor(store))
10762
+ handler: () => withLocalStore("loops_doctor", (store) => runDoctor(store))
9277
10763
  },
9278
10764
  {
9279
10765
  name: "loops_health",
@@ -9285,7 +10771,7 @@ var TOOL_REGISTRATIONS = [
9285
10771
  includeInactive: z2.boolean().optional().describe("Include stopped/expired loops (default false: active and paused only)."),
9286
10772
  limit: limitSchema
9287
10773
  },
9288
- handler: ({ includeArchived, includeInactive, limit }) => withStore((store) => buildHealthReport(store, { includeArchived, includeInactive, limit }))
10774
+ handler: ({ includeArchived, includeInactive, limit }) => withLocalStore("loops_health", (store) => buildHealthReport(store, { includeArchived, includeInactive, limit }))
9289
10775
  },
9290
10776
  {
9291
10777
  name: "loops_health_scan",
@@ -9302,7 +10788,7 @@ var TOOL_REGISTRATIONS = [
9302
10788
  maxFindings: z2.number().int().min(0).max(MAX_LIMIT).optional().describe(`Maximum findings to return (0-${MAX_LIMIT}, default 100).`),
9303
10789
  limit: limitSchema
9304
10790
  },
9305
- handler: ({ includeStatuses, includeArchived, latestRun, doctor, daemon, staleRunningMs, maxFindings, limit }) => withStore((store) => buildHealthScan(store, {
10791
+ handler: ({ includeStatuses, includeArchived, latestRun, doctor, daemon, staleRunningMs, maxFindings, limit }) => withLocalStore("loops_health_scan", (store) => buildHealthScan(store, {
9306
10792
  includeStatuses,
9307
10793
  includeArchived,
9308
10794
  latestRun,
@@ -9324,7 +10810,7 @@ var TOOL_REGISTRATIONS = [
9324
10810
  runLimit: z2.number().int().min(1).max(50).optional().describe("How many recent runs to classify (1-50, default 5)."),
9325
10811
  showOutput: showOutputSchema
9326
10812
  },
9327
- handler: ({ idOrName, runLimit, showOutput }) => withStore((store) => {
10813
+ handler: ({ idOrName, runLimit, showOutput }) => withLocalStore("loops_diagnose", (store) => {
9328
10814
  const loop = store.requireLoop(idOrName);
9329
10815
  const runs = store.listRuns({ loopId: loop.id, limit: runLimit ?? 5 });
9330
10816
  return {
@@ -9344,7 +10830,7 @@ var TOOL_REGISTRATIONS = [
9344
10830
  readOnly: true,
9345
10831
  annotations: READ_ONLY_ANNOTATIONS,
9346
10832
  inputSchema: {},
9347
- handler: () => withStore((store) => daemonStatus(store))
10833
+ handler: () => withLocalStore("loops_daemon_status", (store) => daemonStatus(store))
9348
10834
  },
9349
10835
  {
9350
10836
  name: "loops_workflows_list",
@@ -9357,12 +10843,12 @@ var TOOL_REGISTRATIONS = [
9357
10843
  limit: limitSchema,
9358
10844
  offset: z2.number().int().min(0).optional().describe("Entries to skip before returning results (pagination).")
9359
10845
  },
9360
- handler: ({ status, limit, offset }) => withStore((store) => ({
9361
- workflows: store.listWorkflows({
10846
+ handler: ({ status, limit, offset }) => withStore(async (store) => ({
10847
+ workflows: (await store.listWorkflows({
9362
10848
  status: filteredWorkflowStatus(status),
9363
10849
  limit,
9364
10850
  offset
9365
- }).map(publicWorkflow)
10851
+ })).map(publicWorkflow)
9366
10852
  }))
9367
10853
  },
9368
10854
  {
@@ -9378,16 +10864,16 @@ var TOOL_REGISTRATIONS = [
9378
10864
  runLimit: limitSchema,
9379
10865
  showOutput: showOutputSchema
9380
10866
  },
9381
- handler: ({ idOrName, includeRuns, includeEvents, runLimit, showOutput }) => withStore((store) => {
9382
- const workflow = store.requireWorkflow(idOrName);
9383
- const runs = includeRuns ? store.listWorkflowRuns({ workflowId: workflow.id, limit: runLimit ?? 20 }) : [];
10867
+ handler: ({ idOrName, includeRuns, includeEvents, runLimit, showOutput }) => withStore(async (store) => {
10868
+ const workflow = await store.requireWorkflow(idOrName);
10869
+ const runs = includeRuns ? await store.listWorkflowRuns({ workflowId: workflow.id, limit: runLimit ?? 20 }) : [];
9384
10870
  return {
9385
10871
  workflow: publicWorkflow(workflow),
9386
- runs: runs.map((run) => ({
10872
+ runs: await Promise.all(runs.map(async (run) => ({
9387
10873
  ...publicWorkflowRun(run),
9388
- steps: store.listWorkflowStepRuns(run.id).map((step) => publicWorkflowStepRun(step, showOutput ?? false)),
9389
- events: includeEvents ? store.listWorkflowEvents(run.id).map(publicWorkflowEvent) : undefined
9390
- }))
10874
+ steps: (await store.listWorkflowStepRuns(run.id)).map((step) => publicWorkflowStepRun(step, showOutput ?? false)),
10875
+ events: includeEvents ? (await store.listWorkflowEvents(run.id)).map(publicWorkflowEvent) : undefined
10876
+ })))
9391
10877
  };
9392
10878
  })
9393
10879
  },
@@ -9429,14 +10915,14 @@ var TOOL_REGISTRATIONS = [
9429
10915
  includeEvents: z2.boolean().optional().describe("Include the workflow event log (default true)."),
9430
10916
  showOutput: showOutputSchema
9431
10917
  },
9432
- handler: ({ runId, includeEvents, showOutput }) => withStore((store) => {
9433
- const run = store.getWorkflowRun(runId);
10918
+ handler: ({ runId, includeEvents, showOutput }) => withStore(async (store) => {
10919
+ const run = await store.getWorkflowRun(runId);
9434
10920
  if (!run)
9435
10921
  throw new Error(`workflow run not found: ${runId}`);
9436
10922
  return {
9437
10923
  run: publicWorkflowRun(run),
9438
- steps: store.listWorkflowStepRuns(run.id).map((step) => publicWorkflowStepRun(step, showOutput ?? false)),
9439
- events: includeEvents ?? true ? store.listWorkflowEvents(run.id).map(publicWorkflowEvent) : undefined
10924
+ steps: (await store.listWorkflowStepRuns(run.id)).map((step) => publicWorkflowStepRun(step, showOutput ?? false)),
10925
+ events: includeEvents ?? true ? (await store.listWorkflowEvents(run.id)).map(publicWorkflowEvent) : undefined
9440
10926
  };
9441
10927
  })
9442
10928
  },
@@ -9448,7 +10934,12 @@ var TOOL_REGISTRATIONS = [
9448
10934
  guarded: true,
9449
10935
  annotations: mutationAnnotations({ idempotent: true }),
9450
10936
  inputSchema: { idOrName: loopIdOrNameSchema },
9451
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "paused" })) }))
10937
+ handler: ({ idOrName }) => withStore(async (store) => {
10938
+ const loop = await store.requireUniqueLoop(idOrName);
10939
+ if (loop.archivedAt)
10940
+ throw new LoopArchivedError(idOrName);
10941
+ return { loop: publicLoop(await store.updateLoop(loop.id, { status: "paused" })) };
10942
+ })
9452
10943
  },
9453
10944
  {
9454
10945
  name: "loops_resume",
@@ -9458,14 +10949,16 @@ var TOOL_REGISTRATIONS = [
9458
10949
  guarded: true,
9459
10950
  annotations: mutationAnnotations({ idempotent: true }),
9460
10951
  inputSchema: { idOrName: loopIdOrNameSchema },
9461
- handler: ({ idOrName }) => withStore((store) => {
9462
- const loop = store.requireUniqueLoop(idOrName);
10952
+ handler: ({ idOrName }) => withStore(async (store) => {
10953
+ const loop = await store.requireUniqueLoop(idOrName);
10954
+ if (loop.archivedAt)
10955
+ throw new LoopArchivedError(idOrName);
9463
10956
  let nextRunAt = loop.nextRunAt;
9464
10957
  if (!nextRunAt) {
9465
10958
  const now = new Date;
9466
10959
  nextRunAt = computeNextAfter(loop.schedule, now, now);
9467
10960
  }
9468
- return { loop: publicLoop(store.updateLoop(loop.id, { status: "active", nextRunAt })) };
10961
+ return { loop: publicLoop(await store.updateLoop(loop.id, { status: "active", nextRunAt })) };
9469
10962
  })
9470
10963
  },
9471
10964
  {
@@ -9476,9 +10969,12 @@ var TOOL_REGISTRATIONS = [
9476
10969
  guarded: true,
9477
10970
  annotations: mutationAnnotations({ idempotent: true }),
9478
10971
  inputSchema: { idOrName: loopIdOrNameSchema },
9479
- handler: ({ idOrName }) => withStore((store) => ({
9480
- loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
9481
- }))
10972
+ handler: ({ idOrName }) => withStore(async (store) => {
10973
+ const loop = await store.requireUniqueLoop(idOrName);
10974
+ if (loop.archivedAt)
10975
+ throw new LoopArchivedError(idOrName);
10976
+ return { loop: publicLoop(await store.updateLoop(loop.id, { status: "stopped", nextRunAt: undefined })) };
10977
+ })
9482
10978
  },
9483
10979
  {
9484
10980
  name: "loops_run_now",
@@ -9489,8 +10985,22 @@ var TOOL_REGISTRATIONS = [
9489
10985
  annotations: mutationAnnotations(),
9490
10986
  inputSchema: { idOrName: loopIdOrNameSchema },
9491
10987
  handler: ({ idOrName }) => withStore(async (store) => {
9492
- const daemon = daemonStatus(store);
9493
- const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: `mcp:${process.pid}`, mode: "schedule" });
10988
+ if (store.transport === "cloud-http") {
10989
+ const loop = await store.requireUniqueLoop(idOrName);
10990
+ if (loop.archivedAt)
10991
+ throw new LoopArchivedError(idOrName);
10992
+ const now = new Date().toISOString();
10993
+ const updated = await store.updateLoop(loop.id, { status: "active", nextRunAt: now });
10994
+ return {
10995
+ scheduledFor: now,
10996
+ loop: publicLoop(updated),
10997
+ daemon: undefined,
10998
+ warning: "loops is flipped to self_hosted: the loop is marked due on the hosted control plane; a self-hosted runner must execute it."
10999
+ };
11000
+ }
11001
+ const raw = store.raw;
11002
+ const daemon = daemonStatus(raw);
11003
+ const result = await runLoopNow({ store: raw, idOrName: raw.requireUniqueLoop(idOrName).id, runnerId: `mcp:${process.pid}`, mode: "schedule" });
9494
11004
  return {
9495
11005
  scheduledFor: result.scheduledFor,
9496
11006
  loop: publicLoop(result.loop),
@@ -9506,7 +11016,7 @@ var TOOL_REGISTRATIONS = [
9506
11016
  guarded: true,
9507
11017
  annotations: mutationAnnotations({ idempotent: true }),
9508
11018
  inputSchema: { idOrName: loopIdOrNameSchema },
9509
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.archiveLoop(idOrName)) }))
11019
+ handler: ({ idOrName }) => withStore(async (store) => ({ loop: publicLoop(await store.archiveLoop(idOrName)) }))
9510
11020
  },
9511
11021
  {
9512
11022
  name: "loops_unarchive",
@@ -9515,7 +11025,7 @@ var TOOL_REGISTRATIONS = [
9515
11025
  guarded: true,
9516
11026
  annotations: mutationAnnotations({ idempotent: true }),
9517
11027
  inputSchema: { idOrName: loopIdOrNameSchema },
9518
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.unarchiveLoop(idOrName)) }))
11028
+ handler: ({ idOrName }) => withStore(async (store) => ({ loop: publicLoop(await store.unarchiveLoop(idOrName)) }))
9519
11029
  },
9520
11030
  {
9521
11031
  name: "loops_create_command",
@@ -9531,21 +11041,21 @@ var TOOL_REGISTRATIONS = [
9531
11041
  cwd: z2.string().optional().describe("Working directory for the command."),
9532
11042
  shell: z2.never().optional().describe("Not supported over MCP: shell execution is rejected. Use 'command' plus 'args' instead.")
9533
11043
  },
9534
- handler: (input) => withStore((store) => {
11044
+ handler: (input) => {
9535
11045
  const timeoutMs = input.timeoutMs;
9536
- const loop = store.createLoop(commonCreateInput({
11046
+ const createInput = commonCreateInput({
9537
11047
  ...input,
9538
11048
  target: {
9539
11049
  type: "command",
9540
- command: nonEmpty(input.command, "command"),
11050
+ command: nonEmpty2(input.command, "command"),
9541
11051
  args: input.args,
9542
11052
  cwd: input.cwd,
9543
11053
  shell: false,
9544
11054
  timeoutMs
9545
11055
  }
9546
- }));
9547
- return { loop: publicLoop(loop) };
9548
- })
11056
+ });
11057
+ return withStore(async (store) => ({ loop: publicLoop(await store.createLoop(createInput)) }));
11058
+ }
9549
11059
  },
9550
11060
  {
9551
11061
  name: "loops_create_workflow",
@@ -9559,20 +11069,17 @@ var TOOL_REGISTRATIONS = [
9559
11069
  workflow: workflowIdOrNameSchema,
9560
11070
  workflowInput: z2.record(z2.string(), z2.string()).optional().describe("String key/value input passed to the workflow on each run.")
9561
11071
  },
9562
- handler: (input) => withStore((store) => {
9563
- const workflow = store.requireWorkflow(input.workflow);
11072
+ handler: (input) => {
9564
11073
  const timeoutMs = input.timeoutMs;
9565
- const loop = store.createLoop(commonCreateInput({
9566
- ...input,
9567
- target: {
9568
- type: "workflow",
9569
- workflowId: workflow.id,
9570
- input: input.workflowInput,
9571
- timeoutMs
9572
- }
9573
- }));
9574
- return { workflow: publicWorkflow(workflow), loop: publicLoop(loop) };
9575
- })
11074
+ return withStore(async (store) => {
11075
+ const wf = await store.requireWorkflow(input.workflow);
11076
+ const createInput = commonCreateInput({
11077
+ ...input,
11078
+ target: { type: "workflow", workflowId: wf.id, input: input.workflowInput, timeoutMs }
11079
+ });
11080
+ return { workflow: publicWorkflow(wf), loop: publicLoop(await store.createLoop(createInput)) };
11081
+ });
11082
+ }
9576
11083
  }
9577
11084
  ];
9578
11085
  function toolDescription(tool) {
@@ -9649,8 +11156,20 @@ async function main() {
9649
11156
  console.log(JSON.stringify(listToolsForCli(), null, 2));
9650
11157
  return;
9651
11158
  }
9652
- const server = createLoopsMcpServer();
9653
- await server.connect(new StdioServerTransport);
11159
+ if (isStdioMode()) {
11160
+ const server = createLoopsMcpServer();
11161
+ await server.connect(new StdioServerTransport);
11162
+ return;
11163
+ }
11164
+ const handle = await startMcpHttpServer(() => createLoopsMcpServer(), {
11165
+ port: resolveMcpHttpPort()
11166
+ });
11167
+ process.on("SIGINT", () => {
11168
+ handle.close().finally(() => process.exit(0));
11169
+ });
11170
+ process.on("SIGTERM", () => {
11171
+ handle.close().finally(() => process.exit(0));
11172
+ });
9654
11173
  }
9655
11174
  if (import.meta.main) {
9656
11175
  main().catch((error) => {
@@ -9660,8 +11179,8 @@ if (import.meta.main) {
9660
11179
  }
9661
11180
 
9662
11181
  // src/lib/migration.ts
9663
- import { createHash as createHash4 } from "crypto";
9664
- import { hostname as hostname2 } from "os";
11182
+ import { createHash as createHash5 } from "crypto";
11183
+ import { hostname as hostname3 } from "os";
9665
11184
  var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
9666
11185
  function canonicalize(value) {
9667
11186
  if (Array.isArray(value))
@@ -9672,7 +11191,7 @@ function canonicalize(value) {
9672
11191
  return value;
9673
11192
  }
9674
11193
  function migrationHash(value) {
9675
- return createHash4("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
11194
+ return createHash5("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
9676
11195
  }
9677
11196
  function pushBlocker(rows, resource, id, reason, name) {
9678
11197
  rows.push({ resource, id, name, action: "blocked", reason });
@@ -9748,7 +11267,7 @@ function exportLoopsMigrationBundle(store, opts = {}) {
9748
11267
  source: {
9749
11268
  backend: "sqlite",
9750
11269
  schemaVersion: rows.schemaVersion,
9751
- hostname: hostname2()
11270
+ hostname: hostname3()
9752
11271
  },
9753
11272
  checks: rows.checks,
9754
11273
  importable: sanitized.blockers.length === 0,
@@ -10129,6 +11648,85 @@ async function buildSelfHostedMigrationPlan(store, opts) {
10129
11648
  });
10130
11649
  return { ...plan, importable: false };
10131
11650
  }
11651
+ async function postImportBatch(fetchImpl, config, payload) {
11652
+ const body = await requestJson(fetchImpl, config, "/v1/import", { method: "POST", body: JSON.stringify(payload) });
11653
+ const imported = body.imported ?? {};
11654
+ return {
11655
+ imported: { workflows: imported.workflows ?? 0, loops: imported.loops ?? 0, runs: imported.runs ?? 0 },
11656
+ skippedRunning: typeof body.skippedRunning === "number" ? body.skippedRunning : 0
11657
+ };
11658
+ }
11659
+ async function applySelfHostedPush(store, opts) {
11660
+ const resolved = resolveApiConfig(opts);
11661
+ if (!resolved.apiUrl)
11662
+ throw new ValidationError("LOOPS_API_URL or --api-url is required for self-hosted push");
11663
+ if (!isLocalApiUrl(resolved.apiUrl) && !resolved.token) {
11664
+ throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
11665
+ }
11666
+ const config = { apiUrl: resolved.apiUrl, token: resolved.token };
11667
+ const fetchImpl = opts.fetchImpl ?? fetch;
11668
+ const includeRuns = opts.includeRuns ?? true;
11669
+ const replace = opts.replace ?? false;
11670
+ const batchRows = Math.max(1, opts.batchRows ?? 200);
11671
+ const runBatchBytes = Math.max(64 * 1024, opts.runBatchBytes ?? 4 * 1024 * 1024);
11672
+ const applied = { workflows: 0, loops: 0, runs: 0 };
11673
+ const skipped = { runningRuns: 0, orphanRuns: 0 };
11674
+ let requests = 0;
11675
+ const base = store.exportMigrationRows({ includeRuns: false });
11676
+ const loopIds = new Set(base.loops.map((loop) => loop.id));
11677
+ for (let i = 0;i < base.workflows.length; i += batchRows) {
11678
+ const batch = base.workflows.slice(i, i + batchRows);
11679
+ const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace });
11680
+ applied.workflows += result.imported.workflows;
11681
+ requests += 1;
11682
+ opts.onProgress?.({ phase: "workflows", sent: applied.workflows, requests });
11683
+ }
11684
+ for (let i = 0;i < base.loops.length; i += batchRows) {
11685
+ const batch = base.loops.slice(i, i + batchRows);
11686
+ const result = await postImportBatch(fetchImpl, config, { loops: batch, replace });
11687
+ applied.loops += result.imported.loops;
11688
+ requests += 1;
11689
+ opts.onProgress?.({ phase: "loops", sent: applied.loops, requests });
11690
+ }
11691
+ if (includeRuns) {
11692
+ const pageSize = 500;
11693
+ let pending = [];
11694
+ let pendingBytes = 0;
11695
+ const flush = async () => {
11696
+ if (pending.length === 0)
11697
+ return;
11698
+ const result = await postImportBatch(fetchImpl, config, { runs: pending, replace });
11699
+ applied.runs += result.imported.runs;
11700
+ skipped.runningRuns += result.skippedRunning;
11701
+ requests += 1;
11702
+ opts.onProgress?.({ phase: "runs", sent: applied.runs, requests });
11703
+ pending = [];
11704
+ pendingBytes = 0;
11705
+ };
11706
+ for (let offset = 0;; offset += pageSize) {
11707
+ const page = store.exportMigrationRunPage({ limit: pageSize, offset });
11708
+ if (page.length === 0)
11709
+ break;
11710
+ for (const run of page) {
11711
+ if (!loopIds.has(run.loopId)) {
11712
+ skipped.orphanRuns += 1;
11713
+ continue;
11714
+ }
11715
+ const encoded = JSON.stringify(run).length;
11716
+ if (pending.length > 0 && pendingBytes + encoded > runBatchBytes)
11717
+ await flush();
11718
+ pending.push(run);
11719
+ pendingBytes += encoded;
11720
+ if (pending.length >= batchRows * 4)
11721
+ await flush();
11722
+ }
11723
+ if (page.length < pageSize)
11724
+ break;
11725
+ }
11726
+ await flush();
11727
+ }
11728
+ return { ok: true, apiUrl: config.apiUrl, applied, skipped, requests };
11729
+ }
10132
11730
  async function registerSelfHostedRunner(opts) {
10133
11731
  const config = resolveApiConfig(opts);
10134
11732
  if (!config.apiUrl)
@@ -10174,10 +11772,16 @@ class LoopsClient {
10174
11772
  ownStore;
10175
11773
  runnerId;
10176
11774
  constructor(opts = {}) {
10177
- this.store = opts.store ?? new Store;
11775
+ this.store = opts.store ? new LocalStore(opts.store) : getStore();
10178
11776
  this.ownStore = !opts.store;
10179
11777
  this.runnerId = opts.runnerId ?? `sdk:${process.pid}`;
10180
11778
  }
11779
+ localRuntime(operation) {
11780
+ if (this.store.transport !== "local") {
11781
+ throw new Error(`loops SDK ${operation} operates on this machine's local runtime and is not available while flipped to the hosted OpenLoops API. ` + `Unset HASNA_LOOPS_API_URL/HASNA_LOOPS_API_KEY (or set HASNA_LOOPS_STORAGE_MODE=local) to run it here.`);
11782
+ }
11783
+ return this.store.raw;
11784
+ }
10181
11785
  create(input) {
10182
11786
  return this.store.createLoop(input);
10183
11787
  }
@@ -10192,11 +11796,12 @@ class LoopsClient {
10192
11796
  get(idOrName) {
10193
11797
  return this.store.requireLoop(idOrName);
10194
11798
  }
10195
- pause(idOrName) {
10196
- return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "paused" });
11799
+ async pause(idOrName) {
11800
+ const loop = await this.store.requireUniqueLoop(idOrName);
11801
+ return this.store.updateLoop(loop.id, { status: "paused" });
10197
11802
  }
10198
- resume(idOrName) {
10199
- const loop = this.store.requireUniqueLoop(idOrName);
11803
+ async resume(idOrName) {
11804
+ const loop = await this.store.requireUniqueLoop(idOrName);
10200
11805
  let nextRunAt = loop.nextRunAt;
10201
11806
  if (!nextRunAt) {
10202
11807
  const now = new Date;
@@ -10204,8 +11809,9 @@ class LoopsClient {
10204
11809
  }
10205
11810
  return this.store.updateLoop(loop.id, { status: "active", nextRunAt });
10206
11811
  }
10207
- stop(idOrName) {
10208
- return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined });
11812
+ async stop(idOrName) {
11813
+ const loop = await this.store.requireUniqueLoop(idOrName);
11814
+ return this.store.updateLoop(loop.id, { status: "stopped", nextRunAt: undefined });
10209
11815
  }
10210
11816
  archive(idOrName) {
10211
11817
  return this.store.archiveLoop(idOrName);
@@ -10213,14 +11819,15 @@ class LoopsClient {
10213
11819
  unarchive(idOrName) {
10214
11820
  return this.store.unarchiveLoop(idOrName);
10215
11821
  }
10216
- delete(idOrName) {
10217
- return this.store.deleteLoop(this.store.requireUniqueLoop(idOrName).id);
11822
+ async delete(idOrName) {
11823
+ const loop = await this.store.requireUniqueLoop(idOrName);
11824
+ return this.store.deleteLoop(loop.id);
10218
11825
  }
10219
- runs(idOrName, filters = {}) {
11826
+ async runs(idOrName, filters = {}) {
10220
11827
  let loopId;
10221
11828
  if (idOrName) {
10222
11829
  try {
10223
- loopId = this.get(idOrName).id;
11830
+ loopId = (await this.get(idOrName)).id;
10224
11831
  } catch (error) {
10225
11832
  if (error instanceof LoopNotFoundError)
10226
11833
  return [];
@@ -10229,48 +11836,59 @@ class LoopsClient {
10229
11836
  }
10230
11837
  return this.store.listRuns({ loopId, status: filters.status, limit: filters.limit });
10231
11838
  }
11839
+ writeReceipt(input) {
11840
+ return this.store.writeRunReceipt(input);
11841
+ }
11842
+ receipt(runId) {
11843
+ return this.store.getRunReceipt(runId);
11844
+ }
11845
+ receipts(filters = {}) {
11846
+ return this.store.listRunReceipts(filters);
11847
+ }
11848
+ async goal(idOrName) {
11849
+ const goal = await this.store.getGoal(idOrName) ?? await this.store.findGoalByLoop(idOrName) ?? await this.store.findGoalByRunId(idOrName);
11850
+ return {
11851
+ goal,
11852
+ runs: goal ? await this.store.listGoalRuns({ goalId: goal.goalId }) : []
11853
+ };
11854
+ }
10232
11855
  doctor() {
10233
- return runDoctor(this.store);
11856
+ return runDoctor(this.localRuntime("doctor()"));
10234
11857
  }
10235
11858
  health(opts = {}) {
10236
- return buildHealthReport(this.store, opts);
11859
+ return buildHealthReport(this.localRuntime("health()"), opts);
10237
11860
  }
10238
11861
  healthScan(opts = {}) {
10239
- return buildHealthScan(this.store, {
11862
+ const store = this.localRuntime("healthScan()");
11863
+ return buildHealthScan(store, {
10240
11864
  ...opts,
10241
- doctor: opts.doctor ? runDoctor(this.store) : undefined,
10242
- daemon: opts.daemon ? daemonStatus(this.store) : undefined
11865
+ doctor: opts.doctor ? runDoctor(store) : undefined,
11866
+ daemon: opts.daemon ? daemonStatus(store) : undefined
10243
11867
  });
10244
11868
  }
10245
- goal(idOrName) {
10246
- const goal = this.store.getGoal(idOrName) ?? this.store.findGoalByLoop(idOrName) ?? this.store.findGoalByRunId(idOrName);
10247
- return {
10248
- goal,
10249
- runs: goal ? this.store.listGoalRuns({ goalId: goal.goalId }) : []
10250
- };
10251
- }
10252
11869
  async tick() {
10253
- return tick({ store: this.store, runnerId: this.runnerId });
11870
+ return tick({ store: this.localRuntime("tick()"), runnerId: this.runnerId });
10254
11871
  }
10255
11872
  async runNow(idOrName) {
10256
- const result = await runLoopNow({ store: this.store, idOrName: this.store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
11873
+ const store = this.localRuntime("runNow()");
11874
+ const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
10257
11875
  return result.run;
10258
11876
  }
10259
11877
  exportBundle(opts = {}) {
10260
- return exportLoopsMigrationBundle(this.store, opts);
11878
+ return exportLoopsMigrationBundle(this.localRuntime("exportBundle()"), opts);
10261
11879
  }
10262
11880
  planImport(bundle, opts = {}) {
10263
- return buildImportMigrationPlan(this.store, bundle, opts);
11881
+ return buildImportMigrationPlan(this.localRuntime("planImport()"), bundle, opts);
10264
11882
  }
10265
11883
  importBundle(bundle, opts = {}) {
10266
- return applyImportMigrationBundle(this.store, bundle, opts);
11884
+ return applyImportMigrationBundle(this.localRuntime("importBundle()"), bundle, opts);
10267
11885
  }
10268
11886
  planSelfHostedMigration(opts = {}) {
10269
- return buildSelfHostedMigrationPlan(this.store, { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
11887
+ return buildSelfHostedMigrationPlan(this.localRuntime("planSelfHostedMigration()"), { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
10270
11888
  }
10271
- close() {
11889
+ async close() {
10272
11890
  if (this.ownStore)
10273
- this.store.close();
11891
+ await this.store.close();
10274
11892
  }
10275
11893
  }
10276
11894
  function loops(opts = {}) {
@@ -10363,6 +11981,15 @@ class SqliteLoopStorage {
10363
11981
  deleteLoop(...args) {
10364
11982
  return this.call("deleteLoop", ...args);
10365
11983
  }
11984
+ upsertMigrationLoop(...args) {
11985
+ return this.call("upsertMigrationLoop", ...args);
11986
+ }
11987
+ upsertMigrationRun(...args) {
11988
+ return this.call("upsertMigrationRun", ...args);
11989
+ }
11990
+ upsertMigrationWorkflow(...args) {
11991
+ return this.call("upsertMigrationWorkflow", ...args);
11992
+ }
10366
11993
  createWorkflow(...args) {
10367
11994
  return this.call("createWorkflow", ...args);
10368
11995
  }
@@ -10486,6 +12113,15 @@ class SqliteLoopStorage {
10486
12113
  listRuns(...args) {
10487
12114
  return this.call("listRuns", ...args);
10488
12115
  }
12116
+ writeRunReceipt(...args) {
12117
+ return this.call("writeRunReceipt", ...args);
12118
+ }
12119
+ getRunReceipt(...args) {
12120
+ return this.call("getRunReceipt", ...args);
12121
+ }
12122
+ listRunReceipts(...args) {
12123
+ return this.call("listRunReceipts", ...args);
12124
+ }
10489
12125
  recoverExpiredRunLeases(...args) {
10490
12126
  return this.call("recoverExpiredRunLeases", ...args);
10491
12127
  }
@@ -10535,6 +12171,9 @@ class NotImplementedError extends Error {
10535
12171
  }
10536
12172
  var TERMINAL_RUN_STATUSES3 = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
10537
12173
  var PRUNE_BATCH_SIZE2 = 400;
12174
+ function isUniqueViolation(error) {
12175
+ return typeof error === "object" && error !== null && "code" in error && error.code === "23505";
12176
+ }
10538
12177
  var DEFAULT_RECOVERY_BATCH_LIMIT2 = 100;
10539
12178
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER2 = 5;
10540
12179
 
@@ -10666,19 +12305,22 @@ class PostgresLoopStorage {
10666
12305
  async listLoops(...args) {
10667
12306
  const opts = args[0] ?? {};
10668
12307
  const limit = opts.limit ?? 200;
12308
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
10669
12309
  let rows;
10670
- if (opts.status && opts.archived) {
10671
- rows = await this.client.many("SELECT * FROM loops WHERE status = $1 AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT $2", [opts.status, limit]);
12310
+ if (opts.name != null) {
12311
+ rows = await this.client.many("SELECT * FROM loops WHERE name = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [opts.name, limit, offset]);
12312
+ } else if (opts.status && opts.archived) {
12313
+ rows = await this.client.many("SELECT * FROM loops WHERE status = $1 AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT $2 OFFSET $3", [opts.status, limit, offset]);
10672
12314
  } else if (opts.status && opts.includeArchived) {
10673
- rows = await this.client.many("SELECT * FROM loops WHERE status = $1 ORDER BY next_run_at ASC LIMIT $2", [opts.status, limit]);
12315
+ rows = await this.client.many("SELECT * FROM loops WHERE status = $1 ORDER BY next_run_at ASC LIMIT $2 OFFSET $3", [opts.status, limit, offset]);
10674
12316
  } else if (opts.status) {
10675
- rows = await this.client.many("SELECT * FROM loops WHERE status = $1 AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT $2", [opts.status, limit]);
12317
+ rows = await this.client.many("SELECT * FROM loops WHERE status = $1 AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT $2 OFFSET $3", [opts.status, limit, offset]);
10676
12318
  } else if (opts.archived) {
10677
- rows = await this.client.many("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT $1", [limit]);
12319
+ rows = await this.client.many("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT $1 OFFSET $2", [limit, offset]);
10678
12320
  } else if (opts.includeArchived) {
10679
- rows = await this.client.many("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT $1", [limit]);
12321
+ rows = await this.client.many("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT $1 OFFSET $2", [limit, offset]);
10680
12322
  } else {
10681
- rows = await this.client.many("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT $1", [limit]);
12323
+ rows = await this.client.many("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT $1 OFFSET $2", [limit, offset]);
10682
12324
  }
10683
12325
  return rows.map(rowToLoop);
10684
12326
  }
@@ -10809,6 +12451,172 @@ class PostgresLoopStorage {
10809
12451
  const row = await this.client.get(sql, params);
10810
12452
  return row?.count ?? 0;
10811
12453
  }
12454
+ async upsertMigrationWorkflow(...args) {
12455
+ const [workflow, opts = {}] = args;
12456
+ const existing = await this.getWorkflow(workflow.id);
12457
+ if (existing && !opts.replace)
12458
+ return existing;
12459
+ try {
12460
+ await this.client.execute(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
12461
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9)
12462
+ ON CONFLICT(id) DO UPDATE SET
12463
+ name=EXCLUDED.name,
12464
+ description=EXCLUDED.description,
12465
+ version=EXCLUDED.version,
12466
+ status=EXCLUDED.status,
12467
+ goal_json=EXCLUDED.goal_json,
12468
+ steps_json=EXCLUDED.steps_json,
12469
+ created_at=EXCLUDED.created_at,
12470
+ updated_at=EXCLUDED.updated_at`, [
12471
+ workflow.id,
12472
+ workflow.name,
12473
+ workflow.description ?? null,
12474
+ workflow.version,
12475
+ workflow.status,
12476
+ workflow.goal ? JSON.stringify(workflow.goal) : null,
12477
+ JSON.stringify(workflow.steps),
12478
+ workflow.createdAt,
12479
+ workflow.updatedAt
12480
+ ]);
12481
+ } catch (error) {
12482
+ if (isUniqueViolation(error)) {
12483
+ const owner = await this.client.get("SELECT * FROM workflow_specs WHERE name = $1 AND status = 'active' LIMIT 1", [workflow.name]);
12484
+ if (owner)
12485
+ return rowToWorkflow(owner);
12486
+ }
12487
+ throw error;
12488
+ }
12489
+ const imported = await this.getWorkflow(workflow.id);
12490
+ if (!imported)
12491
+ throw new Error(`workflow not found after migration import: ${workflow.id}`);
12492
+ return imported;
12493
+ }
12494
+ async upsertMigrationLoop(...args) {
12495
+ const [loop, opts = {}] = args;
12496
+ const existing = await this.loadLoop(this.client, loop.id);
12497
+ if (existing && !opts.replace)
12498
+ return existing;
12499
+ await this.client.execute(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
12500
+ goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
12501
+ retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
12502
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
12503
+ ON CONFLICT(id) DO UPDATE SET
12504
+ name=EXCLUDED.name,
12505
+ description=EXCLUDED.description,
12506
+ status=EXCLUDED.status,
12507
+ archived_at=EXCLUDED.archived_at,
12508
+ archived_from_status=EXCLUDED.archived_from_status,
12509
+ schedule_json=EXCLUDED.schedule_json,
12510
+ target_json=EXCLUDED.target_json,
12511
+ goal_json=EXCLUDED.goal_json,
12512
+ machine_json=EXCLUDED.machine_json,
12513
+ next_run_at=EXCLUDED.next_run_at,
12514
+ retry_scheduled_for=EXCLUDED.retry_scheduled_for,
12515
+ catch_up=EXCLUDED.catch_up,
12516
+ catch_up_limit=EXCLUDED.catch_up_limit,
12517
+ overlap=EXCLUDED.overlap,
12518
+ max_attempts=EXCLUDED.max_attempts,
12519
+ retry_delay_ms=EXCLUDED.retry_delay_ms,
12520
+ lease_ms=EXCLUDED.lease_ms,
12521
+ expires_at=EXCLUDED.expires_at,
12522
+ created_at=EXCLUDED.created_at,
12523
+ updated_at=EXCLUDED.updated_at`, [
12524
+ loop.id,
12525
+ loop.name,
12526
+ loop.description ?? null,
12527
+ loop.status,
12528
+ loop.archivedAt ?? null,
12529
+ loop.archivedFromStatus ?? null,
12530
+ JSON.stringify(loop.schedule),
12531
+ JSON.stringify(loop.target),
12532
+ loop.goal ? JSON.stringify(loop.goal) : null,
12533
+ loop.machine ? JSON.stringify(loop.machine) : null,
12534
+ loop.nextRunAt ?? null,
12535
+ loop.retryScheduledFor ?? null,
12536
+ loop.catchUp,
12537
+ loop.catchUpLimit,
12538
+ loop.overlap,
12539
+ loop.maxAttempts,
12540
+ loop.retryDelayMs,
12541
+ loop.leaseMs,
12542
+ loop.expiresAt ?? null,
12543
+ loop.createdAt,
12544
+ loop.updatedAt
12545
+ ]);
12546
+ const imported = await this.loadLoop(this.client, loop.id);
12547
+ if (!imported)
12548
+ throw new Error(`loop not found after migration import: ${loop.id}`);
12549
+ return imported;
12550
+ }
12551
+ async upsertMigrationRun(...args) {
12552
+ const [run, opts = {}] = args;
12553
+ if (run.status === "running")
12554
+ throw new ValidationError(`cannot import running run ${run.id}`);
12555
+ const existing = await this.loadRun(this.client, run.id);
12556
+ if (existing && !opts.replace)
12557
+ return existing;
12558
+ try {
12559
+ await this.client.execute(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
12560
+ claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
12561
+ stdout, stderr, error, goal_run_id, created_at, updated_at)
12562
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NULL,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
12563
+ ON CONFLICT(id) DO UPDATE SET
12564
+ loop_id=EXCLUDED.loop_id,
12565
+ loop_name=EXCLUDED.loop_name,
12566
+ scheduled_for=EXCLUDED.scheduled_for,
12567
+ attempt=EXCLUDED.attempt,
12568
+ status=EXCLUDED.status,
12569
+ started_at=EXCLUDED.started_at,
12570
+ finished_at=EXCLUDED.finished_at,
12571
+ claimed_by=EXCLUDED.claimed_by,
12572
+ claim_token=NULL,
12573
+ lease_expires_at=EXCLUDED.lease_expires_at,
12574
+ pid=EXCLUDED.pid,
12575
+ pgid=EXCLUDED.pgid,
12576
+ process_started_at=EXCLUDED.process_started_at,
12577
+ exit_code=EXCLUDED.exit_code,
12578
+ duration_ms=EXCLUDED.duration_ms,
12579
+ stdout=EXCLUDED.stdout,
12580
+ stderr=EXCLUDED.stderr,
12581
+ error=EXCLUDED.error,
12582
+ goal_run_id=EXCLUDED.goal_run_id,
12583
+ created_at=EXCLUDED.created_at,
12584
+ updated_at=EXCLUDED.updated_at`, [
12585
+ run.id,
12586
+ run.loopId,
12587
+ run.loopName,
12588
+ run.scheduledFor,
12589
+ run.attempt,
12590
+ run.status,
12591
+ run.startedAt ?? null,
12592
+ run.finishedAt ?? null,
12593
+ run.claimedBy ?? null,
12594
+ run.leaseExpiresAt ?? null,
12595
+ run.pid ?? null,
12596
+ run.pgid ?? null,
12597
+ run.processStartedAt ?? null,
12598
+ run.exitCode ?? null,
12599
+ run.durationMs ?? null,
12600
+ persistedRunOutput(run.stdout),
12601
+ persistedRunOutput(run.stderr),
12602
+ scrubbedOrNull(run.error),
12603
+ run.goalRunId ?? null,
12604
+ run.createdAt,
12605
+ run.updatedAt
12606
+ ]);
12607
+ } catch (error) {
12608
+ if (isUniqueViolation(error)) {
12609
+ const slot = await this.loadRunBySlot(this.client, run.loopId, run.scheduledFor);
12610
+ if (slot)
12611
+ return slot;
12612
+ }
12613
+ throw error;
12614
+ }
12615
+ const imported = await this.loadRun(this.client, run.id);
12616
+ if (!imported)
12617
+ throw new Error(`run not found after migration import: ${run.id}`);
12618
+ return imported;
12619
+ }
10812
12620
  async createSkippedRun(...args) {
10813
12621
  const [loop, scheduledFor, reason, opts = {}] = args;
10814
12622
  const now = nowIso();
@@ -10976,18 +12784,102 @@ class PostgresLoopStorage {
10976
12784
  async listRuns(...args) {
10977
12785
  const opts = args[0] ?? {};
10978
12786
  const limit = opts.limit ?? 100;
12787
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
10979
12788
  let rows;
10980
12789
  if (opts.loopId && opts.status) {
10981
- rows = await this.client.many("SELECT * FROM loop_runs WHERE loop_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3", [opts.loopId, opts.status, limit]);
12790
+ rows = await this.client.many("SELECT * FROM loop_runs WHERE loop_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4", [opts.loopId, opts.status, limit, offset]);
10982
12791
  } else if (opts.loopId) {
10983
- rows = await this.client.many("SELECT * FROM loop_runs WHERE loop_id = $1 ORDER BY created_at DESC LIMIT $2", [opts.loopId, limit]);
12792
+ rows = await this.client.many("SELECT * FROM loop_runs WHERE loop_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [opts.loopId, limit, offset]);
10984
12793
  } else if (opts.status) {
10985
- rows = await this.client.many("SELECT * FROM loop_runs WHERE status = $1 ORDER BY created_at DESC LIMIT $2", [opts.status, limit]);
12794
+ rows = await this.client.many("SELECT * FROM loop_runs WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [opts.status, limit, offset]);
10986
12795
  } else {
10987
- rows = await this.client.many("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT $1", [limit]);
12796
+ rows = await this.client.many("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT $1 OFFSET $2", [limit, offset]);
10988
12797
  }
10989
12798
  return rows.map(rowToRun);
10990
12799
  }
12800
+ async writeRunReceipt(...args) {
12801
+ const [input, opts = {}] = args;
12802
+ const inputRunId = typeof input.run_id === "string" && input.run_id.trim() ? input.run_id : undefined;
12803
+ const existing = inputRunId ? await this.getRunReceipt(inputRunId) : undefined;
12804
+ const run = inputRunId ? await this.getRun(inputRunId) : undefined;
12805
+ const loop = input.loop_id ? await this.getLoop(input.loop_id) : run ? await this.getLoop(run.loopId) : undefined;
12806
+ const receipt = normalizeRunReceipt(input, { now: opts.now, run, loop, existing });
12807
+ await this.client.execute(`INSERT INTO run_receipts (run_id, loop_id, machine_json, repo, task_ids_json, knowledge_ids_json, digest_id,
12808
+ started_at, finished_at, status, exit_code, summary_json, evidence_paths_json, created_at, updated_at)
12809
+ VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,$6::jsonb,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14,$15)
12810
+ ON CONFLICT(run_id) DO UPDATE SET
12811
+ loop_id=EXCLUDED.loop_id,
12812
+ machine_json=EXCLUDED.machine_json,
12813
+ repo=EXCLUDED.repo,
12814
+ task_ids_json=EXCLUDED.task_ids_json,
12815
+ knowledge_ids_json=EXCLUDED.knowledge_ids_json,
12816
+ digest_id=EXCLUDED.digest_id,
12817
+ started_at=EXCLUDED.started_at,
12818
+ finished_at=EXCLUDED.finished_at,
12819
+ status=EXCLUDED.status,
12820
+ exit_code=EXCLUDED.exit_code,
12821
+ summary_json=EXCLUDED.summary_json,
12822
+ evidence_paths_json=EXCLUDED.evidence_paths_json,
12823
+ updated_at=EXCLUDED.updated_at`, [
12824
+ receipt.run_id,
12825
+ receipt.loop_id,
12826
+ JSON.stringify(receipt.machine),
12827
+ receipt.repo,
12828
+ JSON.stringify(receipt.task_ids),
12829
+ JSON.stringify(receipt.knowledge_ids),
12830
+ receipt.digest_id,
12831
+ receipt.started_at,
12832
+ receipt.finished_at,
12833
+ receipt.status,
12834
+ receipt.exit_code,
12835
+ JSON.stringify(receipt.summary),
12836
+ JSON.stringify(receipt.evidence_paths),
12837
+ receipt.created_at,
12838
+ receipt.updated_at
12839
+ ]);
12840
+ return await this.getRunReceipt(receipt.run_id) ?? receipt;
12841
+ }
12842
+ async getRunReceipt(...args) {
12843
+ const row = await this.client.get("SELECT * FROM run_receipts WHERE run_id = $1", [args[0]]);
12844
+ return row ? rowToRunReceipt(row) : undefined;
12845
+ }
12846
+ async listRunReceipts(...args) {
12847
+ const opts = args[0] ?? {};
12848
+ const limit = opts.limit ?? 100;
12849
+ const filters = [];
12850
+ const params = [];
12851
+ const next = () => `$${params.length + 1}`;
12852
+ if (opts.loopId) {
12853
+ const slot = next();
12854
+ filters.push(`loop_id = ${slot}`);
12855
+ params.push(opts.loopId);
12856
+ }
12857
+ if (opts.repo) {
12858
+ const slot = next();
12859
+ filters.push(`repo = ${slot}`);
12860
+ params.push(opts.repo);
12861
+ }
12862
+ if (opts.status) {
12863
+ const slot = next();
12864
+ filters.push(`status = ${slot}`);
12865
+ params.push(opts.status);
12866
+ }
12867
+ if (opts.taskId) {
12868
+ const slot = next();
12869
+ filters.push(`task_ids_json ? ${slot}`);
12870
+ params.push(opts.taskId);
12871
+ }
12872
+ if (opts.knowledgeId) {
12873
+ const slot = next();
12874
+ filters.push(`knowledge_ids_json ? ${slot}`);
12875
+ params.push(opts.knowledgeId);
12876
+ }
12877
+ const limitSlot = next();
12878
+ params.push(limit);
12879
+ const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
12880
+ const rows = await this.client.many(`SELECT * FROM run_receipts ${where} ORDER BY created_at DESC LIMIT ${limitSlot}`, params);
12881
+ return rows.map(rowToRunReceipt);
12882
+ }
10991
12883
  async countRuns(...args) {
10992
12884
  const status = args[0];
10993
12885
  const row = status ? await this.client.get("SELECT COUNT(*)::int AS count FROM loop_runs WHERE status = $1", [status]) : await this.client.get("SELECT COUNT(*)::int AS count FROM loop_runs", []);
@@ -11321,6 +13213,7 @@ var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
11321
13213
  var REPORT_ONLY_TEMPLATE_ID = "report-only";
11322
13214
  var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
11323
13215
  var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
13216
+ var ROUTING_REMEDIATION_TEMPLATE_ID = "routing-remediation";
11324
13217
  function projectPathVariable(description = "Repository or project working directory.") {
11325
13218
  return { name: "projectPath", required: true, description };
11326
13219
  }
@@ -11514,6 +13407,28 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
11514
13407
  { name: "name", description: "Workflow name." },
11515
13408
  { name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
11516
13409
  ]
13410
+ },
13411
+ {
13412
+ id: ROUTING_REMEDIATION_TEMPLATE_ID,
13413
+ name: "Routing Remediation",
13414
+ description: "Run a bounded routing-doctor remediation workflow: deterministic preflight, safe Todos CLI repair, blocker task filing, and adversarial verification.",
13415
+ kind: "workflow",
13416
+ variables: [
13417
+ projectPathVariable("Repository/project path used for workflow evidence artifacts."),
13418
+ { name: "todosProjectPath", description: "Todos storage project path to inspect and repair; defaults to projectPath." },
13419
+ { name: "doctorJsonPath", description: "Optional existing todos doctor routing --json output to consume instead of running a fresh dry-run." },
13420
+ { name: "doctorProject", description: "Optional todos doctor routing --project scope (id, slug, or path)." },
13421
+ { name: "tag", description: "Optional todos doctor routing --tag scope." },
13422
+ { name: "status", default: "pending,in_progress", description: "Comma-separated task statuses for the doctor." },
13423
+ { name: "shard", description: "Optional deterministic shard such as 0/6." },
13424
+ { name: "limit", description: "Optional maximum tasks inspected by the doctor." },
13425
+ { name: "maxRepairs", default: "25", description: "Capacity gate: maximum safe_auto findings allowed in one apply run." },
13426
+ { name: "dryRun", default: "true", description: "When true, perform preflight/reporting only and do not apply repairs." },
13427
+ { name: "idempotencyKey", description: "Stable key used for evidence paths, undo records, and dedupe fingerprints." },
13428
+ { name: "evidenceDir", description: "Directory for doctor, preflight, apply, and recheck JSON artifacts." },
13429
+ { name: "undoDir", description: "Directory for todos doctor routing --undo-record output." },
13430
+ ...lifecycleSharedVariables({ worktreeModeDefault: "required" })
13431
+ ]
11517
13432
  }
11518
13433
  ];
11519
13434
  var NO_TMUX_DISPATCH_FRAGMENT = "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.";
@@ -11619,6 +13534,152 @@ function worktreeContextFragment(plan) {
11619
13534
  function shellQuote2(value) {
11620
13535
  return `'${value.replace(/'/g, `'\\''`)}'`;
11621
13536
  }
13537
+ function routingRemediationDoctorScopeArgs(opts) {
13538
+ return [
13539
+ opts.doctorProject ? ["--project", opts.doctorProject] : [],
13540
+ opts.tag ? ["--tag", opts.tag] : [],
13541
+ opts.status ? ["--status", opts.status] : [],
13542
+ opts.shard ? ["--shard", opts.shard] : [],
13543
+ opts.limit ? ["--limit", opts.limit] : []
13544
+ ].flat();
13545
+ }
13546
+ function displayCommand(command, args) {
13547
+ const shellSafe = /^[A-Za-z0-9_./:@%+=,-]+$/;
13548
+ return [command, ...args.map((arg) => shellSafe.test(arg) ? arg : shellQuote2(arg))].join(" ");
13549
+ }
13550
+ function routingRemediationDoctorCommand(opts) {
13551
+ const args = [
13552
+ "--project",
13553
+ opts.todosProjectPath,
13554
+ "doctor",
13555
+ "routing",
13556
+ "--json",
13557
+ ...opts.apply ? ["--apply"] : [],
13558
+ ...opts.apply && opts.undoRecordPath ? ["--undo-record", opts.undoRecordPath] : [],
13559
+ ...routingRemediationDoctorScopeArgs(opts)
13560
+ ];
13561
+ return displayCommand("todos", args);
13562
+ }
13563
+ var ROUTING_REMEDIATION_PREFLIGHT_SCRIPT = [
13564
+ "const { mkdirSync, readFileSync, writeFileSync } = await import('node:fs');",
13565
+ "const { dirname } = await import('node:path');",
13566
+ "const { spawnSync } = await import('node:child_process');",
13567
+ "const env = process.env;",
13568
+ "const required = (name) => {",
13569
+ " const value = env[name];",
13570
+ " if (!value || !value.trim()) throw new Error(`${name} is required`);",
13571
+ " return value.trim();",
13572
+ "};",
13573
+ "const optional = (name) => {",
13574
+ " const value = env[name];",
13575
+ " return value && value.trim() ? value.trim() : undefined;",
13576
+ "};",
13577
+ "const parseJson = (text, label) => {",
13578
+ " try { return JSON.parse(text || '{}'); } catch (error) { throw new Error(`${label} is not valid JSON: ${error?.message || error}`); }",
13579
+ "};",
13580
+ "const writeJson = (path, value) => {",
13581
+ " mkdirSync(dirname(path), { recursive: true });",
13582
+ " writeFileSync(path, `${JSON.stringify(value, null, 2)}\\n`);",
13583
+ "};",
13584
+ "const todosProject = required('OPENLOOPS_ROUTING_REMEDIATION_TODOS_PROJECT');",
13585
+ "const doctorJsonPath = optional('OPENLOOPS_ROUTING_REMEDIATION_DOCTOR_JSON');",
13586
+ "const doctorOutputPath = required('OPENLOOPS_ROUTING_REMEDIATION_DOCTOR_OUTPUT');",
13587
+ "const preflightOutputPath = required('OPENLOOPS_ROUTING_REMEDIATION_PREFLIGHT_OUTPUT');",
13588
+ "const idempotencyKey = required('OPENLOOPS_ROUTING_REMEDIATION_IDEMPOTENCY_KEY');",
13589
+ "const applyCommand = required('OPENLOOPS_ROUTING_REMEDIATION_APPLY_COMMAND');",
13590
+ "const scopeArgs = parseJson(env.OPENLOOPS_ROUTING_REMEDIATION_SCOPE_ARGS || '[]', 'scope args');",
13591
+ "if (!Array.isArray(scopeArgs) || !scopeArgs.every((entry) => typeof entry === 'string')) throw new Error('scope args must be a string array');",
13592
+ "const maxRepairs = Number(env.OPENLOOPS_ROUTING_REMEDIATION_MAX_REPAIRS || '25');",
13593
+ "if (!Number.isInteger(maxRepairs) || maxRepairs < 0) throw new Error('maxRepairs must be a non-negative integer');",
13594
+ "const dryRun = !['0', 'false', 'no', 'off'].includes(String(env.OPENLOOPS_ROUTING_REMEDIATION_DRY_RUN || 'true').toLowerCase());",
13595
+ "let doctor;",
13596
+ "let sourceDoctorRun;",
13597
+ "if (doctorJsonPath) {",
13598
+ " doctor = parseJson(readFileSync(doctorJsonPath, 'utf8'), doctorJsonPath);",
13599
+ " sourceDoctorRun = { type: 'file', path: doctorJsonPath };",
13600
+ "} else {",
13601
+ " const args = ['--project', todosProject, 'doctor', 'routing', '--json', ...scopeArgs];",
13602
+ " const result = spawnSync('todos', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });",
13603
+ " if (![0, 1].includes(result.status ?? -1)) {",
13604
+ " throw new Error(`todos doctor routing preflight failed status=${result.status ?? 'null'} ${String(result.stderr || result.error || result.stdout).slice(0, 500)}`);",
13605
+ " }",
13606
+ " doctor = parseJson(result.stdout, 'todos doctor routing output');",
13607
+ " sourceDoctorRun = { type: 'command', command: ['todos', ...args].join(' ') };",
13608
+ "}",
13609
+ "if (doctor.schema_version !== 'todos.routing_doctor.v1') throw new Error(`unsupported routing doctor schema: ${doctor.schema_version || 'missing'}`);",
13610
+ "const findings = Array.isArray(doctor.findings) ? doctor.findings : [];",
13611
+ "const safeFindings = findings.filter((finding) => finding?.repair_class === 'safe_auto');",
13612
+ "const allowedSafeFields = new Set(['working_dir', 'task_list_id']);",
13613
+ "const safeFields = safeFindings.map((finding) => String(finding?.suggested_repair?.field || finding?.field || '__missing_safe_field__'));",
13614
+ "const unsupportedSafeFields = [...new Set(safeFields.filter((field) => !allowedSafeFields.has(field)))];",
13615
+ "const blockerClasses = new Set(['blocker_human', 'blocker_cross_repo', 'blocker_invalid_path', 'unsupported']);",
13616
+ "const blockerFindings = findings.filter((finding) => blockerClasses.has(String(finding?.repair_class || '')));",
13617
+ "const byCategory = findings.reduce((acc, finding) => {",
13618
+ " const category = String(finding?.category || 'unknown');",
13619
+ " acc[category] = (acc[category] || 0) + 1;",
13620
+ " return acc;",
13621
+ "}, {});",
13622
+ "const preflight = {",
13623
+ " schema_version: 'openloops.routing_remediation_preflight.v1',",
13624
+ " generated_at: new Date().toISOString(),",
13625
+ " ok: unsupportedSafeFields.length === 0 && safeFindings.length <= maxRepairs,",
13626
+ " dry_run: dryRun,",
13627
+ " idempotency_key: idempotencyKey,",
13628
+ " source_doctor_run: sourceDoctorRun,",
13629
+ " doctor_json_path: doctorOutputPath,",
13630
+ " safe_auto: safeFindings.length,",
13631
+ " blocker_findings: blockerFindings.length,",
13632
+ " unsupported_findings: findings.filter((finding) => finding?.repair_class === 'unsupported').length,",
13633
+ " by_category: byCategory,",
13634
+ " allowed_safe_fields: [...allowedSafeFields],",
13635
+ " unsupported_safe_fields: unsupportedSafeFields,",
13636
+ " capacity: { max_repairs: maxRepairs, requested_repairs: safeFindings.length, allowed: safeFindings.length <= maxRepairs },",
13637
+ " apply_allowed: !dryRun && unsupportedSafeFields.length === 0 && safeFindings.length <= maxRepairs,",
13638
+ " apply_command: applyCommand,",
13639
+ " blocker_task_tags: ['from-kai', 'routing-health'],",
13640
+ " blocker_repair_classes: [...blockerClasses],",
13641
+ "};",
13642
+ "writeJson(doctorOutputPath, doctor);",
13643
+ "writeJson(preflightOutputPath, preflight);",
13644
+ "console.log(JSON.stringify({",
13645
+ " ok: preflight.ok,",
13646
+ " dry_run: preflight.dry_run,",
13647
+ " idempotency_key: preflight.idempotency_key,",
13648
+ " doctor_json_path: preflight.doctor_json_path,",
13649
+ " preflight_output_path: preflightOutputPath,",
13650
+ " safe_auto: preflight.safe_auto,",
13651
+ " blocker_findings: preflight.blocker_findings,",
13652
+ " unsupported_safe_fields: preflight.unsupported_safe_fields,",
13653
+ " capacity: preflight.capacity,",
13654
+ "}));",
13655
+ "if (unsupportedSafeFields.length) {",
13656
+ " console.error(`routing remediation preflight blocked unsupported safe_auto fields: ${unsupportedSafeFields.join(', ')}`);",
13657
+ " process.exit(12);",
13658
+ "}",
13659
+ "if (safeFindings.length > maxRepairs) {",
13660
+ " console.error(`routing remediation preflight blocked capacity: safe_auto=${safeFindings.length} maxRepairs=${maxRepairs}`);",
13661
+ " process.exit(12);",
13662
+ "}"
13663
+ ].join(`
13664
+ `);
13665
+ function routingRemediationPreflightCommand(opts) {
13666
+ return [
13667
+ "set -euo pipefail",
13668
+ `export OPENLOOPS_ROUTING_REMEDIATION_TODOS_PROJECT=${shellQuote2(opts.todosProjectPath)}`,
13669
+ `export OPENLOOPS_ROUTING_REMEDIATION_DOCTOR_JSON=${shellQuote2(opts.doctorJsonPath ?? "")}`,
13670
+ `export OPENLOOPS_ROUTING_REMEDIATION_DOCTOR_OUTPUT=${shellQuote2(opts.doctorOutputPath)}`,
13671
+ `export OPENLOOPS_ROUTING_REMEDIATION_PREFLIGHT_OUTPUT=${shellQuote2(opts.preflightOutputPath)}`,
13672
+ `export OPENLOOPS_ROUTING_REMEDIATION_IDEMPOTENCY_KEY=${shellQuote2(opts.idempotencyKey)}`,
13673
+ `export OPENLOOPS_ROUTING_REMEDIATION_MAX_REPAIRS=${shellQuote2(String(opts.maxRepairs))}`,
13674
+ `export OPENLOOPS_ROUTING_REMEDIATION_DRY_RUN=${shellQuote2(opts.dryRun ? "true" : "false")}`,
13675
+ `export OPENLOOPS_ROUTING_REMEDIATION_SCOPE_ARGS=${shellQuote2(JSON.stringify(routingRemediationDoctorScopeArgs(opts)))}`,
13676
+ `export OPENLOOPS_ROUTING_REMEDIATION_APPLY_COMMAND=${shellQuote2(opts.applyCommand)}`,
13677
+ "bun - <<'BUN'",
13678
+ ROUTING_REMEDIATION_PREFLIGHT_SCRIPT,
13679
+ "BUN"
13680
+ ].join(`
13681
+ `);
13682
+ }
11622
13683
  function sourceTaskGateCommand(todosProjectPath, taskId) {
11623
13684
  return [
11624
13685
  "set -euo pipefail",
@@ -11716,6 +13777,7 @@ var PR_HANDOFF_SCRIPT = [
11716
13777
  " }",
11717
13778
  " return undefined;",
11718
13779
  "};",
13780
+ "const scrubUrlCredentials = (value) => String(value || '').replace(/(https?:\\/\\/)[^\\s/@]+:[^\\s/@]+@/gi, '$1').replace(/(https?:\\/\\/)[^\\s/@]+@/gi, '$1');",
11719
13781
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
11720
13782
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
11721
13783
  "const todos = (...args) => run(todosBin, todosArgs(...args));",
@@ -11730,6 +13792,8 @@ var PR_HANDOFF_SCRIPT = [
11730
13792
  "const remote = stringField('remote') || 'origin';",
11731
13793
  "let commit = stringField('commit', 'commitSha', 'sha');",
11732
13794
  "const repo = stringField('githubRepo', 'repoSlug', 'repository');",
13795
+ "const repoDisplay = scrubUrlCredentials(repo || stringField('repo', 'remoteUrl') || '');",
13796
+ "const artifactError = scrubUrlCredentials(artifact.error);",
11733
13797
  "const prUrl = stringField('prUrl', 'pullRequestUrl');",
11734
13798
  "const title = stringField('title', 'prTitle') || `PR handoff for ${taskId}`;",
11735
13799
  "const body = stringField('body', 'prBody') || [",
@@ -11737,16 +13801,16 @@ var PR_HANDOFF_SCRIPT = [
11737
13801
  " `Commit: ${commit || 'unknown'}`,",
11738
13802
  " `Branch: ${branch || 'unknown'}`,",
11739
13803
  " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
11740
- " artifact.error ? `Worker network error: ${artifact.error}` : undefined,",
13804
+ " artifactError ? `Worker network error: ${artifactError}` : undefined,",
11741
13805
  "].filter(Boolean).join('\\n\\n');",
11742
13806
  "const fingerprint = stringField('fingerprint') || `openloops:pr-handoff:${taskId}:${branch || 'missing-branch'}:${commit || 'missing-commit'}`;",
11743
- "const repoTagSource = (repo || stringField('repo', 'remoteUrl') || repoPath).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
13807
+ "const repoTagSource = (repoDisplay || repoPath).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
11744
13808
  "const repoTag = `repo:${repoTagSource.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'}`;",
11745
13809
  "const metadata = {",
11746
13810
  " route_enabled: true,",
11747
13811
  " source: 'openloops.pr-handoff',",
11748
13812
  " original_task_id: taskId,",
11749
- " repo: repo || stringField('repo', 'remoteUrl') || '',",
13813
+ " repo: repoDisplay,",
11750
13814
  " branch: branch || '',",
11751
13815
  " base,",
11752
13816
  " commit: commit || '',",
@@ -11756,18 +13820,19 @@ var PR_HANDOFF_SCRIPT = [
11756
13820
  " no_tmux_dispatch: true,",
11757
13821
  "};",
11758
13822
  "const upsertTask = (why) => {",
13823
+ " const safeWhy = scrubUrlCredentials(why);",
11759
13824
  " const description = [",
11760
13825
  " `OpenLoops could not complete network PR handoff for original task ${taskId}.`,",
11761
- " `Reason: ${why}`,",
13826
+ " `Reason: ${safeWhy}`,",
11762
13827
  " `Fingerprint: ${fingerprint}`,",
11763
- " `Repository: ${repo || stringField('repo', 'remoteUrl') || 'unknown'}`,",
13828
+ " `Repository: ${repoDisplay || 'unknown'}`,",
11764
13829
  " `Worktree: ${repoPath}`,",
11765
13830
  " `Branch: ${branch || 'unknown'}`,",
11766
13831
  " `Base: ${base}`,",
11767
13832
  " `Commit: ${commit || 'unknown'}`,",
11768
13833
  " `Artifact: ${artifactPath}`,",
11769
13834
  " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
11770
- " artifact.error ? `Worker error: ${artifact.error}` : undefined,",
13835
+ " artifactError ? `Worker error: ${artifactError}` : undefined,",
11771
13836
  " 'Do not rerun implementation work. Push the recorded commit/branch, open or update the PR, then comment the original task with the PR URL and validation evidence.',",
11772
13837
  " ].filter(Boolean).join('\\n\\n');",
11773
13838
  " const result = todos(",
@@ -11781,8 +13846,8 @@ var PR_HANDOFF_SCRIPT = [
11781
13846
  " '--metadata-json', JSON.stringify(metadata),",
11782
13847
  " '--working-dir', repoPath,",
11783
13848
  " );",
11784
- " if (result.status !== 0) throw new Error(`todos task upsert failed: ${result.stderr || result.stdout || result.status}`);",
11785
- " comment(`openloops:pr-handoff=pending task=${taskId} artifact=${artifactPath} fingerprint=${fingerprint} reason=${why}`);",
13849
+ " if (result.status !== 0) throw new Error(`todos task upsert failed: ${scrubUrlCredentials(result.stderr || result.stdout || result.status)}`);",
13850
+ " comment(`openloops:pr-handoff=pending task=${taskId} artifact=${artifactPath} fingerprint=${fingerprint} reason=${safeWhy}`);",
11786
13851
  " console.log(`queued PR handoff task fingerprint=${fingerprint}`);",
11787
13852
  "};",
11788
13853
  "const queueNetworkHandoff = (why) => { upsertTask(why); process.exit(0); };",
@@ -11791,6 +13856,10 @@ var PR_HANDOFF_SCRIPT = [
11791
13856
  " console.error(`invalid PR handoff artifact: ${why}`);",
11792
13857
  " process.exit(0);",
11793
13858
  "};",
13859
+ "const preflightGitHub = () => {",
13860
+ " const probe = run(gitBin, ['-C', repoPath, 'ls-remote', '--heads', remote, base]);",
13861
+ " if (probe.status !== 0) queueNetworkHandoff(`github preflight failed before push/PR: ${String(probe.stderr || probe.stdout || probe.status).slice(0, 300)}`);",
13862
+ "};",
11794
13863
  "const canonicalPath = (path) => {",
11795
13864
  " try { return realpathSync(path); } catch { return path; }",
11796
13865
  "};",
@@ -11811,6 +13880,7 @@ var PR_HANDOFF_SCRIPT = [
11811
13880
  "commit = String(resolvedCommit.stdout || commit).trim();",
11812
13881
  "const reachable = run(gitBin, ['-C', repoPath, 'merge-base', '--is-ancestor', commit, 'HEAD']);",
11813
13882
  "if (reachable.status !== 0) invalidArtifact(`artifact commit ${commit} is not reachable from HEAD`);",
13883
+ "preflightGitHub();",
11814
13884
  "if (prUrl) {",
11815
13885
  ` const viewed = run(ghBin, ['pr', 'view', prUrl, '--json', 'url,headRefName', '--jq', '.url + "\\\\n" + .headRefName']);`,
11816
13886
  " if (viewed.status !== 0) queueNetworkHandoff(`could not verify existing PR URL: ${String(viewed.stderr || viewed.stdout || viewed.status).slice(0, 300)}`);",
@@ -11861,6 +13931,34 @@ var PR_HANDOFF_NO_ARTIFACT_SCRIPT = [
11861
13931
  " const result = run(todosBin, todosArgs('comment', taskId, text));",
11862
13932
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
11863
13933
  "};",
13934
+ "const scrubUrlCredentials = (value) => String(value || '').replace(/(https?:\\/\\/)[^\\s/@]+@/gi, '$1').replace(/(https?:\\/\\/)[^\\s/@]+:[^\\s/@]+@/gi, '$1');",
13935
+ "const upsertTask = (why, branch, commit, remoteUrl) => {",
13936
+ " const safeWhy = scrubUrlCredentials(why);",
13937
+ " const displayRemoteUrl = scrubUrlCredentials(remoteUrl);",
13938
+ " const fingerprint = `openloops:pr-handoff:${taskId}:${branch || 'missing-branch'}:${commit || 'missing-commit'}`;",
13939
+ " const repoTagSource = String(displayRemoteUrl || worktree).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
13940
+ " const repoTag = `repo:${repoTagSource.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'}`;",
13941
+ " const metadata = { route_enabled: true, source: 'openloops.pr-handoff', original_task_id: taskId, repo: displayRemoteUrl || '', branch: branch || '', commit: commit || '', fingerprint, automation: { allowed: true, mode: 'auto' }, no_tmux_dispatch: true };",
13942
+ " const description = [",
13943
+ " `OpenLoops could not complete no-artifact PR handoff for original task ${taskId}.`,",
13944
+ " `Reason: ${safeWhy}`,",
13945
+ " `Fingerprint: ${fingerprint}`,",
13946
+ " `Repository: ${displayRemoteUrl || 'unknown'}`,",
13947
+ " `Worktree: ${worktree}`,",
13948
+ " `Branch: ${branch || 'unknown'}`,",
13949
+ " `Commit: ${commit || 'unknown'}`,",
13950
+ " 'Do not rerun implementation work. Use the recorded worktree/branch/commit to verify or create the PR, then comment the original task with the PR URL and validation evidence.',",
13951
+ " ].filter(Boolean).join('\\n\\n');",
13952
+ " const result = run(todosBin, todosArgs('task', 'upsert', '--fingerprint', fingerprint, '--title', `PR handoff for ${taskId}`, '-d', description, '-p', 'high', '-t', ['auto:route', 'pr-handoff', 'github', 'network', repoTag].join(','), '--metadata-json', JSON.stringify(metadata), '--working-dir', worktree));",
13953
+ " if (result.status !== 0) {",
13954
+ " const upsertError = scrubUrlCredentials(result.stderr || result.stdout || result.status);",
13955
+ " console.error(`todos task upsert failed: ${upsertError}`);",
13956
+ " comment(`openloops:pr-handoff=failed task=${taskId} fingerprint=${fingerprint} reason=todos-upsert-failed detail=${String(upsertError).slice(0, 300)}`);",
13957
+ " return;",
13958
+ " }",
13959
+ " comment(`openloops:pr-handoff=pending task=${taskId} fingerprint=${fingerprint} reason=${safeWhy}`);",
13960
+ " console.log(`queued PR handoff task fingerprint=${fingerprint}`);",
13961
+ "};",
11864
13962
  "const main = () => {",
11865
13963
  " let branch = expectedBranch;",
11866
13964
  " if (!branch) {",
@@ -11868,17 +13966,27 @@ var PR_HANDOFF_NO_ARTIFACT_SCRIPT = [
11868
13966
  " branch = String((shown.status === 0 ? shown.stdout : '') || '').trim();",
11869
13967
  " }",
11870
13968
  " if (!branch) { console.log('pr-handoff: no artifact and no resolvable branch; nothing to hand off'); return; }",
13969
+ " const head = run(gitBin, ['-C', worktree, 'rev-parse', 'HEAD']);",
13970
+ " const commitFromHead = String((head.status === 0 ? head.stdout : '') || '').trim();",
13971
+ " const remoteUrlResult = run(gitBin, ['-C', worktree, 'remote', 'get-url', 'origin']);",
13972
+ " const remoteUrl = String((remoteUrlResult.status === 0 ? remoteUrlResult.stdout : '') || '').trim();",
13973
+ " if (remoteUrl) {",
13974
+ " const probe = run(gitBin, ['-C', worktree, 'ls-remote', '--heads', 'origin', branch]);",
13975
+ " if (probe.status !== 0) { upsertTask(`github preflight failed before PR lookup: ${String(probe.stderr || probe.stdout || probe.status).slice(0, 300)}`, branch, commitFromHead, remoteUrl); return; }",
13976
+ " }",
11871
13977
  " const listed = run(ghBin, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url,number,headRefName,headRefOid'], { cwd: worktree });",
11872
- " if (listed.status !== 0) { console.log(`pr-handoff: no artifact; PR lookup failed for branch ${branch}: ${String(listed.stderr || listed.stdout || listed.status).slice(0, 300)}`); return; }",
13978
+ " if (listed.status !== 0) {",
13979
+ " const reason = `gh PR lookup failed for branch ${branch}: ${String(listed.stderr || listed.stdout || listed.status).slice(0, 300)}`;",
13980
+ " if (remoteUrl) upsertTask(reason, branch, commitFromHead, remoteUrl);",
13981
+ " else console.log(`pr-handoff: no artifact; PR lookup failed for branch ${branch}: ${String(listed.stderr || listed.stdout || listed.status).slice(0, 300)}`);",
13982
+ " return;",
13983
+ " }",
11873
13984
  " let prs = [];",
11874
13985
  " try { prs = JSON.parse(String(listed.stdout || '[]')); } catch { prs = []; }",
11875
13986
  " const pr = Array.isArray(prs) ? prs.find((entry) => entry && entry.headRefName === branch && typeof entry.url === 'string' && entry.url) : undefined;",
11876
13987
  " if (!pr) { console.log(`pr-handoff: no artifact and no open PR for branch ${branch}; worker completed without opening a PR`); return; }",
11877
13988
  " let commit = String(pr.headRefOid || '').trim();",
11878
- " if (!commit) {",
11879
- " const head = run(gitBin, ['-C', worktree, 'rev-parse', 'HEAD']);",
11880
- " commit = String((head.status === 0 ? head.stdout : '') || '').trim();",
11881
- " }",
13989
+ " if (!commit) commit = commitFromHead;",
11882
13990
  " comment(`openloops:pr-handoff=done task=${taskId} pr=${pr.url} commit=${commit || 'unknown'} branch=${branch}`);",
11883
13991
  " console.log(`PR handoff complete (worker-opened PR): ${pr.url}`);",
11884
13992
  "};",
@@ -12374,6 +14482,14 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
12374
14482
  throw new Error(`${label} must be a positive integer number of milliseconds`);
12375
14483
  return value;
12376
14484
  }
14485
+ function parseNonNegativeIntegerVar(raw, fallback, label) {
14486
+ if (raw === undefined || raw.trim() === "")
14487
+ return fallback;
14488
+ const value = Number(raw);
14489
+ if (!Number.isInteger(value) || value < 0)
14490
+ throw new Error(`${label} must be a non-negative integer`);
14491
+ return value;
14492
+ }
12377
14493
  function rolePoolValue(pool, seed, role) {
12378
14494
  if (!pool?.length)
12379
14495
  return;
@@ -12920,6 +15036,163 @@ function renderTaskLifecycleWorkflow(input) {
12920
15036
  steps
12921
15037
  };
12922
15038
  }
15039
+ function renderRoutingRemediationWorkflow(input) {
15040
+ if (!input.projectPath?.trim())
15041
+ throw new Error("projectPath is required");
15042
+ const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
15043
+ const status = input.status ?? "pending,in_progress";
15044
+ const scope = {
15045
+ doctorProject: input.doctorProject,
15046
+ tag: input.tag,
15047
+ status,
15048
+ shard: input.shard,
15049
+ limit: input.limit
15050
+ };
15051
+ const idempotencyKey = input.idempotencyKey?.trim() || [
15052
+ "routing-remediation",
15053
+ todosProjectPath,
15054
+ input.doctorJsonPath ?? "doctor-live",
15055
+ input.doctorProject ?? "all-projects",
15056
+ input.tag ?? "all-tags",
15057
+ status,
15058
+ input.shard ?? "all-shards",
15059
+ input.limit ?? "unlimited"
15060
+ ].join(":");
15061
+ const runId = `${slugSegment(idempotencyKey, "routing-remediation").slice(0, 48)}-${stableHex(idempotencyKey)}`;
15062
+ const maxRepairs = input.maxRepairs ?? 25;
15063
+ if (!Number.isInteger(maxRepairs) || maxRepairs < 0)
15064
+ throw new Error("maxRepairs must be a non-negative integer");
15065
+ const dryRun = input.dryRun ?? true;
15066
+ const plan = worktreePlan(input, idempotencyKey);
15067
+ const evidenceDir = input.evidenceDir ?? join9(input.projectPath, ".openloops", "routing-remediation");
15068
+ const undoDir = input.undoDir ?? evidenceDir;
15069
+ const doctorOutputPath = join9(evidenceDir, `routing-doctor-${runId}.json`);
15070
+ const preflightOutputPath = join9(evidenceDir, `routing-remediation-preflight-${runId}.json`);
15071
+ const applyOutputPath = join9(evidenceDir, `routing-remediation-apply-${runId}.json`);
15072
+ const recheckOutputPath = join9(evidenceDir, `routing-remediation-recheck-${runId}.json`);
15073
+ const undoRecordPath = join9(undoDir, `routing-remediation-${runId}.undo.json`);
15074
+ const applyCommand = routingRemediationDoctorCommand({
15075
+ todosProjectPath,
15076
+ apply: true,
15077
+ undoRecordPath,
15078
+ ...scope
15079
+ });
15080
+ const recheckCommand = routingRemediationDoctorCommand({
15081
+ todosProjectPath,
15082
+ ...scope
15083
+ });
15084
+ const preflightCommand = routingRemediationPreflightCommand({
15085
+ todosProjectPath,
15086
+ doctorJsonPath: input.doctorJsonPath,
15087
+ doctorOutputPath,
15088
+ preflightOutputPath,
15089
+ maxRepairs,
15090
+ dryRun,
15091
+ idempotencyKey,
15092
+ applyCommand,
15093
+ ...scope
15094
+ });
15095
+ const context = {
15096
+ idempotencyKey,
15097
+ projectPath: input.projectPath,
15098
+ routeProjectPath: input.routeProjectPath,
15099
+ projectGroup: input.projectGroup,
15100
+ todosProjectPath,
15101
+ doctorJsonPath: input.doctorJsonPath,
15102
+ doctorProject: input.doctorProject,
15103
+ tag: input.tag,
15104
+ status,
15105
+ shard: input.shard,
15106
+ limit: input.limit,
15107
+ maxRepairs,
15108
+ dryRun,
15109
+ evidence: {
15110
+ doctorOutputPath,
15111
+ preflightOutputPath,
15112
+ applyOutputPath,
15113
+ recheckOutputPath,
15114
+ undoRecordPath
15115
+ },
15116
+ worktree: worktreeContextFragment(plan)
15117
+ };
15118
+ const shared = [
15119
+ worktreePrompt(plan),
15120
+ "Routing remediation contract:",
15121
+ `- Todos project path: ${todosProjectPath}`,
15122
+ `- Idempotency key: ${idempotencyKey}`,
15123
+ `- Dry-run/preflight mode: ${dryRun ? "true" : "false"}`,
15124
+ `- Max safe_auto repairs for this run: ${maxRepairs}`,
15125
+ `- Source doctor JSON: ${input.doctorJsonPath ?? "generated by routing-doctor-preflight"}`,
15126
+ `- Normalized doctor JSON: ${doctorOutputPath}`,
15127
+ `- Preflight JSON: ${preflightOutputPath}`,
15128
+ `- Apply JSON target: ${applyOutputPath}`,
15129
+ `- Recheck JSON target: ${recheckOutputPath}`,
15130
+ `- Undo record target: ${undoRecordPath}`,
15131
+ "Never edit the Todos SQLite database, raw DB files, or task JSON storage directly. Do not use sqlite3, ad hoc SQL, or filesystem mutations as a repair mechanism.",
15132
+ "Only supported Todos CLI/API commands may mutate tasks. Safe repairs are limited to doctor findings classified safe_auto whose suggested_repair.field is working_dir or task_list_id.",
15133
+ "Refuse blocker_human, blocker_cross_repo, blocker_invalid_path, unsupported, legal, and other human-judgement findings as mutations. File or update blocker tasks instead.",
15134
+ NO_TMUX_DISPATCH_FRAGMENT,
15135
+ "",
15136
+ `Routing remediation context JSON: ${compactJson(context)}`
15137
+ ].join(`
15138
+ `);
15139
+ const workerPrompt = [
15140
+ ...boundedStepHeaderFragment("Apply bounded routing-doctor remediation from preflight evidence.", "worker", "bounded"),
15141
+ shared,
15142
+ "Read the preflight JSON before making any mutation. If preflight apply_allowed is false, do not run the apply command.",
15143
+ dryRun ? "This workflow was rendered with dryRun=true. Do not run the apply command; produce only a dry-run summary and blocker-task preview/update evidence." : `If preflight permits apply, run the supported repair command and capture stdout JSON to ${applyOutputPath}: ${applyCommand}`,
15144
+ `After any apply run, recheck route state and capture stdout JSON to ${recheckOutputPath}: ${recheckCommand}`,
15145
+ "For every modified task, ensure a task comment records old value, new value, repair command, source doctor run, undo record, and route-state recheck result. If the Todos CLI already wrote a complete per-task repair comment, verify it; otherwise add the missing evidence with todos comment.",
15146
+ "Create or update deduped blocker tasks for every blocker_human, blocker_cross_repo, blocker_invalid_path, unsupported, legal, or otherwise unsupported finding.",
15147
+ [
15148
+ "Blocker task command shape:",
15149
+ `todos --project ${todosProjectPath} task upsert --fingerprint "routing-health:blocker:<source-task-id>:<finding-category>" --title "Routing remediation blocker: <finding-category> for <source-task-id>" -d "<source task id, finding category, repair_class, old value, new value, source doctor run, and why automation refused mutation>" -p high -t from-kai,routing-health`
15150
+ ].join(`
15151
+ `),
15152
+ "Do not change cross-repo task intent. A cross-repo finding can only be changed when the doctor itself classifies the exact field repair as safe_auto and the supported Todos repair command applies it.",
15153
+ "Record compact workflow evidence: changed tasks, blocker fingerprints, apply/recheck artifact paths, undo record path, validation results, and residual risks."
15154
+ ].join(`
15155
+ `);
15156
+ const verifierPrompt = [
15157
+ ...boundedStepHeaderFragment("Verify bounded routing-doctor remediation evidence.", "verifier", "bounded"),
15158
+ shared,
15159
+ adversarialReviewFragment("the preflight artifact, apply output, undo record, blocker tasks, per-task comments, and route-state recheck", TASK_REVIEW_FOCUS),
15160
+ verifierRuntimeGuidance(input),
15161
+ `Re-run or inspect the route-state recheck command as needed: ${recheckCommand}`,
15162
+ "Confirm safe_auto repairs were limited to working_dir and task_list_id, raw DB edits were not used, cross-repo/human/legal/unsupported findings became from-kai,routing-health blocker tasks, and every changed task has old/new/command/source/recheck evidence.",
15163
+ "If dry-run mode was rendered, verify that no apply/repair mutation occurred and that the output is clearly preflight-only.",
15164
+ "If invalid, record precise blocker evidence and create follow-up tasks rather than broad fixes.",
15165
+ VERIFIER_TINY_FIXES_FRAGMENT
15166
+ ].join(`
15167
+ `);
15168
+ return {
15169
+ name: `routing-remediation-${runId}`,
15170
+ description: `Routing doctor remediation workflow; dryRun=${dryRun}; maxRepairs=${maxRepairs}; idempotency=${idempotencyKey}`,
15171
+ version: 1,
15172
+ steps: [
15173
+ commandStep({
15174
+ id: "routing-doctor-preflight",
15175
+ name: "Routing Doctor Preflight",
15176
+ description: "Run or consume routing doctor JSON, enforce safe-field and repair-capacity gates, and write bounded evidence.",
15177
+ command: preflightCommand,
15178
+ cwd: input.projectPath,
15179
+ timeoutMs: 5 * 60000,
15180
+ idleTimeoutMs: 60000,
15181
+ blockedExitCodes: GATE_BLOCKED_EXIT_CODES
15182
+ }),
15183
+ ...workerVerifierSteps({
15184
+ input,
15185
+ seed: idempotencyKey,
15186
+ plan,
15187
+ workerPrompt,
15188
+ verifierPrompt,
15189
+ workerDescription: "Apply only supported Todos CLI safe_auto repairs and file blocker tasks.",
15190
+ verifierDescription: "Adversarially verify routing remediation evidence and safety boundaries.",
15191
+ workerDependsOn: ["routing-doctor-preflight"]
15192
+ })
15193
+ ]
15194
+ };
15195
+ }
12923
15196
  function renderEventWorkerVerifierWorkflow(input) {
12924
15197
  if (!input.eventId?.trim())
12925
15198
  throw new Error("eventId is required");
@@ -13178,10 +15451,32 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
13178
15451
  ]
13179
15452
  };
13180
15453
  }
15454
+ function renderRoutingRemediationTemplate(values) {
15455
+ const base = agentTemplateInput(values);
15456
+ return renderRoutingRemediationWorkflow({
15457
+ ...base,
15458
+ todosProjectPath: values.todosProjectPath ?? values.todosProject,
15459
+ doctorJsonPath: values.doctorJsonPath,
15460
+ doctorProject: values.doctorProject,
15461
+ tag: values.tag,
15462
+ status: values.status,
15463
+ shard: values.shard,
15464
+ limit: values.limit,
15465
+ maxRepairs: parseNonNegativeIntegerVar(values.maxRepairs, 25, "maxRepairs"),
15466
+ dryRun: booleanVar(values.dryRun) ?? true,
15467
+ idempotencyKey: values.idempotencyKey,
15468
+ evidenceDir: values.evidenceDir,
15469
+ undoDir: values.undoDir,
15470
+ worktreeMode: base.worktreeMode ?? "required"
15471
+ });
15472
+ }
13181
15473
  function renderBuiltinLoopTemplate(id, values) {
13182
15474
  if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
13183
15475
  return renderDeterministicCheckCreateTaskWorkflow(values);
13184
15476
  }
15477
+ if (id === ROUTING_REMEDIATION_TEMPLATE_ID) {
15478
+ return renderRoutingRemediationTemplate(values);
15479
+ }
13185
15480
  const lifecycle = renderLifecycleBoundedTemplate(id, values);
13186
15481
  if (lifecycle)
13187
15482
  return lifecycle;