@hasna/loops 0.3.48 → 0.3.49

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 CHANGED
@@ -315,6 +315,38 @@ loops routes show <work-item-id>
315
315
  loops routes invocations
316
316
  ```
317
317
 
318
+ For Hasna OSS task-created routing, use a deterministic drain instead of tmux
319
+ dispatch:
320
+
321
+ ```bash
322
+ loops routes schedule todos-task oss-task-route-drain \
323
+ --every 5m \
324
+ --todos-project "$HOME/.hasna/loops" \
325
+ --project-path-prefix /home/hasna/workspace/hasna/opensource \
326
+ --tags auto:route \
327
+ --provider codewith \
328
+ --auth-profile-pool account004,account005,account006 \
329
+ --add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
330
+ --project-group oss \
331
+ --max-dispatch 2 \
332
+ --scan-limit 5000 \
333
+ --max-active-per-project 1 \
334
+ --max-active-per-project-group 4 \
335
+ --max-active 12 \
336
+ --worktree-mode required \
337
+ --evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
338
+ --compact
339
+ ```
340
+
341
+ Only route tasks that explicitly opt in with `auto:route`, `route_enabled=true`,
342
+ or `automation.allowed=true`. Use Codewith account pools, required worktrees,
343
+ max-active throttles, and bounded evidence directories. Do not dispatch or paste
344
+ task prompts into tmux panes. Keep `--scan-limit` large enough for the local
345
+ ready-task backlog; otherwise low-priority or newly-created tasks can sit beyond
346
+ the scanned window. Generated one-shot task/event route workflow specs are
347
+ archived automatically when their workflow run reaches a terminal state;
348
+ workflow run history and manifests are preserved.
349
+
318
350
  Workflow run manifests are written under
319
351
  `.hasna/loops/runs/<project-slug>/<subject-key>/<run-id>/manifest.json`.
320
352
  `subject-key` is a safe derived path segment, not the raw subject reference.
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) {
@@ -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.49",
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",
@@ -6961,6 +7058,14 @@ function positiveInteger(raw, label) {
6961
7058
  throw new Error(`${label} must be a positive integer`);
6962
7059
  return value;
6963
7060
  }
7061
+ function nonNegativeInteger(raw, label) {
7062
+ if (raw === undefined)
7063
+ return;
7064
+ const value = Number(raw);
7065
+ if (!Number.isInteger(value) || value < 0)
7066
+ throw new Error(`${label} must be a non-negative integer`);
7067
+ return value;
7068
+ }
6964
7069
  function positiveDuration(raw, label) {
6965
7070
  if (raw === undefined)
6966
7071
  return;
@@ -7442,6 +7547,23 @@ function taskRouteEligibility(data, metadata) {
7442
7547
  }
7443
7548
  return { eligible: true, tags };
7444
7549
  }
7550
+ function skippedDrainTask(task, event, reason) {
7551
+ const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
7552
+ return {
7553
+ kind: "skipped",
7554
+ value: {
7555
+ skipped: true,
7556
+ reason,
7557
+ taskId,
7558
+ event,
7559
+ routeError: true
7560
+ },
7561
+ human: `skipped task ${taskId}: ${reason}`
7562
+ };
7563
+ }
7564
+ function isSkippableDrainRouteError(message) {
7565
+ return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
7566
+ }
7445
7567
  function generatedRouteSandboxPreflight(workflow) {
7446
7568
  const checks = [];
7447
7569
  for (const step of workflow.steps) {
@@ -8401,8 +8523,17 @@ function drainTodosTaskRoutes(opts) {
8401
8523
  for (const task of candidates) {
8402
8524
  if (created >= maxDispatch)
8403
8525
  break;
8404
- const event = taskDrainEvent(task);
8405
- const result = routeTodosTaskEvent(event, opts);
8526
+ let event;
8527
+ let result;
8528
+ try {
8529
+ event = taskDrainEvent(task);
8530
+ result = routeTodosTaskEvent(event, opts);
8531
+ } catch (error) {
8532
+ const message = error instanceof Error ? error.message : String(error);
8533
+ if (!isSkippableDrainRouteError(message))
8534
+ throw error;
8535
+ result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
8536
+ }
8406
8537
  results.push(result);
8407
8538
  if (result.kind === "created" && !opts.dryRun)
8408
8539
  created += 1;
@@ -8463,6 +8594,57 @@ function drainTodosTaskRoutes(opts) {
8463
8594
  } : { ...report, evidencePath };
8464
8595
  print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8465
8596
  }
8597
+ function formatTemplateVariable(template, name) {
8598
+ const variable = template.variables.find((entry) => entry.name === name);
8599
+ const placeholder = variable?.default ? variable.default : `<${name}>`;
8600
+ return ` --var ${name}=${placeholder}`;
8601
+ }
8602
+ function printTemplateDetails(template) {
8603
+ console.log(`${template.id} (${template.kind})`);
8604
+ console.log(template.name);
8605
+ console.log("");
8606
+ console.log(template.description);
8607
+ console.log("");
8608
+ console.log("Variables:");
8609
+ const nameWidth = Math.max(...template.variables.map((variable) => variable.name.length), 4);
8610
+ for (const variable of template.variables) {
8611
+ const required = variable.required ? "required" : "optional";
8612
+ const defaultValue = variable.default ? ` default=${variable.default}` : "";
8613
+ const description = variable.description ? ` ${variable.description}` : "";
8614
+ console.log(` ${variable.name.padEnd(nameWidth)} ${required}${defaultValue}${description}`);
8615
+ }
8616
+ const requiredVariables = template.variables.filter((variable) => variable.required).map((variable) => variable.name);
8617
+ const hintVariables = requiredVariables.length ? requiredVariables : template.variables.slice(0, 2).map((variable) => variable.name);
8618
+ const renderArgs = hintVariables.map((name) => formatTemplateVariable(template, name));
8619
+ const renderHint = renderArgs.length ? ` \\
8620
+ ${renderArgs.join(" \\\n")}` : "";
8621
+ console.log("");
8622
+ console.log("Usage:");
8623
+ console.log(` loops templates render ${template.id}${renderHint}`);
8624
+ if (template.kind === "workflow")
8625
+ console.log(` loops templates create-workflow ${template.id}${renderHint}`);
8626
+ }
8627
+ function workflowStatusFromOpts(status, all) {
8628
+ if (all) {
8629
+ if (status && status !== "active")
8630
+ throw new Error("use either --all or --status, not both");
8631
+ return;
8632
+ }
8633
+ const value = status ?? "active";
8634
+ if (value === "all")
8635
+ return;
8636
+ if (value === "active" || value === "archived")
8637
+ return value;
8638
+ throw new Error("--status must be active, archived, or all");
8639
+ }
8640
+ function printWorkflowListWarning(args) {
8641
+ if (args.shown + args.offset >= args.total && args.offset === 0)
8642
+ return;
8643
+ const scope = args.status ?? "all";
8644
+ const nextOffset = args.offset + args.shown;
8645
+ const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
8646
+ console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
8647
+ }
8466
8648
  templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
8467
8649
  const values = listLoopTemplates();
8468
8650
  if (isJson())
@@ -8477,7 +8659,10 @@ templates.command("show <id>").description("show a built-in template").action((i
8477
8659
  const template = getLoopTemplate(id);
8478
8660
  if (!template)
8479
8661
  throw new Error(`template not found: ${id}`);
8480
- print(template, `${template.id} ${template.kind}`);
8662
+ if (isJson())
8663
+ print(template);
8664
+ else
8665
+ printTemplateDetails(template);
8481
8666
  });
8482
8667
  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
8668
  const workflow = renderLoopTemplate(id, parseVars(opts.var));
@@ -8677,10 +8862,14 @@ workflows.command("create <file>").description("validate and store a workflow JS
8677
8862
  store.close();
8678
8863
  }
8679
8864
  });
8680
- workflows.command("list").alias("ls").option("--status <status>", "active or archived", "active").action((opts) => {
8865
+ 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
8866
  const store = new Store;
8682
8867
  try {
8683
- const workflowsList = store.listWorkflows({ status: opts.status });
8868
+ const status = workflowStatusFromOpts(opts.status, opts.all);
8869
+ const limit = positiveInteger(opts.limit, "--limit");
8870
+ const offset = nonNegativeInteger(opts.offset, "--offset") ?? 0;
8871
+ const workflowsList = store.listWorkflows({ status, limit, offset });
8872
+ const total = store.countWorkflows({ status });
8684
8873
  if (isJson())
8685
8874
  print(workflowsList.map(publicWorkflow));
8686
8875
  else {
@@ -8688,6 +8877,8 @@ workflows.command("list").alias("ls").option("--status <status>", "active or arc
8688
8877
  console.log(`${workflow.id} ${workflow.status.padEnd(8)} steps=${workflow.steps.length} ${workflow.name}`);
8689
8878
  }
8690
8879
  }
8880
+ if (limit !== undefined || offset > 0)
8881
+ printWorkflowListWarning({ shown: workflowsList.length, total, status, offset, limit });
8691
8882
  } finally {
8692
8883
  store.close();
8693
8884
  }
@@ -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) {
@@ -5143,7 +5240,7 @@ function enableStartup(result) {
5143
5240
  // package.json
5144
5241
  var package_default = {
5145
5242
  name: "@hasna/loops",
5146
- version: "0.3.48",
5243
+ version: "0.3.49",
5147
5244
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5148
5245
  type: "module",
5149
5246
  main: "dist/index.js",
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 limit = opts.limit ?? 200;
1445
- 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);
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) {
@@ -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?: {
package/dist/lib/store.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 limit = opts.limit ?? 200;
1445
- 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);
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) {
package/dist/sdk/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 limit = opts.limit ?? 200;
1445
- 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);
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) {
package/docs/USAGE.md CHANGED
@@ -367,13 +367,13 @@ loops events drain todos-task \
367
367
  --todos-project "$HOME/.hasna/loops" \
368
368
  --task-list repoops-pr-queue \
369
369
  --tags auto:route \
370
- --project-path-prefix "$HOME/workspace/hasna/opensource" \
370
+ --project-path-prefix /home/hasna/workspace/hasna/opensource \
371
371
  --provider codewith \
372
372
  --auth-profile-pool account004,account005,account006 \
373
373
  --add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
374
374
  --project-group oss \
375
375
  --max-dispatch 2 \
376
- --scan-limit 500 \
376
+ --scan-limit 5000 \
377
377
  --max-active-per-project 1 \
378
378
  --max-active-per-project-group 4 \
379
379
  --max-active 12 \
@@ -394,6 +394,45 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
394
394
  Use `--dry-run` to preview candidates and rendered workflows without mutating
395
395
  OpenLoops state.
396
396
 
397
+ For the Hasna OSS task-created route, keep the drain deterministic and narrow:
398
+
399
+ ```bash
400
+ loops routes schedule todos-task oss-task-route-drain \
401
+ --every 5m \
402
+ --todos-project "$HOME/.hasna/loops" \
403
+ --project-path-prefix /home/hasna/workspace/hasna/opensource \
404
+ --tags auto:route \
405
+ --provider codewith \
406
+ --auth-profile-pool account004,account005,account006 \
407
+ --add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
408
+ --project-group oss \
409
+ --max-dispatch 2 \
410
+ --scan-limit 5000 \
411
+ --max-active-per-project 1 \
412
+ --max-active-per-project-group 4 \
413
+ --max-active 12 \
414
+ --worktree-mode required \
415
+ --evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
416
+ --compact
417
+ ```
418
+
419
+ Only tasks under `/home/hasna/workspace/hasna/opensource` that explicitly opt
420
+ in with the `auto:route` tag, `route_enabled=true`, or
421
+ `automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
422
+ runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
423
+ or paste task prompts into tmux panes. Use max-active throttles and
424
+ `--max-dispatch` to bound agent fan-out, and write compact evidence into a
425
+ bounded reports directory so operators can audit each drain without unbounded
426
+ stdout or loop history growth. Keep `--scan-limit` large enough for the current
427
+ ready-task backlog; if the scan is exhausted before matching tasks appear, the
428
+ drain will correctly do no work.
429
+
430
+ Generated task/event route workflow specs are lifecycle-managed. After a
431
+ generated one-shot route workflow run reaches `succeeded`, `failed`,
432
+ `timed_out`, or `cancelled`, OpenLoops archives the generated workflow spec while
433
+ preserving workflow run, step, event, manifest, loop, invocation, and work-item
434
+ history.
435
+
397
436
  For other Hasna apps that expose `@hasna/events` webhooks, use the generic
398
437
  handler:
399
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",