@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
package/dist/types.d.ts CHANGED
@@ -365,6 +365,9 @@ export interface Loop {
365
365
  retryDelayMs: number;
366
366
  leaseMs: number;
367
367
  expiresAt?: string;
368
+ latestRunId?: string;
369
+ latestRunStatus?: RunStatus;
370
+ lastRunAt?: string;
368
371
  createdAt: string;
369
372
  updatedAt: string;
370
373
  }
@@ -0,0 +1,61 @@
1
+ # Loops Postgres Cutover Runbook
2
+
3
+ Status: **backend foundation landed; daemon still runs on local sqlite.** Do NOT
4
+ flip the running daemon to Postgres until every step below is green.
5
+
6
+ PURE REMOTE (Amendment A1): in cloud mode, reads AND writes hit cloud Postgres
7
+ directly. There is no hybrid/sync/cache mode. After a verified migration the
8
+ local sqlite file is renamed to `<name>.db.pre-cloud-2026-07-06.bak`.
9
+
10
+ ## What shipped in this PR
11
+
12
+ - Vendored `@hasna/contracts` storage kit → `src/generated/storage-kit/`
13
+ (pool/query/tls/mode/migrations/health). Regenerate: `bunx @hasna/contracts vendor-kit`.
14
+ - `src/lib/storage/pg-executor.ts` — `PgPoolExecutor` adapting the vendored
15
+ `pg.Pool` client to `PostgresQueryExecutor` (drives `PostgresStorage.migrate`).
16
+ - `src/lib/storage/pg-runner-claim.ts` — `claimNextRun` / `heartbeatRunLease`
17
+ using `SELECT ... FOR UPDATE SKIP LOCKED` for concurrent runners.
18
+ - `src/lib/storage/postgres-concurrency.test.ts` — two-connection race test
19
+ (skips unless `LOOPS_TEST_DATABASE_URL` is set; verified green against a
20
+ throwaway local Postgres 16).
21
+ - `pg` promoted to a direct dependency; `@types/pg` added as devDependency.
22
+
23
+ ## Remaining before daemon cutover (tracked, NOT done here)
24
+
25
+ 1. **Full `PostgresLoopStorage` implementing `LoopStorageContract`** (60+ methods
26
+ in `src/lib/storage/contract.ts`). Only the migration ledger + claim/lease
27
+ primitives exist today. The claim primitive here is the correctness core to
28
+ build the rest on.
29
+ 2. **Async consumer wiring.** All call sites construct `new Store()` synchronously
30
+ (`src/cli`, `src/daemon`, `src/sdk`, `src/mcp`, `src/lib/route`). PURE REMOTE
31
+ requires routing them through the async `LoopStorageContract` when
32
+ `LOOPS_DATABASE_URL` is set (mode resolved by `src/lib/mode.ts`, which stays
33
+ authoritative). `SqliteLoopStorage` already wraps the sync store for the
34
+ local path.
35
+ 3. **`loops migrate-local` command** (data migration TOOLING only): copy
36
+ definitions + schedules + last 30 days of runs from local sqlite into cloud
37
+ Postgres, then rename the sqlite file to the `.pre-cloud-2026-07-06.bak`
38
+ backup. Does NOT enable cloud mode.
39
+ 4. **Apply schema to shared RDS** via SSM tunnel on local port 15438 using the
40
+ `hasna/oss/loops/database-url-owner` secret, then run
41
+ `PostgresStorage(new PgPoolExecutor(...)).migrate()` (dry-run first). Kill the
42
+ tunnel and remove temp files afterward. Never run the test suite against RDS.
43
+
44
+ ## Enable steps (run only after 1–4 are green)
45
+
46
+ 1. Confirm `PostgresStorage.migrate({ dryRun: true })` reports all four
47
+ migrations already applied on the loops RDS database.
48
+ 2. Stop the loops daemon: `loops daemon stop`.
49
+ 3. `loops migrate-local --database-url "$LOOPS_DATABASE_URL"` (moves defs +
50
+ schedules + 30d of runs; verify counts; sqlite renamed to backup).
51
+ 4. Export `LOOPS_DATABASE_URL` (from `hasna/oss/loops/database-url`) and the
52
+ cloud storage-mode env for the daemon unit; restart the daemon.
53
+ 5. Verify `loops status` reports `deploymentMode: cloud`, `sourceOfTruth:
54
+ cloud_control_plane`, and that a test loop run claims + finalizes against
55
+ Postgres.
56
+
57
+ ## Rollback
58
+
59
+ Cloud mode is off until `LOOPS_DATABASE_URL` is present in the daemon env.
60
+ To roll back: unset it, restore the sqlite backup
61
+ (`mv <name>.db.pre-cloud-2026-07-06.bak <name>.db`), restart the daemon.
@@ -68,7 +68,7 @@ loops import ./loops-export.json --apply
68
68
  Human status output is intentionally compact:
