@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/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 limit = opts.limit ?? 200;
1447
- const rows = opts.status ? this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ?").all(limit);
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 mkdirSync6, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
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.48",
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 listLoopTemplates() {
6383
- return TEMPLATE_SUMMARIES.map((template) => structuredClone(template));
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 getLoopTemplate(id) {
6386
- return listLoopTemplates().find((template) => template.id === id || template.name === id);
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 renderLoopTemplate(id, values) {
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: readFileSync2(stdoutPath, "utf8"),
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
- mkdirSync6(backupDir, { recursive: true, mode: 448 });
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
- writeFileSync4(backupPath, db.serialize(), { mode: 384 });
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(readFileSync2(path, "utf8"));
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
- writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
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
- mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
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
- writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
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 ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
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
- const event = taskDrainEvent(task);
8405
- const result = routeTodosTaskEvent(event, opts);
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
- templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
8467
- const values = listLoopTemplates();
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("show <id>").description("show a built-in template").action((id) => {
8477
- const template = getLoopTemplate(id);
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
- print(template, `${template.id} ${template.kind}`);
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(readFileSync2(file, "utf8")), opts.name);
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(readFileSync2(file, "utf8")), opts.name);
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 archived", "active").action((opts) => {
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 workflowsList = store.listWorkflows({ status: opts.status });
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 = readFileSync2(path, "utf8").trimEnd().split(`
10005
+ const lines = readFileSync3(path, "utf8").trimEnd().split(`
9436
10006
  `);
9437
10007
  console.log(lines.slice(-Number(opts.lines)).join(`
9438
10008
  `));