@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/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) {
@@ -1,4 +1,4 @@
1
- import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
1
+ import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
2
2
  export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
3
3
  export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4
4
  export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -97,9 +97,29 @@ export interface BoundedAgentWorkflowTemplateInput {
97
97
  worktreeBranchPrefix?: string;
98
98
  timeoutMs?: number;
99
99
  }
100
- export declare function listLoopTemplates(): LoopTemplateSummary[];
101
- export declare function getLoopTemplate(id: string): LoopTemplateSummary | undefined;
100
+ export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
101
+ export interface ListLoopTemplatesOptions {
102
+ source?: LoopTemplateSourceFilter;
103
+ }
104
+ export interface CustomLoopTemplateImportOptions {
105
+ replace?: boolean;
106
+ }
107
+ export interface CustomLoopTemplateImportResult {
108
+ template: LoopTemplateSummary;
109
+ path: string;
110
+ replaced: boolean;
111
+ }
112
+ export declare function loopTemplatesDir(): string;
113
+ export declare function validateCustomLoopTemplateFile(file: string): LoopTemplateSummary;
114
+ export declare function importCustomLoopTemplate(file: string, opts?: CustomLoopTemplateImportOptions): CustomLoopTemplateImportResult;
115
+ export declare function validateLoopTemplateRegistry(opts?: ListLoopTemplatesOptions): {
116
+ ok: true;
117
+ templates: LoopTemplateSummary[];
118
+ customDir: string;
119
+ };
120
+ export declare function listLoopTemplates(opts?: ListLoopTemplatesOptions): LoopTemplateSummary[];
121
+ export declare function getLoopTemplate(id: string, opts?: ListLoopTemplatesOptions): LoopTemplateSummary | undefined;
102
122
  export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
103
123
  export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
104
124
  export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
105
- export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>): CreateWorkflowInput;
125
+ export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>, opts?: ListLoopTemplatesOptions): CreateWorkflowInput;
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/dist/types.d.ts CHANGED
@@ -252,11 +252,14 @@ export interface CreateWorkflowInput {
252
252
  version?: number;
253
253
  }
254
254
  export type LoopTemplateKind = "workflow" | "loop";
255
+ export type LoopTemplateSource = "builtin" | "custom";
256
+ export type LoopTemplateVariableType = "string" | "number" | "boolean" | "json" | "string[]";
255
257
  export interface LoopTemplateVariable {
256
258
  name: string;
257
259
  description?: string;
258
260
  required?: boolean;
259
261
  default?: string;
262
+ type?: LoopTemplateVariableType;
260
263
  }
