@hasna/loops 0.3.56 → 0.3.58

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.
@@ -1,7 +1,9 @@
1
+ import type { LanguageModel } from "ai";
1
2
  import type { ExecutableTarget, ExecutorResult, Loop, LoopMachineRef, LoopRun, PersistGuardOptions } from "../types.js";
2
3
  export interface ExecuteOptions extends PersistGuardOptions {
3
4
  maxOutputBytes?: number;
4
5
  env?: NodeJS.ProcessEnv;
6
+ goalModel?: LanguageModel;
5
7
  log?: (message: string) => void;
6
8
  signal?: AbortSignal;
7
9
  onSpawn?: (pid: number) => void;
@@ -1,6 +1,6 @@
1
1
  import type { Loop, LoopRun } from "../types.js";
2
2
  import type { Store } from "./store.js";
3
- export type RunFailureClassification = "rate_limit" | "auth" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "preflight" | "timeout" | "sigsegv" | "skipped_previous_active" | "unknown";
3
+ export type RunFailureClassification = "rate_limit" | "auth" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "preflight" | "route_functional" | "timeout" | "sigsegv" | "skipped_previous_active" | "unknown";
4
4
  export interface RunFailureSignal {
5
5
  classification: RunFailureClassification;
6
6
  fingerprint: string;
@@ -34,7 +34,7 @@ export interface LoopExpectationResult {
34
34
  loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt">;
35
35
  ok: boolean;
36
36
  check: {
37
- id: "latest-run-succeeded";
37
+ id: "latest-run-succeeded" | "route-functional-health";
38
38
  status: "pass" | "fail" | "warn";
39
39
  message: string;
40
40
  };
@@ -71,6 +71,7 @@ export declare class Store {
71
71
  private addColumnIfMissing;
72
72
  private createWorkflowRunBackfillIndexes;
73
73
  private assertDaemonLeaseFence;
74
+ private assertNoNestedWorkflowGoal;
74
75
  createLoop(input: CreateLoopInput, from?: Date): Loop;
75
76
  getLoop(id: string): Loop | undefined;
76
77
  findLoopByName(name: string): Loop | undefined;
@@ -140,6 +141,9 @@ export declare class Store {
140
141
  project: number;
141
142
  projectGroup?: number;
142
143
  };
144
+ requeueWorkflowWorkItem(id: string, patch?: {
145
+ reason?: string;
146
+ }): WorkflowWorkItem;
143
147
  admitWorkflowWorkItem(id: string, patch: {
144
148
  workflowId: string;
145
149
  loopId: string;
package/dist/lib/store.js CHANGED
@@ -459,6 +459,9 @@ function validateTarget(value, label, opts) {
459
459
  }
460
460
  if (value.model !== undefined)
461
461
  assertString(value.model, `${label}.model`);
462
+ if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
463
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
464
+ }
462
465
  if (value.variant !== undefined)
463
466
  assertString(value.variant, `${label}.variant`);
464
467
  if (value.agent !== undefined)
@@ -1255,12 +1258,21 @@ class Store {
1255
1258
  if (!row)
1256
1259
  throw new Error("daemon lease lost");
1257
1260
  }
1261
+ assertNoNestedWorkflowGoal(target, goal) {
1262
+ if (!goal || target.type !== "workflow")
1263
+ return;
1264
+ const workflow = this.getWorkflow(target.workflowId);
1265
+ if (workflow?.goal) {
1266
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1267
+ }
1268
+ }
1258
1269
  createLoop(input, from = new Date) {
1259
1270
  const now = nowIso();
1260
1271
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1261
1272
  name: "loop-target-validation",
1262
1273
  steps: [{ id: "target", target: input.target }]
1263
1274
  }).steps[0].target;
1275
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1264
1276
  const loop = {
1265
1277
  id: genId(),
1266
1278
  name: input.name,
@@ -1432,6 +1444,9 @@ class Store {
1432
1444
  if (this.hasRunningRun(current.id))
1433
1445
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
1446
  const workflow = this.requireWorkflow(workflowId);
1447
+ if (current.goal && workflow.goal) {
1448
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1449
+ }
1435
1450
  const target = { ...current.target, workflowId: workflow.id };
1436
1451
  if (opts.workflowTimeoutMs !== undefined)
1437
1452
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1470,6 +1485,9 @@ class Store {
1470
1485
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
1486
  if (this.hasRunningRun(current.id))
1472
1487
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1488
+ if (current.goal && normalized.goal) {
1489
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1490
+ }
1473
1491
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
1492
  const workflow = {
1475
1493
  id: genId(),
@@ -1761,7 +1779,7 @@ class Store {
1761
1779
  if (!sourceDedupeKey)
1762
1780
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1763
1781
  const now = nowIso();
1764
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1782
+ const claimableStatuses = ["queued", "deferred"];
1765
1783
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1766
1784
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1767
1785
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1842,28 +1860,35 @@ class Store {
1842
1860
  project_group=excluded.project_group,
1843
1861
  priority=excluded.priority,
1844
1862
  status=CASE
1845
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1863
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1846
1864
  THEN workflow_work_items.status
1847
1865
  ELSE excluded.status
1848
1866
  END,
1849
1867
  workflow_id=CASE
1850
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1868
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1851
1869
  ELSE NULL
1852
1870
  END,
1853
1871
  loop_id=CASE
1854
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1872
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1855
1873
  ELSE NULL
1856
1874
  END,
1857
1875
  workflow_run_id=CASE
1858
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1876
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1859
1877
  ELSE NULL
1860
1878
  END,
1861
1879
  lease_expires_at=CASE
1862
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1880
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1863
1881
  ELSE NULL
1864
1882
  END,
1865
1883
  next_attempt_at=excluded.next_attempt_at,
1866
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1884
+ last_reason=CASE
1885
+ WHEN workflow_work_items.attempts > 0
1886
+ AND workflow_work_items.status IN ('queued', 'deferred')
1887
+ AND workflow_work_items.last_reason IS NOT NULL
1888
+ AND excluded.last_reason IS NOT NULL
1889
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1890
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1891
+ END,
1867
1892
  updated_at=excluded.updated_at`).run({
1868
1893
  $id: id,
1869
1894
  $routeKey: input.routeKey,
@@ -1916,11 +1941,39 @@ class Store {
1916
1941
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
1917
1942
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1918
1943
  }
1944
+ requeueWorkflowWorkItem(id, patch = {}) {
1945
+ const current = this.getWorkflowWorkItem(id);
1946
+ if (!current)
1947
+ throw new Error(`workflow work item not found: ${id}`);
1948
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1949
+ if (!requeueableStatuses.includes(current.status)) {
1950
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1951
+ }
1952
+ const now = nowIso();
1953
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1954
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1955
+ const res = this.db.query(`UPDATE workflow_work_items
1956
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1957
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1958
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1959
+ const item = this.getWorkflowWorkItem(id);
1960
+ if (!item)
1961
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1962
+ if (res.changes !== 1)
1963
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1964
+ return item;
1965
+ }
1919
1966
  admitWorkflowWorkItem(id, patch) {
1920
1967
  const now = nowIso();
1921
1968
  const res = this.db.query(`UPDATE workflow_work_items
1922
1969
  SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1923
- next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1970
+ next_attempt_at=NULL,
1971
+ lease_expires_at=NULL,
1972
+ last_reason=CASE
1973
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1974
+ ELSE COALESCE($reason, last_reason)
1975
+ END,
1976
+ updated_at=$updated
1924
1977
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1925
1978
  $id: id,
1926
1979
  $workflowId: patch.workflowId,
@@ -41,6 +41,8 @@ export interface TodosTaskWorkflowTemplateInput {
41
41
  worktreeRoot?: string;
42
42
  worktreeBranchPrefix?: string;
43
43
  timeoutMs?: TimeoutMs;
44
+ verifierIdleTimeoutMs?: number;
45
+ prHandoff?: boolean;
44
46
  eventId?: string;
45
47
  eventType?: string;
46
48
  }
@@ -78,6 +80,7 @@ export interface EventWorkflowTemplateInput {
78
80
  worktreeRoot?: string;
79
81
  worktreeBranchPrefix?: string;
80
82
  timeoutMs?: TimeoutMs;
83
+ verifierIdleTimeoutMs?: number;
81
84
  }
82
85
  export interface BoundedAgentWorkflowTemplateInput {
83
86
  name?: string;
@@ -110,6 +113,7 @@ export interface BoundedAgentWorkflowTemplateInput {
110
113
  worktreeRoot?: string;
111
114
  worktreeBranchPrefix?: string;
112
115
  timeoutMs?: TimeoutMs;
116
+ verifierIdleTimeoutMs?: number;
113
117
  }
114
118
  export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
115
119
  export interface ListLoopTemplatesOptions {
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ export interface LoopsMcpToolMetadata {
4
+ name: string;
5
+ description: string;
6
+ readOnly: boolean;
7
+ guarded?: boolean;
8
+ requiresEnv?: string;
9
+ }
10
+ export declare const LOOPS_MCP_TOOLS: LoopsMcpToolMetadata[];
11
+ export declare function createLoopsMcpServer(): McpServer;
12
+ export declare function listToolsForCli(): LoopsMcpToolMetadata[];