@hasna/loops 0.4.14 → 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 (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 +5419 -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/sdk/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.22",
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",
@@ -4929,7 +5305,7 @@ function deploymentStatusLine(status) {
4929
5305
  // src/daemon/control.ts
4930
5306
  import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync2 } from "fs";
4931
5307
  import { spawnSync as spawnSync3 } from "child_process";
4932
- import { hostname } from "os";
5308
+ import { hostname as hostname2 } from "os";
4933
5309
  import { dirname as dirname3, join as join5 } from "path";
4934
5310
 
4935
5311
  // src/daemon/loop.ts
@@ -5082,7 +5458,7 @@ function daemonStatus(store, path = pidFilePath()) {
5082
5458
  return {
5083
5459
  ...isDaemonRunning(path),
5084
5460
  lease: store.getDaemonLease(),
5085
- host: hostname(),
5461
+ host: hostname2(),
5086
5462
  loops: {
5087
5463
  total: store.countLoops(),
5088
5464
  active: store.countLoops("active"),
@@ -5439,6 +5815,7 @@ var TRANSPORT_ENV_KEYS = new Set([
5439
5815
  "LOGNAME",
5440
5816
  "PATH",
5441
5817
  "SHELL",
5818
+ "SHLVL",
5442
5819
  "SSH_AGENT_PID",
5443
5820
  "SSH_AUTH_SOCK",
5444
5821
  "TERM",
@@ -5660,6 +6037,7 @@ function composeExecutionEnv(spec, metadata, opts, accountEnv) {
5660
6037
  Object.assign(env, spec.env ?? {});
5661
6038
  Object.assign(env, allowlistEnv(spec.allowlist));
5662
6039
  env.PATH = normalizeExecutionPath(env);
6040
+ env.SHLVL ||= "1";
5663
6041
  Object.assign(env, metadataEnv(metadata));
5664
6042
  return env;
5665
6043
  }
@@ -5731,7 +6109,7 @@ function remoteWorktreePrepareLines(worktree) {
5731
6109
  return [
5732
6110
  "__openloops_prepare_worktree() {",
5733
6111
  ` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
5734
- " local top expected_common actual_common current",
6112
+ " local top expected_common actual_common current status recovered",
5735
6113
  ' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
5736
6114
  ' if [ -e "$path" ]; then',
5737
6115
  ' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
@@ -5740,7 +6118,19 @@ function remoteWorktreePrepareLines(worktree) {
5740
6118
  ' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
5741
6119
  ' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
5742
6120
  ' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
5743
- ' if [ "$current" != "$branch" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch" >&2; return 1; fi',
6121
+ ' if [ "$current" != "$branch" ]; then',
6122
+ ' 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; }',
6123
+ ' 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',
6124
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6125
+ ' 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; }',
6126
+ ' elif [ -z "$current" ]; then',
6127
+ ' 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; }',
6128
+ " else",
6129
+ ' 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',
6130
+ " fi",
6131
+ ' recovered="$(git -C "$path" branch --show-current 2>/dev/null)"',
6132
+ ' 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',
6133
+ " fi",
5744
6134
  " return 0",
5745
6135
  " fi",
5746
6136
  ' 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; }',
@@ -5821,8 +6211,21 @@ function transportEnv(opts) {
5821
6211
  env[key] = value;
5822
6212
  }
5823
6213
  env.PATH = normalizeExecutionPath(env);
6214
+ env.SHLVL ||= "1";
5824
6215
  return env;
5825
6216
  }
6217
+ function remotePreflightFailureMessage(machineId, detail) {
6218
+ const normalized = detail.trim();
6219
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .*key in .*known_hosts/i.test(normalized)) {
6220
+ return [
6221
+ `remote preflight failed on ${machineId}: SSH host key verification failed.`,
6222
+ `Verify ${machineId}'s host identity and repair SSH known_hosts/trust material outside OpenLoops;`,
6223
+ "OpenLoops will not disable host-key checking or modify known_hosts automatically.",
6224
+ normalized ? `Transport detail: ${normalized}` : ""
6225
+ ].filter(Boolean).join(" ");
6226
+ }
6227
+ return `remote preflight failed on ${machineId}${normalized ? `: ${normalized}` : ""}`;
6228
+ }
5826
6229
  function assertCodewithProfileListed(profile, result) {
5827
6230
  const profiles = codewithProfileSetFromResult(result);
5828
6231
  if (!profiles.has(profile)) {
@@ -5874,6 +6277,9 @@ async function preflightNativeAuthProfile(spec, env) {
5874
6277
  const tableResult = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
5875
6278
  assertCodewithProfileListed(spec.nativeAuthProfile.profile, tableResult);
5876
6279
  }
6280
+ function spawnDetail(result) {
6281
+ return (result.stderr || result.stdout || result.error || "").toString().trim();
6282
+ }
5877
6283
  function resolvedDirEquals(left, right) {
5878
6284
  try {
5879
6285
  return realpathSync(left) === realpathSync(right);
@@ -5923,8 +6329,45 @@ async function ensureLocalWorktree(worktree, env) {
5923
6329
  }
5924
6330
  const current = await git(["-C", path, "branch", "--show-current"]);
5925
6331
  const actualBranch = current.stdout.trim();
5926
- if (current.error || (current.status ?? 1) !== 0 || actualBranch !== branch) {
5927
- return { error: `existing worktree ${path} is on branch ${actualBranch || "unknown"}, expected ${branch}` };
6332
+ if (current.error || (current.status ?? 1) !== 0) {
6333
+ return { error: `existing worktree ${path} branch could not be inspected${spawnDetail(current) ? `: ${spawnDetail(current)}` : ""}` };
6334
+ }
6335
+ if (actualBranch !== branch) {
6336
+ const actualLabel = actualBranch || "unknown";
6337
+ const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
6338
+ if (status.error || (status.status ?? 1) !== 0) {
6339
+ const detail = spawnDetail(status);
6340
+ return {
6341
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and cleanliness could not be checked; inspect or remove the stale worktree${detail ? `: ${detail}` : ""}`
6342
+ };
6343
+ }
6344
+ if (status.stdout.trim()) {
6345
+ return {
6346
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and has local changes; commit/stash them or remove the stale worktree before retrying`
6347
+ };
6348
+ }
6349
+ const hasBranch2 = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6350
+ const checkout = hasBranch2.status === 0 ? await git(["-C", path, "checkout", branch]) : actualBranch ? {
6351
+ status: 1,
6352
+ stdout: "",
6353
+ 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`,
6354
+ signal: null,
6355
+ timedOut: false
6356
+ } : await git(["-C", path, "checkout", "-b", branch]);
6357
+ if (checkout.error || (checkout.status ?? 1) !== 0) {
6358
+ const detail = spawnDetail(checkout);
6359
+ return {
6360
+ error: `existing worktree ${path} is clean but could not recover branch ${branch} from ${actualLabel}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
6361
+ };
6362
+ }
6363
+ const recovered = await git(["-C", path, "branch", "--show-current"]);
6364
+ if (recovered.error || (recovered.status ?? 1) !== 0 || recovered.stdout.trim() !== branch) {
6365
+ const recoveredBranch = recovered.stdout.trim() || "unknown";
6366
+ const detail = spawnDetail(recovered);
6367
+ return {
6368
+ error: `existing worktree ${path} branch recovery ended on ${recoveredBranch}, expected ${branch}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
6369
+ };
6370
+ }
5928
6371
  }
5929
6372
  return { cwd: worktree.cwd };
5930
6373
  }
@@ -5940,7 +6383,7 @@ async function ensureLocalWorktree(worktree, env) {
5940
6383
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
5941
6384
  const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
5942
6385
  if (add.error || (add.status ?? 1) !== 0) {
5943
- const detail = (add.stderr || add.stdout || add.error || "").toString().trim();
6386
+ const detail = spawnDetail(add);
5944
6387
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
5945
6388
  }
5946
6389
  return { cwd: worktree.cwd };
@@ -5986,7 +6429,7 @@ function preflightRemoteSpec(spec, machine, metadata, opts) {
5986
6429
  throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
5987
6430
  if ((result.status ?? 1) !== 0) {
5988
6431
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
5989
- throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
6432
+ throw new Error(remotePreflightFailureMessage(machine.id, detail));
5990
6433
  }
5991
6434
  }
5992
6435
  async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
@@ -6246,7 +6689,7 @@ async function executeLoop(loop, run, opts = {}) {
6246
6689
  }
6247
6690
 
6248
6691
  // src/lib/health.ts
6249
- import { createHash as createHash2 } from "crypto";
6692
+ import { createHash as createHash3 } from "crypto";
6250
6693
  import { existsSync as existsSync4, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
6251
6694
  import { join as join7 } from "path";
6252
6695
  var EVIDENCE_CHARS = 2000;
@@ -6308,7 +6751,7 @@ function tagsFromValue(value) {
6308
6751
  return [];
6309
6752
  }
6310
6753
  function stableFingerprint(parts) {
6311
- return createHash2("sha256").update(parts.join(`
6754
+ return createHash3("sha256").update(parts.join(`
6312
6755
  `)).digest("hex").slice(0, 16);
6313
6756
  }
6314
6757
  function stableScanFingerprint(parts) {
@@ -7203,8 +7646,8 @@ function runDoctor(store) {
7203
7646
  }
7204
7647
 
7205
7648
  // src/lib/migration.ts
7206
- import { createHash as createHash3 } from "crypto";
7207
- import { hostname as hostname2 } from "os";
7649
+ import { createHash as createHash4 } from "crypto";
7650
+ import { hostname as hostname3 } from "os";
7208
7651
  var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
7209
7652
  function canonicalize(value) {
7210
7653
  if (Array.isArray(value))
@@ -7215,7 +7658,7 @@ function canonicalize(value) {
7215
7658
  return value;
7216
7659
  }
7217
7660
  function migrationHash(value) {
7218
- return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
7661
+ return createHash4("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
7219
7662
  }
7220
7663
  function pushBlocker(rows, resource, id, reason, name) {
7221
7664
  rows.push({ resource, id, name, action: "blocked", reason });
@@ -7291,7 +7734,7 @@ function exportLoopsMigrationBundle(store, opts = {}) {
7291
7734
  source: {
7292
7735
  backend: "sqlite",
7293
7736
  schemaVersion: rows.schemaVersion,
7294
- hostname: hostname2()
7737
+ hostname: hostname3()
7295
7738
  },
7296
7739
  checks: rows.checks,
7297
7740
  importable: sanitized.blockers.length === 0,
@@ -7672,6 +8115,85 @@ async function buildSelfHostedMigrationPlan(store, opts) {
7672
8115
  });
7673
8116
  return { ...plan, importable: false };
7674
8117
  }
8118
+ async function postImportBatch(fetchImpl, config, payload) {
8119
+ const body = await requestJson(fetchImpl, config, "/v1/import", { method: "POST", body: JSON.stringify(payload) });
8120
+ const imported = body.imported ?? {};
8121
+ return {
8122
+ imported: { workflows: imported.workflows ?? 0, loops: imported.loops ?? 0, runs: imported.runs ?? 0 },
8123
+ skippedRunning: typeof body.skippedRunning === "number" ? body.skippedRunning : 0
8124
+ };
8125
+ }
8126
+ async function applySelfHostedPush(store, opts) {
8127
+ const resolved = resolveApiConfig(opts);
8128
+ if (!resolved.apiUrl)
8129
+ throw new ValidationError("LOOPS_API_URL or --api-url is required for self-hosted push");
8130
+ if (!isLocalApiUrl(resolved.apiUrl) && !resolved.token) {
8131
+ throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8132
+ }
8133
+ const config = { apiUrl: resolved.apiUrl, token: resolved.token };
8134
+ const fetchImpl = opts.fetchImpl ?? fetch;
8135
+ const includeRuns = opts.includeRuns ?? true;
8136
+ const replace = opts.replace ?? false;
8137
+ const batchRows = Math.max(1, opts.batchRows ?? 200);
8138
+ const runBatchBytes = Math.max(64 * 1024, opts.runBatchBytes ?? 4 * 1024 * 1024);
8139
+ const applied = { workflows: 0, loops: 0, runs: 0 };
8140
+ const skipped = { runningRuns: 0, orphanRuns: 0 };
8141
+ let requests = 0;
8142
+ const base = store.exportMigrationRows({ includeRuns: false });
8143
+ const loopIds = new Set(base.loops.map((loop) => loop.id));
8144
+ for (let i = 0;i < base.workflows.length; i += batchRows) {
8145
+ const batch = base.workflows.slice(i, i + batchRows);
8146
+ const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace });
8147
+ applied.workflows += result.imported.workflows;
8148
+ requests += 1;
8149
+ opts.onProgress?.({ phase: "workflows", sent: applied.workflows, requests });
8150
+ }
8151
+ for (let i = 0;i < base.loops.length; i += batchRows) {
8152
+ const batch = base.loops.slice(i, i + batchRows);
8153
+ const result = await postImportBatch(fetchImpl, config, { loops: batch, replace });
8154
+ applied.loops += result.imported.loops;
8155
+ requests += 1;
8156
+ opts.onProgress?.({ phase: "loops", sent: applied.loops, requests });
8157
+ }
8158
+ if (includeRuns) {
8159
+ const pageSize = 500;
8160
+ let pending = [];
8161
+ let pendingBytes = 0;
8162
+ const flush = async () => {
8163
+ if (pending.length === 0)
8164
+ return;
8165
+ const result = await postImportBatch(fetchImpl, config, { runs: pending, replace });
8166
+ applied.runs += result.imported.runs;
8167
+ skipped.runningRuns += result.skippedRunning;
8168
+ requests += 1;
8169
+ opts.onProgress?.({ phase: "runs", sent: applied.runs, requests });
8170
+ pending = [];
8171
+ pendingBytes = 0;
8172
+ };
8173
+ for (let offset = 0;; offset += pageSize) {
8174
+ const page = store.exportMigrationRunPage({ limit: pageSize, offset });
8175
+ if (page.length === 0)
8176
+ break;
8177
+ for (const run of page) {
8178
+ if (!loopIds.has(run.loopId)) {
8179
+ skipped.orphanRuns += 1;
8180
+ continue;
8181
+ }
8182
+ const encoded = JSON.stringify(run).length;
8183
+ if (pending.length > 0 && pendingBytes + encoded > runBatchBytes)
8184
+ await flush();
8185
+ pending.push(run);
8186
+ pendingBytes += encoded;
8187
+ if (pending.length >= batchRows * 4)
8188
+ await flush();
8189
+ }
8190
+ if (page.length < pageSize)
8191
+ break;
8192
+ }
8193
+ await flush();
8194
+ }
8195
+ return { ok: true, apiUrl: config.apiUrl, applied, skipped, requests };
8196
+ }
7675
8197
  async function registerSelfHostedRunner(opts) {
7676
8198
  const config = resolveApiConfig(opts);
7677
8199
  if (!config.apiUrl)
@@ -7796,55 +8318,6 @@ ${evidence.join(`
7796
8318
  import { generateObject } from "ai";
7797
8319
  import { z } from "zod";
7798
8320
 
7799
- // src/lib/run-envelope.ts
7800
- var ENVELOPE_EXCERPT_CHARS = 2048;
7801
- var BLOCKED_STEP_ERROR_PREFIX = "blocked:";
7802
- function boundedExcerpt(text, limit = ENVELOPE_EXCERPT_CHARS) {
7803
- if (!text)
7804
- return;
7805
- const scrubbed = scrubSecrets(text);
7806
- if (scrubbed.length <= limit * 2)
7807
- return scrubbed;
7808
- const omitted = scrubbed.length - limit * 2;
7809
- return `${scrubbed.slice(0, limit)}
7810
- [... ${omitted} chars omitted ...]
7811
- ${scrubbed.slice(-limit)}`;
7812
- }
7813
- function summarizeOutput(stdout, stderr) {
7814
- return {
7815
- stdoutBytes: stdout ? Buffer.byteLength(stdout, "utf8") : 0,
7816
- stderrBytes: stderr ? Buffer.byteLength(stderr, "utf8") : 0,
7817
- stdoutExcerpt: boundedExcerpt(stdout),
7818
- stderrExcerpt: boundedExcerpt(stderr)
7819
- };
7820
- }
7821
- function summarizeExecutorResult(result) {
7822
- return {
7823
- ...summarizeOutput(result.stdout, result.stderr),
7824
- status: result.status,
7825
- exitCode: result.exitCode,
7826
- durationMs: result.durationMs,
7827
- error: boundedExcerpt(result.error)
7828
- };
7829
- }
7830
- function isBlockedStepRun(step) {
7831
- return step?.status === "skipped" && (step.error?.startsWith(BLOCKED_STEP_ERROR_PREFIX) ?? false);
7832
- }
7833
- function summarizeWorkflowStepRun(step) {
7834
- return {
7835
- ...summarizeOutput(step.stdout, step.stderr),
7836
- stepId: step.stepId,
7837
- status: step.status,
7838
- exitCode: step.exitCode,
7839
- durationMs: step.durationMs,
7840
- error: boundedExcerpt(step.error),
7841
- blocked: isBlockedStepRun(step)
7842
- };
7843
- }
7844
- function workflowRunEnvelope(run, steps) {
7845
- return JSON.stringify({ workflowRun: run, steps: steps.map(summarizeWorkflowStepRun) }, null, 2);
7846
- }
7847
-
7848
8321
  // src/lib/goal/model-factory.ts
7849
8322
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
7850
8323
  var DEFAULT_GOAL_MODEL = "openai/gpt-4o-mini";
@@ -9084,35 +9557,926 @@ async function tick(deps) {
9084
9557
  return { claimed, completed, skipped, recovered, expired };
9085
9558
  }
9086
9559
 
9087
- // src/sdk/index.ts
9088
- class LoopsClient {
9089
- store;
9090
- ownStore;
9091
- runnerId;
9092
- constructor(opts = {}) {
9093
- this.store = opts.store ?? new Store;
9094
- this.ownStore = !opts.store;
9095
- this.runnerId = opts.runnerId ?? `sdk:${process.pid}`;
9560
+ // src/lib/cloud/mode.ts
9561
+ var DEPRECATED_STORAGE_MODE_ALIASES = ["remote", "hybrid", "self_hosted"];
9562
+ function normalizeStorageMode(value) {
9563
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
9564
+ if (normalized === "local")
9565
+ return { mode: "local", deprecatedAlias: null };
9566
+ if (normalized === "cloud")
9567
+ return { mode: "cloud", deprecatedAlias: null };
9568
+ if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
9569
+ return { mode: "cloud", deprecatedAlias: normalized };
9096
9570
  }
9097
- create(input) {
9098
- return this.store.createLoop(input);
9571
+ throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
9572
+ }
9573
+ function envToken(name) {
9574
+ return name.toUpperCase().replace(/-/g, "_");
9575
+ }
9576
+
9577
+ // src/lib/cloud/transport.ts
9578
+ function defaultCloudBaseUrl(name) {
9579
+ return `https://${name}.hasna.xyz`;
9580
+ }
9581
+ function clientTransportEnvKeys(name) {
9582
+ const token = envToken(name);
9583
+ return {
9584
+ modeKeys: [
9585
+ `HASNA_${token}_STORAGE_MODE`,
9586
+ `HASNA_${token}_MODE`,
9587
+ `${token}_STORAGE_MODE`,
9588
+ `${token}_MODE`
9589
+ ],
9590
+ apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
9591
+ apiKeyKeys: [
9592
+ `HASNA_${token}_API_KEY`,
9593
+ `${token}_API_KEY`,
9594
+ `HASNA_${token}_API_TOKEN`,
9595
+ `${token}_API_TOKEN`
9596
+ ]
9597
+ };
9598
+ }
9599
+ function firstEnv(env, keys) {
9600
+ for (const key of keys) {
9601
+ const value = env[key]?.trim();
9602
+ if (value)
9603
+ return { key, value };
9099
9604
  }
9100
- list(filters = {}) {
9101
- return this.store.listLoops({
9102
- status: filters.status,
9103
- limit: filters.limit,
9104
- includeArchived: filters.includeArchived,
9105
- archived: filters.archivedOnly
9106
- });
9605
+ return null;
9606
+ }
9607
+ function toV1BaseUrl(apiUrl) {
9608
+ const url = new URL(apiUrl);
9609
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
9610
+ throw new Error("API URL must use http or https.");
9611
+ }
9612
+ let path = url.pathname.replace(/\/+$/, "");
9613
+ if (path.endsWith("/v1"))
9614
+ path = path.slice(0, -"/v1".length);
9615
+ url.pathname = `${path}/v1`;
9616
+ url.search = "";
9617
+ url.hash = "";
9618
+ return url.toString().replace(/\/+$/, "");
9619
+ }
9620
+ function resolveClientTransport(name, env = process.env) {
9621
+ const keys = clientTransportEnvKeys(name);
9622
+ const modeHit = firstEnv(env, keys.modeKeys);
9623
+ const urlHit = firstEnv(env, keys.apiUrlKeys);
9624
+ const keyHit = firstEnv(env, keys.apiKeyKeys);
9625
+ let mode = "local";
9626
+ let deprecatedAlias = null;
9627
+ let modeSource = "default";
9628
+ const warnings = [];
9629
+ if (modeHit) {
9630
+ const normalized = normalizeStorageMode(modeHit.value);
9631
+ mode = normalized.mode;
9632
+ deprecatedAlias = normalized.deprecatedAlias;
9633
+ modeSource = modeHit.key;
9634
+ if (deprecatedAlias) {
9635
+ warnings.push(`Deprecated mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Prefer ${keys.modeKeys[0]}=cloud.`);
9636
+ }
9637
+ }
9638
+ if (mode === "local") {
9639
+ return {
9640
+ transport: "local",
9641
+ mode,
9642
+ deprecatedAlias,
9643
+ modeSource,
9644
+ baseUrl: null,
9645
+ apiUrlSource: null,
9646
+ apiKeyPresent: Boolean(keyHit),
9647
+ apiKeySource: keyHit ? keyHit.key : null,
9648
+ misconfigured: false,
9649
+ warning: warnings.length > 0 ? warnings.join(" ") : null
9650
+ };
9651
+ }
9652
+ if (!keyHit) {
9653
+ 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.`);
9654
+ return {
9655
+ transport: "local",
9656
+ mode,
9657
+ deprecatedAlias,
9658
+ modeSource,
9659
+ baseUrl: null,
9660
+ apiUrlSource: null,
9661
+ apiKeyPresent: false,
9662
+ apiKeySource: null,
9663
+ misconfigured: true,
9664
+ warning: warnings.join(" ")
9665
+ };
9666
+ }
9667
+ const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name);
9668
+ const apiUrlSource = urlHit ? urlHit.key : "default";
9669
+ let baseUrl;
9670
+ try {
9671
+ baseUrl = toV1BaseUrl(rawUrl);
9672
+ } catch (error) {
9673
+ const message = error instanceof Error ? error.message : String(error);
9674
+ warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
9675
+ return {
9676
+ transport: "local",
9677
+ mode,
9678
+ deprecatedAlias,
9679
+ modeSource,
9680
+ baseUrl: null,
9681
+ apiUrlSource: null,
9682
+ apiKeyPresent: true,
9683
+ apiKeySource: keyHit.key,
9684
+ misconfigured: true,
9685
+ warning: warnings.join(" ")
9686
+ };
9687
+ }
9688
+ return {
9689
+ transport: "cloud-http",
9690
+ mode,
9691
+ deprecatedAlias,
9692
+ modeSource,
9693
+ baseUrl,
9694
+ apiUrlSource,
9695
+ apiKeyPresent: true,
9696
+ apiKeySource: keyHit.key,
9697
+ misconfigured: false,
9698
+ warning: warnings.length > 0 ? warnings.join(" ") : null
9699
+ };
9700
+ }
9701
+
9702
+ class HasnaHttpError extends Error {
9703
+ status;
9704
+ method;
9705
+ path;
9706
+ body;
9707
+ constructor(method, path, status, body) {
9708
+ super(`Hasna cloud request failed: ${method} ${path} -> ${status}`);
9709
+ this.name = "HasnaHttpError";
9710
+ this.status = status;
9711
+ this.method = method;
9712
+ this.path = path;
9713
+ this.body = body;
9714
+ }
9715
+ }
9716
+ var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
9717
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
9718
+ function appendQuery(path, query) {
9719
+ if (!query)
9720
+ return path;
9721
+ const params = query instanceof URLSearchParams ? query : new URLSearchParams;
9722
+ if (!(query instanceof URLSearchParams)) {
9723
+ for (const [key, value] of Object.entries(query)) {
9724
+ if (value === null || value === undefined)
9725
+ continue;
9726
+ if (Array.isArray(value)) {
9727
+ for (const v of value)
9728
+ params.append(key, String(v));
9729
+ } else {
9730
+ params.append(key, String(value));
9731
+ }
9732
+ }
9733
+ }
9734
+ const qs = params.toString();
9735
+ if (!qs)
9736
+ return path;
9737
+ return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
9738
+ }
9739
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
9740
+ function createHasnaHttpTransport(options) {
9741
+ const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
9742
+ const base = options.baseUrl.replace(/\/+$/, "");
9743
+ const timeoutMs = options.timeoutMs ?? 30000;
9744
+ const sleep = options.sleepImpl ?? defaultSleep;
9745
+ const defaultRetry = options.retry;
9746
+ function resolveRetry(callRetry) {
9747
+ const chosen = callRetry !== undefined ? callRetry : defaultRetry;
9748
+ if (chosen === false)
9749
+ return null;
9750
+ const r = chosen ?? {};
9751
+ return {
9752
+ retries: r.retries ?? 2,
9753
+ baseDelayMs: r.baseDelayMs ?? 200,
9754
+ maxDelayMs: r.maxDelayMs ?? 2000,
9755
+ retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
9756
+ };
9757
+ }
9758
+ async function once2(method, rel, url, body, opts) {
9759
+ const headers = {
9760
+ "x-api-key": options.apiKey,
9761
+ Authorization: `Bearer ${options.apiKey}`,
9762
+ Accept: "application/json",
9763
+ ...options.headers ?? {},
9764
+ ...opts.headers ?? {}
9765
+ };
9766
+ if (opts.idempotencyKey)
9767
+ headers["Idempotency-Key"] = opts.idempotencyKey;
9768
+ const init = { method, headers };
9769
+ if (body !== undefined) {
9770
+ headers["Content-Type"] = "application/json";
9771
+ init.body = JSON.stringify(body);
9772
+ }
9773
+ const controller = new AbortController;
9774
+ const onAbort = () => controller.abort();
9775
+ if (opts.signal) {
9776
+ if (opts.signal.aborted)
9777
+ controller.abort();
9778
+ else
9779
+ opts.signal.addEventListener("abort", onAbort, { once: true });
9780
+ }
9781
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
9782
+ init.signal = controller.signal;
9783
+ let response;
9784
+ try {
9785
+ response = await fetchImpl(url, init);
9786
+ } catch (error) {
9787
+ const err = error instanceof Error ? error : new Error(String(error));
9788
+ if (opts.signal?.aborted)
9789
+ return { ok: false, retryable: false, error: err };
9790
+ return { ok: false, retryable: true, error: err };
9791
+ } finally {
9792
+ clearTimeout(timer);
9793
+ if (opts.signal)
9794
+ opts.signal.removeEventListener("abort", onAbort);
9795
+ }
9796
+ const text = await response.text();
9797
+ let parsed = undefined;
9798
+ if (text.length > 0) {
9799
+ try {
9800
+ parsed = JSON.parse(text);
9801
+ } catch {
9802
+ parsed = text;
9803
+ }
9804
+ }
9805
+ if (!response.ok) {
9806
+ const retry = resolveRetry(opts.retry);
9807
+ const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
9808
+ return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
9809
+ }
9810
+ return { ok: true, value: parsed };
9811
+ }
9812
+ async function request(method, path, body, opts = {}) {
9813
+ const upper = method.toUpperCase();
9814
+ const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
9815
+ const url = `${base}${rel}`;
9816
+ const retry = resolveRetry(opts.retry);
9817
+ const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
9818
+ const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
9819
+ let last = null;
9820
+ for (let attempt = 1;attempt <= maxAttempts; attempt++) {
9821
+ const result = await once2(upper, rel, url, body, opts);
9822
+ if (result.ok)
9823
+ return result.value;
9824
+ last = result;
9825
+ const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
9826
+ if (!canRetry)
9827
+ break;
9828
+ const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
9829
+ const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
9830
+ await sleep(backoff + jitter);
9831
+ }
9832
+ throw last.error;
9833
+ }
9834
+ return {
9835
+ baseUrl: base,
9836
+ request,
9837
+ get: (path, opts) => request("GET", path, undefined, opts),
9838
+ post: (path, body, opts) => request("POST", path, body, opts),
9839
+ put: (path, body, opts) => request("PUT", path, body, opts),
9840
+ patch: (path, body, opts) => request("PATCH", path, body, opts),
9841
+ del: (path, body, opts) => request("DELETE", path, body, opts)
9842
+ };
9843
+ }
9844
+ function createClientTransport(name, env = process.env, overrides) {
9845
+ const resolution = resolveClientTransport(name, env);
9846
+ if (resolution.misconfigured) {
9847
+ throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for cloud mode.`);
9848
+ }
9849
+ if (resolution.transport === "local" || !resolution.baseUrl) {
9850
+ return { transport: "local", client: null, resolution };
9851
+ }
9852
+ const keys = clientTransportEnvKeys(name);
9853
+ const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
9854
+ if (!apiKey) {
9855
+ throw new Error(`Client for '${name}' resolved to cloud-http without an API key.`);
9856
+ }
9857
+ return {
9858
+ transport: "cloud-http",
9859
+ client: createHasnaHttpTransport({
9860
+ name,
9861
+ baseUrl: resolution.baseUrl,
9862
+ apiKey,
9863
+ ...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
9864
+ ...overrides?.headers ? { headers: overrides.headers } : {},
9865
+ ...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
9866
+ ...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
9867
+ ...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
9868
+ }),
9869
+ resolution
9870
+ };
9871
+ }
9872
+
9873
+ // src/lib/cloud/storage.ts
9874
+ function resourcePath(resource) {
9875
+ const trimmed = resource.replace(/^\/+|\/+$/g, "");
9876
+ if (!trimmed)
9877
+ throw new Error("resource must be a non-empty path segment");
9878
+ return `/${trimmed}`;
9879
+ }
9880
+ function entityPath(resource, id) {
9881
+ if (id === undefined || id === null || `${id}`.length === 0) {
9882
+ throw new Error("id must be a non-empty string");
9883
+ }
9884
+ return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
9885
+ }
9886
+ function newIdempotencyKey() {
9887
+ const g = globalThis;
9888
+ if (g.crypto?.randomUUID)
9889
+ return g.crypto.randomUUID();
9890
+ return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
9891
+ }
9892
+ function extractItems(raw) {
9893
+ if (Array.isArray(raw))
9894
+ return raw;
9895
+ if (raw && typeof raw === "object") {
9896
+ const obj = raw;
9897
+ for (const key of ["items", "data", "results", "rows", "records"]) {
9898
+ if (Array.isArray(obj[key]))
9899
+ return obj[key];
9900
+ }
9901
+ }
9902
+ return [];
9903
+ }
9904
+ function extractTotal(raw) {
9905
+ if (raw && typeof raw === "object") {
9906
+ const obj = raw;
9907
+ for (const key of ["total", "count", "totalCount", "total_count"]) {
9908
+ if (typeof obj[key] === "number")
9909
+ return obj[key];
9910
+ }
9911
+ }
9912
+ return null;
9913
+ }
9914
+ function extractCursor(raw) {
9915
+ if (raw && typeof raw === "object") {
9916
+ const obj = raw;
9917
+ for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
9918
+ if (typeof obj[key] === "string")
9919
+ return obj[key];
9920
+ }
9921
+ }
9922
+ return null;
9923
+ }
9924
+ function createHasnaStorageClient(name, transport) {
9925
+ return {
9926
+ name,
9927
+ baseUrl: transport.baseUrl,
9928
+ transport,
9929
+ async list(resource, options = {}) {
9930
+ const raw = await transport.get(resourcePath(resource), options);
9931
+ return {
9932
+ items: extractItems(raw),
9933
+ total: extractTotal(raw),
9934
+ cursor: extractCursor(raw),
9935
+ raw
9936
+ };
9937
+ },
9938
+ async get(resource, id, options = {}) {
9939
+ try {
9940
+ return await transport.get(entityPath(resource, id), options);
9941
+ } catch (error) {
9942
+ if (error instanceof HasnaHttpError && error.status === 404)
9943
+ return null;
9944
+ throw error;
9945
+ }
9946
+ },
9947
+ async create(resource, body, options = {}) {
9948
+ const { idempotencyKey, ...rest } = options;
9949
+ return transport.post(resourcePath(resource), body, {
9950
+ ...rest,
9951
+ idempotencyKey: idempotencyKey ?? newIdempotencyKey()
9952
+ });
9953
+ },
9954
+ async update(resource, id, patch, options = {}) {
9955
+ const { method = "PATCH", idempotencyKey, ...rest } = options;
9956
+ const call = method === "PUT" ? transport.put : transport.patch;
9957
+ return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
9958
+ },
9959
+ async delete(resource, id, options = {}) {
9960
+ try {
9961
+ await transport.del(entityPath(resource, id), undefined, options);
9962
+ } catch (error) {
9963
+ if (error instanceof HasnaHttpError && error.status === 404)
9964
+ return;
9965
+ throw error;
9966
+ }
9967
+ }
9968
+ };
9969
+ }
9970
+
9971
+ // src/lib/cloud/resolve.ts
9972
+ function firstValue(env, keys) {
9973
+ for (const key of keys) {
9974
+ const value = env[key]?.trim();
9975
+ if (value)
9976
+ return value;
9977
+ }
9978
+ return;
9979
+ }
9980
+ function resolveCloudStorage(name, env = process.env) {
9981
+ const token = envToken(name);
9982
+ const modeKeys = [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`];
9983
+ const apiUrlKeys = [`HASNA_${token}_API_URL`, `${token}_API_URL`];
9984
+ const apiKeyKeys = [
9985
+ `HASNA_${token}_API_KEY`,
9986
+ `${token}_API_KEY`,
9987
+ `HASNA_${token}_API_TOKEN`,
9988
+ `${token}_API_TOKEN`
9989
+ ];
9990
+ const explicitMode = firstValue(env, modeKeys);
9991
+ if (explicitMode && normalizeStorageMode(explicitMode).mode === "local") {
9992
+ return { transport: "local", client: null };
9993
+ }
9994
+ const apiUrl = firstValue(env, apiUrlKeys);
9995
+ const apiKey = firstValue(env, apiKeyKeys);
9996
+ if (!apiUrl || !apiKey) {
9997
+ return { transport: "local", client: null };
9998
+ }
9999
+ const cloudEnv = { ...env, [`HASNA_${token}_STORAGE_MODE`]: "self_hosted" };
10000
+ const wired = createClientTransport(name, cloudEnv);
10001
+ if (wired.transport !== "cloud-http") {
10002
+ return { transport: "local", client: null };
10003
+ }
10004
+ return {
10005
+ transport: "cloud-http",
10006
+ client: createHasnaStorageClient(name, wired.client),
10007
+ baseUrl: wired.client.baseUrl
10008
+ };
10009
+ }
10010
+
10011
+ // src/lib/store/index.ts
10012
+ class CloudUnsupportedError extends Error {
10013
+ constructor(operation) {
10014
+ 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.`);
10015
+ this.name = "CloudUnsupportedError";
10016
+ }
10017
+ }
10018
+
10019
+ class LocalStore {
10020
+ transport = "local";
10021
+ store;
10022
+ constructor(store = new Store) {
10023
+ this.store = store;
10024
+ }
10025
+ get raw() {
10026
+ return this.store;
10027
+ }
10028
+ async close() {
10029
+ this.store.close();
10030
+ }
10031
+ async createLoop(input, from) {
10032
+ return from ? this.store.createLoop(input, from) : this.store.createLoop(input);
10033
+ }
10034
+ async getLoop(id) {
10035
+ return this.store.getLoop(id);
10036
+ }
10037
+ async findLoopByName(name) {
10038
+ return this.store.findLoopByName(name);
10039
+ }
10040
+ async requireLoop(idOrName) {
10041
+ return this.store.requireLoop(idOrName);
10042
+ }
10043
+ async requireUniqueLoop(idOrName) {
10044
+ return this.store.requireUniqueLoop(idOrName);
10045
+ }
10046
+ async listLoops(opts = {}) {
10047
+ return this.store.listLoops(opts);
10048
+ }
10049
+ async countLoops(status, opts = {}) {
10050
+ return this.store.countLoops(status, opts);
10051
+ }
10052
+ async updateLoop(id, patch) {
10053
+ return this.store.updateLoop(id, patch);
10054
+ }
10055
+ async renameLoop(id, name) {
10056
+ return this.store.renameLoop(id, name);
10057
+ }
10058
+ async archiveLoop(idOrName) {
10059
+ return this.store.archiveLoop(idOrName);
10060
+ }
10061
+ async unarchiveLoop(idOrName) {
10062
+ return this.store.unarchiveLoop(idOrName);
10063
+ }
10064
+ async deleteLoop(idOrName) {
10065
+ return this.store.deleteLoop(idOrName);
10066
+ }
10067
+ async createWorkflow(input) {
10068
+ return this.store.createWorkflow(input);
10069
+ }
10070
+ async getWorkflow(id) {
10071
+ return this.store.getWorkflow(id);
10072
+ }
10073
+ async findWorkflowByName(name) {
10074
+ return this.store.findWorkflowByName(name);
10075
+ }
10076
+ async requireWorkflow(idOrName) {
10077
+ return this.store.requireWorkflow(idOrName);
10078
+ }
10079
+ async listWorkflows(opts = {}) {
10080
+ return this.store.listWorkflows(opts);
10081
+ }
10082
+ async countWorkflows(opts = {}) {
10083
+ return this.store.countWorkflows(opts);
10084
+ }
10085
+ async archiveWorkflow(idOrName) {
10086
+ return this.store.archiveWorkflow(idOrName);
10087
+ }
10088
+ async getWorkflowRun(id) {
10089
+ return this.store.getWorkflowRun(id);
10090
+ }
10091
+ async requireWorkflowRun(id) {
10092
+ return this.store.requireWorkflowRun(id);
10093
+ }
10094
+ async listWorkflowRuns(opts = {}) {
10095
+ return this.store.listWorkflowRuns(opts);
10096
+ }
10097
+ async listWorkflowStepRuns(workflowRunId) {
10098
+ return this.store.listWorkflowStepRuns(workflowRunId);
10099
+ }
10100
+ async listWorkflowEvents(workflowRunId, limit) {
10101
+ return limit === undefined ? this.store.listWorkflowEvents(workflowRunId) : this.store.listWorkflowEvents(workflowRunId, limit);
10102
+ }
10103
+ async recoverWorkflowRun(workflowRunId, reason) {
10104
+ return reason === undefined ? this.store.recoverWorkflowRun(workflowRunId) : this.store.recoverWorkflowRun(workflowRunId, reason);
10105
+ }
10106
+ async cancelWorkflowRun(workflowRunId, reason) {
10107
+ return reason === undefined ? this.store.cancelWorkflowRun(workflowRunId) : this.store.cancelWorkflowRun(workflowRunId, reason);
10108
+ }
10109
+ async listWorkflowWorkItems(opts = {}) {
10110
+ return this.store.listWorkflowWorkItems(opts);
10111
+ }
10112
+ async getWorkflowWorkItem(id) {
10113
+ return this.store.getWorkflowWorkItem(id);
10114
+ }
10115
+ async requeueWorkflowWorkItem(id, patch = {}) {
10116
+ return this.store.requeueWorkflowWorkItem(id, patch);
10117
+ }
10118
+ async listWorkflowInvocations(opts = {}) {
10119
+ return this.store.listWorkflowInvocations(opts);
10120
+ }
10121
+ async getWorkflowInvocation(id) {
10122
+ return this.store.getWorkflowInvocation(id);
10123
+ }
10124
+ async getGoal(id) {
10125
+ return this.store.getGoal(id);
10126
+ }
10127
+ async findGoalByLoop(idOrName) {
10128
+ return this.store.findGoalByLoop(idOrName);
10129
+ }
10130
+ async findGoalByRunId(id) {
10131
+ return this.store.findGoalByRunId(id);
10132
+ }
10133
+ async listGoals(opts = {}) {
10134
+ return this.store.listGoals(opts);
10135
+ }
10136
+ async listGoalPlanNodes(goalIdOrPlanId) {
10137
+ return this.store.listGoalPlanNodes(goalIdOrPlanId);
10138
+ }
10139
+ async listGoalRuns(opts = {}) {
10140
+ return this.store.listGoalRuns(opts);
10141
+ }
10142
+ async listRuns(opts = {}) {
10143
+ return this.store.listRuns(opts);
10144
+ }
10145
+ async getRun(id) {
10146
+ return this.store.getRun(id);
10147
+ }
10148
+ async writeRunReceipt(input) {
10149
+ return this.store.writeRunReceipt(input);
10150
+ }
10151
+ async getRunReceipt(runId) {
10152
+ return this.store.getRunReceipt(runId);
10153
+ }
10154
+ async listRunReceipts(opts = {}) {
10155
+ return this.store.listRunReceipts(opts);
10156
+ }
10157
+ async pruneHistory(opts) {
10158
+ return this.store.pruneHistory(opts);
10159
+ }
10160
+ }
10161
+ function clean(query) {
10162
+ const out = {};
10163
+ for (const [key, value] of Object.entries(query)) {
10164
+ if (value !== undefined && value !== null && value !== "")
10165
+ out[key] = value;
10166
+ }
10167
+ return out;
10168
+ }
10169
+ function pickArray(raw, key, fallback = []) {
10170
+ if (raw && typeof raw === "object" && Array.isArray(raw[key])) {
10171
+ return raw[key];
10172
+ }
10173
+ return fallback;
10174
+ }
10175
+ function pickObject(raw, key) {
10176
+ if (raw && typeof raw === "object") {
10177
+ const value = raw[key];
10178
+ return value == null ? undefined : value;
10179
+ }
10180
+ return raw == null ? undefined : raw;
10181
+ }
10182
+
10183
+ class ApiStore {
10184
+ client;
10185
+ baseUrl;
10186
+ transport = "cloud-http";
10187
+ constructor(client, baseUrl) {
10188
+ this.client = client;
10189
+ this.baseUrl = baseUrl;
10190
+ }
10191
+ get t() {
10192
+ return this.client.transport;
10193
+ }
10194
+ async close() {}
10195
+ async createLoop(input) {
10196
+ return pickObject(await this.t.post("/loops", input), "loop");
10197
+ }
10198
+ async getLoop(id) {
10199
+ try {
10200
+ return pickObject(await this.t.get(`/loops/${encodeURIComponent(id)}`), "loop");
10201
+ } catch {
10202
+ return;
10203
+ }
10204
+ }
10205
+ async findLoopByName(name) {
10206
+ const matches = (await this.listLoops({ name, limit: 1000 })).filter((loop) => loop.name === name);
10207
+ return matches[0];
10208
+ }
10209
+ async requireLoop(idOrName) {
10210
+ const loop = await this.resolveLoop(idOrName);
10211
+ if (!loop)
10212
+ throw new LoopNotFoundError(idOrName);
10213
+ return loop;
10214
+ }
10215
+ async requireUniqueLoop(idOrName) {
10216
+ const byId = await this.getLoop(idOrName);
10217
+ if (byId)
10218
+ return byId;
10219
+ const matches = (await this.listLoops({ name: idOrName, limit: 1000 })).filter((loop) => loop.name === idOrName);
10220
+ if (matches.length === 0)
10221
+ throw new LoopNotFoundError(idOrName);
10222
+ if (matches.length === 1)
10223
+ return matches[0];
10224
+ const active = matches.filter((loop) => !loop.archivedAt);
10225
+ if (active.length !== 1)
10226
+ throw new AmbiguousNameError(idOrName);
10227
+ return active[0];
10228
+ }
10229
+ async resolveLoop(idOrName) {
10230
+ const byId = await this.getLoop(idOrName);
10231
+ if (byId)
10232
+ return byId;
10233
+ const matches = (await this.listLoops({ name: idOrName, limit: 1000 })).filter((loop) => loop.name === idOrName);
10234
+ if (matches.length === 0)
10235
+ return;
10236
+ if (matches.length === 1)
10237
+ return matches[0];
10238
+ const active = matches.filter((loop) => !loop.archivedAt);
10239
+ if (active.length === 1)
10240
+ return active[0];
10241
+ throw new AmbiguousNameError(idOrName);
10242
+ }
10243
+ async listLoops(opts = {}) {
10244
+ const raw = await this.t.get("/loops", { query: clean({ ...opts }) });
10245
+ return pickArray(raw, "loops");
10246
+ }
10247
+ async countLoops(status, opts = {}) {
10248
+ const raw = await this.t.get("/loops/count", { query: clean({ status, ...opts }) });
10249
+ return Number(pickObject(raw, "count") ?? 0);
10250
+ }
10251
+ async updateLoop(id, patch) {
10252
+ return pickObject(await this.t.patch(`/loops/${encodeURIComponent(id)}`, patch), "loop");
10253
+ }
10254
+ async renameLoop(id, name) {
10255
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(id)}/rename`, { name }), "loop");
10256
+ }
10257
+ async archiveLoop(idOrName) {
10258
+ const loop = await this.requireLoop(idOrName);
10259
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(loop.id)}/archive`), "loop");
10260
+ }
10261
+ async unarchiveLoop(idOrName) {
10262
+ const loop = await this.requireLoop(idOrName);
10263
+ return pickObject(await this.t.post(`/loops/${encodeURIComponent(loop.id)}/unarchive`), "loop");
10264
+ }
10265
+ async deleteLoop(idOrName) {
10266
+ const loop = await this.resolveLoop(idOrName);
10267
+ if (!loop)
10268
+ return false;
10269
+ const raw = await this.t.request("DELETE", `/loops/${encodeURIComponent(loop.id)}`);
10270
+ return Boolean(pickObject(raw, "deleted") ?? true);
10271
+ }
10272
+ async createWorkflow(input) {
10273
+ return pickObject(await this.t.post("/workflows", input), "workflow");
10274
+ }
10275
+ async getWorkflow(id) {
10276
+ try {
10277
+ return pickObject(await this.t.get(`/workflows/${encodeURIComponent(id)}`), "workflow");
10278
+ } catch {
10279
+ return;
10280
+ }
10281
+ }
10282
+ async findWorkflowByName(name) {
10283
+ const matches = (await this.listWorkflows({ limit: 1000 })).filter((wf) => wf.name === name);
10284
+ return matches[0];
10285
+ }
10286
+ async requireWorkflow(idOrName) {
10287
+ const byId = await this.getWorkflow(idOrName);
10288
+ if (byId)
10289
+ return byId;
10290
+ const byName = await this.findWorkflowByName(idOrName);
10291
+ if (byName)
10292
+ return byName;
10293
+ throw new Error(`workflow not found: ${idOrName}`);
10294
+ }
10295
+ async listWorkflows(opts = {}) {
10296
+ const raw = await this.t.get("/workflows", { query: clean({ ...opts }) });
10297
+ return pickArray(raw, "workflows");
10298
+ }
10299
+ async countWorkflows(opts = {}) {
10300
+ const raw = await this.t.get("/workflows/count", { query: clean({ ...opts }) });
10301
+ return Number(pickObject(raw, "count") ?? 0);
10302
+ }
10303
+ async archiveWorkflow(idOrName) {
10304
+ const wf = await this.requireWorkflow(idOrName);
10305
+ return pickObject(await this.t.post(`/workflows/${encodeURIComponent(wf.id)}/archive`), "workflow");
10306
+ }
10307
+ async getWorkflowRun(id) {
10308
+ try {
10309
+ return pickObject(await this.t.get(`/workflow-runs/${encodeURIComponent(id)}`), "workflowRun");
10310
+ } catch {
10311
+ return;
10312
+ }
10313
+ }
10314
+ async requireWorkflowRun(id) {
10315
+ const run = await this.getWorkflowRun(id);
10316
+ if (!run)
10317
+ throw new Error(`workflow run not found: ${id}`);
10318
+ return run;
10319
+ }
10320
+ async listWorkflowRuns(opts = {}) {
10321
+ const raw = await this.t.get("/workflow-runs", { query: clean({ ...opts }) });
10322
+ return pickArray(raw, "workflowRuns");
10323
+ }
10324
+ async listWorkflowStepRuns(workflowRunId) {
10325
+ const raw = await this.t.get(`/workflow-runs/${encodeURIComponent(workflowRunId)}/steps`);
10326
+ return pickArray(raw, "steps");
10327
+ }
10328
+ async listWorkflowEvents(workflowRunId, limit) {
10329
+ const raw = await this.t.get(`/workflow-runs/${encodeURIComponent(workflowRunId)}/events`, { query: clean({ limit }) });
10330
+ return pickArray(raw, "events");
10331
+ }
10332
+ async recoverWorkflowRun(workflowRunId, reason) {
10333
+ const raw = await this.t.post(`/workflow-runs/${encodeURIComponent(workflowRunId)}/recover`, { reason });
10334
+ return {
10335
+ run: pickObject(raw, "workflowRun"),
10336
+ recoveredSteps: pickArray(raw, "recoveredSteps")
10337
+ };
10338
+ }
10339
+ async cancelWorkflowRun(_workflowRunId, _reason) {
10340
+ throw new CloudUnsupportedError("workflows cancel");
10341
+ }
10342
+ async listWorkflowWorkItems(opts = {}) {
10343
+ const raw = await this.t.get("/work-items", { query: clean({ ...opts }) });
10344
+ return pickArray(raw, "workItems");
10345
+ }
10346
+ async getWorkflowWorkItem(id) {
10347
+ try {
10348
+ return pickObject(await this.t.get(`/work-items/${encodeURIComponent(id)}`), "workItem");
10349
+ } catch {
10350
+ return;
10351
+ }
10352
+ }
10353
+ async requeueWorkflowWorkItem(_id, _patch = {}) {
10354
+ throw new CloudUnsupportedError("routes requeue");
10355
+ }
10356
+ async listWorkflowInvocations(opts = {}) {
10357
+ const raw = await this.t.get("/invocations", { query: clean({ ...opts }) });
10358
+ return pickArray(raw, "invocations");
10359
+ }
10360
+ async getWorkflowInvocation(id) {
10361
+ try {
10362
+ return pickObject(await this.t.get(`/invocations/${encodeURIComponent(id)}`), "invocation");
10363
+ } catch {
10364
+ return;
10365
+ }
10366
+ }
10367
+ async getGoal(id) {
10368
+ try {
10369
+ return pickObject(await this.t.get(`/goals/${encodeURIComponent(id)}`), "goal");
10370
+ } catch {
10371
+ return;
10372
+ }
10373
+ }
10374
+ async findGoalByLoop(idOrName) {
10375
+ try {
10376
+ return pickObject(await this.t.get("/goals", { query: clean({ loop: idOrName }) }), "goal");
10377
+ } catch {
10378
+ return;
10379
+ }
10380
+ }
10381
+ async findGoalByRunId(id) {
10382
+ try {
10383
+ return pickObject(await this.t.get("/goals", { query: clean({ runId: id }) }), "goal");
10384
+ } catch {
10385
+ return;
10386
+ }
10387
+ }
10388
+ async listGoals(opts = {}) {
10389
+ const raw = await this.t.get("/goals", { query: clean({ ...opts }) });
10390
+ return pickArray(raw, "goals");
10391
+ }
10392
+ async listGoalPlanNodes(goalIdOrPlanId) {
10393
+ const raw = await this.t.get(`/goals/${encodeURIComponent(goalIdOrPlanId)}/plan-nodes`);
10394
+ return pickArray(raw, "nodes");
10395
+ }
10396
+ async listGoalRuns(opts = {}) {
10397
+ const raw = await this.t.get("/goal-runs", { query: clean({ ...opts }) });
10398
+ return pickArray(raw, "goalRuns");
10399
+ }
10400
+ async listRuns(opts = {}) {
10401
+ const raw = await this.t.get("/runs", { query: clean({ ...opts, showOutput: true }) });
10402
+ return pickArray(raw, "runs");
10403
+ }
10404
+ async getRun(id) {
10405
+ try {
10406
+ return pickObject(await this.t.get(`/runs/${encodeURIComponent(id)}`, { query: { showOutput: true } }), "run");
10407
+ } catch {
10408
+ return;
10409
+ }
10410
+ }
10411
+ async writeRunReceipt(input) {
10412
+ return pickObject(await this.t.post("/receipts", input), "receipt");
10413
+ }
10414
+ async getRunReceipt(runId) {
10415
+ try {
10416
+ return pickObject(await this.t.get(`/receipts/${encodeURIComponent(runId)}`), "receipt");
10417
+ } catch {
10418
+ return;
10419
+ }
10420
+ }
10421
+ async listRunReceipts(opts = {}) {
10422
+ const raw = await this.t.get("/receipts", { query: clean({ ...opts }) });
10423
+ return pickArray(raw, "receipts");
10424
+ }
10425
+ async pruneHistory(opts) {
10426
+ const raw = await this.t.post("/history/prune", {
10427
+ maxAgeDays: opts.maxAgeDays,
10428
+ keepPerLoop: opts.keepPerLoop,
10429
+ dryRun: opts.dryRun
10430
+ });
10431
+ return pickObject(raw, "history");
10432
+ }
10433
+ }
10434
+ function getStore(env = process.env) {
10435
+ const resolution = resolveCloudStorage("loops", env);
10436
+ if (resolution.transport === "cloud-http")
10437
+ return new ApiStore(resolution.client, resolution.baseUrl);
10438
+ return new LocalStore;
10439
+ }
10440
+ function isCloudStore(env = process.env) {
10441
+ return resolveCloudStorage("loops", env).transport === "cloud-http";
10442
+ }
10443
+
10444
+ // src/sdk/index.ts
10445
+ class LoopsClient {
10446
+ store;
10447
+ ownStore;
10448
+ runnerId;
10449
+ constructor(opts = {}) {
10450
+ this.store = opts.store ? new LocalStore(opts.store) : getStore();
10451
+ this.ownStore = !opts.store;
10452
+ this.runnerId = opts.runnerId ?? `sdk:${process.pid}`;
10453
+ }
10454
+ localRuntime(operation) {
10455
+ if (this.store.transport !== "local") {
10456
+ 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.`);
10457
+ }
10458
+ return this.store.raw;
10459
+ }
10460
+ create(input) {
10461
+ return this.store.createLoop(input);
10462
+ }
10463
+ list(filters = {}) {
10464
+ return this.store.listLoops({
10465
+ status: filters.status,
10466
+ limit: filters.limit,
10467
+ includeArchived: filters.includeArchived,
10468
+ archived: filters.archivedOnly
10469
+ });
9107
10470
  }
9108
10471
  get(idOrName) {
9109
10472
  return this.store.requireLoop(idOrName);
9110
10473
  }
9111
- pause(idOrName) {
9112
- return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "paused" });
10474
+ async pause(idOrName) {
10475
+ const loop = await this.store.requireUniqueLoop(idOrName);
10476
+ return this.store.updateLoop(loop.id, { status: "paused" });
9113
10477
  }
9114
- resume(idOrName) {
9115
- const loop = this.store.requireUniqueLoop(idOrName);
10478
+ async resume(idOrName) {
10479
+ const loop = await this.store.requireUniqueLoop(idOrName);
9116
10480
  let nextRunAt = loop.nextRunAt;
9117
10481
  if (!nextRunAt) {
9118
10482
  const now = new Date;
@@ -9120,8 +10484,9 @@ class LoopsClient {
9120
10484
  }
9121
10485
  return this.store.updateLoop(loop.id, { status: "active", nextRunAt });
9122
10486
  }
9123
- stop(idOrName) {
9124
- return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined });
10487
+ async stop(idOrName) {
10488
+ const loop = await this.store.requireUniqueLoop(idOrName);
10489
+ return this.store.updateLoop(loop.id, { status: "stopped", nextRunAt: undefined });
9125
10490
  }
9126
10491
  archive(idOrName) {
9127
10492
  return this.store.archiveLoop(idOrName);
@@ -9129,14 +10494,15 @@ class LoopsClient {
9129
10494
  unarchive(idOrName) {
9130
10495
  return this.store.unarchiveLoop(idOrName);
9131
10496
  }
9132
- delete(idOrName) {
9133
- return this.store.deleteLoop(this.store.requireUniqueLoop(idOrName).id);
10497
+ async delete(idOrName) {
10498
+ const loop = await this.store.requireUniqueLoop(idOrName);
10499
+ return this.store.deleteLoop(loop.id);
9134
10500
  }
9135
- runs(idOrName, filters = {}) {
10501
+ async runs(idOrName, filters = {}) {
9136
10502
  let loopId;
9137
10503
  if (idOrName) {
9138
10504
  try {
9139
- loopId = this.get(idOrName).id;
10505
+ loopId = (await this.get(idOrName)).id;
9140
10506
  } catch (error) {
9141
10507
  if (error instanceof LoopNotFoundError)
9142
10508
  return [];
@@ -9145,48 +10511,59 @@ class LoopsClient {
9145
10511
  }
9146
10512
  return this.store.listRuns({ loopId, status: filters.status, limit: filters.limit });
9147
10513
  }
10514
+ writeReceipt(input) {
10515
+ return this.store.writeRunReceipt(input);
10516
+ }
10517
+ receipt(runId) {
10518
+ return this.store.getRunReceipt(runId);
10519
+ }
10520
+ receipts(filters = {}) {
10521
+ return this.store.listRunReceipts(filters);
10522
+ }
10523
+ async goal(idOrName) {
10524
+ const goal = await this.store.getGoal(idOrName) ?? await this.store.findGoalByLoop(idOrName) ?? await this.store.findGoalByRunId(idOrName);
10525
+ return {
10526
+ goal,
10527
+ runs: goal ? await this.store.listGoalRuns({ goalId: goal.goalId }) : []
10528
+ };
10529
+ }
9148
10530
  doctor() {
9149
- return runDoctor(this.store);
10531
+ return runDoctor(this.localRuntime("doctor()"));
9150
10532
  }
9151
10533
  health(opts = {}) {
9152
- return buildHealthReport(this.store, opts);
10534
+ return buildHealthReport(this.localRuntime("health()"), opts);
9153
10535
  }
9154
10536
  healthScan(opts = {}) {
9155
- return buildHealthScan(this.store, {
10537
+ const store = this.localRuntime("healthScan()");
10538
+ return buildHealthScan(store, {
9156
10539
  ...opts,
9157
- doctor: opts.doctor ? runDoctor(this.store) : undefined,
9158
- daemon: opts.daemon ? daemonStatus(this.store) : undefined
10540
+ doctor: opts.doctor ? runDoctor(store) : undefined,
10541
+ daemon: opts.daemon ? daemonStatus(store) : undefined
9159
10542
  });
9160
10543
  }
9161
- goal(idOrName) {
9162
- const goal = this.store.getGoal(idOrName) ?? this.store.findGoalByLoop(idOrName) ?? this.store.findGoalByRunId(idOrName);
9163
- return {
9164
- goal,
9165
- runs: goal ? this.store.listGoalRuns({ goalId: goal.goalId }) : []
9166
- };
9167
- }
9168
10544
  async tick() {
9169
- return tick({ store: this.store, runnerId: this.runnerId });
10545
+ return tick({ store: this.localRuntime("tick()"), runnerId: this.runnerId });
9170
10546
  }
9171
10547
  async runNow(idOrName) {
9172
- const result = await runLoopNow({ store: this.store, idOrName: this.store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
10548
+ const store = this.localRuntime("runNow()");
10549
+ const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
9173
10550
  return result.run;
9174
10551
  }
9175
10552
  exportBundle(opts = {}) {
9176
- return exportLoopsMigrationBundle(this.store, opts);
10553
+ return exportLoopsMigrationBundle(this.localRuntime("exportBundle()"), opts);
9177
10554
  }
9178
10555
  planImport(bundle, opts = {}) {
9179
- return buildImportMigrationPlan(this.store, bundle, opts);
10556
+ return buildImportMigrationPlan(this.localRuntime("planImport()"), bundle, opts);
9180
10557
  }
9181
10558
  importBundle(bundle, opts = {}) {
9182
- return applyImportMigrationBundle(this.store, bundle, opts);
10559
+ return applyImportMigrationBundle(this.localRuntime("importBundle()"), bundle, opts);
9183
10560
  }
9184
10561
  planSelfHostedMigration(opts = {}) {
9185
- return buildSelfHostedMigrationPlan(this.store, { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
10562
+ return buildSelfHostedMigrationPlan(this.localRuntime("planSelfHostedMigration()"), { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
9186
10563
  }
9187
- close() {
10564
+ async close() {
9188
10565
  if (this.ownStore)
9189
- this.store.close();
10566
+ await this.store.close();
9190
10567
  }
9191
10568
  }
9192
10569
  function loops(opts = {}) {