69
69
 
70
70
  ```text
71
- deploymentMode=local active source=default truth=local_sqlite local=authoritative control_plane=none
71
+ deploymentMode=local active source=default truth=local_sqlite local=authoritative scheduler=local_sqlite control_plane=none
72
72
  ```
73
73
 
74
74
  JSON uses these field names:
@@ -85,6 +85,22 @@ JSON uses these field names:
85
85
  presence signal.
86
86
  - `controlPlane.apiUrl`: a display-safe URL without credentials, query string,
87
87
  or fragment.
88
+ - `schedulerState.localStore`: always names the local SQLite store and local
89
+ run artifact files. In `local` mode this store is authoritative; in
90
+ non-local modes it is a cache, offline spool, and audit copy.
91
+ - `schedulerState.remoteStore`: names the remote scheduler contract:
92
+ `api_control_plane_contract`, `postgres_contract`,
93
+ `hosted_control_plane_contract`, `unconfigured`, or `none`. Remote apply is
94
+ `false` in this public package until a control-plane host wires a full
95
+ storage adapter.
96
+ - `schedulerState.remoteStore.objectArtifacts`: `object_store_contract` means
97
+ remote artifact/object storage is a control-plane contract. The public package
98
+ does not create or mutate S3 buckets, AWS resources, or hosted credentials.
99
+ - `schedulerState.routeAdmission`: names the active route-state store and the
100
+ bounded gates (`max_dispatch`, `max_active`, `max_active_per_project`,
101
+ `max_active_per_project_group`, `max_active_scope`, and `max_per_profile`).
102
+ Live active counts use admitted/running work items; dry-runs do not open or
103
+ migrate the live store to compute counts.
88
104
 
89
105
  `loops-api` is a separate process in the same public package. It is not a
90
106
  separate package at this stage because self-hosted users and the hosted service
@@ -150,6 +166,12 @@ would generate new ids, so it is not a no-loss migration. Local SQLite remains
150
166
  authoritative until a safe import is applied; in non-local modes it may remain
151
167
  a cache, offline spool, and audit copy.
152
168
 
169
+ `LOOPS_DATABASE_URL` selects the self-hosted Postgres scheduler-state contract,
170
+ but it does not make the standalone CLI mutate a remote database by itself.
171
+ Remote execution still flows through a configured control-plane API and runner
172
+ protocol. `loops-runner` needs `LOOPS_API_URL` or `HASNA_LOOPS_API_URL` to claim
173
+ work; a database URL alone is migration/readiness configuration.
174
+
153
175
  `loops self-hosted runner-register` is also preview-only unless `--apply` is
154
176
  present. The dry run prints the runner id, machine id, labels, and
155
177
  capabilities that would be posted, without exposing tokens.
package/docs/USAGE.md CHANGED
@@ -25,6 +25,16 @@ planes:
25
25
  status only, and requires `LOOPS_CLOUD_API_URL` plus `LOOPS_CLOUD_TOKEN` or
26
26
  `HASNA_LOOPS_CLOUD_TOKEN` before status can report ready.
27
27
 
28
+ Scheduler state is explicit in status JSON. `schedulerState.localStore` is
29
+ SQLite plus local run artifact files: authoritative in `local`, cache/spool in
30
+ non-local modes. `schedulerState.remoteStore` names the non-local contract
31
+ (`api_control_plane_contract`, `postgres_contract`, or
32
+ `hosted_control_plane_contract`) and reports `applySupported=false` because this
33
+ public package does not directly mutate remote Postgres, S3/object storage, AWS
34
+ resources, or hosted credentials. Route admission remains bounded by
35
+ `max_dispatch`, `max_active`, `max_active_per_project`,
36
+ `max_active_per_project_group`, `max_active_scope`, and `max_per_profile`.
37
+
28
38
  Useful status commands:
29
39
 
