@hasna/loops 0.3.48 → 0.3.50
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/README.md +106 -0
- package/dist/cli/index.js +606 -36
- package/dist/daemon/index.js +101 -4
- package/dist/index.js +428 -13
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +100 -3
- package/dist/lib/templates.d.ts +24 -4
- package/dist/sdk/index.js +100 -3
- package/dist/types.d.ts +5 -0
- package/docs/USAGE.md +114 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -623,6 +623,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
623
623
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
624
624
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
625
625
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
626
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
627
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
626
628
|
function rowToLoop(row) {
|
|
627
629
|
return {
|
|
628
630
|
id: row.id,
|
|
@@ -1443,10 +1445,23 @@ class Store {
|
|
|
1443
1445
|
})();
|
|
1444
1446
|
}
|
|
1445
1447
|
listWorkflows(opts = {}) {
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1449
|
+
let rows;
|
|
1450
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1452
|
+
} else if (opts.status) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1454
|
+
} else if (opts.limit !== undefined) {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1456
|
+
} else {
|
|
1457
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1458
|
+
}
|
|
1448
1459
|
return rows.map(rowToWorkflow);
|
|
1449
1460
|
}
|
|
1461
|
+
countWorkflows(opts = {}) {
|
|
1462
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1463
|
+
return row?.count ?? 0;
|
|
1464
|
+
}
|
|
1450
1465
|
archiveWorkflow(idOrName) {
|
|
1451
1466
|
const workflow = this.requireWorkflow(idOrName);
|
|
1452
1467
|
const updated = nowIso();
|
|
@@ -1456,6 +1471,64 @@ class Store {
|
|
|
1456
1471
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1457
1472
|
return archived;
|
|
1458
1473
|
}
|
|
1474
|
+
generatedRouteArchiveContext(args) {
|
|
1475
|
+
if (!args.loopId || !args.workItemId)
|
|
1476
|
+
return;
|
|
1477
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1478
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1479
|
+
return;
|
|
1480
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1481
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1482
|
+
return;
|
|
1483
|
+
const loop = this.getLoop(args.loopId);
|
|
1484
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1485
|
+
return;
|
|
1486
|
+
const input = loop.target.input ?? {};
|
|
1487
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1490
|
+
return;
|
|
1491
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1492
|
+
return;
|
|
1493
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1494
|
+
if (!workflow)
|
|
1495
|
+
return;
|
|
1496
|
+
return { workflow, loop, workItem, invocation };
|
|
1497
|
+
}
|
|
1498
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1499
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1500
|
+
if (!context)
|
|
1501
|
+
return;
|
|
1502
|
+
const { workflow, loop, workItem } = context;
|
|
1503
|
+
if (!workflow || workflow.status !== "active")
|
|
1504
|
+
return;
|
|
1505
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1506
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1507
|
+
if (nonTerminal > 0)
|
|
1508
|
+
return;
|
|
1509
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1510
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1511
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1512
|
+
workflowId: args.workflowId,
|
|
1513
|
+
loopId: loop.id,
|
|
1514
|
+
workItemId: workItem.id,
|
|
1515
|
+
routeKey: workItem.routeKey,
|
|
1516
|
+
reason: "terminal generated one-shot route workflow"
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1521
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1522
|
+
if (!run)
|
|
1523
|
+
return;
|
|
1524
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1525
|
+
workflowId: run.workflowId,
|
|
1526
|
+
loopId: run.loopId,
|
|
1527
|
+
workItemId: run.workItemId,
|
|
1528
|
+
workflowRunId,
|
|
1529
|
+
updated
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1459
1532
|
createWorkflowInvocation(input) {
|
|
1460
1533
|
const now = nowIso();
|
|
1461
1534
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2241,6 +2314,7 @@ class Store {
|
|
|
2241
2314
|
if (changed) {
|
|
2242
2315
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2243
2316
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2317
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2244
2318
|
}
|
|
2245
2319
|
this.db.exec("COMMIT");
|
|
2246
2320
|
} catch (error) {
|
|
@@ -2268,6 +2342,7 @@ class Store {
|
|
|
2268
2342
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2269
2343
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2270
2344
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2345
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2271
2346
|
}
|
|
2272
2347
|
this.db.exec("COMMIT");
|
|
2273
2348
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2520,8 +2595,20 @@ class Store {
|
|
|
2520
2595
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2521
2596
|
if (opts.claimedBy && res.changes !== 1)
|
|
2522
2597
|
return run;
|
|
2523
|
-
if (res.changes === 1)
|
|
2598
|
+
if (res.changes === 1) {
|
|
2524
2599
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2600
|
+
const loop = this.getLoop(run.loopId);
|
|
2601
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2602
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2603
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2604
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2605
|
+
workflowId: loop.target.workflowId,
|
|
2606
|
+
loopId: loop.id,
|
|
2607
|
+
workItemId,
|
|
2608
|
+
updated: finishedAt
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2525
2612
|
return run;
|
|
2526
2613
|
}
|
|
2527
2614
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2642,6 +2729,7 @@ class Store {
|
|
|
2642
2729
|
loopRunId: row.id
|
|
2643
2730
|
});
|
|
2644
2731
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2732
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2645
2733
|
}
|
|
2646
2734
|
const loop = this.getLoop(row.loop_id);
|
|
2647
2735
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2649,6 +2737,15 @@ class Store {
|
|
|
2649
2737
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2650
2738
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2651
2739
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2740
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2741
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2742
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2743
|
+
workflowId: loop.target.workflowId,
|
|
2744
|
+
loopId: loop.id,
|
|
2745
|
+
workItemId,
|
|
2746
|
+
updated: finished
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2652
2749
|
}
|
|
2653
2750
|
this.db.exec("COMMIT");
|
|
2654
2751
|
} catch (error) {
|
|
@@ -2758,7 +2855,7 @@ class Store {
|
|
|
2758
2855
|
|
|
2759
2856
|
// src/cli/index.ts
|
|
2760
2857
|
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2761
|
-
import { closeSync, existsSync as existsSync5, mkdirSync as
|
|
2858
|
+
import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
2762
2859
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2763
2860
|
import { join as join6, resolve as resolve2 } from "path";
|
|
2764
2861
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -5811,7 +5908,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5811
5908
|
// package.json
|
|
5812
5909
|
var package_default = {
|
|
5813
5910
|
name: "@hasna/loops",
|
|
5814
|
-
version: "0.3.
|
|
5911
|
+
version: "0.3.50",
|
|
5815
5912
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5816
5913
|
type: "module",
|
|
5817
5914
|
main: "dist/index.js",
|
|
@@ -5901,7 +5998,7 @@ function packageVersion() {
|
|
|
5901
5998
|
|
|
5902
5999
|
// src/lib/templates.ts
|
|
5903
6000
|
import { execFileSync } from "child_process";
|
|
5904
|
-
import { existsSync as existsSync4 } from "fs";
|
|
6001
|
+
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
5905
6002
|
import { homedir as homedir3 } from "os";
|
|
5906
6003
|
import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
|
|
5907
6004
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
@@ -6102,6 +6199,11 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6102
6199
|
]
|
|
6103
6200
|
}
|
|
6104
6201
|
];
|
|
6202
|
+
var CUSTOM_TEMPLATE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
6203
|
+
var CUSTOM_TEMPLATE_VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
6204
|
+
var CUSTOM_TEMPLATE_VARIABLE_TYPES = new Set(["string", "number", "boolean", "json", "string[]"]);
|
|
6205
|
+
var CUSTOM_TEMPLATE_PLACEHOLDER = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
6206
|
+
var CUSTOM_TEMPLATE_EXACT_PLACEHOLDER = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/;
|
|
6105
6207
|
function compactJson(value) {
|
|
6106
6208
|
return JSON.stringify(value);
|
|
6107
6209
|
}
|
|
@@ -6379,11 +6481,310 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
6379
6481
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
6380
6482
|
];
|
|
6381
6483
|
}
|
|
6382
|
-
function
|
|
6383
|
-
|
|
6484
|
+
function assertRecord(value, label) {
|
|
6485
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
6486
|
+
throw new Error(`${label} must be an object`);
|
|
6487
|
+
}
|
|
6488
|
+
function assertTemplateString(value, label) {
|
|
6489
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
6490
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
6491
|
+
return value.trim();
|
|
6492
|
+
}
|
|
6493
|
+
function assertTemplateKind(value, label) {
|
|
6494
|
+
const kind = assertTemplateString(value, label);
|
|
6495
|
+
if (kind !== "workflow")
|
|
6496
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
6497
|
+
return kind;
|
|
6498
|
+
}
|
|
6499
|
+
function customLoopTemplatesDir() {
|
|
6500
|
+
return join5(dataDir(), "templates");
|
|
6501
|
+
}
|
|
6502
|
+
function ensureCustomLoopTemplatesDir() {
|
|
6503
|
+
const dir = customLoopTemplatesDir();
|
|
6504
|
+
mkdirSync6(dir, { recursive: true, mode: 448 });
|
|
6505
|
+
return dir;
|
|
6506
|
+
}
|
|
6507
|
+
function loopTemplatesDir() {
|
|
6508
|
+
return customLoopTemplatesDir();
|
|
6509
|
+
}
|
|
6510
|
+
function builtinLoopTemplates() {
|
|
6511
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
6512
|
+
}
|
|
6513
|
+
function getBuiltinLoopTemplate(id) {
|
|
6514
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
6515
|
+
}
|
|
6516
|
+
function builtinTemplateKeys() {
|
|
6517
|
+
const keys = new Set;
|
|
6518
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
6519
|
+
keys.add(template.id);
|
|
6520
|
+
keys.add(template.name);
|
|
6521
|
+
}
|
|
6522
|
+
return keys;
|
|
6523
|
+
}
|
|
6524
|
+
function validateCustomTemplateId(id, label) {
|
|
6525
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
6526
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
6527
|
+
}
|
|
6384
6528
|
}
|
|
6385
|
-
function
|
|
6386
|
-
|
|
6529
|
+
function validateCustomTemplateVariables(value, label) {
|
|
6530
|
+
if (value === undefined)
|
|
6531
|
+
return [];
|
|
6532
|
+
if (!Array.isArray(value))
|
|
6533
|
+
throw new Error(`${label} must be an array`);
|
|
6534
|
+
const seen = new Set;
|
|
6535
|
+
return value.map((entry, index) => {
|
|
6536
|
+
const entryLabel = `${label}[${index}]`;
|
|
6537
|
+
assertRecord(entry, entryLabel);
|
|
6538
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
6539
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
6540
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
6541
|
+
}
|
|
6542
|
+
if (seen.has(name))
|
|
6543
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
6544
|
+
seen.add(name);
|
|
6545
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
6546
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
6547
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
6548
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
6549
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
6550
|
+
}
|
|
6551
|
+
if (defaultValue === "danger-full-access") {
|
|
6552
|
+
throw new Error(`${entryLabel}.default cannot be danger-full-access in a custom template`);
|
|
6553
|
+
}
|
|
6554
|
+
return {
|
|
6555
|
+
name,
|
|
6556
|
+
description,
|
|
6557
|
+
required: entry.required === undefined ? undefined : Boolean(entry.required),
|
|
6558
|
+
default: defaultValue,
|
|
6559
|
+
type
|
|
6560
|
+
};
|
|
6561
|
+
});
|
|
6562
|
+
}
|
|
6563
|
+
function assertNoDangerFullAccess(value, label) {
|
|
6564
|
+
if (!value || typeof value !== "object")
|
|
6565
|
+
return;
|
|
6566
|
+
if (Array.isArray(value)) {
|
|
6567
|
+
value.forEach((entry, index) => assertNoDangerFullAccess(entry, `${label}[${index}]`));
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
6571
|
+
if (key === "sandbox" && entry === "danger-full-access") {
|
|
6572
|
+
throw new Error(`${label}.${key} uses danger-full-access; custom templates must not request danger-full-access`);
|
|
6573
|
+
}
|
|
6574
|
+
assertNoDangerFullAccess(entry, `${label}.${key}`);
|
|
6575
|
+
}
|
|
6576
|
+
}
|
|
6577
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
6578
|
+
assertRecord(value, sourcePath);
|
|
6579
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
6580
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
6581
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
6582
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
6583
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
6584
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
6585
|
+
if (value.workflow === undefined)
|
|
6586
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
6587
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
6588
|
+
assertNoDangerFullAccess(value.workflow, `${sourcePath}.workflow`);
|
|
6589
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
6590
|
+
}
|
|
6591
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
6592
|
+
return {
|
|
6593
|
+
id: definition.id,
|
|
6594
|
+
name: definition.name,
|
|
6595
|
+
description: definition.description,
|
|
6596
|
+
kind: definition.kind,
|
|
6597
|
+
variables: structuredClone(definition.variables),
|
|
6598
|
+
source: "custom",
|
|
6599
|
+
sourcePath
|
|
6600
|
+
};
|
|
6601
|
+
}
|
|
6602
|
+
function readCustomTemplateFile(file) {
|
|
6603
|
+
let parsed;
|
|
6604
|
+
try {
|
|
6605
|
+
parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
6606
|
+
} catch (error) {
|
|
6607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6608
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
6609
|
+
}
|
|
6610
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
6611
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
6612
|
+
}
|
|
6613
|
+
function assertNoTemplateCollisions(entries) {
|
|
6614
|
+
const builtinKeys = builtinTemplateKeys();
|
|
6615
|
+
const seen = new Map;
|
|
6616
|
+
for (const entry of entries) {
|
|
6617
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
6618
|
+
if (builtinKeys.has(key)) {
|
|
6619
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
6620
|
+
}
|
|
6621
|
+
const existing = seen.get(key);
|
|
6622
|
+
if (existing) {
|
|
6623
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
6624
|
+
}
|
|
6625
|
+
seen.set(key, entry.definition.id);
|
|
6626
|
+
}
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
function loadCustomLoopTemplates() {
|
|
6630
|
+
const dir = customLoopTemplatesDir();
|
|
6631
|
+
if (!existsSync4(dir))
|
|
6632
|
+
return [];
|
|
6633
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
6634
|
+
const file = join5(dir, entry.name);
|
|
6635
|
+
if (entry.isSymbolicLink())
|
|
6636
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
6637
|
+
if (!entry.isFile())
|
|
6638
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
6639
|
+
return readCustomTemplateFile(file);
|
|
6640
|
+
});
|
|
6641
|
+
assertNoTemplateCollisions(entries);
|
|
6642
|
+
return entries;
|
|
6643
|
+
}
|
|
6644
|
+
function getCustomLoopTemplate(id) {
|
|
6645
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
6646
|
+
}
|
|
6647
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
6648
|
+
const normalizedType = type ?? "string";
|
|
6649
|
+
if (normalizedType === "string")
|
|
6650
|
+
return String(raw);
|
|
6651
|
+
if (normalizedType === "number") {
|
|
6652
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
6653
|
+
if (!Number.isFinite(value))
|
|
6654
|
+
throw new Error(`${label} must be a finite number`);
|
|
6655
|
+
return value;
|
|
6656
|
+
}
|
|
6657
|
+
if (normalizedType === "boolean") {
|
|
6658
|
+
if (typeof raw === "boolean")
|
|
6659
|
+
return raw;
|
|
6660
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
6661
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
6662
|
+
return true;
|
|
6663
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
6664
|
+
return false;
|
|
6665
|
+
throw new Error(`${label} must be a boolean`);
|
|
6666
|
+
}
|
|
6667
|
+
if (normalizedType === "json") {
|
|
6668
|
+
if (typeof raw !== "string")
|
|
6669
|
+
return raw;
|
|
6670
|
+
try {
|
|
6671
|
+
return JSON.parse(raw);
|
|
6672
|
+
} catch (error) {
|
|
6673
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6674
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
if (normalizedType === "string[]") {
|
|
6678
|
+
if (Array.isArray(raw))
|
|
6679
|
+
return raw.map((entry) => String(entry));
|
|
6680
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
6681
|
+
}
|
|
6682
|
+
return String(raw);
|
|
6683
|
+
}
|
|
6684
|
+
function customTemplateValues(definition, values) {
|
|
6685
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
6686
|
+
for (const [name, value] of Object.entries(values)) {
|
|
6687
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
6688
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
6689
|
+
}
|
|
6690
|
+
}
|
|
6691
|
+
const rendered = {};
|
|
6692
|
+
for (const variable of definition.variables) {
|
|
6693
|
+
const raw = values[variable.name] ?? variable.default;
|
|
6694
|
+
if (raw === undefined || raw === "") {
|
|
6695
|
+
if (variable.required)
|
|
6696
|
+
throw new Error(`${variable.name} is required`);
|
|
6697
|
+
continue;
|
|
6698
|
+
}
|
|
6699
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
6700
|
+
}
|
|
6701
|
+
return rendered;
|
|
6702
|
+
}
|
|
6703
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
6704
|
+
if (!(name in values))
|
|
6705
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
6706
|
+
return values[name];
|
|
6707
|
+
}
|
|
6708
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
6709
|
+
if (typeof value === "string")
|
|
6710
|
+
return value;
|
|
6711
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
6712
|
+
return String(value);
|
|
6713
|
+
if (value === null)
|
|
6714
|
+
return "null";
|
|
6715
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
6716
|
+
return JSON.stringify(value);
|
|
6717
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
6718
|
+
}
|
|
6719
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
6720
|
+
if (typeof value === "string") {
|
|
6721
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
6722
|
+
if (exact)
|
|
6723
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
6724
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
6725
|
+
}
|
|
6726
|
+
if (Array.isArray(value))
|
|
6727
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
6728
|
+
if (!value || typeof value !== "object")
|
|
6729
|
+
return value;
|
|
6730
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
6731
|
+
}
|
|
6732
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
6733
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
6734
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
6735
|
+
assertNoDangerFullAccess(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
6736
|
+
return workflowBodyFromJson(rendered);
|
|
6737
|
+
}
|
|
6738
|
+
function validateCustomLoopTemplateFile(file) {
|
|
6739
|
+
const entry = readCustomTemplateFile(resolve(file));
|
|
6740
|
+
assertNoTemplateCollisions([entry]);
|
|
6741
|
+
return structuredClone(entry.summary);
|
|
6742
|
+
}
|
|
6743
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
6744
|
+
const source = resolve(file);
|
|
6745
|
+
const entry = readCustomTemplateFile(source);
|
|
6746
|
+
assertNoTemplateCollisions([entry]);
|
|
6747
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
6748
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
6749
|
+
const replaced = existsSync4(destination);
|
|
6750
|
+
if (replaced) {
|
|
6751
|
+
const stat = lstatSync(destination);
|
|
6752
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
6753
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
6754
|
+
if (!opts.replace)
|
|
6755
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
6756
|
+
}
|
|
6757
|
+
writeFileSync4(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
6758
|
+
`, { mode: 384 });
|
|
6759
|
+
const imported = readCustomTemplateFile(destination);
|
|
6760
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
6761
|
+
}
|
|
6762
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
6763
|
+
return {
|
|
6764
|
+
ok: true,
|
|
6765
|
+
templates: listLoopTemplates(opts),
|
|
6766
|
+
customDir: customLoopTemplatesDir()
|
|
6767
|
+
};
|
|
6768
|
+
}
|
|
6769
|
+
function listLoopTemplates(opts = {}) {
|
|
6770
|
+
const source = opts.source ?? "all";
|
|
6771
|
+
const templates = [];
|
|
6772
|
+
if (source !== "custom")
|
|
6773
|
+
templates.push(...builtinLoopTemplates());
|
|
6774
|
+
if (source !== "builtin")
|
|
6775
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
6776
|
+
return templates;
|
|
6777
|
+
}
|
|
6778
|
+
function getLoopTemplate(id, opts = {}) {
|
|
6779
|
+
const source = opts.source ?? "all";
|
|
6780
|
+
if (source !== "custom") {
|
|
6781
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6782
|
+
if (builtin)
|
|
6783
|
+
return builtin;
|
|
6784
|
+
if (source === "builtin")
|
|
6785
|
+
return;
|
|
6786
|
+
}
|
|
6787
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
6387
6788
|
}
|
|
6388
6789
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
6389
6790
|
if (!input.taskId?.trim())
|
|
@@ -6728,7 +7129,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
6728
7129
|
]
|
|
6729
7130
|
};
|
|
6730
7131
|
}
|
|
6731
|
-
function
|
|
7132
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
6732
7133
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
6733
7134
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
6734
7135
|
}
|
|
@@ -6842,6 +7243,20 @@ function booleanVar(value) {
|
|
|
6842
7243
|
function accountPoolVar(value, tool) {
|
|
6843
7244
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
6844
7245
|
}
|
|
7246
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
7247
|
+
const source = opts.source ?? "all";
|
|
7248
|
+
if (source !== "custom") {
|
|
7249
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
7250
|
+
if (builtin)
|
|
7251
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
7252
|
+
if (source === "builtin")
|
|
7253
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
7254
|
+
}
|
|
7255
|
+
const custom = getCustomLoopTemplate(id);
|
|
7256
|
+
if (custom)
|
|
7257
|
+
return renderCustomLoopTemplate(custom, values);
|
|
7258
|
+
throw new Error(`unknown template: ${id}`);
|
|
7259
|
+
}
|
|
6845
7260
|
|
|
6846
7261
|
// src/cli/index.ts
|
|
6847
7262
|
var program = new Command;
|
|
@@ -6961,6 +7376,14 @@ function positiveInteger(raw, label) {
|
|
|
6961
7376
|
throw new Error(`${label} must be a positive integer`);
|
|
6962
7377
|
return value;
|
|
6963
7378
|
}
|
|
7379
|
+
function nonNegativeInteger(raw, label) {
|
|
7380
|
+
if (raw === undefined)
|
|
7381
|
+
return;
|
|
7382
|
+
const value = Number(raw);
|
|
7383
|
+
if (!Number.isInteger(value) || value < 0)
|
|
7384
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
7385
|
+
return value;
|
|
7386
|
+
}
|
|
6964
7387
|
function positiveDuration(raw, label) {
|
|
6965
7388
|
if (raw === undefined)
|
|
6966
7389
|
return;
|
|
@@ -7125,7 +7548,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
7125
7548
|
return {
|
|
7126
7549
|
ok: result.status === 0,
|
|
7127
7550
|
status: result.status,
|
|
7128
|
-
stdout:
|
|
7551
|
+
stdout: readFileSync3(stdoutPath, "utf8"),
|
|
7129
7552
|
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
7130
7553
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
7131
7554
|
};
|
|
@@ -7147,11 +7570,11 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
7147
7570
|
function backupLoopsDatabase(reason) {
|
|
7148
7571
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
7149
7572
|
const backupDir = join6(dataDir(), "backups");
|
|
7150
|
-
|
|
7573
|
+
mkdirSync7(backupDir, { recursive: true, mode: 448 });
|
|
7151
7574
|
const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
7152
7575
|
const db = new Database2(dbPath(), { readonly: true });
|
|
7153
7576
|
try {
|
|
7154
|
-
|
|
7577
|
+
writeFileSync5(backupPath, db.serialize(), { mode: 384 });
|
|
7155
7578
|
} finally {
|
|
7156
7579
|
db.close();
|
|
7157
7580
|
}
|
|
@@ -7169,7 +7592,7 @@ function readRouteCursors() {
|
|
|
7169
7592
|
if (!existsSync5(path))
|
|
7170
7593
|
return {};
|
|
7171
7594
|
try {
|
|
7172
|
-
const parsed = JSON.parse(
|
|
7595
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
7173
7596
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
7174
7597
|
} catch {
|
|
7175
7598
|
return {};
|
|
@@ -7180,15 +7603,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
7180
7603
|
return;
|
|
7181
7604
|
const cursors = readRouteCursors();
|
|
7182
7605
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
7183
|
-
|
|
7606
|
+
writeFileSync5(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
7184
7607
|
}
|
|
7185
7608
|
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
7186
7609
|
if (!evidenceDir)
|
|
7187
7610
|
return;
|
|
7188
|
-
|
|
7611
|
+
mkdirSync7(evidenceDir, { recursive: true, mode: 448 });
|
|
7189
7612
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
7190
7613
|
const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
7191
|
-
|
|
7614
|
+
writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
7192
7615
|
return evidencePath;
|
|
7193
7616
|
}
|
|
7194
7617
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -7442,6 +7865,23 @@ function taskRouteEligibility(data, metadata) {
|
|
|
7442
7865
|
}
|
|
7443
7866
|
return { eligible: true, tags };
|
|
7444
7867
|
}
|
|
7868
|
+
function skippedDrainTask(task, event, reason) {
|
|
7869
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
|
|
7870
|
+
return {
|
|
7871
|
+
kind: "skipped",
|
|
7872
|
+
value: {
|
|
7873
|
+
skipped: true,
|
|
7874
|
+
reason,
|
|
7875
|
+
taskId,
|
|
7876
|
+
event,
|
|
7877
|
+
routeError: true
|
|
7878
|
+
},
|
|
7879
|
+
human: `skipped task ${taskId}: ${reason}`
|
|
7880
|
+
};
|
|
7881
|
+
}
|
|
7882
|
+
function isSkippableDrainRouteError(message) {
|
|
7883
|
+
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
7884
|
+
}
|
|
7445
7885
|
function generatedRouteSandboxPreflight(workflow) {
|
|
7446
7886
|
const checks = [];
|
|
7447
7887
|
for (const step of workflow.steps) {
|
|
@@ -7564,7 +8004,7 @@ function routeThrottleDryRunPreview(args) {
|
|
|
7564
8004
|
};
|
|
7565
8005
|
}
|
|
7566
8006
|
async function readEventEnvelopeInput(opts = {}) {
|
|
7567
|
-
const raw = opts.eventJson ?? (opts.eventFile ?
|
|
8007
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync3(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
7568
8008
|
const event = JSON.parse(raw);
|
|
7569
8009
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
7570
8010
|
throw new Error("event JSON must be an object");
|
|
@@ -8401,8 +8841,17 @@ function drainTodosTaskRoutes(opts) {
|
|
|
8401
8841
|
for (const task of candidates) {
|
|
8402
8842
|
if (created >= maxDispatch)
|
|
8403
8843
|
break;
|
|
8404
|
-
|
|
8405
|
-
|
|
8844
|
+
let event;
|
|
8845
|
+
let result;
|
|
8846
|
+
try {
|
|
8847
|
+
event = taskDrainEvent(task);
|
|
8848
|
+
result = routeTodosTaskEvent(event, opts);
|
|
8849
|
+
} catch (error) {
|
|
8850
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8851
|
+
if (!isSkippableDrainRouteError(message))
|
|
8852
|
+
throw error;
|
|
8853
|
+
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
|
|
8854
|
+
}
|
|
8406
8855
|
results.push(result);
|
|
8407
8856
|
if (result.kind === "created" && !opts.dryRun)
|
|
8408
8857
|
created += 1;
|
|
@@ -8463,30 +8912,108 @@ function drainTodosTaskRoutes(opts) {
|
|
|
8463
8912
|
} : { ...report, evidencePath };
|
|
8464
8913
|
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
8465
8914
|
}
|
|
8466
|
-
|
|
8467
|
-
const
|
|
8915
|
+
function formatTemplateVariable(template, name) {
|
|
8916
|
+
const variable = template.variables.find((entry) => entry.name === name);
|
|
8917
|
+
const placeholder = variable?.default ? variable.default : `<${name}>`;
|
|
8918
|
+
return ` --var ${name}=${placeholder}`;
|
|
8919
|
+
}
|
|
8920
|
+
function templateSource(value) {
|
|
8921
|
+
const source = value ?? "all";
|
|
8922
|
+
if (source === "all" || source === "builtin" || source === "custom")
|
|
8923
|
+
return source;
|
|
8924
|
+
throw new Error("--source must be all, builtin, or custom");
|
|
8925
|
+
}
|
|
8926
|
+
function addTemplateSourceOption(command, defaultValue = "all") {
|
|
8927
|
+
return command.option("--source <source>", "template source: all, builtin, or custom", defaultValue);
|
|
8928
|
+
}
|
|
8929
|
+
function printTemplateDetails(template) {
|
|
8930
|
+
console.log(`${template.id} (${template.kind})`);
|
|
8931
|
+
console.log(template.name);
|
|
8932
|
+
if (template.source)
|
|
8933
|
+
console.log(`source: ${template.source}${template.sourcePath ? ` (${template.sourcePath})` : ""}`);
|
|
8934
|
+
console.log("");
|
|
8935
|
+
console.log(template.description);
|
|
8936
|
+
console.log("");
|
|
8937
|
+
console.log("Variables:");
|
|
8938
|
+
const nameWidth = Math.max(...template.variables.map((variable) => variable.name.length), 4);
|
|
8939
|
+
for (const variable of template.variables) {
|
|
8940
|
+
const required = variable.required ? "required" : "optional";
|
|
8941
|
+
const defaultValue = variable.default ? ` default=${variable.default}` : "";
|
|
8942
|
+
const description = variable.description ? ` ${variable.description}` : "";
|
|
8943
|
+
console.log(` ${variable.name.padEnd(nameWidth)} ${required}${defaultValue}${description}`);
|
|
8944
|
+
}
|
|
8945
|
+
const requiredVariables = template.variables.filter((variable) => variable.required).map((variable) => variable.name);
|
|
8946
|
+
const hintVariables = requiredVariables.length ? requiredVariables : template.variables.slice(0, 2).map((variable) => variable.name);
|
|
8947
|
+
const renderArgs = hintVariables.map((name) => formatTemplateVariable(template, name));
|
|
8948
|
+
const renderHint = renderArgs.length ? ` \\
|
|
8949
|
+
${renderArgs.join(" \\\n")}` : "";
|
|
8950
|
+
console.log("");
|
|
8951
|
+
console.log("Usage:");
|
|
8952
|
+
console.log(` loops templates render ${template.id}${renderHint}`);
|
|
8953
|
+
if (template.kind === "workflow")
|
|
8954
|
+
console.log(` loops templates create-workflow ${template.id}${renderHint}`);
|
|
8955
|
+
}
|
|
8956
|
+
function workflowStatusFromOpts(status, all) {
|
|
8957
|
+
if (all) {
|
|
8958
|
+
if (status && status !== "active")
|
|
8959
|
+
throw new Error("use either --all or --status, not both");
|
|
8960
|
+
return;
|
|
8961
|
+
}
|
|
8962
|
+
const value = status ?? "active";
|
|
8963
|
+
if (value === "all")
|
|
8964
|
+
return;
|
|
8965
|
+
if (value === "active" || value === "archived")
|
|
8966
|
+
return value;
|
|
8967
|
+
throw new Error("--status must be active, archived, or all");
|
|
8968
|
+
}
|
|
8969
|
+
function printWorkflowListWarning(args) {
|
|
8970
|
+
if (args.shown + args.offset >= args.total && args.offset === 0)
|
|
8971
|
+
return;
|
|
8972
|
+
const scope = args.status ?? "all";
|
|
8973
|
+
const nextOffset = args.offset + args.shown;
|
|
8974
|
+
const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
|
|
8975
|
+
console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
|
|
8976
|
+
}
|
|
8977
|
+
templates.command("list").alias("ls").description("list OpenLoops templates").option("--source <source>", "template source: all, builtin, or custom", "all").action((opts) => {
|
|
8978
|
+
const values = listLoopTemplates({ source: templateSource(opts.source) });
|
|
8468
8979
|
if (isJson())
|
|
8469
8980
|
print(values);
|
|
8470
8981
|
else {
|
|
8471
8982
|
for (const template of values) {
|
|
8472
|
-
console.log(`${template.id} ${template.kind} ${template.description}`);
|
|
8983
|
+
console.log(`${template.id} ${template.source ?? "builtin"} ${template.kind} ${template.description}`);
|
|
8473
8984
|
}
|
|
8474
8985
|
}
|
|
8475
8986
|
});
|
|
8476
|
-
templates.command("
|
|
8477
|
-
const
|
|
8987
|
+
templates.command("import <file>").alias("add").description("import a custom workflow template JSON file into the local registry").option("--replace", "replace an existing custom template with the same id").action((file, opts) => {
|
|
8988
|
+
const result = importCustomLoopTemplate(file, { replace: Boolean(opts.replace) });
|
|
8989
|
+
print(result, `${result.replaced ? "replaced" : "imported"} custom template ${result.template.id} -> ${result.path}`);
|
|
8990
|
+
});
|
|
8991
|
+
templates.command("validate [file]").description("validate a custom template JSON file or the local template registry").option("--source <source>", "template source to validate when no file is given: all, builtin, or custom", "all").action((file, opts) => {
|
|
8992
|
+
if (file) {
|
|
8993
|
+
const template = validateCustomLoopTemplateFile(file);
|
|
8994
|
+
print({ ok: true, template, customDir: loopTemplatesDir() }, `valid custom template ${template.id}`);
|
|
8995
|
+
return;
|
|
8996
|
+
}
|
|
8997
|
+
const result = validateLoopTemplateRegistry({ source: templateSource(opts.source) });
|
|
8998
|
+
print(result, `valid template registry (${result.templates.length} templates) customDir=${result.customDir}`);
|
|
8999
|
+
});
|
|
9000
|
+
addTemplateSourceOption(templates.command("show <id>").description("show a template")).action((id, opts) => {
|
|
9001
|
+
const template = getLoopTemplate(id, { source: templateSource(opts.source) });
|
|
8478
9002
|
if (!template)
|
|
8479
9003
|
throw new Error(`template not found: ${id}`);
|
|
8480
|
-
|
|
9004
|
+
if (isJson())
|
|
9005
|
+
print(template);
|
|
9006
|
+
else
|
|
9007
|
+
printTemplateDetails(template);
|
|
8481
9008
|
});
|
|
8482
|
-
templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
|
|
8483
|
-
const workflow = renderLoopTemplate(id, parseVars(opts.var));
|
|
9009
|
+
addTemplateSourceOption(templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, [])).action((id, opts) => {
|
|
9010
|
+
const workflow = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8484
9011
|
print(workflow, JSON.stringify(workflow, null, 2));
|
|
8485
9012
|
});
|
|
8486
|
-
templates.command("create-workflow <id>").description("render and store a template as a workflow").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
|
|
9013
|
+
addTemplateSourceOption(templates.command("create-workflow <id>").description("render and store a template as a workflow").option("--var <key=value>", "template variable; may be repeated", collectValues, [])).action((id, opts) => {
|
|
8487
9014
|
const store = new Store;
|
|
8488
9015
|
try {
|
|
8489
|
-
const body = renderLoopTemplate(id, parseVars(opts.var));
|
|
9016
|
+
const body = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8490
9017
|
const workflow = store.createWorkflow(body);
|
|
8491
9018
|
print(publicWorkflow(workflow), `created workflow ${workflow.id} (${workflow.name}) steps=${workflow.steps.length}`);
|
|
8492
9019
|
} finally {
|
|
@@ -8658,7 +9185,7 @@ machines.command("show <id>").description("resolve a machine assignment").action
|
|
|
8658
9185
|
print(resolveLoopMachine(id));
|
|
8659
9186
|
});
|
|
8660
9187
|
workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
|
|
8661
|
-
const body = workflowBodyFromJson(JSON.parse(
|
|
9188
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8662
9189
|
const workflow = workflowSpecForPreflight(body);
|
|
8663
9190
|
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
8664
9191
|
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
@@ -8666,7 +9193,7 @@ workflows.command("validate <file>").description("validate a workflow JSON file
|
|
|
8666
9193
|
workflows.command("create <file>").description("validate and store a workflow JSON file").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables before storing").action((file, opts) => {
|
|
8667
9194
|
const store = new Store;
|
|
8668
9195
|
try {
|
|
8669
|
-
const body = workflowBodyFromJson(JSON.parse(
|
|
9196
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8670
9197
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(body, "creation-preflight"), { name: body.name, type: "workflow" }, {}) : undefined;
|
|
8671
9198
|
const workflow = store.createWorkflow(body);
|
|
8672
9199
|
if (preflight !== undefined)
|
|
@@ -8677,10 +9204,14 @@ workflows.command("create <file>").description("validate and store a workflow JS
|
|
|
8677
9204
|
store.close();
|
|
8678
9205
|
}
|
|
8679
9206
|
});
|
|
8680
|
-
workflows.command("list").alias("ls").option("--status <status>", "active or
|
|
9207
|
+
workflows.command("list").alias("ls").option("--status <status>", "active, archived, or all", "active").option("--all", "include active and archived workflows").option("--limit <n>", "maximum rows to print; omitted means all matching workflows").option("--offset <n>", "number of matching rows to skip before printing", "0").action((opts) => {
|
|
8681
9208
|
const store = new Store;
|
|
8682
9209
|
try {
|
|
8683
|
-
const
|
|
9210
|
+
const status = workflowStatusFromOpts(opts.status, opts.all);
|
|
9211
|
+
const limit = positiveInteger(opts.limit, "--limit");
|
|
9212
|
+
const offset = nonNegativeInteger(opts.offset, "--offset") ?? 0;
|
|
9213
|
+
const workflowsList = store.listWorkflows({ status, limit, offset });
|
|
9214
|
+
const total = store.countWorkflows({ status });
|
|
8684
9215
|
if (isJson())
|
|
8685
9216
|
print(workflowsList.map(publicWorkflow));
|
|
8686
9217
|
else {
|
|
@@ -8688,6 +9219,8 @@ workflows.command("list").alias("ls").option("--status <status>", "active or arc
|
|
|
8688
9219
|
console.log(`${workflow.id} ${workflow.status.padEnd(8)} steps=${workflow.steps.length} ${workflow.name}`);
|
|
8689
9220
|
}
|
|
8690
9221
|
}
|
|
9222
|
+
if (limit !== undefined || offset > 0)
|
|
9223
|
+
printWorkflowListWarning({ shown: workflowsList.length, total, status, offset, limit });
|
|
8691
9224
|
} finally {
|
|
8692
9225
|
store.close();
|
|
8693
9226
|
}
|
|
@@ -9294,6 +9827,43 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
9294
9827
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
9295
9828
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
9296
9829
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|
|
9830
|
+
program.command("rename <idOrName> <newName>").description("rename a loop without changing its id, schedule, runs, or history").action((idOrName, newName) => {
|
|
9831
|
+
const store = new Store;
|
|
9832
|
+
try {
|
|
9833
|
+
const loop = store.requireLoop(idOrName);
|
|
9834
|
+
const oldName = loop.name;
|
|
9835
|
+
const trimmed = String(newName).trim();
|
|
9836
|
+
if (!trimmed)
|
|
9837
|
+
throw new Error("loop name must not be empty");
|
|
9838
|
+
const existing = store.findLoopByName(trimmed);
|
|
9839
|
+
if (existing && existing.id !== loop.id) {
|
|
9840
|
+
throw new Error(`loop name already exists: ${trimmed} (${existing.id})`);
|
|
9841
|
+
}
|
|
9842
|
+
if (trimmed === oldName) {
|
|
9843
|
+
print({
|
|
9844
|
+
changed: false,
|
|
9845
|
+
id: loop.id,
|
|
9846
|
+
oldName,
|
|
9847
|
+
newName: oldName,
|
|
9848
|
+
loop: publicLoop(loop)
|
|
9849
|
+
}, `${loop.id} unchanged (${oldName})`);
|
|
9850
|
+
return;
|
|
9851
|
+
}
|
|
9852
|
+
const backupPath = backupLoopsDatabase("rename");
|
|
9853
|
+
const renamed = store.renameLoop(loop.id, trimmed);
|
|
9854
|
+
print({
|
|
9855
|
+
changed: true,
|
|
9856
|
+
id: renamed.id,
|
|
9857
|
+
oldName,
|
|
9858
|
+
newName: renamed.name,
|
|
9859
|
+
backupPath,
|
|
9860
|
+
loop: publicLoop(renamed)
|
|
9861
|
+
}, `${renamed.id} renamed ${oldName} -> ${renamed.name}
|
|
9862
|
+
backup=${backupPath}`);
|
|
9863
|
+
} finally {
|
|
9864
|
+
store.close();
|
|
9865
|
+
}
|
|
9866
|
+
});
|
|
9297
9867
|
function updateStatus(idOrName, status) {
|
|
9298
9868
|
const store = new Store;
|
|
9299
9869
|
try {
|
|
@@ -9432,7 +10002,7 @@ daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) =>
|
|
|
9432
10002
|
console.log("");
|
|
9433
10003
|
return;
|
|
9434
10004
|
}
|
|
9435
|
-
const lines =
|
|
10005
|
+
const lines = readFileSync3(path, "utf8").trimEnd().split(`
|
|
9436
10006
|
`);
|
|
9437
10007
|
console.log(lines.slice(-Number(opts.lines)).join(`
|
|
9438
10008
|
`));
|