@hasna/loops 0.4.12 → 0.4.14

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +106 -3
  2. package/README.md +70 -17
  3. package/dist/api/index.d.ts +35 -0
  4. package/dist/api/index.js +750 -10
  5. package/dist/cli/index.js +1744 -361
  6. package/dist/cli/ui.d.ts +44 -0
  7. package/dist/daemon/index.js +948 -212
  8. package/dist/generated/storage-kit/health.d.ts +19 -0
  9. package/dist/generated/storage-kit/index.d.ts +7 -0
  10. package/dist/generated/storage-kit/migrations.d.ts +47 -0
  11. package/dist/generated/storage-kit/mode.d.ts +47 -0
  12. package/dist/generated/storage-kit/pool.d.ts +33 -0
  13. package/dist/generated/storage-kit/query.d.ts +35 -0
  14. package/dist/generated/storage-kit/tls.d.ts +25 -0
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +6513 -4840
  17. package/dist/lib/health.d.ts +83 -3
  18. package/dist/lib/mode.d.ts +27 -0
  19. package/dist/lib/mode.js +69 -2
  20. package/dist/lib/scheduler.d.ts +5 -2
  21. package/dist/lib/storage/index.d.ts +1 -0
  22. package/dist/lib/storage/index.js +1329 -211
  23. package/dist/lib/storage/pg-executor.d.ts +27 -0
  24. package/dist/lib/storage/pg-runner-claim.d.ts +40 -0
  25. package/dist/lib/storage/postgres-loop-storage.d.ts +114 -0
  26. package/dist/lib/storage/sqlite.js +336 -8
  27. package/dist/lib/store.d.ts +234 -0
  28. package/dist/lib/store.js +350 -8
  29. package/dist/mcp/index.js +1447 -479
  30. package/dist/runner/index.js +179 -25
  31. package/dist/sdk/http.d.ts +115 -0
  32. package/dist/sdk/http.js +145 -0
  33. package/dist/sdk/index.d.ts +5 -1
  34. package/dist/sdk/index.js +1106 -296
  35. package/dist/serve/index.d.ts +4 -0
  36. package/dist/serve/index.js +7619 -0
  37. package/dist/types.d.ts +3 -0
  38. package/docs/CUTOVER-RUNBOOK.md +61 -0
  39. package/docs/DEPLOYMENT_MODES.md +23 -1
  40. package/docs/USAGE.md +66 -16
  41. package/package.json +14 -2