30
40
  ```bash
@@ -468,21 +478,39 @@ worker finishes; pass `--verifier-idle-timeout none` or template variable
468
478
  `verifierIdleTimeoutMs=none` only when another heartbeat is guaranteed. Use a
469
479
  positive numeric `timeoutMs` only when an agentic step is intentionally bounded.
470
480
 
471
- To migrate existing workflow loops, do not edit `workflow_specs.steps_json`
472
- directly because historical workflow runs must keep pointing at their original
473
- spec. Use the append-only migrator:
481
+ To migrate existing agentic loops, use the timeout migrator instead of editing
482
+ the database directly. Workflow loops are migrated append-only because
483
+ historical workflow runs must keep pointing at their original spec; direct
484
+ agent loops selected with `--loop` update their stored target in place for
485
+ future executions:
474
486
 
475
487
  ```bash
476
488
  loops workflows migrate-agent-timeouts --loop <loop-id-or-name>
477
489
  loops workflows migrate-agent-timeouts --loop <loop-id-or-name> --apply
490
+ loops workflows migrate-goal-wrappers --loop <loop-id-or-name>
491
+ loops workflows migrate-goal-wrappers --loop <loop-id-or-name> --apply
492
+ loops workflows migrate-goal-wrappers --loop <loop-id-or-name> --apply --archive-old
478
493
  ```
479
494
 
480
- The command dry-runs by default. With `--apply`, it creates a new workflow spec
481
- with the requested agent timeout policy, retargets only future executions of
482
- eligible non-running workflow loops, and leaves terminal timed-out workflow runs
483
- as audit history. Use `loops workflows recover` only for interrupted `running`
484
- workflow runs whose recorded child process is gone; terminal `timed_out` runs
485
- must be requeued with `loops routes requeue <work-item-id> --reason "<cause fixed>"` before
495
+ Both migrators dry-run by default. For eligible non-running workflow loops,
496
+ `--apply` creates a new workflow spec and retargets only future executions;
497
+ historical workflow runs keep pointing at their original spec. For direct agent
498
+ loops selected with `--loop`, `migrate-agent-timeouts --apply` updates the
499
+ stored target in place for future executions. Use `--archive-old` to archive
500
+ superseded workflow specs when no active loops still reference them.
501
+
502
+ `migrate-agent-timeouts` clones the workflow with the requested agent timeout
503
+ policy (`--timeout none` by default). `migrate-goal-wrappers` targets loops that
504
+ define both a loop-level goal and a redundant workflow-level top-level goal: it
505
+ clones a goal-free workflow spec, retargets the loop, and leaves the loop-level
506
+ goal as the sole orchestration wrapper. Loops with only a workflow-level goal or
507
+ only a loop-level goal are skipped. New workflow loops cannot combine both
508
+ wrappers; use loop-level `--goal` on `loops create workflow` instead of nesting
509
+ a top-level `"goal"` in the workflow JSON.
510
+
511
+ Use `loops workflows recover` only for interrupted `running` workflow runs whose
512
+ recorded child process is gone; terminal `timed_out` runs must be requeued with
513
+ `loops routes requeue <work-item-id> --reason "<cause fixed>"` before
486
514
  re-delivering or draining the original task/event route.
487
515
 
488
516
  ```json
@@ -674,11 +702,12 @@ idempotency key before rendering worktree plans or checking route limits. In
674
702
  dry-run mode, throttle counts are not evaluated because opening the live loop
675
703
  store can create or migrate the local database.
676
704
  Terminal routed work items such as failed, dead-letter, cancelled, or succeeded
677
- history remain deduped until an operator runs `loops routes requeue
678
- <work-item-id> --reason "<cause fixed>"`; a later drain should not create
679
- another worker just because the previous workflow ended. The next route-created
680
- output records `requeue` evidence with the previous work item id, previous
681
- attempts, operator reason, new attempt, workflow id, and loop id.
705
+ history are re-admitted only when the todos task is still actionable, the
706
+ per-attempt backoff has elapsed, and the redispatch cap has not been reached.
707
+ Operators can still force a retry with `loops routes requeue <work-item-id>
708
+ --reason "<cause fixed>"`. The next route-created output records `requeue`
709
+ evidence with the previous work item id, previous attempts, reason, new attempt,
710
+ workflow id, and loop id.
682
711
 
683
712
  When a sandboxed Codewith/Codex worker must update app stores outside the repo
684
713
  worktree, pass those stores explicitly with `--add-dir` or template `addDirs`.
@@ -854,25 +883,46 @@ agent-run failures for default-loop SLOs:
854
883
 
855
884
  ```bash
856
885
  loops health --json
886
+ loops health scan --include active,paused --latest-run --doctor --daemon --json
857
887
  loops expectations <loop-id-or-name> --json
858
888
  ```
