@hasna/loops 0.4.13 → 0.4.22

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 (62) hide show
  1. package/CHANGELOG.md +99 -3
  2. package/README.md +171 -39
  3. package/dist/api/index.d.ts +36 -0
  4. package/dist/api/index.js +1518 -15
  5. package/dist/cli/index.js +7280 -3213
  6. package/dist/cli/ui.d.ts +44 -0
  7. package/dist/daemon/index.js +1433 -303
  8. package/dist/generated/storage-kit/health.d.ts +19 -0
  9. package/dist/generated/storage-kit/index.d.ts +7 -0
  10. package/dist/generated/storage-kit/migrations.d.ts +47 -0
  11. package/dist/generated/storage-kit/mode.d.ts +47 -0
  12. package/dist/generated/storage-kit/pool.d.ts +33 -0
  13. package/dist/generated/storage-kit/query.d.ts +35 -0
  14. package/dist/generated/storage-kit/tls.d.ts +25 -0
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +8689 -4837
  17. package/dist/lib/cloud/mode.d.ts +17 -0
  18. package/dist/lib/cloud/resolve.d.ts +16 -0
  19. package/dist/lib/cloud/storage.d.ts +82 -0
  20. package/dist/lib/cloud/transport.d.ts +148 -0
  21. package/dist/lib/format.d.ts +2 -1
  22. package/dist/lib/health.d.ts +83 -3
  23. package/dist/lib/migration.d.ts +40 -0
  24. package/dist/lib/mode.js +15 -3
  25. package/dist/lib/route/index.d.ts +2 -0
  26. package/dist/lib/route/policies.d.ts +51 -0
  27. package/dist/lib/route/provider-admission.d.ts +37 -0
  28. package/dist/lib/route/throttle.d.ts +4 -0
  29. package/dist/lib/route/types.d.ts +8 -0
  30. package/dist/lib/run-receipts.d.ts +14 -0
  31. package/dist/lib/scheduler.d.ts +5 -2
  32. package/dist/lib/storage/contract.d.ts +7 -1
  33. package/dist/lib/storage/index.d.ts +1 -0
  34. package/dist/lib/storage/index.js +2028 -231
  35. package/dist/lib/storage/pg-executor.d.ts +27 -0
  36. package/dist/lib/storage/pg-runner-claim.d.ts +40 -0
  37. package/dist/lib/storage/postgres-loop-storage.d.ts +120 -0
  38. package/dist/lib/storage/postgres-schema.js +29 -0
  39. package/dist/lib/storage/postgres.js +29 -0
  40. package/dist/lib/storage/sqlite.d.ts +6 -0
  41. package/dist/lib/storage/sqlite.js +748 -26
  42. package/dist/lib/store/index.d.ts +268 -0
  43. package/dist/lib/store.d.ts +282 -1
  44. package/dist/lib/store.js +746 -26
  45. package/dist/lib/template-kit.d.ts +25 -0
  46. package/dist/lib/templates.d.ts +16 -1
  47. package/dist/mcp/http.d.ts +16 -0
  48. package/dist/mcp/index.js +2885 -634
  49. package/dist/runner/index.js +198 -32
  50. package/dist/sdk/http.d.ts +182 -0
  51. package/dist/sdk/http.js +166 -0
  52. package/dist/sdk/index.d.ts +55 -18
  53. package/dist/sdk/index.js +2734 -617
  54. package/dist/serve/index.d.ts +4 -0
  55. package/dist/serve/index.js +9095 -0
  56. package/dist/types.d.ts +52 -0
  57. package/docs/AUTOMATION_RUNTIME_DESIGN.md +442 -1
  58. package/docs/CUTOVER-RUNBOOK.md +78 -0
  59. package/docs/DEPLOYMENT_MODES.md +35 -18
  60. package/docs/RUNTIME_BOUNDARY.md +203 -0
  61. package/docs/USAGE.md +195 -38
  62. package/package.json +15 -3
package/dist/lib/store.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // @bun
2
2
  // src/lib/store.ts
3
3
  import { Database } from "bun:sqlite";
4
- import { chmodSync, existsSync, mkdirSync as mkdirSync3, mkdtempSync, rmSync as rmSync2 } from "fs";
5
- import { tmpdir } from "os";
6
- import { basename as basename2, dirname as dirname2, join as join3 } from "path";
4
+ import { chmodSync, existsSync, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, rmSync as rmSync3 } from "fs";
5
+ import { tmpdir as tmpdir2 } from "os";
6
+ import { basename as basename2, dirname as dirname2, join as join4 } from "path";
7
7
 