261
264
  export interface LoopTemplateSummary {
262
265
  id: string;
@@ -264,6 +267,8 @@ export interface LoopTemplateSummary {
264
267
  description: string;
265
268
  kind: LoopTemplateKind;
266
269
  variables: LoopTemplateVariable[];
270
+ source?: LoopTemplateSource;
271
+ sourcePath?: string;
267
272
  }
268
273
  export interface WorkflowRun {
269
274
  id: string;
package/docs/USAGE.md CHANGED
@@ -254,6 +254,65 @@ loops templates render deterministic-check-create-task \
254
254
  --var checkCommand='your deterministic check and todos upsert command'
255
255
  ```
256
256
 
257
+ Custom reusable workflow templates live under the OpenLoops app data directory:
258
+ `~/.hasna/loops/templates` by default, or `$LOOPS_DATA_DIR/templates` when
259
+ `LOOPS_DATA_DIR` is set. Store templates as declarative JSON files; listing,
260
+ showing, and rendering templates never executes workflow steps or mutates the
261
+ registry.
262
+
263
+ ```json
264
+ {
265
+ "id": "custom-report",
266
+ "name": "Custom Report",
267
+ "description": "Run a custom report workflow from the local template registry.",
268
+ "kind": "workflow",
269
+ "variables": [
270
+ { "name": "objective", "required": true, "description": "Report objective." },
271
+ { "name": "projectPath", "required": true, "description": "Working directory." },
272
+ { "name": "timeoutMs", "default": "300000", "type": "number" }
273
+ ],
274
+ "workflow": {
275
+ "name": "custom-report-${objective}",
276
+ "steps": [
277
+ {
278
+ "id": "worker",
279
+ "target": {
280
+ "type": "agent",
281
+ "provider": "codewith",
282
+ "prompt": "/goal ${objective}\nProduce the requested report only.",
283
+ "cwd": "${projectPath}",
284
+ "configIsolation": "safe",
285
+ "permissionMode": "bypass",
286
+ "sandbox": "workspace-write",
287
+ "timeoutMs": "${timeoutMs}"
288
+ },
289
+ "timeoutMs": "${timeoutMs}"
290
+ }
291
+ ]
292
+ }
293
+ }
294
+ ```
295
+
296
+ ```bash
297
+ loops templates validate ./custom-report.json
298
+ loops templates import ./custom-report.json
299
+ loops templates list --source custom
300
+ loops templates show custom-report
301
+ loops templates render custom-report \
302
+ --var objective="Check docs drift" \
303
+ --var projectPath=/path/to/repo
304
+ loops templates create-workflow custom-report \
305
+ --var objective="Check docs drift" \
306
+ --var projectPath=/path/to/repo
307
+ ```
308
+
309
+ Use `--source builtin`, `--source custom`, or `--source all` on
310
+ `list`, `show`, `render`, and `create-workflow` when automation needs an
311
+ explicit source. Custom template ids and names cannot override built-ins.
312
+ Custom templates fail closed for `danger-full-access`; use built-in templates
313
+ with explicit break-glass handling for emergency workflows that need that
314
+ sandbox.
315
+
257
316
  Repo-mutating task/event routes should set `worktreeMode=required` so the
258
317
  workflow fails fast instead of falling back to the main checkout. When
259
318
  `projectPath` is an existing git repository, OpenLoops inserts a
@@ -367,13 +426,13 @@ loops events drain todos-task \
367
426
  --todos-project "$HOME/.hasna/loops" \
368
427
  --task-list repoops-pr-queue \
369
428
  --tags auto:route \
370
- --project-path-prefix "$HOME/workspace/hasna/opensource" \
429
+ --project-path-prefix /home/hasna/workspace/hasna/opensource \
371
430
  --provider codewith \
372
431
  --auth-profile-pool account004,account005,account006 \
373
432
  --add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
374
433
  --project-group oss \
375
434
  --max-dispatch 2 \
376
- --scan-limit 500 \
435
+ --scan-limit 5000 \
377
436
  --max-active-per-project 1 \
378
437
  --max-active-per-project-group 4 \
379
438
  --max-active 12 \
@@ -394,6 +453,45 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
394
453
  Use `--dry-run` to preview candidates and rendered workflows without mutating
395
454
  OpenLoops state.
396
455
 
456
+ For the Hasna OSS task-created route, keep the drain deterministic and narrow:
457
+
458
+ ```bash
459
+ loops routes schedule todos-task oss-task-route-drain \
460
+ --every 5m \
461
+ --todos-project "$HOME/.hasna/loops" \
462
+ --project-path-prefix /home/hasna/workspace/hasna/opensource \
463
+ --tags auto:route \
464
+ --provider codewith \
465
+ --auth-profile-pool account004,account005,account006 \
466
+ --add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
467
+ --project-group oss \
468
+ --max-dispatch 2 \
469
+ --scan-limit 5000 \
470
+ --max-active-per-project 1 \
471
+ --max-active-per-project-group 4 \
472
+ --max-active 12 \
473
+ --worktree-mode required \
474
+ --evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
475
+ --compact
476
+ ```
477
+
478
+ Only tasks under `/home/hasna/workspace/hasna/opensource` that explicitly opt
479
+ in with the `auto:route` tag, `route_enabled=true`, or
480
+ `automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
481
+ runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
482
+ or paste task prompts into tmux panes. Use max-active throttles and
483
+ `--max-dispatch` to bound agent fan-out, and write compact evidence into a
484
+ bounded reports directory so operators can audit each drain without unbounded
485
+ stdout or loop history growth. Keep `--scan-limit` large enough for the current
486
+ ready-task backlog; if the scan is exhausted before matching tasks appear, the
487
+ drain will correctly do no work.
488
+
489
+ Generated task/event route workflow specs are lifecycle-managed. After a
490
+ generated one-shot route workflow run reaches `succeeded`, `failed`,
491
+ `timed_out`, or `cancelled`, OpenLoops archives the generated workflow spec while
492
+ preserving workflow run, step, event, manifest, loop, invocation, and work-item
493
+ history.
494
+
397
495
  For other Hasna apps that expose `@hasna/events` webhooks, use the generic
398
496
  handler:
399
497
 
@@ -515,6 +613,20 @@ production loop. Route commands store a small cursor in
515
613
  through all findings over repeated scheduled runs instead of reprocessing only
516
614
  the first batch.
517
615
 
616
+ For a deliberate operator rename, use the first-class rename command instead of
617
+ turning a one-off naming preference into hygiene policy:
618
+
619
+ ```bash
620
+ loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
621
+ loops --json rename <loop-id> machine-ops-loop-health-route-tasks
622
+ ```
623
+
624
+ `rename` preserves the loop id, schedule, run history, archive state, and target
625
+ configuration. It rejects empty or duplicate names and writes a SQLite backup
626
+ under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Human-facing
627
+ names should describe scope and responsibility; cadence belongs in schedule
628
+ metadata and operator tables.
629
+
518
630
  Archive loops when retiring old automation but preserving history:
519
631
 
520
632
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.48",
3
+ "version": "0.3.50",
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",