859
889
 
860
890
  The JSON contains the expectation result, bounded error/stdout/stderr evidence,
861
891
  a stable failure fingerprint, route metadata, and recommended task fields.
862
- OpenLoops does not mutate Todos from `health` or `expectations`. To turn failed
863
- expectations into deduped tasks, use the explicit routing command:
892
+ OpenLoops does not mutate Todos from `health`, `expectations`, or read-only
893
+ `health scan`. To turn failed expectations or scan findings into deduped tasks,
894
+ use an explicit mutating command:
864
895
 
865
896
  ```bash
866
897
  loops health route-tasks \
867
898
  --project ~/.hasna/loops \
868
899
  --task-list loop-error-self-heal \
869
900
  --max-actions 5
901
+
902
+ loops health scan \
903
+ --include active,paused \
904
+ --latest-run \
905
+ --doctor \
906
+ --daemon \
907
+ --upsert-todos \
908
+ --dry-run \
909
+ --max-actions 5 \
910
+ --evidence-dir ~/.hasna/loops/reports/health-scan
870
911
  ```
871
912
 
872
913
  Use `--dry-run --json` first when testing a new automation path. Routed tasks
873
914
  include the stable failure fingerprint, classification, loop id/name, and
874
915
  `no_tmux_dispatch=true` metadata.
875
916
 
917
+ `health scan` replaces local loop-error self-heal scripts with package-owned
918
+ CLI/SDK/MCP primitives. It inventories included loop statuses, detects daemon,
919
+ doctor, preflight, latest-run, and stale-running issues, writes bounded
920
+ `summary.json` and `report.md` files under
921
+ `$LOOPS_DATA_DIR/reports/health-scan` or `--report-dir`/`--evidence-dir`, and
922
+ keeps output compact. It is read-only by default. The only safe self-heal is
923
+ `--start-daemon`, which starts the daemon only when status proves it is not
924
+ running; it does not stop, resume, archive, delete, or reap loops.
925
+
876
926
  Use `--evidence-dir <dir>` when a deterministic loop needs a compact JSON
877
927
  heartbeat/report on disk. Use `--auto-route` only on task lists that should feed
878
928
  the task-created headless worker/verifier workflow; it adds the `auto:route`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
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",
@@ -9,6 +9,7 @@
9
9
  "loops": "dist/cli/index.js",
10
10
  "loops-daemon": "dist/daemon/index.js",
11
11
  "loops-api": "dist/api/index.js",
12
+ "loops-serve": "dist/serve/index.js",
12
13
  "loops-runner": "dist/runner/index.js",
13
14
  "loops-mcp": "dist/mcp/index.js"
14
15
  },
@@ -21,6 +22,14 @@
21
22
  "types": "./dist/sdk/index.d.ts",
22
23
  "import": "./dist/sdk/index.js"
23
24
  },
25
+ "./sdk/http": {
26
+ "types": "./dist/sdk/http.d.ts",
27
+ "import": "./dist/sdk/http.js"
28
+ },
29
+ "./serve": {
30
+ "types": "./dist/serve/index.d.ts",
31
+ "import": "./dist/serve/index.js"
32
+ },
24
33
  "./mcp": {
25
34
  "types": "./dist/mcp/index.d.ts",
26
35
  "import": "./dist/mcp/index.js"
@@ -66,7 +75,7 @@
66
75
  "LICENSE"
67
76
  ],
68
77
  "scripts": {
69
- "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",
78
+ "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",
70
79
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
71
80
  "typecheck": "tsc --noEmit",
72
81
  "test": "bun test",
@@ -101,11 +110,13 @@
101
110
  "bun": ">=1.0.0"
102
111
  },
103
112
  "dependencies": {
113
+ "@hasna/contracts": "^0.4.1",
104
114
  "@hasna/events": "^0.1.9",
105
115
  "@modelcontextprotocol/sdk": "^1.29.0",
106
116
  "@openrouter/ai-sdk-provider": "2.9.1",
107
117
  "ai": "6.0.204",
108
118
  "commander": "^13.1.0",
119
+ "pg": "^8.13.1",
109
120
  "zod": "4.4.3"
110
121
  },
111
122
  "optionalDependencies": {
@@ -113,6 +124,7 @@
113
124
  },
114
125
  "devDependencies": {
115
126
  "@types/bun": "latest",
127
+ "@types/pg": "^8.11.10",
116
128
  "typescript": "^5.7.3"
117
129
  },
118
130
  "publishConfig": {