8
8
  // src/lib/errors.ts
9
9
  class CodedError extends Error {
@@ -1202,15 +1202,378 @@ 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
+
1374
+ // src/lib/route/todos-cli.ts
1375
+ import { closeSync, mkdtempSync, openSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
1376
+ import { spawnSync as spawnSync2 } from "child_process";
1377
+ import { join as join3 } from "path";
1378
+ import { homedir as homedir2, tmpdir } from "os";
1379
+
1380
+ // src/lib/format.ts
1381
+ var TEXT_OUTPUT_LIMIT = 32 * 1024;
1382
+ var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
1383
+ function redact(value, visible = 0) {
1384
+ if (!value)
1385
+ return value;
1386
+ if (value.length <= visible)
1387
+ return value;
1388
+ if (visible <= 0)
1389
+ return `[redacted ${value.length} chars]`;
1390
+ return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
1391
+ }
1392
+ function truncateTextOutput(value) {
1393
+ if (value.length <= TEXT_OUTPUT_LIMIT)
1394
+ return value;
1395
+ return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
1396
+ [truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
1397
+ }
1398
+ function redactSensitivePayload(value, key) {
1399
+ if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
1400
+ if (typeof value === "string")
1401
+ return redact(value);
1402
+ if (value === undefined || value === null)
1403
+ return value;
1404
+ return "[redacted]";
1405
+ }
1406
+ if (Array.isArray(value))
1407
+ return value.map((item) => redactSensitivePayload(item));
1408
+ if (value && typeof value === "object") {
1409
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
1410
+ }
1411
+ return value;
1412
+ }
1413
+ function textOutputBlocks(value, opts = {}) {
1414
+ const indent = opts.indent ?? "";
1415
+ const nested = `${indent} `;
1416
+ const blocks = [];
1417
+ for (const [label, output] of [
1418
+ ["stdout", value.stdout],
1419
+ ["stderr", value.stderr]
1420
+ ]) {
1421
+ if (!output)
1422
+ continue;
1423
+ blocks.push(`${indent}${label}:`);
1424
+ for (const line of truncateTextOutput(output).replace(/\s+$/, "").split(/\r?\n/)) {
1425
+ blocks.push(`${nested}${line}`);
1426
+ }
1427
+ }
1428
+ return blocks;
1429
+ }
1430
+ function publicLoop(loop) {
1431
+ const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : loop.target.type === "agent" ? { ...loop.target, prompt: redact(loop.target.prompt) } : loop.target;
1432
+ return {
1433
+ ...loop,
1434
+ target
1435
+ };
1436
+ }
1437
+ function publicRun(run, showOutput = false, opts = {}) {
1438
+ return {
1439
+ ...run,
1440
+ stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
1441
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
1442
+ error: opts.redactError ? redact(run.error) : run.error
1443
+ };
1444
+ }
1445
+ function publicRunReceipt(receipt) {
1446
+ return { ...receipt };
1447
+ }
1448
+ function publicExecutorResult(result, showOutput = false) {
1449
+ return {
1450
+ ...result,
1451
+ stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
1452
+ stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
1453
+ error: redact(result.error)
1454
+ };
1455
+ }
1456
+ function publicWorkflow(workflow) {
1457
+ return {
1458
+ ...workflow,
1459
+ steps: workflow.steps.map((step) => ({
1460
+ ...step,
1461
+ target: step.target.type === "agent" ? { ...step.target, prompt: redact(step.target.prompt) } : step.target.type === "command" && step.target.env ? { ...step.target, env: "[redacted]" } : step.target
1462
+ }))
1463
+ };
1464
+ }
1465
+ function publicWorkflowRun(run) {
1466
+ return { ...run, error: redact(run.error) };
1467
+ }
1468
+ function publicWorkflowInvocation(invocation) {
1469
+ return redactSensitivePayload(invocation);
1470
+ }
1471
+ function publicWorkflowWorkItem(item) {
1472
+ return { ...item, lastReason: redact(item.lastReason, 240) };
1473
+ }
1474
+ function publicWorkflowStepRun(run, showOutput = false) {
1475
+ return {
1476
+ ...run,
1477
+ stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
1478
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
1479
+ error: redact(run.error)
1480
+ };
1481
+ }
1482
+ function publicWorkflowEvent(event) {
1483
+ return { ...event, payload: redactSensitivePayload(event.payload) };
1484
+ }
1485
+ function publicGoal(goal) {
1486
+ return {
1487
+ ...goal,
1488
+ objective: redact(goal.objective, 120)
1489
+ };
1490
+ }
1491
+ function publicGoalRun(run) {
1492
+ return {
1493
+ ...run,
1494
+ evidence: redactSensitivePayload(run.evidence),
1495
+ rawResponse: redactSensitivePayload(run.rawResponse)
1496
+ };
1497
+ }
1498
+
1499
+ // src/lib/route/todos-cli.ts
1500
+ function defaultLoopsProject() {
1501
+ return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME || homedir2()}/.hasna/loops`;
1502
+ }
1503
+ function runLocalCommand(command, args, opts = {}) {
1504
+ const result = spawnSync2(command, args, {
1505
+ input: opts.input,
1506
+ encoding: "utf8",
1507
+ timeout: opts.timeoutMs ?? 30000,
1508
+ maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
1509
+ env: process.env
1510
+ });
1511
+ return {
1512
+ ok: result.status === 0,
1513
+ status: result.status,
1514
+ stdout: result.stdout || "",
1515
+ stderr: result.stderr || "",
1516
+ error: result.error ? String(result.error.message || result.error) : ""
1517
+ };
1518
+ }
1519
+ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
1520
+ const tempDir = mkdtempSync(join3(tmpdir(), "loops-command-output-"));
1521
+ const stdoutPath = join3(tempDir, "stdout");
1522
+ const stdoutFd = openSync(stdoutPath, "w");
1523
+ let result;
1524
+ try {
1525
+ result = spawnSync2(command, args, {
1526
+ input: opts.input,
1527
+ encoding: "utf8",
1528
+ timeout: opts.timeoutMs ?? 30000,
1529
+ maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
1530
+ env: process.env,
1531
+ stdio: ["pipe", stdoutFd, "pipe"]
1532
+ });
1533
+ } finally {
1534
+ closeSync(stdoutFd);
1535
+ }
1536
+ try {
1537
+ return {
1538
+ ok: result.status === 0,
1539
+ status: result.status,
1540
+ stdout: readFileSync3(stdoutPath, "utf8"),
1541
+ stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
1542
+ error: result.error ? String(result.error.message || result.error) : ""
1543
+ };
1544
+ } finally {
1545
+ rmSync2(tempDir, { recursive: true, force: true });
1546
+ }
1547
+ }
1548
+ function ensureTodosTaskList(project, slug, name, description) {
1549
+ runLocalCommand("todos", ["--project", project, "task-lists", "--add", name, "--slug", slug, "-d", description]);
1550
+ const list = runLocalCommand("todos", ["--project", project, "--json", "task-lists"]);
1551
+ if (!list.ok)
1552
+ throw new Error(list.stderr || list.error || "failed to list todos task lists");
1553
+ const values = JSON.parse(list.stdout || "[]");
1554
+ const found = values.find((entry) => entry.slug === slug);
1555
+ if (!found)
1556
+ throw new Error(`todos task list not found after ensure: ${slug}`);
1557
+ return found.id;
1558
+ }
1559
+ function todosMutationSummary(result) {
1560
+ return {
1561
+ ok: result.ok,
1562
+ status: result.status,
1563
+ error: result.ok ? undefined : redact(result.stderr || result.error || "todos mutation failed", 320)
1564
+ };
1565
+ }
1566
+
1205
1567
  // src/lib/store.ts
1206
1568
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1207
1569
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1208
1570
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1209
- var SCHEMA_USER_VERSION = 7;
1571
+ var SCHEMA_USER_VERSION = 8;
1210
1572
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1211
1573
  var PRUNE_BATCH_SIZE = 400;
1212
1574
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
1213
1575
  var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
1576
+ var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
1214
1577
  function rowToLoop(row) {
1215
1578
  return {
1216
1579
  id: row.id,
@@ -1261,6 +1624,28 @@ function rowToRun(row) {
1261
1624
  updatedAt: row.updated_at
1262
1625
  };
1263
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
+ }
1646
+ function latestRunTime(row) {
1647
+ return row.finished_at ?? row.started_at ?? row.created_at;
1648
+ }
1264
1649
  function rowToWorkflow(row) {
1265
1650
  return {
1266
1651
  id: row.id,
@@ -1321,6 +1706,7 @@ function rowToWorkflowWorkItem(row) {
1321
1706
  subjectRef: row.subject_ref,
1322
1707
  projectKey: row.project_key ?? undefined,
1323
1708
  projectGroup: row.project_group ?? undefined,
1709
+ machineId: row.machine_id ?? undefined,
1324
1710
  routeScope: row.route_scope ?? undefined,
1325
1711
  priority: row.priority,
1326
1712
  status: row.status,
@@ -1478,6 +1864,7 @@ function scrubbedOrNull(value) {
1478
1864
  return value == null ? null : scrubSecrets(value);
1479
1865
  }
1480
1866
  var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
1867
+ var MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS = 64 * 1024;
1481
1868
  function clampPersistedRunOutput(value) {
1482
1869
  if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
1483
1870
  return value;
@@ -1492,6 +1879,42 @@ ${tail}`;
1492
1879
  function persistedRunOutput(value) {
1493
1880
  return clampPersistedRunOutput(scrubbedOrNull(value));
1494
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
+ }
1495
1918
  function chmodIfExists(path, mode) {
1496
1919
  try {
1497
1920
  if (existsSync(path))
@@ -1515,7 +1938,7 @@ class Store {
1515
1938
  const file = path ?? dbPath();
1516
1939
  if (file !== ":memory:")
1517
1940
  ensurePrivateStorePath(file);
1518
- this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1941
+ this.rootDir = file === ":memory:" ? mkdtempSync2(join4(tmpdir2(), "open-loops-store-")) : dirname2(file);
1519
1942
  if (file === ":memory:")
1520
1943
  this.memoryRootDir = this.rootDir;
1521
1944
  this.db = new Database(file);
@@ -1609,6 +2032,17 @@ class Store {
1609
2032
  this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
1610
2033
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
1611
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
+ }
1612
2046
  }
1613
2047
  ];
1614
2048
  }
@@ -1670,6 +2104,28 @@ class Store {
1670
2104
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1671
2105
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1672
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
+
1673
2129
  CREATE TABLE IF NOT EXISTS daemon_lease (
1674
2130
  id TEXT PRIMARY KEY,
1675
2131
  pid INTEGER NOT NULL,
@@ -1756,6 +2212,7 @@ class Store {
1756
2212
  subject_ref TEXT NOT NULL,
1757
2213
  project_key TEXT,
1758
2214
  project_group TEXT,
2215
+ machine_id TEXT,
1759
2216
  route_scope TEXT,
1760
2217
  priority INTEGER NOT NULL,
1761
2218
  status TEXT NOT NULL,
@@ -1774,10 +2231,10 @@ class Store {
1774
2231
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1775
2232
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1776
2233
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1777
- -- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
1778
- -- 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
1779
2236
  -- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
1780
- -- 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
1781
2238
  -- here would execute before the column exists and crash the open
1782
2239
  -- ("no such column: route_scope"). New columns may be folded into the
1783
2240
  -- CREATE TABLE (fresh-db only); their indexes must live in the migration.
@@ -1888,6 +2345,31 @@ class Store {
1888
2345
  CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
1889
2346
  `);
1890
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
+ }
1891
2373
  addColumnIfMissing(table, column, definition) {
1892
2374
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
1893
2375
  if (columns.some((c) => c.name === column))
@@ -1999,21 +2481,51 @@ class Store {
1999
2481
  }
2000
2482
  listLoops(opts = {}) {
2001
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
+ }
2002
2489
  let rows;
2003
2490
  if (opts.status && opts.archived) {
2004
- 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);
2005
2492
  } else if (opts.status && opts.includeArchived) {
2006
- 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);
2007
2494
  } else if (opts.status) {
2008
- 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);
2009
2496
  } else if (opts.archived) {
2010
- 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);
2011
2498
  } else if (opts.includeArchived) {
2012
- 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);
2013
2500
  } else {
2014
- 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);
2015
2502
  }
2016
- return rows.map(rowToLoop);
2503
+ return this.withLatestRunSummaries(rows.map(rowToLoop));
2504
+ }
2505
+ withLatestRunSummaries(loops) {
2506
+ if (loops.length === 0)
2507
+ return loops;
2508
+ const placeholders = loops.map(() => "?").join(",");
2509
+ const rows = this.db.query(`SELECT loop_id, id, status, started_at, finished_at, created_at
2510
+ FROM (
2511
+ SELECT loop_id, id, status, started_at, finished_at, created_at,
2512
+ ROW_NUMBER() OVER (PARTITION BY loop_id ORDER BY created_at DESC, id DESC) AS rn
2513
+ FROM loop_runs
2514
+ WHERE loop_id IN (${placeholders})
2515
+ )
2516
+ WHERE rn = 1`).all(...loops.map((loop) => loop.id));
2517
+ const latestByLoopId = new Map(rows.map((row) => [row.loop_id, row]));
2518
+ return loops.map((loop) => {
2519
+ const latest = latestByLoopId.get(loop.id);
2520
+ if (!latest)
2521
+ return loop;
2522
+ return {
2523
+ ...loop,
2524
+ latestRunId: latest.id,
2525
+ latestRunStatus: latest.status,
2526
+ lastRunAt: latestRunTime(latest)
2527
+ };
2528
+ });
2017
2529
  }
2018
2530
  dueLoops(now, limit = 500) {
2019
2531
  const rows = this.db.query(`SELECT * FROM loops
@@ -2132,6 +2644,45 @@ class Store {
2132
2644
  throw error;
2133
2645
  }
2134
2646
  }
2647
+ updateAgentLoopTimeout(idOrName, timeoutMs, opts = {}) {
2648
+ const updated = (opts.now ?? new Date).toISOString();
2649
+ this.db.exec("BEGIN IMMEDIATE");
2650
+ try {
2651
+ const current = this.requireUniqueLoop(idOrName);
2652
+ if (current.archivedAt)
2653
+ throw new LoopArchivedError(current.name || current.id);
2654
+ if (current.target.type !== "agent")
2655
+ throw new Error(`loop is not an agent loop: ${idOrName}`);
2656
+ if (this.hasRunningRun(current.id))
2657
+ throw new Error(`refusing to update running loop: ${current.id}`);
2658
+ const target = { ...current.target, timeoutMs };
2659
+ if (timeoutMs === null && target.idleTimeoutMs !== undefined)
2660
+ delete target.idleTimeoutMs;
2661
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
2662
+ WHERE id=$id
2663
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2664
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2665
+ ))`).run({
2666
+ $id: current.id,
2667
+ $target: JSON.stringify(target),
2668
+ $updated: updated,
2669
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
2670
+ $now: updated
2671
+ });
2672
+ if (res.changes !== 1)
2673
+ throw new Error("daemon lease lost");
2674
+ this.db.exec("COMMIT");
2675
+ const after = this.getLoop(current.id);
2676
+ if (!after)
2677
+ throw new Error(`loop not found after timeout update: ${current.id}`);
2678
+ return after;
2679
+ } catch (error) {
2680
+ try {
2681
+ this.db.exec("ROLLBACK");
2682
+ } catch {}
2683
+ throw error;
2684
+ }
2685
+ }
2135
2686
  createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
2136
2687
  const normalized = normalizeCreateWorkflowInput(workflowInput);
2137
2688
  const updated = (opts.now ?? new Date).toISOString();
@@ -2462,6 +3013,59 @@ class Store {
2462
3013
  updated
2463
3014
  });
2464
3015
  }
3016
+ taskLifecycleTodosPointerContext(workflowRunId) {
3017
+ const run = this.getWorkflowRun(workflowRunId);
3018
+ if (!run || run.status !== "succeeded" || !run.invocationId || !run.workItemId || !run.manifestPath)
3019
+ return;
3020
+ const workItem = this.getWorkflowWorkItem(run.workItemId);
3021
+ if (!workItem || workItem.routeKey !== "todos-task")
3022
+ return;
3023
+ const invocation = this.getWorkflowInvocation(run.invocationId);
3024
+ if (!invocation || invocation.templateId !== TASK_LIFECYCLE_TEMPLATE_ID)
3025
+ return;
3026
+ const projectPath = workItem.projectKey ?? invocation.scope?.projectPath;
3027
+ const taskId = invocation.subjectRef.id ?? workItem.subjectRef;
3028
+ if (!projectPath || !taskId)
3029
+ return;
3030
+ return {
3031
+ projectPath,
3032
+ taskId,
3033
+ invocationId: invocation.id,
3034
+ workflowRunId: run.id,
3035
+ manifestPath: run.manifestPath
3036
+ };
3037
+ }
3038
+ syncSuccessfulTaskLifecycleTodosPointers(workflowRunId) {
3039
+ const context = this.taskLifecycleTodosPointerContext(workflowRunId);
3040
+ if (!context)
3041
+ return;
3042
+ const result = runLocalCommand("todos", [
3043
+ "--project",
3044
+ context.projectPath,
3045
+ "task",
3046
+ "workflow-pointers",
3047
+ context.taskId,
3048
+ "--clear",
3049
+ "--invocation",
3050
+ context.invocationId,
3051
+ "--run",
3052
+ context.workflowRunId,
3053
+ "--manifest",
3054
+ context.manifestPath,
3055
+ "--state",
3056
+ "succeeded",
3057
+ "--actor",
3058
+ "openloops:task-lifecycle"
3059
+ ]);
3060
+ this.appendWorkflowEvent(workflowRunId, result.ok ? "todos_workflow_pointers_synced" : "todos_workflow_pointers_sync_failed", undefined, {
3061
+ projectPath: context.projectPath,
3062
+ taskId: context.taskId,
3063
+ invocationId: context.invocationId,
3064
+ workflowRunId: context.workflowRunId,
3065
+ manifestPath: context.manifestPath,
3066
+ mutation: todosMutationSummary(result)
3067
+ });
3068
+ }
2465
3069
  createWorkflowInvocation(input) {
2466
3070
  const now = nowIso();
2467
3071
  const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
@@ -2572,10 +3176,10 @@ class Store {
2572
3176
  const id = genId();
2573
3177
  const status = input.status ?? "queued";
2574
3178
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2575
- 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,
2576
3180
  workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2577
3181
  VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2578
- $projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
3182
+ $projectKey, $projectGroup, $machineId, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2579
3183
  $lastReason, $created, $updated)
2580
3184
  ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2581
3185
  invocation_id=excluded.invocation_id,
@@ -2584,6 +3188,10 @@ class Store {
2584
3188
  subject_ref=excluded.subject_ref,
2585
3189
  project_key=excluded.project_key,
2586
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,
2587
3195
  route_scope=excluded.route_scope,
2588
3196
  priority=excluded.priority,
2589
3197
  status=CASE
@@ -2626,6 +3234,7 @@ class Store {
2626
3234
  $subjectRef: input.subjectRef,
2627
3235
  $projectKey: input.projectKey ?? null,
2628
3236
  $projectGroup: input.projectGroup ?? null,
3237
+ $machineId: input.machineId ?? null,
2629
3238
  $routeScope: input.routeScope ?? null,
2630
3239
  $priority: input.priority ?? 0,
2631
3240
  $status: status,
@@ -3135,7 +3744,7 @@ class Store {
3135
3744
  VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
3136
3745
  $id: genId(),
3137
3746
  $workflowRunId: runId,
3138
- $payload: JSON.stringify({
3747
+ $payload: persistedWorkflowEventPayload({
3139
3748
  workflowId: input.workflow.id,
3140
3749
  workflowName: input.workflow.name,
3141
3750
  stepCount: input.workflow.steps.length,
@@ -3424,6 +4033,8 @@ class Store {
3424
4033
  const run = this.getWorkflowRun(workflowRunId);
3425
4034
  if (!run)
3426
4035
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
4036
+ if (changed && status === "succeeded")
4037
+ this.syncSuccessfulTaskLifecycleTodosPointers(workflowRunId);
3427
4038
  return run;
3428
4039
  }
3429
4040
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -3464,7 +4075,7 @@ class Store {
3464
4075
  $sequence: sequence,
3465
4076
  $eventType: eventType,
3466
4077
  $stepId: stepId ?? null,
3467
- $payload: payload ? JSON.stringify(payload) : null,
4078
+ $payload: persistedWorkflowEventPayload(payload),
3468
4079
  $created: now
3469
4080
  });
3470
4081
  const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
@@ -3485,6 +4096,17 @@ class Store {
3485
4096
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
3486
4097
  return (row?.count ?? 0) > 0;
3487
4098
  }
4099
+ hasBlockingRunningRunForOtherSlot(loopId, scheduledFor, nowIso2) {
4100
+ const rows = this.db.query(`SELECT * FROM loop_runs
4101
+ WHERE loop_id = ? AND scheduled_for <> ? AND status = 'running'`).all(loopId, scheduledFor);
4102
+ return rows.some((row) => {
4103
+ if (!row.lease_expires_at || row.lease_expires_at > nowIso2)
4104
+ return true;
4105
+ if (isRecordedProcessAlive(row.pid, row.process_started_at))
4106
+ return true;
4107
+ return this.hasLiveWorkflowStepProcesses(row.id);
4108
+ });
4109
+ }
3488
4110
  markRunPid(id, pid, claimedBy, opts = {}) {
3489
4111
  const now = (opts.now ?? new Date).toISOString();
3490
4112
  const startedMs = processStartTimeMs(pid);
@@ -3617,6 +4239,10 @@ class Store {
3617
4239
  loop = currentLoop;
3618
4240
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
3619
4241
  const existing = this.getRunBySlot(loop.id, scheduledFor);
4242
+ if (loop.overlap === "skip" && this.hasBlockingRunningRunForOtherSlot(loop.id, scheduledFor, startedAt)) {
4243
+ this.db.exec("COMMIT");
4244
+ return;
4245
+ }
3620
4246
  if (existing) {
3621
4247
  if (existing.status === "running") {
3622
4248
  if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && isRecordedProcessAlive(existing.pid, existing.processStartedAt)) {
@@ -3772,18 +4398,93 @@ class Store {
3772
4398
  }
3773
4399
  listRuns(opts = {}) {
3774
4400
  const limit = opts.limit ?? 100;
4401
+ const offset = Math.max(0, Math.floor(opts.offset ?? 0));
3775
4402
  let rows;
3776
4403
  if (opts.loopId && opts.status) {
3777
- 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);
3778
4405
  } else if (opts.loopId) {
3779
- 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);
3780
4407
  } else if (opts.status) {
3781
- 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);
3782
4409
  } else {
3783
- 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);
3784
4411
  }
3785
4412
  return rows.map(rowToRun);
3786
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
+ }
3787
4488
  deferLiveExpiredRun(id, now, opts = {}) {
3788
4489
  const updated = now.toISOString();
3789
4490
  const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
@@ -3942,6 +4643,9 @@ class Store {
3942
4643
  const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
3943
4644
  return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
3944
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
+ }
3945
4649
  countTable(table) {
3946
4650
  const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
3947
4651
  return row?.count ?? 0;
@@ -4188,9 +4892,9 @@ class Store {
4188
4892
  const runDir = dirname2(manifestPath);
4189
4893
  try {
4190
4894
  if (/^[0-9a-f]{12,64}$/.test(basename2(runDir))) {
4191
- rmSync2(runDir, { recursive: true, force: true });
4895
+ rmSync3(runDir, { recursive: true, force: true });
4192
4896
  } else {
4193
- rmSync2(manifestPath, { force: true });
4897
+ rmSync3(manifestPath, { force: true });
4194
4898
  }
4195
4899
  } catch {}
4196
4900
  }
@@ -4257,12 +4961,28 @@ class Store {
4257
4961
  this.db.close();
4258
4962
  if (this.memoryRootDir) {
4259
4963
  try {
4260
- rmSync2(this.memoryRootDir, { recursive: true, force: true });
4964
+ rmSync3(this.memoryRootDir, { recursive: true, force: true });
4261
4965
  } catch {}
4262
4966
  this.memoryRootDir = undefined;
4263
4967
  }
4264
4968
  }
4265
4969
  }
4266
4970
  export {
4971
+ workItemStatusForLoopRun,
4972
+ scrubbedOrNull,
4973
+ rowToWorkflowWorkItem,
4974
+ rowToWorkflowStepRun,
4975
+ rowToWorkflowRun,
4976
+ rowToWorkflowInvocation,
4977
+ rowToWorkflowEvent,
4978
+ rowToWorkflow,
4979
+ rowToRunReceipt,
4980
+ rowToRun,
4981
+ rowToLoop,
4982
+ rowToLease,
4983
+ rowToGoalRun,
4984
+ rowToGoalPlanNode,
4985
+ rowToGoal,
4986
+ persistedRunOutput,
4267
4987
  Store
4268
4988
  };