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