@hasna/loops 0.4.14 → 0.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5419 -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/daemon/index.js
CHANGED
|
@@ -1204,6 +1204,175 @@ function discardWorkflowRunManifest(staged) {
|
|
|
1204
1204
|
} catch {}
|
|
1205
1205
|
}
|
|
1206
1206
|
|
|
1207
|
+
// src/lib/run-receipts.ts
|
|
1208
|
+
import { createHash as createHash2 } from "crypto";
|
|
1209
|
+
import { hostname } from "os";
|
|
1210
|
+
|
|
1211
|
+
// src/lib/run-envelope.ts
|
|
1212
|
+
var ENVELOPE_EXCERPT_CHARS = 2048;
|
|
1213
|
+
var BLOCKED_STEP_ERROR_PREFIX = "blocked:";
|
|
1214
|
+
function boundedExcerpt(text, limit = ENVELOPE_EXCERPT_CHARS) {
|
|
1215
|
+
if (!text)
|
|
1216
|
+
return;
|
|
1217
|
+
const scrubbed = scrubSecrets(text);
|
|
1218
|
+
if (scrubbed.length <= limit * 2)
|
|
1219
|
+
return scrubbed;
|
|
1220
|
+
const omitted = scrubbed.length - limit * 2;
|
|
1221
|
+
return `${scrubbed.slice(0, limit)}
|
|
1222
|
+
[... ${omitted} chars omitted ...]
|
|
1223
|
+
${scrubbed.slice(-limit)}`;
|
|
1224
|
+
}
|
|
1225
|
+
function summarizeOutput(stdout, stderr) {
|
|
1226
|
+
return {
|
|
1227
|
+
stdoutBytes: stdout ? Buffer.byteLength(stdout, "utf8") : 0,
|
|
1228
|
+
stderrBytes: stderr ? Buffer.byteLength(stderr, "utf8") : 0,
|
|
1229
|
+
stdoutExcerpt: boundedExcerpt(stdout),
|
|
1230
|
+
stderrExcerpt: boundedExcerpt(stderr)
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function summarizeExecutorResult(result) {
|
|
1234
|
+
return {
|
|
1235
|
+
...summarizeOutput(result.stdout, result.stderr),
|
|
1236
|
+
status: result.status,
|
|
1237
|
+
exitCode: result.exitCode,
|
|
1238
|
+
durationMs: result.durationMs,
|
|
1239
|
+
error: boundedExcerpt(result.error)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
function isBlockedStepRun(step) {
|
|
1243
|
+
return step?.status === "skipped" && (step.error?.startsWith(BLOCKED_STEP_ERROR_PREFIX) ?? false);
|
|
1244
|
+
}
|
|
1245
|
+
function summarizeWorkflowStepRun(step) {
|
|
1246
|
+
return {
|
|
1247
|
+
...summarizeOutput(step.stdout, step.stderr),
|
|
1248
|
+
stepId: step.stepId,
|
|
1249
|
+
status: step.status,
|
|
1250
|
+
exitCode: step.exitCode,
|
|
1251
|
+
durationMs: step.durationMs,
|
|
1252
|
+
error: boundedExcerpt(step.error),
|
|
1253
|
+
blocked: isBlockedStepRun(step)
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function workflowRunEnvelope(run, steps) {
|
|
1257
|
+
return JSON.stringify({ workflowRun: run, steps: steps.map(summarizeWorkflowStepRun) }, null, 2);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/lib/run-receipts.ts
|
|
1261
|
+
var RUN_RECEIPT_SUMMARY_TEXT_CHARS = 4096;
|
|
1262
|
+
var RUN_RECEIPT_MAX_IDS = 100;
|
|
1263
|
+
var RUN_RECEIPT_MAX_EVIDENCE_PATHS = 100;
|
|
1264
|
+
var RUN_RECEIPT_MAX_PATH_CHARS = 1024;
|
|
1265
|
+
function canonicalJson(value) {
|
|
1266
|
+
if (Array.isArray(value))
|
|
1267
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
1268
|
+
if (value && typeof value === "object") {
|
|
1269
|
+
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
1270
|
+
}
|
|
1271
|
+
return JSON.stringify(value);
|
|
1272
|
+
}
|
|
1273
|
+
function digestReceipt(receipt) {
|
|
1274
|
+
return `sha256:${createHash2("sha256").update(canonicalJson(receipt)).digest("hex")}`;
|
|
1275
|
+
}
|
|
1276
|
+
function isoOrNull(value, label) {
|
|
1277
|
+
if (value === undefined || value === null || value === "")
|
|
1278
|
+
return null;
|
|
1279
|
+
const date = new Date(value);
|
|
1280
|
+
if (Number.isNaN(date.getTime()))
|
|
1281
|
+
throw new Error(`${label} must be a valid ISO date/time`);
|
|
1282
|
+
return date.toISOString();
|
|
1283
|
+
}
|
|
1284
|
+
function nonEmpty(value, label) {
|
|
1285
|
+
const trimmed = value?.trim();
|
|
1286
|
+
if (!trimmed)
|
|
1287
|
+
throw new Error(`${label} must be non-empty`);
|
|
1288
|
+
return trimmed;
|
|
1289
|
+
}
|
|
1290
|
+
function normalizedStringArray(values, opts) {
|
|
1291
|
+
const out = [];
|
|
1292
|
+
const seen = new Set;
|
|
1293
|
+
for (const raw of values ?? []) {
|
|
1294
|
+
const value = String(raw).trim();
|
|
1295
|
+
if (!value || seen.has(value))
|
|
1296
|
+
continue;
|
|
1297
|
+
seen.add(value);
|
|
1298
|
+
out.push(opts.itemMax ? value.slice(0, opts.itemMax) : value);
|
|
1299
|
+
if (out.length >= opts.max)
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
if ((values?.length ?? 0) > opts.max) {
|
|
1303
|
+
out.push(`[truncated ${values.length - opts.max} ${opts.label}]`);
|
|
1304
|
+
}
|
|
1305
|
+
return out;
|
|
1306
|
+
}
|
|
1307
|
+
function receiptMachine(input, opts) {
|
|
1308
|
+
if (typeof input.machine === "string")
|
|
1309
|
+
return nonEmpty(input.machine, "machine");
|
|
1310
|
+
if (input.machine && typeof input.machine === "object")
|
|
1311
|
+
return input.machine;
|
|
1312
|
+
if (opts.loop?.machine)
|
|
1313
|
+
return { ...opts.loop.machine };
|
|
1314
|
+
if (typeof opts.defaultMachine === "string")
|
|
1315
|
+
return nonEmpty(opts.defaultMachine, "machine");
|
|
1316
|
+
if (opts.defaultMachine && typeof opts.defaultMachine === "object")
|
|
1317
|
+
return opts.defaultMachine;
|
|
1318
|
+
return hostname();
|
|
1319
|
+
}
|
|
1320
|
+
function normalizeSummary(input, run) {
|
|
1321
|
+
const stdout = input.stdout ?? run?.stdout;
|
|
1322
|
+
const stderr = input.stderr ?? run?.stderr;
|
|
1323
|
+
const output = summarizeOutput(stdout, stderr);
|
|
1324
|
+
const summaryInput = input.summary;
|
|
1325
|
+
const provided = typeof summaryInput === "object" && summaryInput !== null ? summaryInput : {};
|
|
1326
|
+
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;
|
|
1327
|
+
return {
|
|
1328
|
+
text,
|
|
1329
|
+
stdout_bytes: Number.isFinite(provided.stdout_bytes) ? Number(provided.stdout_bytes) : output.stdoutBytes,
|
|
1330
|
+
stderr_bytes: Number.isFinite(provided.stderr_bytes) ? Number(provided.stderr_bytes) : output.stderrBytes,
|
|
1331
|
+
stdout_excerpt: typeof provided.stdout_excerpt === "string" ? boundedExcerpt(provided.stdout_excerpt) : output.stdoutExcerpt,
|
|
1332
|
+
stderr_excerpt: typeof provided.stderr_excerpt === "string" ? boundedExcerpt(provided.stderr_excerpt) : output.stderrExcerpt,
|
|
1333
|
+
error: typeof provided.error === "string" ? boundedExcerpt(provided.error) : boundedExcerpt(input.error ?? run?.error),
|
|
1334
|
+
duration_ms: Number.isFinite(provided.duration_ms) ? Number(provided.duration_ms) : input.duration_ms ?? run?.durationMs
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
function normalizeRunReceipt(input, opts = {}) {
|
|
1338
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1339
|
+
const run = opts.run;
|
|
1340
|
+
const loopId = input.loop_id ?? run?.loopId ?? opts.loop?.id;
|
|
1341
|
+
const normalized = {
|
|
1342
|
+
loop_id: nonEmpty(loopId, "loop_id"),
|
|
1343
|
+
run_id: nonEmpty(input.run_id ?? run?.id, "run_id"),
|
|
1344
|
+
machine: receiptMachine(input, opts),
|
|
1345
|
+
repo: nonEmpty(input.repo ?? opts.defaultRepo ?? targetRepo(opts.loop) ?? process.cwd(), "repo"),
|
|
1346
|
+
task_ids: normalizedStringArray(input.task_ids, { max: RUN_RECEIPT_MAX_IDS, label: "task_ids" }),
|
|
1347
|
+
knowledge_ids: normalizedStringArray(input.knowledge_ids, { max: RUN_RECEIPT_MAX_IDS, label: "knowledge_ids" }),
|
|
1348
|
+
started_at: isoOrNull(input.started_at ?? run?.startedAt, "started_at"),
|
|
1349
|
+
finished_at: isoOrNull(input.finished_at ?? run?.finishedAt, "finished_at"),
|
|
1350
|
+
status: nonEmpty(input.status ?? run?.status, "status"),
|
|
1351
|
+
exit_code: input.exit_code === undefined ? run?.exitCode ?? null : input.exit_code,
|
|
1352
|
+
summary: normalizeSummary(input, run),
|
|
1353
|
+
evidence_paths: normalizedStringArray(input.evidence_paths, {
|
|
1354
|
+
max: RUN_RECEIPT_MAX_EVIDENCE_PATHS,
|
|
1355
|
+
label: "evidence_paths",
|
|
1356
|
+
itemMax: RUN_RECEIPT_MAX_PATH_CHARS
|
|
1357
|
+
})
|
|
1358
|
+
};
|
|
1359
|
+
return {
|
|
1360
|
+
...normalized,
|
|
1361
|
+
digest_id: input.digest_id?.trim() || digestReceipt(normalized),
|
|
1362
|
+
created_at: opts.existing?.created_at ?? now,
|
|
1363
|
+
updated_at: now
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function targetRepo(loop) {
|
|
1367
|
+
if (!loop)
|
|
1368
|
+
return;
|
|
1369
|
+
if (loop.target.type === "command")
|
|
1370
|
+
return loop.target.cwd;
|
|
1371
|
+
if (loop.target.type === "agent")
|
|
1372
|
+
return loop.target.cwd;
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1207
1376
|
// src/lib/route/todos-cli.ts
|
|
1208
1377
|
import { closeSync, mkdtempSync, openSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
|
|
1209
1378
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
@@ -1275,6 +1444,9 @@ function publicRun(run, showOutput = false, opts = {}) {
|
|
|
1275
1444
|
error: opts.redactError ? redact(run.error) : run.error
|
|
1276
1445
|
};
|
|
1277
1446
|
}
|
|
1447
|
+
function publicRunReceipt(receipt) {
|
|
1448
|
+
return { ...receipt };
|
|
1449
|
+
}
|
|
1278
1450
|
function publicExecutorResult(result, showOutput = false) {
|
|
1279
1451
|
return {
|
|
1280
1452
|
...result,
|
|
@@ -1398,7 +1570,7 @@ function todosMutationSummary(result) {
|
|
|
1398
1570
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
1399
1571
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
1400
1572
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
1401
|
-
var SCHEMA_USER_VERSION =
|
|
1573
|
+
var SCHEMA_USER_VERSION = 8;
|
|
1402
1574
|
var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
|
|
1403
1575
|
var PRUNE_BATCH_SIZE = 400;
|
|
1404
1576
|
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
|
|
@@ -1454,6 +1626,25 @@ function rowToRun(row) {
|
|
|
1454
1626
|
updatedAt: row.updated_at
|
|
1455
1627
|
};
|
|
1456
1628
|
}
|
|
1629
|
+
function rowToRunReceipt(row) {
|
|
1630
|
+
return {
|
|
1631
|
+
loop_id: row.loop_id,
|
|
1632
|
+
run_id: row.run_id,
|
|
1633
|
+
machine: JSON.parse(row.machine_json),
|
|
1634
|
+
repo: row.repo,
|
|
1635
|
+
task_ids: JSON.parse(row.task_ids_json),
|
|
1636
|
+
knowledge_ids: JSON.parse(row.knowledge_ids_json),
|
|
1637
|
+
digest_id: row.digest_id,
|
|
1638
|
+
started_at: row.started_at,
|
|
1639
|
+
finished_at: row.finished_at,
|
|
1640
|
+
status: row.status,
|
|
1641
|
+
exit_code: row.exit_code,
|
|
1642
|
+
summary: JSON.parse(row.summary_json),
|
|
1643
|
+
evidence_paths: JSON.parse(row.evidence_paths_json),
|
|
1644
|
+
created_at: row.created_at,
|
|
1645
|
+
updated_at: row.updated_at
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1457
1648
|
function latestRunTime(row) {
|
|
1458
1649
|
return row.finished_at ?? row.started_at ?? row.created_at;
|
|
1459
1650
|
}
|
|
@@ -1517,6 +1708,7 @@ function rowToWorkflowWorkItem(row) {
|
|
|
1517
1708
|
subjectRef: row.subject_ref,
|
|
1518
1709
|
projectKey: row.project_key ?? undefined,
|
|
1519
1710
|
projectGroup: row.project_group ?? undefined,
|
|
1711
|
+
machineId: row.machine_id ?? undefined,
|
|
1520
1712
|
routeScope: row.route_scope ?? undefined,
|
|
1521
1713
|
priority: row.priority,
|
|
1522
1714
|
status: row.status,
|
|
@@ -1674,6 +1866,7 @@ function scrubbedOrNull(value) {
|
|
|
1674
1866
|
return value == null ? null : scrubSecrets(value);
|
|
1675
1867
|
}
|
|
1676
1868
|
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1869
|
+
var MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS = 64 * 1024;
|
|
1677
1870
|
function clampPersistedRunOutput(value) {
|
|
1678
1871
|
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1679
1872
|
return value;
|
|
@@ -1688,6 +1881,42 @@ ${tail}`;
|
|
|
1688
1881
|
function persistedRunOutput(value) {
|
|
1689
1882
|
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1690
1883
|
}
|
|
1884
|
+
function clampTextToChars(value, maxChars, reason) {
|
|
1885
|
+
if (value.length <= maxChars)
|
|
1886
|
+
return value;
|
|
1887
|
+
const marker = `
|
|
1888
|
+
...[truncated by ${reason}]...
|
|
1889
|
+
`;
|
|
1890
|
+
const budget = Math.max(0, maxChars - marker.length);
|
|
1891
|
+
const headLength = Math.ceil(budget / 2);
|
|
1892
|
+
const tailLength = Math.floor(budget / 2);
|
|
1893
|
+
return `${value.slice(0, headLength)}${marker}${value.slice(value.length - tailLength)}`;
|
|
1894
|
+
}
|
|
1895
|
+
function boundedWorkflowEventPayloadJson(scrubbedJson) {
|
|
1896
|
+
if (scrubbedJson.length <= MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS)
|
|
1897
|
+
return scrubbedJson;
|
|
1898
|
+
const base = {
|
|
1899
|
+
truncated: true,
|
|
1900
|
+
originalChars: scrubbedJson.length,
|
|
1901
|
+
maxChars: MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS,
|
|
1902
|
+
preview: ""
|
|
1903
|
+
};
|
|
1904
|
+
const baseChars = JSON.stringify(base).length;
|
|
1905
|
+
let previewBudget = Math.max(0, MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS - baseChars - 64);
|
|
1906
|
+
while (true) {
|
|
1907
|
+
const preview = clampTextToChars(scrubbedJson, previewBudget, "loops workflow-event payload retention");
|
|
1908
|
+
const bounded = JSON.stringify({ ...base, preview });
|
|
1909
|
+
if (bounded.length <= MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS || previewBudget === 0)
|
|
1910
|
+
return bounded;
|
|
1911
|
+
previewBudget = Math.max(0, previewBudget - (bounded.length - MAX_PERSISTED_WORKFLOW_EVENT_PAYLOAD_CHARS) - 64);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
function persistedWorkflowEventPayload(payload) {
|
|
1915
|
+
if (payload == null)
|
|
1916
|
+
return null;
|
|
1917
|
+
const scrubbed = scrubSecretsDeep(payload);
|
|
1918
|
+
return boundedWorkflowEventPayloadJson(scrubSecrets(JSON.stringify(scrubbed)));
|
|
1919
|
+
}
|
|
1691
1920
|
function chmodIfExists(path, mode) {
|
|
1692
1921
|
try {
|
|
1693
1922
|
if (existsSync(path))
|
|
@@ -1805,6 +2034,17 @@ class Store {
|
|
|
1805
2034
|
this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
|
|
1806
2035
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
|
|
1807
2036
|
}
|
|
2037
|
+
},
|
|
2038
|
+
{
|
|
2039
|
+
id: "0009_run_receipts",
|
|
2040
|
+
apply: () => this.createRunReceiptsSchema()
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
id: "0010_work_item_machine_id",
|
|
2044
|
+
apply: () => {
|
|
2045
|
+
this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
|
|
2046
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
|
|
2047
|
+
}
|
|
1808
2048
|
}
|
|
1809
2049
|
];
|
|
1810
2050
|
}
|
|
@@ -1866,6 +2106,28 @@ class Store {
|
|
|
1866
2106
|
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
1867
2107
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
1868
2108
|
|
|
2109
|
+
CREATE TABLE IF NOT EXISTS run_receipts (
|
|
2110
|
+
run_id TEXT PRIMARY KEY,
|
|
2111
|
+
loop_id TEXT NOT NULL,
|
|
2112
|
+
machine_json TEXT NOT NULL,
|
|
2113
|
+
repo TEXT NOT NULL,
|
|
2114
|
+
task_ids_json TEXT NOT NULL,
|
|
2115
|
+
knowledge_ids_json TEXT NOT NULL,
|
|
2116
|
+
digest_id TEXT NOT NULL,
|
|
2117
|
+
started_at TEXT,
|
|
2118
|
+
finished_at TEXT,
|
|
2119
|
+
status TEXT NOT NULL,
|
|
2120
|
+
exit_code INTEGER,
|
|
2121
|
+
summary_json TEXT NOT NULL,
|
|
2122
|
+
evidence_paths_json TEXT NOT NULL,
|
|
2123
|
+
created_at TEXT NOT NULL,
|
|
2124
|
+
updated_at TEXT NOT NULL
|
|
2125
|
+
);
|
|
2126
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_loop ON run_receipts(loop_id, created_at DESC);
|
|
2127
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_repo ON run_receipts(repo, created_at DESC);
|
|
2128
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_digest ON run_receipts(digest_id);
|
|
2129
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_status ON run_receipts(status, created_at DESC);
|
|
2130
|
+
|
|
1869
2131
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
1870
2132
|
id TEXT PRIMARY KEY,
|
|
1871
2133
|
pid INTEGER NOT NULL,
|
|
@@ -1952,6 +2214,7 @@ class Store {
|
|
|
1952
2214
|
subject_ref TEXT NOT NULL,
|
|
1953
2215
|
project_key TEXT,
|
|
1954
2216
|
project_group TEXT,
|
|
2217
|
+
machine_id TEXT,
|
|
1955
2218
|
route_scope TEXT,
|
|
1956
2219
|
priority INTEGER NOT NULL,
|
|
1957
2220
|
status TEXT NOT NULL,
|
|
@@ -1970,10 +2233,10 @@ class Store {
|
|
|
1970
2233
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1971
2234
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1972
2235
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1973
|
-
--
|
|
1974
|
-
--
|
|
2236
|
+
-- New-column indexes (route_scope, machine_id, etc.) are created ONLY by
|
|
2237
|
+
-- their additive migrations, never here: this baseline DDL
|
|
1975
2238
|
-- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
|
|
1976
|
-
-- database the CREATE TABLE above is a no-op, so an index on
|
|
2239
|
+
-- database the CREATE TABLE above is a no-op, so an index on a new column
|
|
1977
2240
|
-- here would execute before the column exists and crash the open
|
|
1978
2241
|
-- ("no such column: route_scope"). New columns may be folded into the
|
|
1979
2242
|
-- CREATE TABLE (fresh-db only); their indexes must live in the migration.
|
|
@@ -2084,6 +2347,31 @@ class Store {
|
|
|
2084
2347
|
CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
|
|
2085
2348
|
`);
|
|
2086
2349
|
}
|
|
2350
|
+
createRunReceiptsSchema() {
|
|
2351
|
+
this.db.exec(`
|
|
2352
|
+
CREATE TABLE IF NOT EXISTS run_receipts (
|
|
2353
|
+
run_id TEXT PRIMARY KEY,
|
|
2354
|
+
loop_id TEXT NOT NULL,
|
|
2355
|
+
machine_json TEXT NOT NULL,
|
|
2356
|
+
repo TEXT NOT NULL,
|
|
2357
|
+
task_ids_json TEXT NOT NULL,
|
|
2358
|
+
knowledge_ids_json TEXT NOT NULL,
|
|
2359
|
+
digest_id TEXT NOT NULL,
|
|
2360
|
+
started_at TEXT,
|
|
2361
|
+
finished_at TEXT,
|
|
2362
|
+
status TEXT NOT NULL,
|
|
2363
|
+
exit_code INTEGER,
|
|
2364
|
+
summary_json TEXT NOT NULL,
|
|
2365
|
+
evidence_paths_json TEXT NOT NULL,
|
|
2366
|
+
created_at TEXT NOT NULL,
|
|
2367
|
+
updated_at TEXT NOT NULL
|
|
2368
|
+
);
|
|
2369
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_loop ON run_receipts(loop_id, created_at DESC);
|
|
2370
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_repo ON run_receipts(repo, created_at DESC);
|
|
2371
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_digest ON run_receipts(digest_id);
|
|
2372
|
+
CREATE INDEX IF NOT EXISTS idx_run_receipts_status ON run_receipts(status, created_at DESC);
|
|
2373
|
+
`);
|
|
2374
|
+
}
|
|
2087
2375
|
addColumnIfMissing(table, column, definition) {
|
|
2088
2376
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
2089
2377
|
if (columns.some((c) => c.name === column))
|
|
@@ -2195,19 +2483,24 @@ class Store {
|
|
|
2195
2483
|
}
|
|
2196
2484
|
listLoops(opts = {}) {
|
|
2197
2485
|
const limit = opts.limit ?? 200;
|
|
2486
|
+
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
|
2487
|
+
if (opts.name != null) {
|
|
2488
|
+
const rows2 = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.name, limit, offset);
|
|
2489
|
+
return this.withLatestRunSummaries(rows2.map(rowToLoop));
|
|
2490
|
+
}
|
|
2198
2491
|
let rows;
|
|
2199
2492
|
if (opts.status && opts.archived) {
|
|
2200
|
-
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);
|
|
2493
|
+
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);
|
|
2201
2494
|
} else if (opts.status && opts.includeArchived) {
|
|
2202
|
-
rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
|
|
2495
|
+
rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
|
|
2203
2496
|
} else if (opts.status) {
|
|
2204
|
-
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);
|
|
2497
|
+
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);
|
|
2205
2498
|
} else if (opts.archived) {
|
|
2206
|
-
rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
|
|
2499
|
+
rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ? OFFSET ?").all(limit, offset);
|
|
2207
2500
|
} else if (opts.includeArchived) {
|
|
2208
|
-
rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
|
|
2501
|
+
rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ? OFFSET ?").all(limit, offset);
|
|
2209
2502
|
} else {
|
|
2210
|
-
rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
|
|
2503
|
+
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);
|
|
2211
2504
|
}
|
|
2212
2505
|
return this.withLatestRunSummaries(rows.map(rowToLoop));
|
|
2213
2506
|
}
|
|
@@ -2885,10 +3178,10 @@ class Store {
|
|
|
2885
3178
|
const id = genId();
|
|
2886
3179
|
const status = input.status ?? "queued";
|
|
2887
3180
|
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
2888
|
-
subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
3181
|
+
subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2889
3182
|
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
2890
3183
|
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
2891
|
-
$projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
3184
|
+
$projectKey, $projectGroup, $machineId, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2892
3185
|
$lastReason, $created, $updated)
|
|
2893
3186
|
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
2894
3187
|
invocation_id=excluded.invocation_id,
|
|
@@ -2897,6 +3190,10 @@ class Store {
|
|
|
2897
3190
|
subject_ref=excluded.subject_ref,
|
|
2898
3191
|
project_key=excluded.project_key,
|
|
2899
3192
|
project_group=excluded.project_group,
|
|
3193
|
+
machine_id=CASE
|
|
3194
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.machine_id
|
|
3195
|
+
ELSE excluded.machine_id
|
|
3196
|
+
END,
|
|
2900
3197
|
route_scope=excluded.route_scope,
|
|
2901
3198
|
priority=excluded.priority,
|
|
2902
3199
|
status=CASE
|
|
@@ -2939,6 +3236,7 @@ class Store {
|
|
|
2939
3236
|
$subjectRef: input.subjectRef,
|
|
2940
3237
|
$projectKey: input.projectKey ?? null,
|
|
2941
3238
|
$projectGroup: input.projectGroup ?? null,
|
|
3239
|
+
$machineId: input.machineId ?? null,
|
|
2942
3240
|
$routeScope: input.routeScope ?? null,
|
|
2943
3241
|
$priority: input.priority ?? 0,
|
|
2944
3242
|
$status: status,
|
|
@@ -3448,7 +3746,7 @@ class Store {
|
|
|
3448
3746
|
VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
|
|
3449
3747
|
$id: genId(),
|
|
3450
3748
|
$workflowRunId: runId,
|
|
3451
|
-
$payload:
|
|
3749
|
+
$payload: persistedWorkflowEventPayload({
|
|
3452
3750
|
workflowId: input.workflow.id,
|
|
3453
3751
|
workflowName: input.workflow.name,
|
|
3454
3752
|
stepCount: input.workflow.steps.length,
|
|
@@ -3779,7 +4077,7 @@ class Store {
|
|
|
3779
4077
|
$sequence: sequence,
|
|
3780
4078
|
$eventType: eventType,
|
|
3781
4079
|
$stepId: stepId ?? null,
|
|
3782
|
-
$payload:
|
|
4080
|
+
$payload: persistedWorkflowEventPayload(payload),
|
|
3783
4081
|
$created: now
|
|
3784
4082
|
});
|
|
3785
4083
|
const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
|
|
@@ -4102,18 +4400,93 @@ class Store {
|
|
|
4102
4400
|
}
|
|
4103
4401
|
listRuns(opts = {}) {
|
|
4104
4402
|
const limit = opts.limit ?? 100;
|
|
4403
|
+
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
|
4105
4404
|
let rows;
|
|
4106
4405
|
if (opts.loopId && opts.status) {
|
|
4107
|
-
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);
|
|
4406
|
+
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);
|
|
4108
4407
|
} else if (opts.loopId) {
|
|
4109
|
-
rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, limit);
|
|
4408
|
+
rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.loopId, limit, offset);
|
|
4110
4409
|
} else if (opts.status) {
|
|
4111
|
-
rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit);
|
|
4410
|
+
rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?").all(opts.status, limit, offset);
|
|
4112
4411
|
} else {
|
|
4113
|
-
rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
4412
|
+
rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ? OFFSET ?").all(limit, offset);
|
|
4114
4413
|
}
|
|
4115
4414
|
return rows.map(rowToRun);
|
|
4116
4415
|
}
|
|
4416
|
+
writeRunReceipt(input, opts = {}) {
|
|
4417
|
+
const inputRunId = typeof input.run_id === "string" && input.run_id.trim() ? input.run_id : undefined;
|
|
4418
|
+
const existing = inputRunId ? this.getRunReceipt(inputRunId) : undefined;
|
|
4419
|
+
const run = inputRunId ? this.getRun(inputRunId) : undefined;
|
|
4420
|
+
const loop = input.loop_id ? this.getLoop(input.loop_id) : run ? this.getLoop(run.loopId) : undefined;
|
|
4421
|
+
const receipt = normalizeRunReceipt(input, { now: opts.now, run, loop, existing });
|
|
4422
|
+
this.db.query(`INSERT INTO run_receipts (run_id, loop_id, machine_json, repo, task_ids_json, knowledge_ids_json, digest_id,
|
|
4423
|
+
started_at, finished_at, status, exit_code, summary_json, evidence_paths_json, created_at, updated_at)
|
|
4424
|
+
VALUES ($runId, $loopId, $machineJson, $repo, $taskIdsJson, $knowledgeIdsJson, $digestId,
|
|
4425
|
+
$startedAt, $finishedAt, $status, $exitCode, $summaryJson, $evidencePathsJson, $createdAt, $updatedAt)
|
|
4426
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
4427
|
+
loop_id=excluded.loop_id,
|
|
4428
|
+
machine_json=excluded.machine_json,
|
|
4429
|
+
repo=excluded.repo,
|
|
4430
|
+
task_ids_json=excluded.task_ids_json,
|
|
4431
|
+
knowledge_ids_json=excluded.knowledge_ids_json,
|
|
4432
|
+
digest_id=excluded.digest_id,
|
|
4433
|
+
started_at=excluded.started_at,
|
|
4434
|
+
finished_at=excluded.finished_at,
|
|
4435
|
+
status=excluded.status,
|
|
4436
|
+
exit_code=excluded.exit_code,
|
|
4437
|
+
summary_json=excluded.summary_json,
|
|
4438
|
+
evidence_paths_json=excluded.evidence_paths_json,
|
|
4439
|
+
updated_at=excluded.updated_at`).run({
|
|
4440
|
+
$runId: receipt.run_id,
|
|
4441
|
+
$loopId: receipt.loop_id,
|
|
4442
|
+
$machineJson: JSON.stringify(receipt.machine),
|
|
4443
|
+
$repo: receipt.repo,
|
|
4444
|
+
$taskIdsJson: JSON.stringify(receipt.task_ids),
|
|
4445
|
+
$knowledgeIdsJson: JSON.stringify(receipt.knowledge_ids),
|
|
4446
|
+
$digestId: receipt.digest_id,
|
|
4447
|
+
$startedAt: receipt.started_at,
|
|
4448
|
+
$finishedAt: receipt.finished_at,
|
|
4449
|
+
$status: receipt.status,
|
|
4450
|
+
$exitCode: receipt.exit_code,
|
|
4451
|
+
$summaryJson: JSON.stringify(receipt.summary),
|
|
4452
|
+
$evidencePathsJson: JSON.stringify(receipt.evidence_paths),
|
|
4453
|
+
$createdAt: receipt.created_at,
|
|
4454
|
+
$updatedAt: receipt.updated_at
|
|
4455
|
+
});
|
|
4456
|
+
return this.getRunReceipt(receipt.run_id) ?? receipt;
|
|
4457
|
+
}
|
|
4458
|
+
getRunReceipt(runId) {
|
|
4459
|
+
const row = this.db.query("SELECT * FROM run_receipts WHERE run_id = ?").get(runId);
|
|
4460
|
+
return row ? rowToRunReceipt(row) : undefined;
|
|
4461
|
+
}
|
|
4462
|
+
listRunReceipts(opts = {}) {
|
|
4463
|
+
const limit = opts.limit ?? 100;
|
|
4464
|
+
const filters = [];
|
|
4465
|
+
const params = [];
|
|
4466
|
+
if (opts.loopId) {
|
|
4467
|
+
filters.push("loop_id = ?");
|
|
4468
|
+
params.push(opts.loopId);
|
|
4469
|
+
}
|
|
4470
|
+
if (opts.repo) {
|
|
4471
|
+
filters.push("repo = ?");
|
|
4472
|
+
params.push(opts.repo);
|
|
4473
|
+
}
|
|
4474
|
+
if (opts.status) {
|
|
4475
|
+
filters.push("status = ?");
|
|
4476
|
+
params.push(opts.status);
|
|
4477
|
+
}
|
|
4478
|
+
if (opts.taskId) {
|
|
4479
|
+
filters.push("EXISTS (SELECT 1 FROM json_each(run_receipts.task_ids_json) WHERE value = ?)");
|
|
4480
|
+
params.push(opts.taskId);
|
|
4481
|
+
}
|
|
4482
|
+
if (opts.knowledgeId) {
|
|
4483
|
+
filters.push("EXISTS (SELECT 1 FROM json_each(run_receipts.knowledge_ids_json) WHERE value = ?)");
|
|
4484
|
+
params.push(opts.knowledgeId);
|
|
4485
|
+
}
|
|
4486
|
+
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
|
4487
|
+
const rows = this.db.query(`SELECT * FROM run_receipts ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit);
|
|
4488
|
+
return rows.map(rowToRunReceipt);
|
|
4489
|
+
}
|
|
4117
4490
|
deferLiveExpiredRun(id, now, opts = {}) {
|
|
4118
4491
|
const updated = now.toISOString();
|
|
4119
4492
|
const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
|
|
@@ -4272,6 +4645,9 @@ class Store {
|
|
|
4272
4645
|
const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
|
|
4273
4646
|
return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
|
|
4274
4647
|
}
|
|
4648
|
+
exportMigrationRunPage(opts) {
|
|
4649
|
+
return this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?").all(opts.limit, opts.offset).map(rowToRun);
|
|
4650
|
+
}
|
|
4275
4651
|
countTable(table) {
|
|
4276
4652
|
const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
4277
4653
|
return row?.count ?? 0;
|
|
@@ -4599,11 +4975,11 @@ import { Command } from "commander";
|
|
|
4599
4975
|
|
|
4600
4976
|
// src/daemon/daemon.ts
|
|
4601
4977
|
import { copyFileSync, existsSync as existsSync5, openSync as openSync2, renameSync as renameSync2, rmSync as rmSync5, statSync, truncateSync } from "fs";
|
|
4602
|
-
import { hostname as
|
|
4978
|
+
import { hostname as hostname3 } from "os";
|
|
4603
4979
|
import { spawn as spawn3 } from "child_process";
|
|
4604
4980
|
|
|
4605
4981
|
// src/lib/health.ts
|
|
4606
|
-
import { createHash as
|
|
4982
|
+
import { createHash as createHash3 } from "crypto";
|
|
4607
4983
|
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
4608
4984
|
import { join as join5 } from "path";
|
|
4609
4985
|
var EVIDENCE_CHARS = 2000;
|
|
@@ -4665,7 +5041,7 @@ function tagsFromValue(value) {
|
|
|
4665
5041
|
return [];
|
|
4666
5042
|
}
|
|
4667
5043
|
function stableFingerprint(parts) {
|
|
4668
|
-
return
|
|
5044
|
+
return createHash3("sha256").update(parts.join(`
|
|
4669
5045
|
`)).digest("hex").slice(0, 16);
|
|
4670
5046
|
}
|
|
4671
5047
|
function stableScanFingerprint(parts) {
|
|
@@ -5728,6 +6104,7 @@ var TRANSPORT_ENV_KEYS = new Set([
|
|
|
5728
6104
|
"LOGNAME",
|
|
5729
6105
|
"PATH",
|
|
5730
6106
|
"SHELL",
|
|
6107
|
+
"SHLVL",
|
|
5731
6108
|
"SSH_AGENT_PID",
|
|
5732
6109
|
"SSH_AUTH_SOCK",
|
|
5733
6110
|
"TERM",
|
|
@@ -5949,6 +6326,7 @@ function composeExecutionEnv(spec, metadata, opts, accountEnv) {
|
|
|
5949
6326
|
Object.assign(env, spec.env ?? {});
|
|
5950
6327
|
Object.assign(env, allowlistEnv(spec.allowlist));
|
|
5951
6328
|
env.PATH = normalizeExecutionPath(env);
|
|
6329
|
+
env.SHLVL ||= "1";
|
|
5952
6330
|
Object.assign(env, metadataEnv(metadata));
|
|
5953
6331
|
return env;
|
|
5954
6332
|
}
|
|
@@ -6020,7 +6398,7 @@ function remoteWorktreePrepareLines(worktree) {
|
|
|
6020
6398
|
return [
|
|
6021
6399
|
"__openloops_prepare_worktree() {",
|
|
6022
6400
|
` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
|
|
6023
|
-
" local top expected_common actual_common current",
|
|
6401
|
+
" local top expected_common actual_common current status recovered",
|
|
6024
6402
|
' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
|
|
6025
6403
|
' if [ -e "$path" ]; then',
|
|
6026
6404
|
' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
|
|
@@ -6029,7 +6407,19 @@ function remoteWorktreePrepareLines(worktree) {
|
|
|
6029
6407
|
' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
|
|
6030
6408
|
' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
|
|
6031
6409
|
' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
|
|
6032
|
-
' if [ "$current" != "$branch" ]; then
|
|
6410
|
+
' if [ "$current" != "$branch" ]; then',
|
|
6411
|
+
' 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; }',
|
|
6412
|
+
' 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',
|
|
6413
|
+
' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
|
|
6414
|
+
' 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; }',
|
|
6415
|
+
' elif [ -z "$current" ]; then',
|
|
6416
|
+
' 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; }',
|
|
6417
|
+
" else",
|
|
6418
|
+
' 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',
|
|
6419
|
+
" fi",
|
|
6420
|
+
' recovered="$(git -C "$path" branch --show-current 2>/dev/null)"',
|
|
6421
|
+
' 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',
|
|
6422
|
+
" fi",
|
|
6033
6423
|
" return 0",
|
|
6034
6424
|
" fi",
|
|
6035
6425
|
' 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; }',
|
|
@@ -6110,8 +6500,21 @@ function transportEnv(opts) {
|
|
|
6110
6500
|
env[key] = value;
|
|
6111
6501
|
}
|
|
6112
6502
|
env.PATH = normalizeExecutionPath(env);
|
|
6503
|
+
env.SHLVL ||= "1";
|
|
6113
6504
|
return env;
|
|
6114
6505
|
}
|
|
6506
|
+
function remotePreflightFailureMessage(machineId, detail) {
|
|
6507
|
+
const normalized = detail.trim();
|
|
6508
|
+
if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .*key in .*known_hosts/i.test(normalized)) {
|
|
6509
|
+
return [
|
|
6510
|
+
`remote preflight failed on ${machineId}: SSH host key verification failed.`,
|
|
6511
|
+
`Verify ${machineId}'s host identity and repair SSH known_hosts/trust material outside OpenLoops;`,
|
|
6512
|
+
"OpenLoops will not disable host-key checking or modify known_hosts automatically.",
|
|
6513
|
+
normalized ? `Transport detail: ${normalized}` : ""
|
|
6514
|
+
].filter(Boolean).join(" ");
|
|
6515
|
+
}
|
|
6516
|
+
return `remote preflight failed on ${machineId}${normalized ? `: ${normalized}` : ""}`;
|
|
6517
|
+
}
|
|
6115
6518
|
function assertCodewithProfileListed(profile, result) {
|
|
6116
6519
|
const profiles = codewithProfileSetFromResult(result);
|
|
6117
6520
|
if (!profiles.has(profile)) {
|
|
@@ -6163,6 +6566,9 @@ async function preflightNativeAuthProfile(spec, env) {
|
|
|
6163
6566
|
const tableResult = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
|
|
6164
6567
|
assertCodewithProfileListed(spec.nativeAuthProfile.profile, tableResult);
|
|
6165
6568
|
}
|
|
6569
|
+
function spawnDetail(result) {
|
|
6570
|
+
return (result.stderr || result.stdout || result.error || "").toString().trim();
|
|
6571
|
+
}
|
|
6166
6572
|
function resolvedDirEquals(left, right) {
|
|
6167
6573
|
try {
|
|
6168
6574
|
return realpathSync(left) === realpathSync(right);
|
|
@@ -6212,8 +6618,45 @@ async function ensureLocalWorktree(worktree, env) {
|
|
|
6212
6618
|
}
|
|
6213
6619
|
const current = await git(["-C", path, "branch", "--show-current"]);
|
|
6214
6620
|
const actualBranch = current.stdout.trim();
|
|
6215
|
-
if (current.error || (current.status ?? 1) !== 0
|
|
6216
|
-
return { error: `existing worktree ${path}
|
|
6621
|
+
if (current.error || (current.status ?? 1) !== 0) {
|
|
6622
|
+
return { error: `existing worktree ${path} branch could not be inspected${spawnDetail(current) ? `: ${spawnDetail(current)}` : ""}` };
|
|
6623
|
+
}
|
|
6624
|
+
if (actualBranch !== branch) {
|
|
6625
|
+
const actualLabel = actualBranch || "unknown";
|
|
6626
|
+
const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
6627
|
+
if (status.error || (status.status ?? 1) !== 0) {
|
|
6628
|
+
const detail = spawnDetail(status);
|
|
6629
|
+
return {
|
|
6630
|
+
error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and cleanliness could not be checked; inspect or remove the stale worktree${detail ? `: ${detail}` : ""}`
|
|
6631
|
+
};
|
|
6632
|
+
}
|
|
6633
|
+
if (status.stdout.trim()) {
|
|
6634
|
+
return {
|
|
6635
|
+
error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and has local changes; commit/stash them or remove the stale worktree before retrying`
|
|
6636
|
+
};
|
|
6637
|
+
}
|
|
6638
|
+
const hasBranch2 = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
6639
|
+
const checkout = hasBranch2.status === 0 ? await git(["-C", path, "checkout", branch]) : actualBranch ? {
|
|
6640
|
+
status: 1,
|
|
6641
|
+
stdout: "",
|
|
6642
|
+
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`,
|
|
6643
|
+
signal: null,
|
|
6644
|
+
timedOut: false
|
|
6645
|
+
} : await git(["-C", path, "checkout", "-b", branch]);
|
|
6646
|
+
if (checkout.error || (checkout.status ?? 1) !== 0) {
|
|
6647
|
+
const detail = spawnDetail(checkout);
|
|
6648
|
+
return {
|
|
6649
|
+
error: `existing worktree ${path} is clean but could not recover branch ${branch} from ${actualLabel}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
|
|
6650
|
+
};
|
|
6651
|
+
}
|
|
6652
|
+
const recovered = await git(["-C", path, "branch", "--show-current"]);
|
|
6653
|
+
if (recovered.error || (recovered.status ?? 1) !== 0 || recovered.stdout.trim() !== branch) {
|
|
6654
|
+
const recoveredBranch = recovered.stdout.trim() || "unknown";
|
|
6655
|
+
const detail = spawnDetail(recovered);
|
|
6656
|
+
return {
|
|
6657
|
+
error: `existing worktree ${path} branch recovery ended on ${recoveredBranch}, expected ${branch}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
|
|
6658
|
+
};
|
|
6659
|
+
}
|
|
6217
6660
|
}
|
|
6218
6661
|
return { cwd: worktree.cwd };
|
|
6219
6662
|
}
|
|
@@ -6229,7 +6672,7 @@ async function ensureLocalWorktree(worktree, env) {
|
|
|
6229
6672
|
const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
6230
6673
|
const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
6231
6674
|
if (add.error || (add.status ?? 1) !== 0) {
|
|
6232
|
-
const detail = (add
|
|
6675
|
+
const detail = spawnDetail(add);
|
|
6233
6676
|
return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
|
|
6234
6677
|
}
|
|
6235
6678
|
return { cwd: worktree.cwd };
|
|
@@ -6275,7 +6718,7 @@ function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
|
6275
6718
|
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
6276
6719
|
if ((result.status ?? 1) !== 0) {
|
|
6277
6720
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
6278
|
-
throw new Error(
|
|
6721
|
+
throw new Error(remotePreflightFailureMessage(machine.id, detail));
|
|
6279
6722
|
}
|
|
6280
6723
|
}
|
|
6281
6724
|
async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
|
|
@@ -6619,55 +7062,6 @@ ${evidence.join(`
|
|
|
6619
7062
|
import { generateObject } from "ai";
|
|
6620
7063
|
import { z } from "zod";
|
|
6621
7064
|
|
|
6622
|
-
// src/lib/run-envelope.ts
|
|
6623
|
-
var ENVELOPE_EXCERPT_CHARS = 2048;
|
|
6624
|
-
var BLOCKED_STEP_ERROR_PREFIX = "blocked:";
|
|
6625
|
-
function boundedExcerpt(text, limit = ENVELOPE_EXCERPT_CHARS) {
|
|
6626
|
-
if (!text)
|
|
6627
|
-
return;
|
|
6628
|
-
const scrubbed = scrubSecrets(text);
|
|
6629
|
-
if (scrubbed.length <= limit * 2)
|
|
6630
|
-
return scrubbed;
|
|
6631
|
-
const omitted = scrubbed.length - limit * 2;
|
|
6632
|
-
return `${scrubbed.slice(0, limit)}
|
|
6633
|
-
[... ${omitted} chars omitted ...]
|
|
6634
|
-
${scrubbed.slice(-limit)}`;
|
|
6635
|
-
}
|
|
6636
|
-
function summarizeOutput(stdout, stderr) {
|
|
6637
|
-
return {
|
|
6638
|
-
stdoutBytes: stdout ? Buffer.byteLength(stdout, "utf8") : 0,
|
|
6639
|
-
stderrBytes: stderr ? Buffer.byteLength(stderr, "utf8") : 0,
|
|
6640
|
-
stdoutExcerpt: boundedExcerpt(stdout),
|
|
6641
|
-
stderrExcerpt: boundedExcerpt(stderr)
|
|
6642
|
-
};
|
|
6643
|
-
}
|
|
6644
|
-
function summarizeExecutorResult(result) {
|
|
6645
|
-
return {
|
|
6646
|
-
...summarizeOutput(result.stdout, result.stderr),
|
|
6647
|
-
status: result.status,
|
|
6648
|
-
exitCode: result.exitCode,
|
|
6649
|
-
durationMs: result.durationMs,
|
|
6650
|
-
error: boundedExcerpt(result.error)
|
|
6651
|
-
};
|
|
6652
|
-
}
|
|
6653
|
-
function isBlockedStepRun(step) {
|
|
6654
|
-
return step?.status === "skipped" && (step.error?.startsWith(BLOCKED_STEP_ERROR_PREFIX) ?? false);
|
|
6655
|
-
}
|
|
6656
|
-
function summarizeWorkflowStepRun(step) {
|
|
6657
|
-
return {
|
|
6658
|
-
...summarizeOutput(step.stdout, step.stderr),
|
|
6659
|
-
stepId: step.stepId,
|
|
6660
|
-
status: step.status,
|
|
6661
|
-
exitCode: step.exitCode,
|
|
6662
|
-
durationMs: step.durationMs,
|
|
6663
|
-
error: boundedExcerpt(step.error),
|
|
6664
|
-
blocked: isBlockedStepRun(step)
|
|
6665
|
-
};
|
|
6666
|
-
}
|
|
6667
|
-
function workflowRunEnvelope(run, steps) {
|
|
6668
|
-
return JSON.stringify({ workflowRun: run, steps: steps.map(summarizeWorkflowStepRun) }, null, 2);
|
|
6669
|
-
}
|
|
6670
|
-
|
|
6671
7065
|
// src/lib/goal/model-factory.ts
|
|
6672
7066
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
6673
7067
|
var DEFAULT_GOAL_MODEL = "openai/gpt-4o-mini";
|
|
@@ -7910,7 +8304,7 @@ async function tick(deps) {
|
|
|
7910
8304
|
// src/daemon/control.ts
|
|
7911
8305
|
import { existsSync as existsSync4, mkdirSync as mkdirSync6, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
7912
8306
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
7913
|
-
import { hostname } from "os";
|
|
8307
|
+
import { hostname as hostname2 } from "os";
|
|
7914
8308
|
import { dirname as dirname4, join as join7 } from "path";
|
|
7915
8309
|
|
|
7916
8310
|
// src/daemon/loop.ts
|
|
@@ -8063,7 +8457,7 @@ function daemonStatus(store, path = pidFilePath()) {
|
|
|
8063
8457
|
return {
|
|
8064
8458
|
...isDaemonRunning(path),
|
|
8065
8459
|
lease: store.getDaemonLease(),
|
|
8066
|
-
host:
|
|
8460
|
+
host: hostname2(),
|
|
8067
8461
|
loops: {
|
|
8068
8462
|
total: store.countLoops(),
|
|
8069
8463
|
active: store.countLoops("active"),
|
|
@@ -8197,7 +8591,7 @@ async function runDaemon(opts = {}) {
|
|
|
8197
8591
|
const ownStore = !opts.store;
|
|
8198
8592
|
const store = opts.store ?? new Store;
|
|
8199
8593
|
const leaseId = genId();
|
|
8200
|
-
const runnerId = `${
|
|
8594
|
+
const runnerId = `${hostname3()}:${process.pid}:${leaseId}`;
|
|
8201
8595
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
8202
8596
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
8203
8597
|
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
@@ -8206,7 +8600,7 @@ async function runDaemon(opts = {}) {
|
|
|
8206
8600
|
const lease = store.acquireDaemonLease({
|
|
8207
8601
|
id: leaseId,
|
|
8208
8602
|
pid: process.pid,
|
|
8209
|
-
hostname:
|
|
8603
|
+
hostname: hostname3(),
|
|
8210
8604
|
ttlMs: leaseTtlMs
|
|
8211
8605
|
});
|
|
8212
8606
|
if (!lease)
|
|
@@ -8241,7 +8635,7 @@ async function runDaemon(opts = {}) {
|
|
|
8241
8635
|
reacquired = store.acquireDaemonLease({
|
|
8242
8636
|
id: leaseId,
|
|
8243
8637
|
pid: process.pid,
|
|
8244
|
-
hostname:
|
|
8638
|
+
hostname: hostname3(),
|
|
8245
8639
|
ttlMs: leaseTtlMs
|
|
8246
8640
|
});
|
|
8247
8641
|
} catch {
|
|
@@ -8540,7 +8934,7 @@ function enableStartup(result) {
|
|
|
8540
8934
|
// package.json
|
|
8541
8935
|
var package_default = {
|
|
8542
8936
|
name: "@hasna/loops",
|
|
8543
|
-
version: "0.4.
|
|
8937
|
+
version: "0.4.22",
|
|
8544
8938
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8545
8939
|
type: "module",
|
|
8546
8940
|
main: "dist/index.js",
|
|
@@ -8650,7 +9044,6 @@ var package_default = {
|
|
|
8650
9044
|
bun: ">=1.0.0"
|
|
8651
9045
|
},
|
|
8652
9046
|
dependencies: {
|
|
8653
|
-
"@hasna/contracts": "^0.4.1",
|
|
8654
9047
|
"@hasna/events": "^0.1.9",
|
|
8655
9048
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
8656
9049
|
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
@@ -8660,7 +9053,8 @@ var package_default = {
|
|
|
8660
9053
|
zod: "4.4.3"
|
|
8661
9054
|
},
|
|
8662
9055
|
optionalDependencies: {
|
|
8663
|
-
"@hasna/machines": "0.0.49"
|
|
9056
|
+
"@hasna/machines": "0.0.49",
|
|
9057
|
+
"@hasna/contracts": "^0.4.2"
|
|
8664
9058
|
},
|
|
8665
9059
|
devDependencies: {
|
|
8666
9060
|
"@types/bun": "latest",
|