@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/index.js
CHANGED
|
@@ -621,6 +621,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
621
621
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
622
622
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
623
623
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
624
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
625
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
624
626
|
function rowToLoop(row) {
|
|
625
627
|
return {
|
|
626
628
|
id: row.id,
|
|
@@ -1441,10 +1443,23 @@ class Store {
|
|
|
1441
1443
|
})();
|
|
1442
1444
|
}
|
|
1443
1445
|
listWorkflows(opts = {}) {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1447
|
+
let rows;
|
|
1448
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1449
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1450
|
+
} else if (opts.status) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1452
|
+
} else if (opts.limit !== undefined) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1454
|
+
} else {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1456
|
+
}
|
|
1446
1457
|
return rows.map(rowToWorkflow);
|
|
1447
1458
|
}
|
|
1459
|
+
countWorkflows(opts = {}) {
|
|
1460
|
+
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();
|
|
1461
|
+
return row?.count ?? 0;
|
|
1462
|
+
}
|
|
1448
1463
|
archiveWorkflow(idOrName) {
|
|
1449
1464
|
const workflow = this.requireWorkflow(idOrName);
|
|
1450
1465
|
const updated = nowIso();
|
|
@@ -1454,6 +1469,64 @@ class Store {
|
|
|
1454
1469
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1455
1470
|
return archived;
|
|
1456
1471
|
}
|
|
1472
|
+
generatedRouteArchiveContext(args) {
|
|
1473
|
+
if (!args.loopId || !args.workItemId)
|
|
1474
|
+
return;
|
|
1475
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1476
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1477
|
+
return;
|
|
1478
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1479
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1480
|
+
return;
|
|
1481
|
+
const loop = this.getLoop(args.loopId);
|
|
1482
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1483
|
+
return;
|
|
1484
|
+
const input = loop.target.input ?? {};
|
|
1485
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1486
|
+
return;
|
|
1487
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1490
|
+
return;
|
|
1491
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1492
|
+
if (!workflow)
|
|
1493
|
+
return;
|
|
1494
|
+
return { workflow, loop, workItem, invocation };
|
|
1495
|
+
}
|
|
1496
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1497
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1498
|
+
if (!context)
|
|
1499
|
+
return;
|
|
1500
|
+
const { workflow, loop, workItem } = context;
|
|
1501
|
+
if (!workflow || workflow.status !== "active")
|
|
1502
|
+
return;
|
|
1503
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1504
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1505
|
+
if (nonTerminal > 0)
|
|
1506
|
+
return;
|
|
1507
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1508
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1509
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1510
|
+
workflowId: args.workflowId,
|
|
1511
|
+
loopId: loop.id,
|
|
1512
|
+
workItemId: workItem.id,
|
|
1513
|
+
routeKey: workItem.routeKey,
|
|
1514
|
+
reason: "terminal generated one-shot route workflow"
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1519
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1520
|
+
if (!run)
|
|
1521
|
+
return;
|
|
1522
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1523
|
+
workflowId: run.workflowId,
|
|
1524
|
+
loopId: run.loopId,
|
|
1525
|
+
workItemId: run.workItemId,
|
|
1526
|
+
workflowRunId,
|
|
1527
|
+
updated
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1457
1530
|
createWorkflowInvocation(input) {
|
|
1458
1531
|
const now = nowIso();
|
|
1459
1532
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2239,6 +2312,7 @@ class Store {
|
|
|
2239
2312
|
if (changed) {
|
|
2240
2313
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2241
2314
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2315
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2242
2316
|
}
|
|
2243
2317
|
this.db.exec("COMMIT");
|
|
2244
2318
|
} catch (error) {
|
|
@@ -2266,6 +2340,7 @@ class Store {
|
|
|
2266
2340
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2267
2341
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2268
2342
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2343
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2269
2344
|
}
|
|
2270
2345
|
this.db.exec("COMMIT");
|
|
2271
2346
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2518,8 +2593,20 @@ class Store {
|
|
|
2518
2593
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2519
2594
|
if (opts.claimedBy && res.changes !== 1)
|
|
2520
2595
|
return run;
|
|
2521
|
-
if (res.changes === 1)
|
|
2596
|
+
if (res.changes === 1) {
|
|
2522
2597
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2598
|
+
const loop = this.getLoop(run.loopId);
|
|
2599
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2600
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2601
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2602
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2603
|
+
workflowId: loop.target.workflowId,
|
|
2604
|
+
loopId: loop.id,
|
|
2605
|
+
workItemId,
|
|
2606
|
+
updated: finishedAt
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2523
2610
|
return run;
|
|
2524
2611
|
}
|
|
2525
2612
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2640,6 +2727,7 @@ class Store {
|
|
|
2640
2727
|
loopRunId: row.id
|
|
2641
2728
|
});
|
|
2642
2729
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2730
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2643
2731
|
}
|
|
2644
2732
|
const loop = this.getLoop(row.loop_id);
|
|
2645
2733
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2647,6 +2735,15 @@ class Store {
|
|
|
2647
2735
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2648
2736
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2649
2737
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2738
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2739
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2740
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2741
|
+
workflowId: loop.target.workflowId,
|
|
2742
|
+
loopId: loop.id,
|
|
2743
|
+
workItemId,
|
|
2744
|
+
updated: finished
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2650
2747
|
}
|
|
2651
2748
|
this.db.exec("COMMIT");
|
|
2652
2749
|
} catch (error) {
|
|
@@ -4880,7 +4977,7 @@ function openAutomationsRuntimeBinding(overrides = {}) {
|
|
|
4880
4977
|
}
|
|
4881
4978
|
// src/lib/templates.ts
|
|
4882
4979
|
import { execFileSync } from "child_process";
|
|
4883
|
-
import { existsSync as existsSync3 } from "fs";
|
|
4980
|
+
import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
4884
4981
|
import { homedir as homedir3 } from "os";
|
|
4885
4982
|
import { basename as basename2, isAbsolute, join as join5, relative, resolve } from "path";
|
|
4886
4983
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
@@ -5081,6 +5178,11 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5081
5178
|
]
|
|
5082
5179
|
}
|
|
5083
5180
|
];
|
|
5181
|
+
var CUSTOM_TEMPLATE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
5182
|
+
var CUSTOM_TEMPLATE_VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
5183
|
+
var CUSTOM_TEMPLATE_VARIABLE_TYPES = new Set(["string", "number", "boolean", "json", "string[]"]);
|
|
5184
|
+
var CUSTOM_TEMPLATE_PLACEHOLDER = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
5185
|
+
var CUSTOM_TEMPLATE_EXACT_PLACEHOLDER = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/;
|
|
5084
5186
|
function compactJson(value) {
|
|
5085
5187
|
return JSON.stringify(value);
|
|
5086
5188
|
}
|
|
@@ -5358,11 +5460,310 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
5358
5460
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
5359
5461
|
];
|
|
5360
5462
|
}
|
|
5361
|
-
function
|
|
5362
|
-
|
|
5463
|
+
function assertRecord(value, label) {
|
|
5464
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5465
|
+
throw new Error(`${label} must be an object`);
|
|
5466
|
+
}
|
|
5467
|
+
function assertTemplateString(value, label) {
|
|
5468
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
5469
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
5470
|
+
return value.trim();
|
|
5471
|
+
}
|
|
5472
|
+
function assertTemplateKind(value, label) {
|
|
5473
|
+
const kind = assertTemplateString(value, label);
|
|
5474
|
+
if (kind !== "workflow")
|
|
5475
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
5476
|
+
return kind;
|
|
5477
|
+
}
|
|
5478
|
+
function customLoopTemplatesDir() {
|
|
5479
|
+
return join5(dataDir(), "templates");
|
|
5480
|
+
}
|
|
5481
|
+
function ensureCustomLoopTemplatesDir() {
|
|
5482
|
+
const dir = customLoopTemplatesDir();
|
|
5483
|
+
mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
5484
|
+
return dir;
|
|
5485
|
+
}
|
|
5486
|
+
function loopTemplatesDir() {
|
|
5487
|
+
return customLoopTemplatesDir();
|
|
5488
|
+
}
|
|
5489
|
+
function builtinLoopTemplates() {
|
|
5490
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
5491
|
+
}
|
|
5492
|
+
function getBuiltinLoopTemplate(id) {
|
|
5493
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
5494
|
+
}
|
|
5495
|
+
function builtinTemplateKeys() {
|
|
5496
|
+
const keys = new Set;
|
|
5497
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
5498
|
+
keys.add(template.id);
|
|
5499
|
+
keys.add(template.name);
|
|
5500
|
+
}
|
|
5501
|
+
return keys;
|
|
5502
|
+
}
|
|
5503
|
+
function validateCustomTemplateId(id, label) {
|
|
5504
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
5505
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
function validateCustomTemplateVariables(value, label) {
|
|
5509
|
+
if (value === undefined)
|
|
5510
|
+
return [];
|
|
5511
|
+
if (!Array.isArray(value))
|
|
5512
|
+
throw new Error(`${label} must be an array`);
|
|
5513
|
+
const seen = new Set;
|
|
5514
|
+
return value.map((entry, index) => {
|
|
5515
|
+
const entryLabel = `${label}[${index}]`;
|
|
5516
|
+
assertRecord(entry, entryLabel);
|
|
5517
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
5518
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
5519
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
5520
|
+
}
|
|
5521
|
+
if (seen.has(name))
|
|
5522
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
5523
|
+
seen.add(name);
|
|
5524
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
5525
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
5526
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
5527
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
5528
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
5529
|
+
}
|
|
5530
|
+
if (defaultValue === "danger-full-access") {
|
|
5531
|
+
throw new Error(`${entryLabel}.default cannot be danger-full-access in a custom template`);
|
|
5532
|
+
}
|
|
5533
|
+
return {
|
|
5534
|
+
name,
|
|
5535
|
+
description,
|
|
5536
|
+
required: entry.required === undefined ? undefined : Boolean(entry.required),
|
|
5537
|
+
default: defaultValue,
|
|
5538
|
+
type
|
|
5539
|
+
};
|
|
5540
|
+
});
|
|
5541
|
+
}
|
|
5542
|
+
function assertNoDangerFullAccess(value, label) {
|
|
5543
|
+
if (!value || typeof value !== "object")
|
|
5544
|
+
return;
|
|
5545
|
+
if (Array.isArray(value)) {
|
|
5546
|
+
value.forEach((entry, index) => assertNoDangerFullAccess(entry, `${label}[${index}]`));
|
|
5547
|
+
return;
|
|
5548
|
+
}
|
|
5549
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
5550
|
+
if (key === "sandbox" && entry === "danger-full-access") {
|
|
5551
|
+
throw new Error(`${label}.${key} uses danger-full-access; custom templates must not request danger-full-access`);
|
|
5552
|
+
}
|
|
5553
|
+
assertNoDangerFullAccess(entry, `${label}.${key}`);
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
5557
|
+
assertRecord(value, sourcePath);
|
|
5558
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
5559
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
5560
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
5561
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
5562
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
5563
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
5564
|
+
if (value.workflow === undefined)
|
|
5565
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
5566
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
5567
|
+
assertNoDangerFullAccess(value.workflow, `${sourcePath}.workflow`);
|
|
5568
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
5569
|
+
}
|
|
5570
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
5571
|
+
return {
|
|
5572
|
+
id: definition.id,
|
|
5573
|
+
name: definition.name,
|
|
5574
|
+
description: definition.description,
|
|
5575
|
+
kind: definition.kind,
|
|
5576
|
+
variables: structuredClone(definition.variables),
|
|
5577
|
+
source: "custom",
|
|
5578
|
+
sourcePath
|
|
5579
|
+
};
|
|
5363
5580
|
}
|
|
5364
|
-
function
|
|
5365
|
-
|
|
5581
|
+
function readCustomTemplateFile(file) {
|
|
5582
|
+
let parsed;
|
|
5583
|
+
try {
|
|
5584
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
5585
|
+
} catch (error) {
|
|
5586
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5587
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
5588
|
+
}
|
|
5589
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
5590
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
5591
|
+
}
|
|
5592
|
+
function assertNoTemplateCollisions(entries) {
|
|
5593
|
+
const builtinKeys = builtinTemplateKeys();
|
|
5594
|
+
const seen = new Map;
|
|
5595
|
+
for (const entry of entries) {
|
|
5596
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
5597
|
+
if (builtinKeys.has(key)) {
|
|
5598
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
5599
|
+
}
|
|
5600
|
+
const existing = seen.get(key);
|
|
5601
|
+
if (existing) {
|
|
5602
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
5603
|
+
}
|
|
5604
|
+
seen.set(key, entry.definition.id);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
function loadCustomLoopTemplates() {
|
|
5609
|
+
const dir = customLoopTemplatesDir();
|
|
5610
|
+
if (!existsSync3(dir))
|
|
5611
|
+
return [];
|
|
5612
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
5613
|
+
const file = join5(dir, entry.name);
|
|
5614
|
+
if (entry.isSymbolicLink())
|
|
5615
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
5616
|
+
if (!entry.isFile())
|
|
5617
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
5618
|
+
return readCustomTemplateFile(file);
|
|
5619
|
+
});
|
|
5620
|
+
assertNoTemplateCollisions(entries);
|
|
5621
|
+
return entries;
|
|
5622
|
+
}
|
|
5623
|
+
function getCustomLoopTemplate(id) {
|
|
5624
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
5625
|
+
}
|
|
5626
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
5627
|
+
const normalizedType = type ?? "string";
|
|
5628
|
+
if (normalizedType === "string")
|
|
5629
|
+
return String(raw);
|
|
5630
|
+
if (normalizedType === "number") {
|
|
5631
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
5632
|
+
if (!Number.isFinite(value))
|
|
5633
|
+
throw new Error(`${label} must be a finite number`);
|
|
5634
|
+
return value;
|
|
5635
|
+
}
|
|
5636
|
+
if (normalizedType === "boolean") {
|
|
5637
|
+
if (typeof raw === "boolean")
|
|
5638
|
+
return raw;
|
|
5639
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
5640
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
5641
|
+
return true;
|
|
5642
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
5643
|
+
return false;
|
|
5644
|
+
throw new Error(`${label} must be a boolean`);
|
|
5645
|
+
}
|
|
5646
|
+
if (normalizedType === "json") {
|
|
5647
|
+
if (typeof raw !== "string")
|
|
5648
|
+
return raw;
|
|
5649
|
+
try {
|
|
5650
|
+
return JSON.parse(raw);
|
|
5651
|
+
} catch (error) {
|
|
5652
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5653
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
if (normalizedType === "string[]") {
|
|
5657
|
+
if (Array.isArray(raw))
|
|
5658
|
+
return raw.map((entry) => String(entry));
|
|
5659
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5660
|
+
}
|
|
5661
|
+
return String(raw);
|
|
5662
|
+
}
|
|
5663
|
+
function customTemplateValues(definition, values) {
|
|
5664
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
5665
|
+
for (const [name, value] of Object.entries(values)) {
|
|
5666
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
5667
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
const rendered = {};
|
|
5671
|
+
for (const variable of definition.variables) {
|
|
5672
|
+
const raw = values[variable.name] ?? variable.default;
|
|
5673
|
+
if (raw === undefined || raw === "") {
|
|
5674
|
+
if (variable.required)
|
|
5675
|
+
throw new Error(`${variable.name} is required`);
|
|
5676
|
+
continue;
|
|
5677
|
+
}
|
|
5678
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
5679
|
+
}
|
|
5680
|
+
return rendered;
|
|
5681
|
+
}
|
|
5682
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
5683
|
+
if (!(name in values))
|
|
5684
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
5685
|
+
return values[name];
|
|
5686
|
+
}
|
|
5687
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
5688
|
+
if (typeof value === "string")
|
|
5689
|
+
return value;
|
|
5690
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
5691
|
+
return String(value);
|
|
5692
|
+
if (value === null)
|
|
5693
|
+
return "null";
|
|
5694
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
5695
|
+
return JSON.stringify(value);
|
|
5696
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
5697
|
+
}
|
|
5698
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
5699
|
+
if (typeof value === "string") {
|
|
5700
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
5701
|
+
if (exact)
|
|
5702
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
5703
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
5704
|
+
}
|
|
5705
|
+
if (Array.isArray(value))
|
|
5706
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
5707
|
+
if (!value || typeof value !== "object")
|
|
5708
|
+
return value;
|
|
5709
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
5710
|
+
}
|
|
5711
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
5712
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
5713
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
5714
|
+
assertNoDangerFullAccess(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
5715
|
+
return workflowBodyFromJson(rendered);
|
|
5716
|
+
}
|
|
5717
|
+
function validateCustomLoopTemplateFile(file) {
|
|
5718
|
+
const entry = readCustomTemplateFile(resolve(file));
|
|
5719
|
+
assertNoTemplateCollisions([entry]);
|
|
5720
|
+
return structuredClone(entry.summary);
|
|
5721
|
+
}
|
|
5722
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
5723
|
+
const source = resolve(file);
|
|
5724
|
+
const entry = readCustomTemplateFile(source);
|
|
5725
|
+
assertNoTemplateCollisions([entry]);
|
|
5726
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
5727
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
5728
|
+
const replaced = existsSync3(destination);
|
|
5729
|
+
if (replaced) {
|
|
5730
|
+
const stat = lstatSync(destination);
|
|
5731
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
5732
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
5733
|
+
if (!opts.replace)
|
|
5734
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
5735
|
+
}
|
|
5736
|
+
writeFileSync2(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
5737
|
+
`, { mode: 384 });
|
|
5738
|
+
const imported = readCustomTemplateFile(destination);
|
|
5739
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
5740
|
+
}
|
|
5741
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
5742
|
+
return {
|
|
5743
|
+
ok: true,
|
|
5744
|
+
templates: listLoopTemplates(opts),
|
|
5745
|
+
customDir: customLoopTemplatesDir()
|
|
5746
|
+
};
|
|
5747
|
+
}
|
|
5748
|
+
function listLoopTemplates(opts = {}) {
|
|
5749
|
+
const source = opts.source ?? "all";
|
|
5750
|
+
const templates = [];
|
|
5751
|
+
if (source !== "custom")
|
|
5752
|
+
templates.push(...builtinLoopTemplates());
|
|
5753
|
+
if (source !== "builtin")
|
|
5754
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
5755
|
+
return templates;
|
|
5756
|
+
}
|
|
5757
|
+
function getLoopTemplate(id, opts = {}) {
|
|
5758
|
+
const source = opts.source ?? "all";
|
|
5759
|
+
if (source !== "custom") {
|
|
5760
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
5761
|
+
if (builtin)
|
|
5762
|
+
return builtin;
|
|
5763
|
+
if (source === "builtin")
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
5366
5767
|
}
|
|
5367
5768
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
5368
5769
|
if (!input.taskId?.trim())
|
|
@@ -5707,7 +6108,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
5707
6108
|
]
|
|
5708
6109
|
};
|
|
5709
6110
|
}
|
|
5710
|
-
function
|
|
6111
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
5711
6112
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
5712
6113
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
5713
6114
|
}
|
|
@@ -5821,12 +6222,26 @@ function booleanVar(value) {
|
|
|
5821
6222
|
function accountPoolVar(value, tool) {
|
|
5822
6223
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5823
6224
|
}
|
|
6225
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
6226
|
+
const source = opts.source ?? "all";
|
|
6227
|
+
if (source !== "custom") {
|
|
6228
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6229
|
+
if (builtin)
|
|
6230
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
6231
|
+
if (source === "builtin")
|
|
6232
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
6233
|
+
}
|
|
6234
|
+
const custom = getCustomLoopTemplate(id);
|
|
6235
|
+
if (custom)
|
|
6236
|
+
return renderCustomLoopTemplate(custom, values);
|
|
6237
|
+
throw new Error(`unknown template: ${id}`);
|
|
6238
|
+
}
|
|
5824
6239
|
// src/lib/doctor.ts
|
|
5825
6240
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5826
6241
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
5827
6242
|
|
|
5828
6243
|
// src/daemon/control.ts
|
|
5829
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
6244
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5830
6245
|
import { hostname } from "os";
|
|
5831
6246
|
import { dirname as dirname2 } from "path";
|
|
5832
6247
|
|
|
@@ -5857,15 +6272,15 @@ function readPid(path = pidFilePath()) {
|
|
|
5857
6272
|
if (!existsSync4(path))
|
|
5858
6273
|
return;
|
|
5859
6274
|
try {
|
|
5860
|
-
const pid = Number(
|
|
6275
|
+
const pid = Number(readFileSync2(path, "utf8").trim());
|
|
5861
6276
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
5862
6277
|
} catch {
|
|
5863
6278
|
return;
|
|
5864
6279
|
}
|
|
5865
6280
|
}
|
|
5866
6281
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
5867
|
-
|
|
5868
|
-
|
|
6282
|
+
mkdirSync5(dirname2(path), { recursive: true, mode: 448 });
|
|
6283
|
+
writeFileSync3(path, String(pid));
|
|
5869
6284
|
}
|
|
5870
6285
|
function removePid(path = pidFilePath()) {
|
|
5871
6286
|
rmSync2(path, { force: true });
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -94,8 +94,15 @@ export declare class Store {
|
|
|
94
94
|
listWorkflows(opts?: {
|
|
95
95
|
status?: WorkflowSpec["status"];
|
|
96
96
|
limit?: number;
|
|
97
|
+
offset?: number;
|
|
97
98
|
}): WorkflowSpec[];
|
|
99
|
+
countWorkflows(opts?: {
|
|
100
|
+
status?: WorkflowSpec["status"];
|
|
101
|
+
}): number;
|
|
98
102
|
archiveWorkflow(idOrName: string): WorkflowSpec;
|
|
103
|
+
private generatedRouteArchiveContext;
|
|
104
|
+
private maybeArchiveGeneratedRouteWorkflow;
|
|
105
|
+
private maybeArchiveTerminalGeneratedRouteWorkflow;
|
|
99
106
|
createWorkflowInvocation(input: CreateWorkflowInvocationInput): WorkflowInvocation;
|
|
100
107
|
getWorkflowInvocation(id: string): WorkflowInvocation | undefined;
|
|
101
108
|
listWorkflowInvocations(opts?: {
|