@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/lib/store.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;
@@ -4593,12 +4969,14 @@ class Store {
4593
4969
  }
4594
4970
  export {
4595
4971
  workItemStatusForLoopRun,
4972
+ scrubbedOrNull,
4596
4973
  rowToWorkflowWorkItem,
4597
4974
  rowToWorkflowStepRun,
4598
4975
  rowToWorkflowRun,
4599
4976
  rowToWorkflowInvocation,
4600
4977
  rowToWorkflowEvent,
4601
4978
  rowToWorkflow,
4979
+ rowToRunReceipt,
4602
4980
  rowToRun,
4603
4981
  rowToLoop,
4604
4982
  rowToLease,
@@ -17,6 +17,7 @@ export declare const KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
17
17
  export declare const REPORT_ONLY_TEMPLATE_ID = "report-only";
18
18
  export declare const INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
19
19
  export declare const DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
20
+ export declare const ROUTING_REMEDIATION_TEMPLATE_ID = "routing-remediation";
20
21
  export type AgentWorkflowRole = "triage" | "planner" | "worker" | "verifier";
21
22
  export declare const BUILTIN_TEMPLATE_SUMMARIES: LoopTemplateSummary[];
22
23
  export declare const 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.";
@@ -62,6 +63,30 @@ export declare function todosExactCommandsFragment(todosProjectPath: string, tas
62
63
  export declare function worktreePrompt(plan: AgentWorktreeSpec): string;
63
64
  export declare function worktreeContextFragment(plan: AgentWorktreeSpec): Record<string, unknown>;
64
65
  export declare function shellQuote(value: string): string;
66
+ export interface RoutingRemediationScopeOptions {
67
+ doctorProject?: string;
68
+ tag?: string;
69
+ status?: string;
70
+ shard?: string;
71
+ limit?: string;
72
+ }
73
+ export interface RoutingRemediationPreflightCommandOptions extends RoutingRemediationScopeOptions {
74
+ todosProjectPath: string;
75
+ doctorJsonPath?: string;
76
+ doctorOutputPath: string;
77
+ preflightOutputPath: string;
78
+ maxRepairs: number;
79
+ dryRun: boolean;
80
+ idempotencyKey: string;
81
+ applyCommand: string;
82
+ }
83
+ export declare function routingRemediationDoctorScopeArgs(opts: RoutingRemediationScopeOptions): string[];
84
+ export declare function routingRemediationDoctorCommand(opts: RoutingRemediationScopeOptions & {
85
+ todosProjectPath: string;
86
+ apply?: boolean;
87
+ undoRecordPath?: string;
88
+ }): string;
89
+ export declare function routingRemediationPreflightCommand(opts: RoutingRemediationPreflightCommandOptions): string;
65
90
  export declare function sourceTaskGateCommand(todosProjectPath: string, taskId: string): string;
66
91
  export type LifecycleGateStage = "triage" | "planner";
67
92
  export declare function lifecycleGateCommand(todosProjectPath: string, taskId: string, stage: LifecycleGateStage, goMarker: string, blockedMarker: string): string;
@@ -1,5 +1,5 @@
1
1
  import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary, TimeoutMs } from "../types.js";
2
- export { BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID, DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID, EVENT_WORKER_VERIFIER_TEMPLATE_ID, INCIDENT_RESPONSE_TEMPLATE_ID, KNOWLEDGE_REFRESH_TEMPLATE_ID, PR_REVIEW_TEMPLATE_ID, REPORT_ONLY_TEMPLATE_ID, SCHEDULED_AUDIT_TEMPLATE_ID, TASK_LIFECYCLE_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, } from "./template-kit.js";
2
+ export { BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID, DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID, EVENT_WORKER_VERIFIER_TEMPLATE_ID, INCIDENT_RESPONSE_TEMPLATE_ID, KNOWLEDGE_REFRESH_TEMPLATE_ID, PR_REVIEW_TEMPLATE_ID, REPORT_ONLY_TEMPLATE_ID, ROUTING_REMEDIATION_TEMPLATE_ID, SCHEDULED_AUDIT_TEMPLATE_ID, TASK_LIFECYCLE_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, } from "./template-kit.js";
3
3
  import type { CustomLoopTemplateImportOptions, CustomLoopTemplateImportResult } from "./templates-custom.js";
4
4
  export type { CustomLoopTemplateImportOptions, CustomLoopTemplateImportResult } from "./templates-custom.js";
5
5
  /** Agent/account/worktree fields shared by every agent-backed builtin template input. */
@@ -65,6 +65,20 @@ export interface BoundedAgentWorkflowTemplateInput extends AgentWorkflowTemplate
65
65
  objective: string;
66
66
  prompt?: string;
67
67
  }
68
+ export interface RoutingRemediationWorkflowTemplateInput extends AgentWorkflowTemplateBaseInput {
69
+ todosProjectPath?: string;
70
+ doctorJsonPath?: string;
71
+ doctorProject?: string;
72
+ tag?: string;
73
+ status?: string;
74
+ shard?: string;
75
+ limit?: string;
76
+ maxRepairs?: number;
77
+ dryRun?: boolean;
78
+ idempotencyKey?: string;
79
+ evidenceDir?: string;
80
+ undoDir?: string;
81
+ }
68
82
  export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
69
83
  export interface ListLoopTemplatesOptions {
70
84
  source?: LoopTemplateSourceFilter;
@@ -81,6 +95,7 @@ export declare function listLoopTemplates(opts?: ListLoopTemplatesOptions): Loop
81
95
  export declare function getLoopTemplate(id: string, opts?: ListLoopTemplatesOptions): LoopTemplateSummary | undefined;
82
96
  export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
83
97
  export declare function renderTaskLifecycleWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
98
+ export declare function renderRoutingRemediationWorkflow(input: RoutingRemediationWorkflowTemplateInput): CreateWorkflowInput;
84
99
  export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
85
100
  export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
86
101
  export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>, opts?: ListLoopTemplatesOptions): CreateWorkflowInput;
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare const MCP_HTTP_SERVICE_NAME = "loops";
3
+ export declare const DEFAULT_MCP_HTTP_PORT = 8890;
4
+ export declare function isHttpMode(argv?: string[], env?: NodeJS.ProcessEnv): boolean;
5
+ export declare function isStdioMode(argv?: string[], env?: NodeJS.ProcessEnv): boolean;
6
+ export declare function resolveMcpHttpPort(argv?: string[], env?: NodeJS.ProcessEnv): number;
7
+ export type McpHttpServerHandle = {
8
+ port: number;
9
+ host: string;
10
+ close: () => Promise<void>;
11
+ };
12
+ export declare function startMcpHttpServer(buildServer: () => McpServer, options?: {
13
+ port?: number;
14
+ host?: string;
15
+ serviceName?: string;
16
+ }): Promise<McpHttpServerHandle>;