@@ -1,10 +1,13 @@
1
- import type { Loop, LoopRun } from "../types.js";
1
+ import type { Loop, LoopRun, LoopStatus } from "../types.js";
2
+ import type { DaemonStatus } from "../daemon/control.js";
3
+ import type { DoctorCheck, DoctorReport } from "./doctor.js";
2
4
  import type { Store } from "./store.js";
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" | "circuit_breaker" | "unknown";
5
+ export type RunFailureClassification = "rate_limit" | "auth" | "provider_unavailable" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "preflight" | "route_functional" | "timeout" | "sigsegv" | "restart_interrupted" | "skipped_previous_active" | "circuit_breaker" | "unknown";
4
6
  export interface RunFailureSignal {
5
7
  classification: RunFailureClassification;
6
8
  fingerprint: string;
7
9
  evidence: {
10
+ summary?: string;
8
11
  error?: string;
9
12
  stdout?: string;
10
13
  stderr?: string;
@@ -31,7 +34,7 @@ export interface RecommendedTaskUpsert {
31
34
  };
32
35
  }
33
36
  export interface LoopExpectationResult {
34
- loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt">;
37
+ loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt" | "retryScheduledFor">;
35
38
  ok: boolean;
36
39
  check: {
37
40
  id: "latest-run-succeeded" | "route-functional-health";
@@ -62,6 +65,81 @@ export interface LoopsHealthReport {
62
65
  classifications: Record<RunFailureClassification, number>;
63
66
  expectations: LoopExpectationResult[];
64
67
  }
68
+ export type HealthScanStatus = "ok" | "degraded" | "critical";
69
+ export type HealthScanFindingKind = "daemon" | "doctor" | "preflight" | "latest-run" | "stale-running";
70
+ export type HealthScanFindingSeverity = "critical" | "high" | "medium" | "low";
71
+ export interface HealthScanSelfHealAction {
72
+ kind: "daemon-start";
73
+ attempted: boolean;
74
+ ok?: boolean;
75
+ reason: string;
76
+ result?: Record<string, unknown>;
77
+ }
78
+ export interface HealthScanFinding {
79
+ kind: HealthScanFindingKind;
80
+ severity: HealthScanFindingSeverity;
81
+ fingerprint: string;
82
+ title: string;
83
+ message: string;
84
+ loop?: Pick<Loop, "id" | "name" | "status" | "nextRunAt"> & {
85
+ leaseMs?: number;
86
+ };
87
+ run?: LoopRun;
88
+ route?: LoopExpectationResult["route"];
89
+ ageMs?: number;
90
+ staleThresholdMs?: number;
91
+ classification?: RunFailureClassification;
92
+ doctorCheck?: DoctorCheck;
93
+ recommendedTask?: RecommendedTaskUpsert;
94
+ }
95
+ export interface LoopsHealthScan {
96
+ ok: boolean;
97
+ status: HealthScanStatus;
98
+ generatedAt: string;
99
+ includedStatuses: LoopStatus[];
100
+ counts: {
101
+ loops: number;
102
+ active: number;
103
+ paused: number;
104
+ stopped: number;
105
+ expired: number;
106
+ latestRunFindings: number;
107
+ staleRunning: number;
108
+ daemonFindings: number;
109
+ doctorFindings: number;
110
+ preflightFindings: number;
111
+ findings: number;
112
+ reportedFindings: number;
113
+ truncatedFindings: number;
114
+ };
115
+ daemon?: Pick<DaemonStatus, "running" | "stale" | "pid" | "host" | "loops" | "runs" | "logPath">;
116
+ doctor?: DoctorReport;
117
+ health: LoopsHealthReport;
118
+ selfHeals: HealthScanSelfHealAction[];
119
+ findings: HealthScanFinding[];
120
+ reports?: {
121
+ dir: string;
122
+ json: string;
123
+ markdown: string;
124
+ };
125
+ todos?: Record<string, unknown>;
126
+ }
127
+ export interface BuildHealthScanOptions {
128
+ includeStatuses?: LoopStatus[];
129
+ includeArchived?: boolean;
130
+ limit?: number;
131
+ latestRun?: boolean;
132
+ doctor?: DoctorReport;
133
+ daemon?: DaemonStatus;
134
+ selfHeals?: HealthScanSelfHealAction[];
135
+ maxFindings?: number;
136
+ staleRunningMs?: number;
137
+ now?: Date;
138
+ }
139
+ export interface WriteHealthScanReportsOptions {
140
+ reportDir?: string;
141
+ }
142
+ export declare const RESTART_INTERRUPTED_RUN_PREFIX = "daemon restart interrupted active run";
65
143
  export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | undefined;
66
144
  export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
67
145
  export declare function buildHealthReport(store: Store, opts?: {
@@ -69,3 +147,5 @@ export declare function buildHealthReport(store: Store, opts?: {
69
147
  includeInactive?: boolean;
70
148
  limit?: number;
71
149
  }): LoopsHealthReport;
150
+ export declare function buildHealthScan(store: Store, opts?: BuildHealthScanOptions): LoopsHealthScan;
151
+ export declare function writeHealthScanReports(scan: LoopsHealthScan, opts?: WriteHealthScanReportsOptions): LoopsHealthScan;
@@ -1,6 +1,10 @@
1
1
  export declare const LOOP_DEPLOYMENT_MODES: readonly ["local", "self_hosted", "cloud"];
2
2
  export type LoopDeploymentMode = (typeof LOOP_DEPLOYMENT_MODES)[number];
3
3
  export type LoopSourceOfTruth = "local_sqlite" | "self_hosted_control_plane" | "cloud_control_plane";
4
+ export type LoopRemoteSchedulerBackend = "none" | "unconfigured" | "api_control_plane_contract" | "postgres_contract" | "hosted_control_plane_contract";
5
+ export type LoopRemoteArtifactStore = "none" | "object_store_contract";
6
+ export type LoopRouteAdmissionStateStore = "local_sqlite" | "control_plane_contract";
7
+ export type LoopRouteAdmissionGate = "max_dispatch" | "max_active" | "max_active_per_project" | "max_active_per_project_group" | "max_active_scope" | "max_per_profile";
4
8
  export interface LoopModeResolution {
5
9
  deploymentMode: LoopDeploymentMode;
6
10
  source: string;
@@ -34,8 +38,31 @@ export interface LoopDeploymentStatus {
34
38
  required: boolean;
35
39
  role: "daemon" | "control_plane_worker";
36
40
  };
41
+ schedulerState: LoopSchedulerStateStatus;
37
42
  warnings: string[];
38
43
  }
44
+ export interface LoopSchedulerStateStatus {
45
+ authority: LoopSourceOfTruth;
46
+ localStore: {
47
+ backend: "sqlite";
48
+ role: "authoritative" | "cache_and_spool";
49
+ runArtifacts: "local_files";
50
+ routeAdmissionState: "workflow_work_items";
51
+ };
52
+ remoteStore: {
53
+ backend: LoopRemoteSchedulerBackend;
54
+ configured: boolean;
55
+ applySupported: boolean;
56
+ objectArtifacts: LoopRemoteArtifactStore;
57
+ mutatesAws: false;
58
+ };
59
+ routeAdmission: {
60
+ stateStore: LoopRouteAdmissionStateStore;
61
+ activeStatuses: readonly ["admitted", "running"];
62
+ gates: readonly LoopRouteAdmissionGate[];
63
+ dryRunEvaluatesLiveCounts: false;
64
+ };
65
+ }
39
66
  type Env = Record<string, string | undefined>;
40
67
  export declare function normalizeLoopDeploymentMode(value: string): LoopDeploymentMode;
41
68
  export declare function resolveLoopDeploymentMode(env?: Env): LoopModeResolution;
package/dist/lib/mode.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@hasna/loops",
5
- version: "0.4.12",
5
+ version: "0.4.14",
6
6
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7
7
  type: "module",
8
8
  main: "dist/index.js",
@@ -11,6 +11,7 @@ var package_default = {
11
11
  loops: "dist/cli/index.js",
12
12
  "loops-daemon": "dist/daemon/index.js",
13
13
  "loops-api": "dist/api/index.js",
14
+ "loops-serve": "dist/serve/index.js",
14
15
  "loops-runner": "dist/runner/index.js",
15
16
  "loops-mcp": "dist/mcp/index.js"
16
17
  },
@@ -23,6 +24,14 @@ var package_default = {
23
24
  types: "./dist/sdk/index.d.ts",
24
25
  import: "./dist/sdk/index.js"
25
26
  },
27
+ "./sdk/http": {
28
+ types: "./dist/sdk/http.d.ts",
29
+ import: "./dist/sdk/http.js"
30
+ },
31
+ "./serve": {
32
+ types: "./dist/serve/index.d.ts",
33
+ import: "./dist/serve/index.js"
34
+ },
26
35
  "./mcp": {
27
36
  types: "./dist/mcp/index.d.ts",
28
37
  import: "./dist/mcp/index.js"
@@ -68,7 +77,7 @@ var package_default = {
68
77
  "LICENSE"
69
78
  ],
70
79
  scripts: {
71
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
80
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/serve/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/sdk/http.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/serve/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
72
81
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
73
82
  typecheck: "tsc --noEmit",
74
83
  test: "bun test",
@@ -103,11 +112,13 @@ var package_default = {
103
112
  bun: ">=1.0.0"
104
113
  },
105
114
  dependencies: {
115
+ "@hasna/contracts": "^0.4.1",
106
116
  "@hasna/events": "^0.1.9",
107
117
  "@modelcontextprotocol/sdk": "^1.29.0",
108
118
  "@openrouter/ai-sdk-provider": "2.9.1",
109
119
  ai: "6.0.204",
110
120
  commander: "^13.1.0",
121
+ pg: "^8.13.1",
111
122
  zod: "4.4.3"
112
123
  },
113
124
  optionalDependencies: {
@@ -115,6 +126,7 @@ var package_default = {
115
126
  },
116
127
  devDependencies: {
117
128
  "@types/bun": "latest",
129
+ "@types/pg": "^8.11.10",
118
130
  typescript: "^5.7.3"
119
131
  },
120
132
  publishConfig: {
@@ -193,6 +205,50 @@ function sourceOfTruthForMode(mode) {
193
205
  return "self_hosted_control_plane";
194
206
  return "cloud_control_plane";
195
207
  }
208
+ var ROUTE_ADMISSION_GATES = [
209
+ "max_dispatch",
210
+ "max_active",
211
+ "max_active_per_project",
212
+ "max_active_per_project_group",
213
+ "max_active_scope",
214
+ "max_per_profile"
215
+ ];
216
+ function remoteSchedulerBackendForMode(mode, config) {
217
+ if (mode === "local")
218
+ return "none";
219
+ if (mode === "cloud")
220
+ return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
221
+ if (config.databaseUrlPresent)
222
+ return "postgres_contract";
223
+ if (config.apiUrl)
224
+ return "api_control_plane_contract";
225
+ return "unconfigured";
226
+ }
227
+ function schedulerStateForMode(args) {
228
+ const nonLocal = args.deploymentMode !== "local";
229
+ return {
230
+ authority: args.sourceOfTruth,
231
+ localStore: {
232
+ backend: "sqlite",
233
+ role: args.localRole,
234
+ runArtifacts: "local_files",
235
+ routeAdmissionState: "workflow_work_items"
236
+ },
237
+ remoteStore: {
238
+ backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
239
+ configured: nonLocal && args.controlPlaneConfigured,
240
+ applySupported: false,
241
+ objectArtifacts: nonLocal ? "object_store_contract" : "none",
242
+ mutatesAws: false
243
+ },
244
+ routeAdmission: {
245
+ stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
246
+ activeStatuses: ["admitted", "running"],
247
+ gates: ROUTE_ADMISSION_GATES,
248
+ dryRunEvaluatesLiveCounts: false
249
+ }
250
+ };
251
+ }
196
252
  function displayControlPlaneUrl(value) {
197
253
  if (!value)
198
254
  return;
@@ -218,6 +274,9 @@ function buildDeploymentStatus(opts = {}) {
218
274
  if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
219
275
  warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
220
276
  }
277
+ if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
278
+ warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
279
+ }
221
280
  if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
222
281
  warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
223
282
  }
@@ -251,6 +310,13 @@ function buildDeploymentStatus(opts = {}) {
251
310
  required: deploymentMode !== "local",
252
311
  role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
253
312
  },
313
+ schedulerState: schedulerStateForMode({
314
+ deploymentMode,
315
+ sourceOfTruth: sourceOfTruthForMode(deploymentMode),
316
+ localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
317
+ controlPlaneConfigured,
318
+ config
319
+ }),
254
320
  warnings
255
321
  };
256
322
  }
@@ -263,6 +329,7 @@ function deploymentStatusLine(status) {
263
329
  `source=${status.deploymentModeSource}`,
264
330
  `truth=${status.sourceOfTruth}`,
265
331
  `local=${status.localStore.role}`,
332
+ `scheduler=${status.schedulerState.routeAdmission.stateStore}`,
266
333
  `control_plane=${configured}`
267
334
  ].join(" ");
268
335
  }
@@ -97,8 +97,8 @@ export declare const MAX_SKIPS_PER_LOOP_PER_TICK = 10;
97
97
  /**
98
98
  * Exponential retry backoff with jitter:
99
99
  * delay = retryDelayMs * 2^(attempt-1) * (0.5 + random), capped at 6h.
100
- * Failures classified as rate_limit/auth back off 4x harder, since hammering
101
- * a throttled or misconfigured provider only makes things worse.
100
+ * Failures classified as provider-gated back off 4x harder, since hammering
101
+ * a throttled, misconfigured, or unavailable provider only makes things worse.
102
102
  */
103
103
  export declare function retryBackoffDelayMs(loop: Loop, run: LoopRun, random?: () => number): number;
104
104
  export interface AdvanceLoopOptions {
@@ -129,6 +129,9 @@ export declare function executeClaimedRun(deps: {
129
129
  beforeFinalize?: (loop: Loop, run: LoopRun) => void;
130
130
  daemonLeaseId?: string;
131
131
  execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
132
+ finalizeResult?: (result: ExecutorResult, loop: Loop, run: LoopRun) => Omit<ExecutorResult, "status"> & {
133
+ status: LoopRun["status"];
134
+ };
132
135
  onError?: (loop: Loop, error: unknown) => void;
133
136
  }): Promise<LoopRun>;
134
137
  export declare function claimDueRuns(deps: SchedulerDeps & {
@@ -2,5 +2,6 @@ export type { AppliedStorageMigration, AuditEventRecord, LoopStorageBackend, Loo
2
2
  export { SqliteLoopStorage, createSqliteLoopStorage } from "./sqlite.js";
3
3
  export { PostgresStorage, createPostgresStorage } from "./postgres.js";
4
4
  export type { PostgresQueryExecutor } from "./postgres.js";
5
+ export { PostgresLoopStorage, createPostgresLoopStorage, NotImplementedError, } from "./postgres-loop-storage.js";
5
6
  export { POSTGRES_MIGRATION_LEDGER_TABLE, POSTGRES_STORAGE_MIGRATIONS, checksumStorageSql, } from "./postgres-schema.js";
6
7
  export { Store } from "../store.js";