@starci/hfs 4.0.10 → 4.2.0

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 (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -1,1367 +0,0 @@
1
- // engine/db/machine.mjs — the ONE writer of machine.sqlite (DBTREE.sql Part B, schema 'starci/machine@1', user_version MACHINE_VERSION).
2
- //
3
- // machine.sqlite is the host's single operational store: the ledger registry, the Supervisor (sup_*), the engine
4
- // reconciler (process_runs, engine_leader, leader_history, schedules, engine_actions, controller_modes, sla_episodes),
5
- // services, seats and deliveries, throttle and pool backoff, GC, the land queue, lanes, pushes, machine_logs (+FTS5),
6
- // metrics and notifications. It replaces reconciler.sqlite, the supervisor ledger, journal.sqlite, the JSON state files
7
- // (ram-throttle.json, gc-state.json, reconciler-starts.json, the heartbeat, the land queue dirs, connectors/, env-servers/,
8
- // uat-slots/) and the text logs. The DDL is engine/db/migrations/machine/0001-init.sql (executed as data).
9
- //
10
- // Connection policy (DBTREE header, RESEARCH-STORAGE §3):
11
- // new file : page_size=4096, auto_vacuum=INCREMENTAL before the first table, then journal_mode=WAL (anything else refuses)
12
- // writer : synchronous=NORMAL, foreign_keys=ON, busy_timeout=15000, temp_store=MEMORY, cache_size=-16000,
13
- // journal_size_limit=64 MiB, trusted_schema=OFF, wal_autocheckpoint=0 on EVERY connection. The one checkpointer
14
- // is the reconciler engine LEADER: openMachine({checkpointer:true}) + checkpoint({name, holder, epoch}) on its
15
- // 60 s timer, a PASSIVE checkpoint fenced on the engine_leader row (a standby or a draining engine never
16
- // checkpoints). SQLite 3.50.4 (Node 25.2.1) is in the WAL-reset bug range 3.7.0–3.51.2: two checkpoints close
17
- // together while another connection resets the WAL can drop a committed transaction (incident 2026-09-28).
18
- // writes : BEGIN IMMEDIATE transactions (handle.transaction) or single autocommit statements, busy_timeout 15000.
19
- // corrupt : a transient SQLITE_CORRUPT / SQLITE_NOTADB is retried after reopening the connection (CORRUPT_RETRY_DELAYS_MS):
20
- // an autocommit statement alone, a transaction as a whole. A retry that recovers is a machine_logs warn row;
21
- // one that persists throws STARCI_MACHINE_CORRUPT and is recorded as an incident (machine_logs error row,
22
- // or the outbox when the store refuses it). Never swallowed: readMachine rethrows it too.
23
- // outbox : <machine.sqlite>.outbox.jsonl, append-only: a typed write the store refused (writeOrDefer) waits there
24
- // and the next flushOutbox (the land gate) applies it. Only idempotent writers are deferrable (DEFERRABLE).
25
- // reader : readOnly, query_only=ON, busy_timeout=15000; observer diagnostics never persist
26
- // startup : sqlite_version, node_version, journal_mode and user_version are recorded in machine_meta; a file that is
27
- // not 'starci/machine@1' at user_version MACHINE_VERSION is refused — a fresh machine.sqlite is created by
28
- // openMachine on first use from engine/db/migrations/machine/0001-init.sql. Current physical objects are validated; existing journals remain historical evidence. Unsupported versions refuse without upgrade.
29
- // Nothing outside engine/ opens machine.sqlite with `new DatabaseSync`: callers use openMachine / openMachineReader / openMachineObserver /
30
- // withMachine / readMachine and the typed functions on the handle.
31
- import { assertMutationFence } from '../../scripts/lib/mutation-fence.mjs';
32
- import fs from 'node:fs';
33
- import os from 'node:os';
34
- import path from 'node:path';
35
- import crypto from 'node:crypto';
36
- import { createRequire } from 'node:module';
37
- import { pathToFileURL, fileURLToPath } from 'node:url';
38
- import { putBlob as storeBlob, blobPath, getBlob } from './blob.mjs';
39
- import { redactBytes, redactData, redactText } from '../../scripts/lib/redact.mjs'; import { isMain } from '../../scripts/lib/is-main.mjs';
40
- import { isSpecRun, readEnv } from '../../scripts/lib/env.mjs';
41
- import { pathKey } from '../../scripts/lib/path-key.mjs';
42
- import { insertPairs, insertRowWith } from '../../scripts/lib/sqlite.mjs';
43
- import { need as refuseUnless } from '../refuse.mjs';
44
- import { sha256 } from '../digest.mjs';
45
- import { machineSchemaMethods } from './machine-schema.mjs';
46
- import { machineConnectionMethods, corruptDiagnostic, MACHINE_BUSY_TIMEOUT_MS, MACHINE_CORRUPT_CODE, CORRUPT_RETRY_DELAYS_MS,
47
- waitForRetry, isCorruptError, isBusyError, errText } from './machine-connection.mjs';
48
- export { MACHINE_BUSY_CODE, isMachineBusy, isBusyError } from './machine-connection.mjs';
49
- import { providerReservationMethods } from './provider-reservations.mjs';
50
-
51
- const require = createRequire(import.meta.url);
52
- const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
53
- export const MACHINE_SCHEMA = 'starci/machine@1';
54
- export const MACHINE_VERSION = 3;
55
- /** Test seam: STARCI_MACHINE_BUSY_TIMEOUT_MS (a positive integer) replaces the writer's busy_timeout; unset in production. */
56
- const busyTimeoutOf = (env = process.env) => { const n = Number(env?.STARCI_MACHINE_BUSY_TIMEOUT_MS); return Number.isInteger(n) && n > 0 ? n : MACHINE_BUSY_TIMEOUT_MS; };
57
- export const INIT_SQL_FILE = path.join(ENGINE_DIR, 'migrations', 'machine', '0001-init.sql');
58
- export const CONTROLLERS = Object.freeze(['job', 'workflow', 'resource', 'host', 'gc', 'workers', 'learning']);
59
-
60
- // ---------------------------------------------------------------------------------------------------------------------
61
- // Paths
62
- // ---------------------------------------------------------------------------------------------------------------------
63
- /**
64
- * Overrides the per-host state base itself (machine.sqlite, projects/, archive/), the one seam starciLocalRoot and
65
- * engine/db/ledger.mjs (which re-exports starciLocalRoot for its own projectsRootFor) both read. Debug probes and
66
- * throwaway repos (.starci/host/maintenance.md) point this at a temp directory so they never touch the real
67
- * %LOCALAPPDATA%/StarCi and leak fake ledgers/workflows into it (2026-09-30 incident: probe-*, dbg-ask-*, dbg-env*
68
- * repos left six fake-worker ledgers in the live store). STARCI_PROJECTS_ROOT (ledger-db.mjs) and
69
- * STARCI_TEST_MACHINE_FILE (TEST_REGISTRY_ENV) are narrower overrides that still win over this one when set.
70
- */
71
- export const LOCAL_ROOT_ENV = 'STARCI_LOCAL_ROOT';
72
- /** %LOCALAPPDATA%/StarCi (or ~/.local/state/StarCi): the per-host state base. LOCAL_ROOT_ENV overrides it wholesale. */
73
- export const starciLocalRoot = (env = process.env) =>
74
- env[LOCAL_ROOT_ENV] ? path.resolve(env[LOCAL_ROOT_ENV]) : path.join(env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'state'), 'StarCi');
75
- /** <local root>/projects: one directory per ledger (decision Q1). */
76
- export const localProjectsRoot = (env = process.env) => path.join(starciLocalRoot(env), 'projects');
77
- /** The runtime.sqlite of one ledger (decision Q1): %LOCALAPPDATA%/StarCi/projects/<ledger_id>/runtime.sqlite. */
78
- export const projectLedgerFile = (ledgerId, env = process.env) => {
79
- if (!/^[A-Za-z0-9-]{8,64}$/.test(String(ledgerId ?? ''))) throw Error(`projectLedgerFile needs a ledger id, got ${ledgerId}`);
80
- return path.join(localProjectsRoot(env), String(ledgerId), 'runtime.sqlite');
81
- };
82
- /** The explicit test registry: a machine.sqlite that replaces the host's for this process tree (tests/setup/isolated-registry.mjs). */
83
- export const TEST_REGISTRY_ENV = 'STARCI_TEST_MACHINE_FILE';
84
- const normDir = (file) => pathKey(file, { fold: true });
85
- const tempDirsOf = (env = process.env) => [...new Set([os.tmpdir(), env.TEMP, env.TMP].filter(Boolean)
86
- .flatMap((dir) => { const out = [normDir(dir)]; try { out.push(normDir(fs.realpathSync.native(dir))); } catch { /* missing */ } return out; }))]
87
- .filter((dir) => !/^(?:[a-z]:)?$/.test(dir));
88
- /** True when `file` sits under an OS temp directory, as written or as its realpath. */
89
- export function isUnderTempDir(file, { env = process.env, tempDirs = tempDirsOf(env) } = {}) {
90
- if (typeof file !== 'string' || !file) return false;
91
- const forms = [normDir(file)];
92
- try { forms.push(normDir(fs.realpathSync.native(file))); } catch { /* missing */ }
93
- return forms.some((form) => tempDirs.map(normDir).some((dir) => form.startsWith(`${dir}/`)));
94
- }
95
- /**
96
- * machine.sqlite for `env`: TEST_REGISTRY_ENV when set; else %LOCALAPPDATA%/StarCi/machine.sqlite (beside projects/,
97
- * NOT in the old runtime/ directory, so the new store never meets the old file at the same path) — except inside a node --test
98
- * process tree whose runtime root is not under the temp directory, which gets a shared temp registry instead.
99
- */
100
- export const machineFileFor = (env = process.env) => {
101
- if (env[TEST_REGISTRY_ENV]) return path.resolve(env[TEST_REGISTRY_ENV]);
102
- const file = path.join(starciLocalRoot(env), 'machine.sqlite');
103
- if (isSpecRun(env) && !isUnderTempDir(file, { env })) return path.join(os.tmpdir(), 'starci-test-registry', 'machine.sqlite');
104
- return file;
105
- };
106
-
107
- // ---------------------------------------------------------------------------------------------------------------------
108
- // Small helpers
109
- // ---------------------------------------------------------------------------------------------------------------------
110
- const need = (ok, message, code = 'STARCI_MACHINE_DB') => refuseUnless(ok, message, code);
111
- const hex = (bytes) => crypto.randomBytes(bytes).toString('hex');
112
- export const newTraceId = () => hex(16);
113
- export const newSpanId = () => hex(8);
114
- const JSON_LIMIT = 65536;
115
- const toJson = (value) => (value === undefined || value === null ? null : typeof value === 'string' ? (JSON.parse(value), value) : JSON.stringify(value));
116
- const parse = (text) => { if (text == null) return null; try { return JSON.parse(text); } catch { return null; } };
117
- const int = (v) => (v === undefined || v === null || v === '' ? null : Math.trunc(Number(v)));
118
- const bool = (v) => (v === undefined || v === null ? null : v ? 1 : 0);
119
- const writerPragmas = (env) => ({ synchronous: 'NORMAL', busy_timeout: busyTimeoutOf(env), temp_store: 'MEMORY', cache_size: -16000,
120
- journal_size_limit: 67108864, trusted_schema: 'OFF' });
121
- const pragma = (db, name) => { const row = db.prepare(`PRAGMA ${name}`).get(); return row ? Object.values(row)[0] : null; };
122
- /** True while `pid` names a live process (EPERM counts as alive). */
123
- export const pidAlive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } };
124
- const { openWithRetry, connectionState } = machineConnectionMethods({ reportIncident: corruptIncident });
125
- /** Operational failures also persist their incident through a fresh writer or the deferred outbox. */
126
- function corruptIncident(file, error, details) {
127
- const out = corruptDiagnostic(file, error, details);
128
- if (out === error) return out;
129
- const { retries, where } = details;
130
- const row = { actor: 'harness', kind: 'machine-db.corrupt', level: 'error', msg: out.message,
131
- data: { file, where, retries, error: errText(error), errcode: error?.errcode ?? null, quickCheck: out.quickCheck, pid: process.pid, sqlite: process.versions.sqlite, node: process.version } };
132
- try {
133
- const { DatabaseSync } = require('node:sqlite');
134
- const db = new DatabaseSync(file, { timeout: MACHINE_BUSY_TIMEOUT_MS });
135
- try {
136
- db.exec('PRAGMA wal_autocheckpoint=0;');
137
- db.prepare('INSERT INTO machine_logs(at,actor,level,kind,msg,data_json) VALUES(?,?,?,?,?,?)').run(Date.now(), row.actor, row.level, row.kind, row.msg, JSON.stringify(row.data));
138
- } finally { db.close(); }
139
- } catch (e) {
140
- try { out.deferred = deferWrite({ op: 'log', args: [{ ...row, src: null }], file, error: e }); } catch (e2) { process.stderr.write(`[machine-db] INCIDENT could not be recorded: ${errText(e2)}\n`); }
141
- }
142
- return out;
143
- }
144
-
145
- /**
146
- * The rev of the runtime this process runs, as its callers know it: '<HEAD committer time, ms, 13 digits>:<short sha>' of
147
- * the .claude checkout, which scripts/machine/home.mjs runtimeRevOf computes and the caller passes in (`rev`,
148
- * `writerRev`, `sourceRev`). The db never spawns git: with no rev passed the default is STARCI_RUNTIME_REV, else
149
- * 'unknown'. The time prefix orders two revs, so a writer can refuse a store row written by a NEWER runtime (MB-15);
150
- * 'unknown' sorts before every real rev.
151
- */
152
- export function runtimeRev() {
153
- return readEnv('STARCI_RUNTIME_REV') ? String(readEnv('STARCI_RUNTIME_REV')) : 'unknown';
154
- }
155
- /** Order of two runtime revs: <0 when a is older than b (time prefix; anything unparsable is oldest). */
156
- const compareRevs = (a, b) => { const t = (r) => (/^\d{13}:/.test(String(r ?? '')) ? Number(String(r).slice(0, 13)) : -1); return t(a) - t(b); };
157
-
158
- const { checkSchema, createSchema } = machineSchemaMethods({ schema: MACHINE_SCHEMA, version: MACHINE_VERSION, initSqlFile: INIT_SQL_FILE, controllers: CONTROLLERS, runtimeRev, pragma });
159
-
160
- function openConnection(file, { readOnly = false, env = process.env, now = Date.now, onCorrupt = corruptIncident } = {}) {
161
- const { DatabaseSync } = require('node:sqlite');
162
- need(typeof file === 'string' && file.trim(), 'openMachine needs a file');
163
- if (!readOnly) fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
164
- return openWithRetry(() => {
165
- let db;
166
- try {
167
- if (readOnly) {
168
- db = new DatabaseSync(file, { readOnly: true, timeout: MACHINE_BUSY_TIMEOUT_MS });
169
- db.exec('PRAGMA query_only=ON; PRAGMA temp_store=MEMORY; PRAGMA cache_size=-16000;');
170
- checkSchema(db, file);
171
- return db;
172
- }
173
- db = new DatabaseSync(file, { timeout: busyTimeoutOf(env) });
174
- const empty = Number(pragma(db, 'user_version')) === 0 && !db.prepare("SELECT 1 FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' LIMIT 1").get();
175
- if (!empty) checkSchema(db, file); // Refuse existing stores before persistent WAL/facts changes.
176
- if (empty) db.exec('PRAGMA page_size=4096; PRAGMA auto_vacuum=INCREMENTAL;');
177
- const mode = String(pragma(db, 'journal_mode=WAL')).toLowerCase();
178
- need(mode === 'wal', `machine.sqlite journal_mode is '${mode}', not wal (${file})`, 'STARCI_MACHINE_NOT_WAL');
179
- db.exec('PRAGMA foreign_keys=ON;');
180
- for (const [k, v] of Object.entries(writerPragmas(env))) db.exec(`PRAGMA ${k}=${v};`);
181
- // Never an automatic checkpoint: the engine leader's fenced checkpoint() is the only one (header, G17).
182
- db.exec('PRAGMA wal_autocheckpoint=0;');
183
- if (empty) createSchema(db, { file, env, now });
184
- checkSchema(db, file);
185
- recordFacts(db);
186
- return db;
187
- } catch (error) {
188
- try { db?.close(); } catch { /* closed */ }
189
- throw error;
190
- }
191
- }, { file, onCorrupt });
192
- }
193
-
194
- function recordFacts(db) {
195
- const facts = { sqlite_version: db.prepare('select sqlite_version() v').get().v, node_version: process.version, journal_mode: String(pragma(db, 'journal_mode')).toLowerCase() };
196
- const stale = Object.entries(facts).filter(([k, v]) => db.prepare('SELECT value FROM machine_meta WHERE key=?').get(k)?.value !== v);
197
- if (!stale.length) return;
198
- const put = db.prepare('INSERT INTO machine_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value');
199
- try { for (const [k, v] of stale) put.run(k, v); } catch (error) { if (!isBusyError(error)) throw error; /* another writer records them */ }
200
- }
201
-
202
- // ---------------------------------------------------------------------------------------------------------------------
203
- // Generic row writes (column names are checked against the live table; table names are this module's constants)
204
- // ---------------------------------------------------------------------------------------------------------------------
205
- const columnsCache = new WeakMap();
206
- function columnsOf(db, table) {
207
- let byTable = columnsCache.get(db);
208
- if (!byTable) { byTable = new Map(); columnsCache.set(db, byTable); }
209
- if (!byTable.has(table)) {
210
- const cols = db.prepare(`PRAGMA table_xinfo(${JSON.stringify(table)})`).all().filter((c) => !c.hidden).map((c) => c.name);
211
- need(cols.length, `machine-db: unknown table ${table}`);
212
- byTable.set(table, new Set(cols));
213
- }
214
- return byTable.get(table);
215
- }
216
- const cellOf = (key, value) => {
217
- if (value === undefined) return undefined;
218
- if (value === null) return null;
219
- if (key.endsWith('_json')) return toJson(value);
220
- if (typeof value === 'boolean') return value ? 1 : 0;
221
- if (typeof value === 'object') return JSON.stringify(value);
222
- return value;
223
- };
224
- function rowCells(db, table, row) {
225
- const cols = columnsOf(db, table);
226
- const entries = Object.entries(row).map(([k, v]) => [k, cellOf(k, v)]).filter(([, v]) => v !== undefined);
227
- for (const [k] of entries) need(cols.has(k), `machine-db: ${table} has no column ${k}`);
228
- return entries;
229
- }
230
- const insertRow = insertRowWith(rowCells);
231
- function upsertRow(db, table, row, keys) {
232
- const entries = rowCells(db, table, row);
233
- const updates = entries.filter(([k]) => !keys.includes(k));
234
- const sql = `INSERT INTO ${table}(${entries.map(([k]) => k).join(',')}) VALUES(${entries.map(() => '?').join(',')}) ON CONFLICT(${keys.join(',')}) DO `
235
- + (updates.length ? `UPDATE SET ${updates.map(([k]) => `${k}=excluded.${k}`).join(',')}` : 'NOTHING');
236
- return db.prepare(sql).run(...entries.map(([, v]) => v));
237
- }
238
- function updateRow(db, table, set, where) {
239
- const s = rowCells(db, table, set), w = rowCells(db, table, where);
240
- need(s.length && w.length, `machine-db: update ${table} needs set and where`);
241
- return db.prepare(`UPDATE ${table} SET ${s.map(([k]) => `${k}=?`).join(',')} WHERE ${w.map(([k]) => `${k} IS ?`).join(' AND ')}`)
242
- .run(...s.map(([, v]) => v), ...w.map(([, v]) => v));
243
- }
244
-
245
- // ---------------------------------------------------------------------------------------------------------------------
246
- // The handle
247
- // ---------------------------------------------------------------------------------------------------------------------
248
- /**
249
- * Open machine.sqlite read-write (creating it from 0001-init on an empty file). Every connection runs wal_autocheckpoint=0;
250
- * `checkpointer:true` marks the reconciler engine's connection, the only one allowed to call checkpoint(), and only while
251
- * the engine_leader row names it (header).
252
- */
253
- export function openMachine({ file = null, env = process.env, now = Date.now, checkpointer = false, tempDirs = null } = {}) {
254
- const resolved = path.resolve(file ?? machineFileFor(env));
255
- const live = !env[TEST_REGISTRY_ENV] && !isUnderTempDir(resolved, { env, tempDirs: tempDirs ?? tempDirsOf(env) });
256
- return makeHandle(() => openConnection(resolved, { env, now }), { file: resolved, env, now, live, readOnly: false, checkpointer, tempDirs: tempDirs ?? tempDirsOf(env) });
257
- }
258
- /** Open machine.sqlite read-only (query_only); null when the file does not exist yet. */
259
- export function openMachineReader({ file = null, env = process.env, now = Date.now } = {}) {
260
- const resolved = path.resolve(file ?? machineFileFor(env));
261
- if (!fs.existsSync(resolved)) return null;
262
- return makeHandle(() => openConnection(resolved, { readOnly: true }), { file: resolved, env, now, live: false, readOnly: true, checkpointer: false, tempDirs: [] });
263
- }
264
- /** Observation-only reads: supported schemas, bounded retries and diagnostics without incident or recovery writes. */
265
- export function openMachineObserver({ file = null, env = process.env, now = Date.now } = {}) {
266
- const resolved = path.resolve(file ?? machineFileFor(env));
267
- if (!fs.existsSync(resolved)) return null;
268
- const { db, recovered, close } = connectionState(() => openConnection(resolved, { readOnly: true, env, now, onCorrupt: corruptDiagnostic }),
269
- { file: resolved, onCorrupt: corruptDiagnostic });
270
- const observer = { schema: MACHINE_SCHEMA, file: resolved, path: resolved, readOnly: true, db, recovered, close };
271
- for (const [name, fn] of Object.entries({ listLedgers, services, seats, providerHealth, logs, latestMetrics, throttleState, poolBackoff, quotas, hostLeases, budgets }))
272
- observer[name] = (...args) => fn(observer, ...args);
273
- return observer;
274
- }
275
- /** fn(handle) over a writer, closed afterwards. */
276
- export function withMachine(fn, options = {}) {
277
- const m = openMachine(options);
278
- try { return fn(m); } finally { m.close(); }
279
- }
280
- /** fn(handle) over a reader; `fallback` when the store does not exist or cannot be read — but a corrupt store is thrown, never hidden. */
281
- export function readMachine(fn, fallback = null, options = {}) {
282
- let m = null;
283
- try { m = openMachineReader(options); return m ? fn(m) : fallback; }
284
- catch (error) { if (isCorruptError(error)) throw error; return fallback; }
285
- finally { try { m?.close(); } catch { /* closed */ } }
286
- }
287
-
288
- function makeHandle(openRaw, { file, env, now, live, readOnly, checkpointer, tempDirs }) {
289
- let depth = 0;
290
- const { db, recovered, noteRecovered, close } = connectionState(openRaw, { file, inTransaction: () => depth > 0 });
291
- /**
292
- * BEGIN IMMEDIATE … COMMIT (nested calls join the open one). A transient corrupt error anywhere in the unit rolls it
293
- * back, reopens the connection and runs the unit again (bodies are DB-only, RESEARCH-STORAGE §3 rule 3, so a re-run is
294
- * exact); still corrupt after CORRUPT_RETRY_DELAYS_MS it is an incident.
295
- */
296
- const transaction = (fn) => {
297
- if (depth > 0) return fn(db); // nested: join the open transaction
298
- need(!readOnly, 'machine-db: read-only handle');
299
- for (let retries = 0; ; retries += 1) {
300
- try {
301
- db.exec('BEGIN IMMEDIATE'); // a refused BEGIN changed nothing: db.exec backs off (BUSY_RETRY_DELAYS_MS), then throws STARCI_MACHINE_BUSY
302
- depth += 1;
303
- let out;
304
- try { assertMutationFence({ kind: 'machine-write', db }); out = fn(db); db.exec('COMMIT'); } catch (error) { try { if (db.isTransaction) db.raw.exec('ROLLBACK'); } catch { /* none */ } throw error; } finally { depth -= 1; }
305
- if (retries) noteRecovered({ where: 'transaction', retries });
306
- return out;
307
- } catch (error) {
308
- if (!isCorruptError(error) || error.code === MACHINE_CORRUPT_CODE) throw error;
309
- if (retries >= CORRUPT_RETRY_DELAYS_MS.length) throw corruptIncident(file, error, { retries, where: 'transaction' });
310
- waitForRetry(CORRUPT_RETRY_DELAYS_MS[retries]);
311
- try { db.reopen(); } catch (openError) { if (!isCorruptError(openError)) throw openError; }
312
- }
313
- }
314
- };
315
- const m = { schema: MACHINE_SCHEMA, file, path: file, db, env, now, live, readOnly, checkpointer: Boolean(checkpointer) && !readOnly, transaction,
316
- /** Retries that recovered on this handle ({where, retries, at}); close() records them as one machine_logs warn row. */
317
- recovered,
318
- close() {
319
- if (recovered.length) {
320
- const row = { actor: 'harness', kind: 'machine-db.corrupt-recovered', level: 'warn', msg: `transient SQLITE_CORRUPT recovered ${recovered.length} time(s) on ${path.basename(file)}`,
321
- data: { file, pid: process.pid, recovered: recovered.slice(0, 20), sqlite: process.versions.sqlite, node: process.version } };
322
- recovered.length = 0;
323
- // a reader cannot write: its notice waits in the outbox for the next flush
324
- try { if (readOnly) throw Error('read-only handle'); API.log(m, row); } catch (error) { try { deferWrite({ op: 'log', args: [row], file, error }); } catch { /* stderr already has it */ } }
325
- }
326
- close();
327
- } };
328
- for (const [name, fn] of Object.entries(API)) m[name] = (...args) => {
329
- if (!readOnly) assertMutationFence({ kind: 'machine-access', name });
330
- return fn(m, ...args);
331
- };
332
- m.tempDirs = tempDirs;
333
- return m;
334
- }
335
-
336
- // ---------------------------------------------------------------------------------------------------------------------
337
- // Blobs
338
- // ---------------------------------------------------------------------------------------------------------------------
339
- /** Store bytes (Buffer | string | file path via {file}) in the blob store and record the blobs row. Returns the sha. */
340
- function putMachineBlob(m, content, { mediaType = 'application/octet-stream', pinned = false } = {}) {
341
- // Every blob is redacted before it is stored (scripts/lib/redact.mjs): text media 'v1', anything else 'binary'.
342
- const raw = Buffer.isBuffer(content) ? content : Buffer.from(typeof content === 'string' ? content : JSON.stringify(content));
343
- const { bytes, redaction } = redactBytes(raw, mediaType);
344
- const { sha, size, mediaType: stored } = storeBlob(bytes, { mediaType });
345
- const file = blobPath(sha);
346
- insertRow(m.db, 'blobs', { sha256: sha, bytes: size, media_type: stored, redaction, file_uri: String(file).replace(/\\/g, '/'), created_at: m.now(), pinned: pinned ? 1 : 0 }, { orIgnore: true });
347
- return sha;
348
- }
349
- /** A JSON value as `{json, sha}`: inline when it fits `limit`, else a small stub inline and the full value as a blob. */
350
- function jsonOrBlob(m, value, limit = JSON_LIMIT) {
351
- if (value === undefined || value === null) return { json: null, sha: null };
352
- const text = typeof value === 'string' ? value : JSON.stringify(value);
353
- if (Buffer.byteLength(text) <= limit) return { json: text, sha: null };
354
- const sha = putMachineBlob(m, text, { mediaType: 'application/json' });
355
- return { json: JSON.stringify({ truncated: true, bytes: Buffer.byteLength(text), sha256: sha }), sha };
356
- }
357
- /** The full JSON value behind a jsonOrBlob stub ({truncated, sha256}); the value itself otherwise. */
358
- export function fullJson(value) {
359
- if (value && typeof value === 'object' && value.truncated === true && typeof value.sha256 === 'string') {
360
- try { return JSON.parse(getBlob(value.sha256).toString('utf8')); } catch { return value; }
361
- }
362
- return value;
363
- }
364
- const textBlob = (m, text, mediaType = 'text/plain') => (text == null || text === '' ? null : putMachineBlob(m, String(text), { mediaType }));
365
-
366
- // ---------------------------------------------------------------------------------------------------------------------
367
- // B0. Ledgers registry and repositories
368
- // ---------------------------------------------------------------------------------------------------------------------
369
- const ledgerRow = (row) => (row ? { ledgerId: row.ledger_id, name: row.name, product: row.product, repoRoot: row.repo_root, file: row.file, state: row.state,
370
- schemaVersion: row.schema_version, registeredAt: row.registered_at, seenAt: row.seen_at, retiredAt: row.retired_at, retiredReason: row.retired_reason } : null);
371
- /** A ledger's own meta (key → value), read-only; {} when the file cannot be read. */
372
- function ledgerMetaOf(file) {
373
- let db = null;
374
- try {
375
- if (!fs.existsSync(file)) return {};
376
- const { DatabaseSync } = require('node:sqlite');
377
- db = new DatabaseSync(file, { readOnly: true, timeout: MACHINE_BUSY_TIMEOUT_MS });
378
- return Object.fromEntries(db.prepare('SELECT key, value FROM meta').all().map((r) => [r.key, r.value]));
379
- } catch { return {}; } finally { try { db?.close(); } catch { /* closed */ } }
380
- }
381
- const repoKey = (root) => path.resolve(String(root)).replace(/\\/g, '/').replace(/^[a-z]:/, (d) => d.toUpperCase());
382
- export const repoKeyOf = repoKey;
383
- /**
384
- * Register (or refresh) a ledger. `ledgerId` is the ledger's own meta.ledger_id. A new row needs name and repoRoot;
385
- * `file` defaults to projectLedgerFile(ledgerId). The live registry refuses a ledger file under the OS temp directory
386
- * (returns {registered:false, refused}); nothing is written then.
387
- */
388
- function registerLedger(m, { ledgerId, name = null, repoRoot = null, file = null, product = null, schemaVersion = null } = {}) {
389
- need(ledgerId, 'registerLedger needs the ledger meta.ledger_id');
390
- const target = path.resolve(file ?? projectLedgerFile(ledgerId, m.env));
391
- if (m.live && isUnderTempDir(target, { env: m.env, tempDirs: m.tempDirs }))
392
- return { ledgerId, registered: false, refused: `registry-temp-ledger: ${target} is under the OS temp directory and ${m.file} is the live registry; set ${TEST_REGISTRY_ENV}` };
393
- // The live registry also refuses a repository under the OS temp directory (a repro or test run outside the isolated
394
- // registry would leave a junk workflow in the harness UI): typed refusal, nothing written.
395
- const tempRepo = (root) => (m.live && root && isUnderTempDir(String(root), { env: m.env, tempDirs: m.tempDirs })
396
- ? { ledgerId, registered: false, code: 'STARCI_REGISTRY_TEMP_REPO',
397
- refused: `registry-temp-repo: repo_root ${repoKey(root)} is under the OS temp directory and ${m.file} is the live registry; set ${TEST_REGISTRY_ENV}` } : null);
398
- return m.transaction((db) => {
399
- const at = m.now();
400
- const existing = db.prepare('SELECT * FROM ledgers WHERE ledger_id=?').get(ledgerId);
401
- const refusedRepo = tempRepo(repoRoot ?? (existing ? null : ledgerMetaOf(target).repo_root ?? null));
402
- if (refusedRepo) return refusedRepo;
403
- if (existing) {
404
- updateRow(db, 'ledgers', { file: target, seen_at: at, ...(name ? { name } : {}), ...(repoRoot ? { repo_root: repoKey(repoRoot) } : {}),
405
- ...(product ? { product } : {}), ...(schemaVersion != null ? { schema_version: int(schemaVersion) } : {}),
406
- ...(existing.state === 'retired' ? {} : { state: 'active' }) }, { ledger_id: ledgerId });
407
- } else {
408
- // A ledger opened by its writer names itself: repo_root / product come from its own meta when not given.
409
- if (!repoRoot) { const own = ledgerMetaOf(target); repoRoot = own.repo_root ?? null; product = product ?? own.product ?? null; }
410
- name = name ?? (repoRoot ? path.basename(path.resolve(repoRoot)) : null);
411
- if (!name || !repoRoot) return { ledgerId, registered: false, refused: `registry-no-repo-root: ${target} names no repo_root in its meta and none was given` };
412
- insertRow(db, 'ledgers', { ledger_id: ledgerId, name, product, repo_root: repoKey(repoRoot), file: target, state: 'active',
413
- schema_version: int(schemaVersion), registered_at: at, seen_at: at });
414
- }
415
- if (repoRoot) upsertRow(db, 'repositories', { repo_root: repoKey(repoRoot), name: name ?? existing?.name ?? path.basename(repoRoot), role: 'backend', ledger_id: ledgerId, seen_at: at }, ['repo_root']);
416
- return { ledgerId, registered: true, file: target };
417
- });
418
- }
419
- /**
420
- * The ledger for a repository root, name or id; null when unregistered. `create:true` with repoRoot (and name) mints a
421
- * new ledger id, registers it at projectLedgerFile(id) and returns it — the caller then creates runtime.sqlite there
422
- * with meta.ledger_id = ledgerId.
423
- */
424
- function resolveLedger(m, { ledgerId = null, name = null, repoRoot = null, create = false, product = null } = {}) {
425
- const db = m.db;
426
- let row = null;
427
- if (ledgerId) row = db.prepare('SELECT * FROM ledgers WHERE ledger_id=?').get(ledgerId);
428
- else if (repoRoot) row = db.prepare("SELECT * FROM ledgers WHERE repo_root=? ORDER BY state='retired', seen_at DESC LIMIT 1").get(repoKey(repoRoot));
429
- else if (name) row = db.prepare('SELECT * FROM ledgers WHERE name=?').get(name);
430
- if (row || !create) return ledgerRow(row);
431
- need(repoRoot, 'resolveLedger create needs repoRoot');
432
- const id = crypto.randomUUID();
433
- const made = registerLedger(m, { ledgerId: id, name: name ?? path.basename(path.resolve(repoRoot)), repoRoot, product });
434
- if (made.refused) throw Object.assign(Error(made.refused), { code: made.code ?? 'STARCI_MACHINE_DB' });
435
- return ledgerRow(db.prepare('SELECT * FROM ledgers WHERE ledger_id=?').get(id));
436
- }
437
- function listLedgers(m, { state = null, includeRetired = false } = {}) {
438
- const rows = state ? m.db.prepare('SELECT * FROM ledgers WHERE state=? ORDER BY name').all(state)
439
- : m.db.prepare(`SELECT * FROM ledgers ${includeRetired ? '' : "WHERE state<>'retired'"} ORDER BY name`).all();
440
- return rows.map(ledgerRow);
441
- }
442
- function touchLedger(m, ledgerId, { at = m.now() } = {}) { return m.db.prepare('UPDATE ledgers SET seen_at=? WHERE ledger_id=?').run(at, ledgerId).changes > 0; }
443
- function setLedgerState(m, ledgerId, state, { reason = null } = {}) {
444
- const at = m.now();
445
- return m.db.prepare('UPDATE ledgers SET state=?, retired_at=CASE WHEN ?=\'retired\' THEN ? ELSE NULL END, retired_reason=CASE WHEN ?=\'retired\' THEN ? ELSE NULL END WHERE ledger_id=?')
446
- .run(state, state, at, state, reason, ledgerId).changes > 0;
447
- }
448
- function upsertRepository(m, { repoRoot, name, role, ledgerId = null, defaultBranch = null, remote = null }) {
449
- return upsertRow(m.db, 'repositories', { repo_root: repoKey(repoRoot), name, role, ledger_id: ledgerId, default_branch: defaultBranch, remote, seen_at: m.now() }, ['repo_root']);
450
- }
451
- /**
452
- * Run fn({ledger, db}) over every registered ledger opened READ-ONLY, one at a time (B8: no ATTACH-UNION). A ledger that
453
- * cannot be opened yields {ledger, error}. Returns the array of results.
454
- */
455
- function forEachLedger(m, fn, { state = 'active' } = {}) {
456
- const { DatabaseSync } = require('node:sqlite');
457
- const out = [];
458
- for (const ledger of listLedgers(m, { state })) {
459
- let db = null;
460
- try {
461
- need(fs.existsSync(ledger.file), `ledger file missing: ${ledger.file}`);
462
- db = new DatabaseSync(ledger.file, { readOnly: true, timeout: MACHINE_BUSY_TIMEOUT_MS });
463
- db.exec('PRAGMA query_only=ON;');
464
- out.push({ ledger, result: fn({ ledger, db }) });
465
- } catch (error) { out.push({ ledger, error: String(error?.message ?? error) }); } finally { try { db?.close(); } catch { /* closed */ } }
466
- }
467
- return out;
468
- }
469
- /** Manual queries only: ATTACH up to 9 ledgers read-only to this handle as l0..l8. Returns the attached names. */
470
- function attachLedgers(m, ledgers = listLedgers(m)) {
471
- need(ledgers.length <= 9, 'attachLedgers: at most 9 ledgers per batch');
472
- return ledgers.map((l, i) => { m.db.exec(`ATTACH DATABASE ${JSON.stringify(pathToFileURL(l.file).href + '?mode=ro')} AS l${i}`); return { alias: `l${i}`, ...l }; });
473
- }
474
-
475
- // ---------------------------------------------------------------------------------------------------------------------
476
- // B1. Supervisor
477
- // ---------------------------------------------------------------------------------------------------------------------
478
- /** Append one sup_events row; the digest chain is computed here (JS), inside the caller's or a new transaction. */
479
- function supEvent(m, { entityType = 'supervisor', entityId = 'main', kind, payload = null, spanId = null, at = m.now(), eventId = null }) {
480
- need(kind, 'supEvent needs kind');
481
- return m.transaction((db) => {
482
- const { json, sha } = jsonOrBlob(m, payload == null ? null : redactData(payload), 16384);
483
- const prev = db.prepare('SELECT digest FROM sup_events ORDER BY seq DESC LIMIT 1').get()?.digest ?? null;
484
- const id = eventId ?? crypto.randomUUID();
485
- const digest = sha256([prev ?? '', id, entityType, entityId, kind, json ?? '', sha ?? '', at].join('\n'));
486
- const r = insertRow(db, 'sup_events', { event_id: id, entity_type: entityType, entity_id: String(entityId), kind, span_id: spanId, payload_json: json,
487
- payload_sha: sha, prev_digest: prev, digest, created_at: at });
488
- return { seq: Number(r.lastInsertRowid), eventId: id, digest };
489
- });
490
- }
491
- function supEvents(m, { kind = null, kinds = null, entityType = null, entityId = null, since = null, limit = 200, order = 'desc' } = {}) {
492
- const where = [], args = [];
493
- const list = kinds ?? (kind ? [kind] : null);
494
- if (list) { where.push(`kind IN (${list.map(() => '?').join(',')})`); args.push(...list); }
495
- if (entityType) { where.push('entity_type=?'); args.push(entityType); }
496
- if (entityId) { where.push('entity_id=?'); args.push(String(entityId)); }
497
- if (since != null) { where.push('seq>?'); args.push(since); }
498
- const rows = m.db.prepare(`SELECT * FROM sup_events ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY seq ${order === 'asc' ? 'ASC' : 'DESC'} LIMIT ?`).all(...args, limit);
499
- return rows.map((r) => ({ ...r, payload: fullJson(parse(r.payload_json)) }));
500
- }
501
- const newestSupEvent = (m, kind) => supEvents(m, { kind, limit: 1 })[0] ?? null;
502
-
503
- function upsertSupJob(m, { jobId, traceId = null, kind, role = 'worker', cluster = null, title, status = 'queued', lane = null, files = null, brief = null, payload = null }) {
504
- const at = m.now();
505
- const existing = m.db.prepare('SELECT trace_id, created_at FROM sup_jobs WHERE job_id=?').get(jobId);
506
- return upsertRow(m.db, 'sup_jobs', { job_id: jobId, trace_id: existing?.trace_id ?? traceId ?? newTraceId(), kind, role, cluster, title, status, lane,
507
- files_json: files, brief, payload_json: payload, created_at: existing?.created_at ?? at, updated_at: at }, ['job_id']);
508
- }
509
- function setSupJobStatus(m, jobId, status, { payload = undefined } = {}) {
510
- return m.transaction(() => {
511
- const changed = updateRow(m.db, 'sup_jobs', { status, updated_at: m.now(), ...(payload !== undefined ? { payload_json: payload } : {}) }, { job_id: jobId }).changes;
512
- if (changed) supEvent(m, { entityType: 'sup-job', entityId: jobId, kind: `sup-job-${status}` });
513
- return changed > 0;
514
- });
515
- }
516
- const supJob = (m, jobId) => { const r = m.db.prepare('SELECT * FROM sup_jobs WHERE job_id=?').get(jobId); return r ? { ...r, files: parse(r.files_json), payload: parse(r.payload_json) } : null; };
517
- function listSupJobs(m, { status = null, statuses = null, kind = null } = {}) {
518
- const where = [], args = [];
519
- const list = statuses ?? (status ? [status] : null);
520
- if (list) { where.push(`status IN (${list.map(() => '?').join(',')})`); args.push(...list); }
521
- if (kind) { where.push('kind=?'); args.push(kind); }
522
- return m.db.prepare(`SELECT * FROM sup_jobs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at, job_id`).all(...args)
523
- .map((r) => ({ ...r, files: parse(r.files_json), payload: parse(r.payload_json) }));
524
- }
525
- function acquireSupLeases(m, jobId, paths, { ttlMs = 3600000 } = {}) {
526
- return m.transaction((db) => {
527
- const at = m.now();
528
- db.prepare('DELETE FROM sup_leases WHERE expires_at<?').run(at);
529
- const held = paths.map((p) => db.prepare('SELECT job_id FROM sup_leases WHERE path=?').get(p)).map((r, i) => (r && r.job_id !== jobId ? { path: paths[i], holder: r.job_id } : null)).filter(Boolean);
530
- if (held.length) return { ok: false, conflicts: held };
531
- for (const p of paths) upsertRow(db, 'sup_leases', { path: p, job_id: jobId, acquired_at: at, expires_at: at + ttlMs }, ['path']);
532
- return { ok: true };
533
- });
534
- }
535
- const releaseSupLeases = (m, jobId) => m.db.prepare('DELETE FROM sup_leases WHERE job_id=?').run(jobId).changes;
536
- const supLeases = (m) => m.db.prepare('SELECT * FROM sup_leases ORDER BY path').all();
537
- function startSupAttempt(m, { jobId, spanId = newSpanId(), parentSpanId = null, ...rest }) {
538
- const seq = Number(m.db.prepare('SELECT COALESCE(max(dispatch_seq),0)+1 n FROM sup_attempts WHERE job_id=?').get(jobId).n);
539
- const cols = snake(rest);
540
- const r = insertRow(m.db, 'sup_attempts', { job_id: jobId, dispatch_seq: seq, span_id: spanId, parent_span_id: parentSpanId, spawned_at: m.now(), ...cols });
541
- return { attemptId: Number(r.lastInsertRowid), dispatchSeq: seq, spanId };
542
- }
543
- const updateSupAttempt = (m, attemptId, fields) => updateRow(m.db, 'sup_attempts', snake(fields), { attempt_id: attemptId }).changes > 0;
544
- const latestSupAttempt = (m, jobId) => m.db.prepare('SELECT * FROM sup_attempts WHERE job_id=? ORDER BY dispatch_seq DESC LIMIT 1').get(jobId) ?? null;
545
- function recordSupReport(m, { attemptId, jobId, outcome, report, reportText = null }) {
546
- const reportSha = reportText ? textBlob(m, reportText, 'text/markdown') : null;
547
- const r = upsertRow(m.db, 'sup_reports', { attempt_id: attemptId, job_id: jobId, outcome, report_json: report ?? {}, report_sha: reportSha, created_at: m.now() }, ['attempt_id']);
548
- return { reportId: Number(r.lastInsertRowid), reportSha };
549
- }
550
- const supReports = (m, { jobId = null, unconsumed = false } = {}) => m.db.prepare(`SELECT * FROM sup_reports WHERE 1=1 ${jobId ? 'AND job_id=?' : ''} ${unconsumed ? 'AND consumed_at IS NULL' : ''} ORDER BY report_id`)
551
- .all(...(jobId ? [jobId] : [])).map((r) => ({ ...r, report: parse(r.report_json) }));
552
- const consumeSupReport = (m, reportId) => m.db.prepare('UPDATE sup_reports SET consumed_at=? WHERE report_id=? AND consumed_at IS NULL').run(m.now(), reportId).changes > 0;
553
-
554
- /**
555
- * Open a Supervisor Decision Item, idempotent on its key (MB-07: '<kind>:<entity>:<signature>:<head>', no empty part).
556
- * Returns {diId, created}. An existing open item with the same key is returned unchanged.
557
- */
558
- function openSupDecision(m, { keyParts, kind, decider = 'supervisor', summary, ledgerId = null, workflowId = null, entityType = null, entityId = null,
559
- openedBy = 'reconciler', dueAt = null, escalateTo = null, evidence = null, options = null, allowedVerbs = null, payload = {} }) {
560
- need(keyParts && typeof keyParts === 'object' && !Array.isArray(keyParts), 'openSupDecision needs keyParts {kind, entity, signature, head, ...}');
561
- const parts = Object.values(keyParts).map((v) => String(v ?? ''));
562
- need(parts.length >= 2 && parts.every((p) => p.trim() && !p.includes(':')), `openSupDecision: empty or ':'-bearing key part in ${JSON.stringify(keyParts)} (MB-07)`);
563
- const key = parts.join(':');
564
- return m.transaction((db) => {
565
- const hit = db.prepare('SELECT di_id, status FROM sup_decision_items WHERE idempotency_key=?').get(key);
566
- if (hit) return { diId: hit.di_id, created: false, status: hit.status };
567
- const diId = `sdi-${crypto.randomUUID()}`;
568
- insertRow(db, 'sup_decision_items', { di_id: diId, idempotency_key: key, key_parts_json: keyParts, ledger_id: ledgerId, workflow_id: workflowId, kind, decider,
569
- entity_type: entityType, entity_id: entityId, summary, status: 'open', opened_by: openedBy, opened_at: m.now(), due_at: dueAt, escalate_to: escalateTo,
570
- evidence_json: evidence, options_json: options, allowed_verbs_json: allowedVerbs, payload_json: payload ?? {} });
571
- supEvent(m, { entityType: 'sup-decision', entityId: diId, kind: 'sup-decision-opened', payload: { key, kind, summary } });
572
- return { diId, created: true, status: 'open' };
573
- });
574
- }
575
- function setSupDecision(m, diId, { status, by = null, verb = null, choice = null, rationale = null, result = null, supersededBy = null, spanId = newSpanId() }) {
576
- return m.transaction((db) => {
577
- const at = m.now();
578
- const set = { status };
579
- if (status === 'claimed') Object.assign(set, { claim_by: by, claim_at: at });
580
- if (status === 'escalated') db.prepare('UPDATE sup_decision_items SET escalations=escalations+1 WHERE di_id=?').run(diId);
581
- if (status === 'superseded') set.superseded_by = supersededBy;
582
- let decisionId = null;
583
- if (status === 'resolved') {
584
- decisionId = `sdec-${crypto.randomUUID()}`;
585
- const di = db.prepare('SELECT * FROM sup_decision_items WHERE di_id=?').get(diId);
586
- insertRow(db, 'sup_decisions', { decision_id: decisionId, di_id: diId, decider: by ?? di?.decider ?? 'supervisor', span_id: spanId, ledger_id: di?.ledger_id, workflow_id: di?.workflow_id,
587
- subject_type: di?.entity_type, subject_id: di?.entity_id, choice: choice ?? verb ?? 'resolved', rationale, result_json: result, decided_at: at });
588
- Object.assign(set, { resolved_by: by, resolved_at: at, resolution_verb: verb, decision_id: decisionId });
589
- }
590
- const changed = updateRow(db, 'sup_decision_items', set, { di_id: diId }).changes;
591
- if (changed) supEvent(m, { entityType: 'sup-decision', entityId: diId, kind: `sup-decision-${status}`, payload: { by, verb } });
592
- return { changed: changed > 0, decisionId };
593
- });
594
- }
595
- const markSupDecisionDelivered = (m, diId) => m.db.prepare('UPDATE sup_decision_items SET delivered_at=COALESCE(delivered_at,?) WHERE di_id=?').run(m.now(), diId).changes > 0;
596
- function listSupDecisions(m, { open = true, kind = null } = {}) {
597
- return m.db.prepare(`SELECT * FROM sup_decision_items WHERE 1=1 ${open ? "AND status IN ('open','claimed','escalated')" : ''} ${kind ? 'AND kind=?' : ''} ORDER BY opened_at`)
598
- .all(...(kind ? [kind] : [])).map((r) => ({ ...r, keyParts: parse(r.key_parts_json), payload: parse(r.payload_json), evidence: parse(r.evidence_json) }));
599
- }
600
- function openOwed(m, { owedId, kind, subject, cluster = null, dueAt = null, detail = null }) {
601
- return m.transaction((db) => {
602
- const hit = db.prepare('SELECT state FROM sup_owed WHERE owed_id=?').get(owedId);
603
- if (hit) { if (detail !== null) updateRow(db, 'sup_owed', { detail_json: detail, ...(dueAt ? { due_at: dueAt } : {}) }, { owed_id: owedId }); return { owedId, created: false, state: hit.state }; }
604
- insertRow(db, 'sup_owed', { owed_id: owedId, kind, subject, cluster, state: 'open', opened_at: m.now(), due_at: dueAt, detail_json: detail });
605
- return { owedId, created: true, state: 'open' };
606
- });
607
- }
608
- const ackOwed = (m, owedId, { by = 'supervisor' } = {}) => m.db.prepare("UPDATE sup_owed SET state='acked', acked_at=?, acked_by=? WHERE owed_id=? AND state='open'").run(m.now(), by, owedId).changes > 0;
609
- const closeOwed = (m, owedId) => m.db.prepare("UPDATE sup_owed SET state='closed', closed_at=? WHERE owed_id=? AND state<>'closed'").run(m.now(), owedId).changes > 0;
610
- const listOwed = (m, { open = true } = {}) => m.db.prepare(`SELECT * FROM sup_owed ${open ? "WHERE state<>'closed'" : ''} ORDER BY opened_at`).all().map((r) => ({ ...r, detail: parse(r.detail_json) }));
611
- function upsertLearning(m, { itemId, kind, parentId = null, title, state = null, sourceRef = null, lane = null, landedSha = null, detail = null }) {
612
- const at = m.now();
613
- const created = m.db.prepare('SELECT created_at FROM sup_learning WHERE item_id=?').get(itemId)?.created_at ?? at;
614
- return upsertRow(m.db, 'sup_learning', { item_id: itemId, kind, parent_id: parentId, title, state, source_ref: sourceRef, lane, landed_sha: landedSha, detail_json: detail, created_at: created, updated_at: at }, ['item_id']);
615
- }
616
- const listLearning = (m, { kind = null } = {}) => m.db.prepare(`SELECT * FROM sup_learning ${kind ? 'WHERE kind=?' : ''} ORDER BY created_at, item_id`).all(...(kind ? [kind] : [])).map((r) => ({ ...r, detail: parse(r.detail_json) }));
617
- const recordOwnerRuling = (m, { rulingId = `rul-${crypto.randomUUID()}`, saidAt, channel = null, verbatim, paraphrase = null, appliesTo = null, contractRef = null, recordedBy = null }) =>
618
- (insertRow(m.db, 'sup_owner_rulings', { ruling_id: rulingId, said_at: saidAt, channel, verbatim, paraphrase, applies_to: appliesTo, contract_ref: contractRef, recorded_by: recordedBy, created_at: m.now() }), rulingId);
619
- function upsertBridge(m, { bridgeId, ledgerId = null, action, state, approvedBy = null, detail = null }) {
620
- const at = m.now();
621
- const created = m.db.prepare('SELECT created_at FROM sup_bridges WHERE bridge_id=?').get(bridgeId)?.created_at ?? at;
622
- return upsertRow(m.db, 'sup_bridges', { bridge_id: bridgeId, ledger_id: ledgerId, action, state, approved_by: approvedBy, detail_json: detail, created_at: created, updated_at: at }, ['bridge_id']);
623
- }
624
- function recordSupMessage(m, { msgId = `msg-${crypto.randomUUID()}`, direction, channel, chatId = null, messageId = null, from = null, to = null, via = null, text, ok = null, at = m.now() }) {
625
- insertRow(m.db, 'sup_messages', { msg_id: msgId, direction, channel, chat_id: chatId, message_id: messageId, from_ref: from, to_ref: to, via, text, ok: bool(ok), at }, { orIgnore: true });
626
- return msgId;
627
- }
628
- const supMessages = (m, { direction = null, unread = false, limit = 200 } = {}) => m.db.prepare(`SELECT * FROM sup_messages WHERE 1=1 ${direction ? 'AND direction=?' : ''} ${unread ? 'AND read_at IS NULL' : ''} ORDER BY at DESC LIMIT ?`)
629
- .all(...(direction ? [direction] : []), limit).reverse();
630
- const markSupMessagesRead = (m, ids) => { let n = 0; for (const id of ids) n += m.db.prepare('UPDATE sup_messages SET read_at=? WHERE msg_id=? AND read_at IS NULL').run(m.now(), id).changes; return n; };
631
- function setSupSignal(m, { scope, key = 'main', value = null, token = null, holderPid = process.pid, expiresAt = null }) {
632
- return upsertRow(m.db, 'sup_signals', { scope, key, holder_pid: holderPid, token, value_json: value, at: m.now(), expires_at: expiresAt }, ['scope', 'key']);
633
- }
634
- const supSignal = (m, scope, key = 'main') => { const r = m.db.prepare('SELECT * FROM sup_signals WHERE scope=? AND key=?').get(scope, key); return r ? { ...r, value: parse(r.value_json) } : null; };
635
- const clearSupSignal = (m, scope, key = 'main') => m.db.prepare('DELETE FROM sup_signals WHERE scope=? AND key=?').run(scope, key).changes > 0;
636
- function recordMachineLlmUsage(m, { subjectType, supAttemptId = null, turnRef = null, spanId = null, provider, requestModel = null, responseModel = null, source = 'cli-transcript', ...counts }) {
637
- return Number(insertRow(m.db, 'llm_usage', { subject_type: subjectType, sup_attempt_id: supAttemptId, turn_ref: turnRef, span_id: spanId, provider, request_model: requestModel,
638
- response_model: responseModel, source, at: m.now(), ...snake(counts) }).lastInsertRowid);
639
- }
640
-
641
- // ---------------------------------------------------------------------------------------------------------------------
642
- // B2. Engine: process runs, leader, cursors, queue, schedules, actions, modes, SLA, invariants
643
- // ---------------------------------------------------------------------------------------------------------------------
644
- /** A long-lived runtime process starts (MB-04, G1). Returns run_id. */
645
- function startProcessRun(m, { role, pid = process.pid, rev = runtimeRev(), epoch = null, parentActionId = null, startReason = 'manual' }) {
646
- return Number(insertRow(m.db, 'process_runs', { role, pid, host: os.hostname(), rev, epoch, parent_action_id: parentActionId, start_reason: startReason,
647
- started_at: m.now(), last_heartbeat_at: m.now() }).lastInsertRowid);
648
- }
649
- const heartbeatProcessRun = (m, runId, { draining = null } = {}) => m.db.prepare('UPDATE process_runs SET last_heartbeat_at=?, draining_since=CASE WHEN ?=1 THEN COALESCE(draining_since,?) ELSE draining_since END WHERE run_id=? AND ended_at IS NULL')
650
- .run(m.now(), draining ? 1 : 0, m.now(), runId).changes > 0;
651
- /** A process run ends, once (trigger process_runs_end_once). exitReason: clean|reload-handover|crash|killed|lost-lease|stopped|unknown. */
652
- function endProcessRun(m, runId, { exitCode = null, exitReason, killedBy = null }) {
653
- const at = m.now();
654
- const row = m.db.prepare('SELECT last_heartbeat_at, ended_at FROM process_runs WHERE run_id=?').get(runId);
655
- if (!row || row.ended_at != null) return false;
656
- return m.db.prepare('UPDATE process_runs SET ended_at=?, exit_code=?, exit_reason=?, killed_by=?, heartbeat_age_at_end_ms=? WHERE run_id=? AND ended_at IS NULL')
657
- .run(at, exitCode, exitReason, killedBy, row.last_heartbeat_at != null ? at - row.last_heartbeat_at : null, runId).changes > 0;
658
- }
659
- const openProcessRuns = (m, { role = null } = {}) => m.db.prepare(`SELECT * FROM process_runs WHERE ended_at IS NULL ${role ? 'AND role=?' : ''} ORDER BY run_id`).all(...(role ? [role] : []));
660
- const processRuns = (m, { role = null, sinceMs = null, limit = 200 } = {}) => m.db.prepare(`SELECT * FROM process_runs WHERE 1=1 ${role ? 'AND role=?' : ''} ${sinceMs != null ? 'AND started_at>?' : ''} ORDER BY run_id DESC LIMIT ?`)
661
- .all(...(role ? [role] : []), ...(sinceMs != null ? [m.now() - sinceMs] : []), limit);
662
-
663
- const leaderOf = (m, name = 'reconciler') => m.db.prepare('SELECT * FROM engine_leader WHERE name=?').get(name) ?? null;
664
- /**
665
- * Acquire or renew the leader lease (fencing epoch). A fresh/expired lease is taken with a new epoch and a leader_history
666
- * row; renewal by the holder keeps the epoch. Returns {leader:boolean, epoch, row}.
667
- */
668
- function acquireLeader(m, { name = 'reconciler', holder, pid = process.pid, leaseMs, rev = runtimeRev(), processRunId = null, handover = false }) {
669
- return m.transaction((db) => {
670
- const at = m.now();
671
- const cur = db.prepare('SELECT * FROM engine_leader WHERE name=?').get(name);
672
- if (cur && cur.holder === holder && cur.pid === pid) {
673
- db.prepare('UPDATE engine_leader SET heartbeat_at=?, expires_at=?, rev=?, process_run_id=COALESCE(?,process_run_id) WHERE name=?').run(at, at + leaseMs, rev, processRunId, name);
674
- return { leader: true, epoch: cur.epoch, renewed: true };
675
- }
676
- if (cur && cur.expires_at > at && !handover) return { leader: false, epoch: cur.epoch, holder: cur.holder, pid: cur.pid };
677
- const epoch = Math.max(Number(cur?.epoch ?? 0), Number(db.prepare('SELECT COALESCE(max(epoch),0) e FROM leader_history').get().e)) + 1;
678
- if (cur) db.prepare('UPDATE leader_history SET released_at=?, release_reason=? WHERE epoch=? AND released_at IS NULL').run(at, handover ? 'reload' : 'lost', cur.epoch);
679
- upsertRow(db, 'engine_leader', { name, holder, pid, epoch, process_run_id: processRunId, heartbeat_at: at, expires_at: at + leaseMs, rev, draining: 0, passes: 0, last_pass_ms: null, last_error: null }, ['name']);
680
- insertRow(db, 'leader_history', { epoch, holder, pid, process_run_id: processRunId, rev, acquired_at: at, acquired_how: !cur ? 'fresh' : handover ? 'handover' : 'takeover-stale' });
681
- return { leader: true, epoch, renewed: false };
682
- });
683
- }
684
- /** Renew only if still the holder at `epoch` (fence). Also records pass stats. */
685
- function renewLeader(m, { name = 'reconciler', epoch, leaseMs, passes = null, lastPassMs = null, lastError = undefined, draining = null }) {
686
- const at = m.now();
687
- return m.db.prepare(`UPDATE engine_leader SET heartbeat_at=?, expires_at=?, passes=COALESCE(?,passes), last_pass_ms=COALESCE(?,last_pass_ms)
688
- ${lastError !== undefined ? ', last_error=?' : ''} ${draining != null ? ', draining=?' : ''} WHERE name=? AND epoch=?`)
689
- .run(at, at + leaseMs, passes, lastPassMs, ...(lastError !== undefined ? [lastError] : []), ...(draining != null ? [draining ? 1 : 0] : []), name, epoch).changes > 0;
690
- }
691
- /** Release the lease held at `epoch`; closes its leader_history row with the reason (reload|lost|killed|stop|crash). */
692
- function releaseLeader(m, { name = 'reconciler', epoch, reason = 'stop' }) {
693
- return m.transaction((db) => {
694
- const n = db.prepare('DELETE FROM engine_leader WHERE name=? AND epoch=?').run(name, epoch).changes;
695
- db.prepare('UPDATE leader_history SET released_at=?, release_reason=? WHERE epoch=? AND released_at IS NULL').run(m.now(), reason, epoch);
696
- return n > 0;
697
- });
698
- }
699
- const leaderHistory = (m, { limit = 50 } = {}) => m.db.prepare('SELECT * FROM leader_history ORDER BY epoch DESC LIMIT ?').all(limit);
700
-
701
- const cursorOf = (m, ledgerId) => m.db.prepare('SELECT last_seq FROM engine_cursors WHERE ledger_id=?').get(ledgerId)?.last_seq ?? null;
702
- const setCursor = (m, ledgerId, lastSeq) => upsertRow(m.db, 'engine_cursors', { ledger_id: ledgerId, last_seq: lastSeq, updated_at: m.now() }, ['ledger_id']);
703
- const cursors = (m) => m.db.prepare('SELECT * FROM engine_cursors').all();
704
-
705
- function enqueue(m, { controller, key, dueAt = m.now(), reason = null }) {
706
- return m.db.prepare('INSERT INTO engine_queue(controller,key,due_at,reason) VALUES(?,?,?,?) ON CONFLICT(controller,key) DO UPDATE SET due_at=min(COALESCE(due_at,excluded.due_at),excluded.due_at), reason=excluded.reason')
707
- .run(controller, key, dueAt, reason);
708
- }
709
- const dueQueue = (m, { controller = null, limit = 100 } = {}) => m.db.prepare(`SELECT * FROM engine_queue WHERE due_at<=? ${controller ? 'AND controller=?' : ''} ORDER BY due_at LIMIT ?`)
710
- .all(m.now(), ...(controller ? [controller] : []), limit);
711
- const queueRows = (m) => m.db.prepare('SELECT * FROM engine_queue ORDER BY due_at').all();
712
- const dequeue = (m, controller, key) => m.db.prepare('DELETE FROM engine_queue WHERE controller=? AND key=?').run(controller, key).changes > 0;
713
- const requeue = (m, { controller, key, dueAt, error = null }) => m.db.prepare('UPDATE engine_queue SET due_at=?, tries=tries+1, last_error=? WHERE controller=? AND key=?').run(dueAt, error, controller, key).changes > 0;
714
-
715
- /** MB-01: the schedule row of a periodic duty; created with next_due_at = now + interval when absent (never "first run now"). */
716
- function ensureSchedule(m, { controller, duty, intervalMs, firstDueAt = null }) {
717
- m.db.prepare('INSERT INTO schedules(controller,duty,interval_ms,next_due_at) VALUES(?,?,?,?) ON CONFLICT(controller,duty) DO UPDATE SET interval_ms=excluded.interval_ms')
718
- .run(controller, duty, intervalMs, firstDueAt ?? m.now() + intervalMs);
719
- return m.db.prepare('SELECT * FROM schedules WHERE controller=? AND duty=?').get(controller, duty);
720
- }
721
- /** Claim a due duty (no overlap): true when this caller should run it now. */
722
- /**
723
- * Claim a due duty (no overlap): true when this caller should run it now. A claim whose holder process is dead, or that
724
- * is older than `ttlMs` (default: the interval, at least 1 h), is released first, so a crashed run never blocks the duty.
725
- */
726
- function claimSchedule(m, { controller, duty, actionId = null, pid = process.pid, ttlMs = null, alive = pidAlive }) {
727
- return m.transaction((db) => {
728
- const at = m.now();
729
- const row = db.prepare('SELECT * FROM schedules WHERE controller=? AND duty=?').get(controller, duty);
730
- if (!row) return false;
731
- if (row.running_pid != null && row.running_pid !== pid) {
732
- const ttl = ttlMs ?? Math.max(row.interval_ms, 3600000);
733
- if (!alive(row.running_pid) || at - (row.last_started_at ?? 0) > ttl)
734
- db.prepare("UPDATE schedules SET running_pid=NULL, last_result='unknown', last_finished_at=? WHERE controller=? AND duty=? AND running_pid=?").run(at, controller, duty, row.running_pid);
735
- }
736
- return db.prepare('UPDATE schedules SET last_started_at=?, running_pid=?, last_action_id=COALESCE(?,last_action_id) WHERE controller=? AND duty=? AND next_due_at<=? AND (running_pid IS NULL OR running_pid=?)')
737
- .run(at, pid, actionId, controller, duty, at, pid).changes > 0;
738
- });
739
- }
740
- function finishSchedule(m, { controller, duty, result = 'done', digest = null, nextDueAt = null }) {
741
- const at = m.now();
742
- const row = m.db.prepare('SELECT interval_ms FROM schedules WHERE controller=? AND duty=?').get(controller, duty);
743
- if (!row) return false;
744
- return m.db.prepare('UPDATE schedules SET last_finished_at=?, last_result=?, last_result_digest=?, next_due_at=?, running_pid=NULL WHERE controller=? AND duty=?')
745
- .run(at, result, digest, nextDueAt ?? at + row.interval_ms, controller, duty).changes > 0;
746
- }
747
- const schedules = (m) => m.db.prepare('SELECT * FROM schedules ORDER BY controller, duty').all();
748
-
749
- /** Deterministic action id = sha(controller, key, verb, epoch, observed_generation). */
750
- const actionIdOf = ({ controller, key = '', verb = '', epoch = 0, observedGeneration = 0 }) => sha256([controller, key, verb, epoch, observedGeneration].join('\u0000')).slice(0, 32);
751
- /** Record an action intent (idempotent on id). */
752
- function actionIntent(m, { id = null, controller, duty = null, key = null, verb = null, argvDigest = null, epoch = null, observedGeneration = null, spanId = newSpanId(), traceId = null,
753
- mode = null, ledgerId = null, workflowId = null, jobId = null, attemptId = null }) {
754
- const actionId = id ?? actionIdOf({ controller, key, verb, epoch, observedGeneration });
755
- insertRow(m.db, 'engine_actions', { id: actionId, controller, duty, key, verb, argv_digest: argvDigest, epoch, observed_generation: observedGeneration, span_id: spanId, trace_id: traceId,
756
- state: 'intent', mode, ledger_id: ledgerId, workflow_id: workflowId, job_id: jobId, attempt_id: attemptId }, { orIgnore: true });
757
- return actionId;
758
- }
759
- const actionRunning = (m, id, { requestId = null, childRunId = null } = {}) => m.db.prepare("UPDATE engine_actions SET state='running', started_at=COALESCE(started_at,?), request_id=COALESCE(?,request_id), child_run_id=COALESCE(?,child_run_id) WHERE id=? AND state IN ('intent','running')")
760
- .run(m.now(), requestId, childRunId, id).changes > 0;
761
- /**
762
- * Finish an action (MB-03): `result` is kept IN FULL as a blob (result_sha); result_json holds only a ≤8 KiB summary.
763
- * stdout/stderr are stored in full as blobs.
764
- */
765
- function actionFinish(m, id, { state = 'done', exitCode = null, result: rawResult = null, summary: rawSummary = null, stdout = null, stderr = null, errorSignature = null }) {
766
- const result = rawResult == null ? null : redactData(rawResult);
767
- const summary = rawSummary == null ? null : redactData(rawSummary);
768
- const full = result == null ? null : typeof result === 'string' ? result : JSON.stringify(result);
769
- const resultSha = full == null ? null : putMachineBlob(m, full, { mediaType: typeof result === 'string' ? 'text/plain' : 'application/json' });
770
- let brief = summary ?? (full != null && Buffer.byteLength(full) <= 8192 && typeof result !== 'string' ? result : null);
771
- if (brief != null) { const s = JSON.stringify(brief); if (Buffer.byteLength(s) > 8192) brief = { truncated: true, bytes: Buffer.byteLength(s), sha256: resultSha }; }
772
- const stdoutSha = textBlob(m, stdout), stderrSha = textBlob(m, stderr);
773
- return m.db.prepare('UPDATE engine_actions SET state=?, finished_at=?, started_at=COALESCE(started_at,?), exit_code=?, result_json=?, result_sha=?, stdout_sha=?, stderr_sha=?, error_signature=? WHERE id=?')
774
- .run(state, m.now(), m.now(), exitCode, brief == null ? null : JSON.stringify(brief), resultSha, stdoutSha, stderrSha, errorSignature, id).changes > 0;
775
- }
776
- /** Actions left in intent/running by a dead engine become unknown (never replayed). */
777
- const markStaleActionsUnknown = (m, { olderThanMs = 0, exceptEpoch = null } = {}) => m.db.prepare(`UPDATE engine_actions SET state='unknown', finished_at=? WHERE state IN ('intent','running') AND COALESCE(started_at,0)<=? ${exceptEpoch != null ? 'AND COALESCE(epoch,-1)<>?' : ''}`)
778
- .run(m.now(), m.now() - olderThanMs, ...(exceptEpoch != null ? [exceptEpoch] : [])).changes;
779
- const actionOf = (m, id) => { const r = m.db.prepare('SELECT * FROM engine_actions WHERE id=?').get(id); return r ? { ...r, result: parse(r.result_json) } : null; };
780
- function actions(m, { controller = null, state = null, sinceMs = null, limit = 200 } = {}) {
781
- const where = [], args = [];
782
- if (controller) { where.push('controller=?'); args.push(controller); }
783
- if (state) { where.push('state=?'); args.push(state); }
784
- if (sinceMs != null) { where.push('COALESCE(finished_at,started_at)>?'); args.push(m.now() - sinceMs); }
785
- return m.db.prepare(`SELECT * FROM engine_actions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY COALESCE(finished_at,started_at) DESC LIMIT ?`).all(...args, limit)
786
- .map((r) => ({ ...r, result: parse(r.result_json) }));
787
- }
788
- function actionStep(m, actionId, { step, ms = null, ok = null, detail = null }) {
789
- const n = Number(m.db.prepare('SELECT COALESCE(max(step_no),0)+1 n FROM action_steps WHERE action_id=?').get(actionId).n);
790
- insertRow(m.db, 'action_steps', { action_id: actionId, step_no: n, step, started_at: m.now(), ms, ok: bool(ok), detail: detail == null ? null : String(detail) });
791
- return n;
792
- }
793
-
794
- const controllerModes = (m) => Object.fromEntries(m.db.prepare('SELECT controller, mode FROM controller_modes').all().map((r) => [r.controller, r.mode]));
795
- /** Change a controller mode: a mode_changes row first (who and why, G6), then controller_modes (trigger enforces the order). */
796
- function setControllerMode(m, { controller, mode, by, reason }) {
797
- need(by && reason, 'setControllerMode needs by and reason');
798
- return m.transaction((db) => {
799
- const from = db.prepare('SELECT mode FROM controller_modes WHERE controller=?').get(controller)?.mode ?? null;
800
- if (from === mode) return { changed: false, mode };
801
- insertRow(db, 'mode_changes', { controller, from_mode: from, to_mode: mode, by, reason, at: m.now() });
802
- upsertRow(db, 'controller_modes', { controller, mode, set_at: m.now(), set_by: by }, ['controller']);
803
- return { changed: true, from, mode };
804
- });
805
- }
806
- const modeChanges = (m, { limit = 100 } = {}) => m.db.prepare('SELECT * FROM mode_changes ORDER BY change_id DESC LIMIT ?').all(limit);
807
-
808
- /** Open an SLA episode for (entity,state) unless one is open (G3). Returns the episode id. */
809
- function openSlaEpisode(m, { entity, state, code, severity = 'warn', ledgerId = null, workflowId = null, slaMs, enteredAt = m.now() }) {
810
- const open = m.db.prepare('SELECT episode_id FROM sla_episodes WHERE entity=? AND state=? AND cleared_at IS NULL').get(entity, state);
811
- if (open) return Number(open.episode_id);
812
- return Number(insertRow(m.db, 'sla_episodes', { entity, state, code, severity, ledger_id: ledgerId, workflow_id: workflowId, entered_at: enteredAt, sla_ms: slaMs }).lastInsertRowid);
813
- }
814
- const markSlaViolated = (m, episodeId) => m.db.prepare('UPDATE sla_episodes SET violated_at=? WHERE episode_id=? AND violated_at IS NULL').run(m.now(), episodeId).changes > 0;
815
- const markSlaReported = (m, episodeId, { diId = null } = {}) => m.db.prepare('UPDATE sla_episodes SET reported_at=?, di_id=COALESCE(di_id,?) WHERE episode_id=? AND reported_at IS NULL').run(m.now(), diId, episodeId).changes > 0;
816
- function clearSla(m, { entity, state = null, reason = 'resolved' }) {
817
- return m.db.prepare(`UPDATE sla_episodes SET cleared_at=?, clear_reason=? WHERE entity=? ${state ? 'AND state=?' : ''} AND cleared_at IS NULL`).run(m.now(), reason, entity, ...(state ? [state] : [])).changes;
818
- }
819
- const openSla = (m) => m.db.prepare('SELECT * FROM v_sla_open ORDER BY entered_at').all();
820
- function recordViolation(m, { code, severity = 'warn', entity, ledgerId = null, workflowId = null, episodeId = null, diId = null, lessonId = null, detail = null }) {
821
- return Number(insertRow(m.db, 'invariant_violations', { code, severity, entity, ledger_id: ledgerId, workflow_id: workflowId, episode_id: episodeId, violated_at: m.now(), di_id: diId, lesson_id: lessonId, detail_json: detail }).lastInsertRowid);
822
- }
823
- const clearViolation = (m, violationId) => m.db.prepare('UPDATE invariant_violations SET cleared_at=? WHERE violation_id=? AND cleared_at IS NULL').run(m.now(), violationId).changes > 0;
824
-
825
- // ---------------------------------------------------------------------------------------------------------------------
826
- // B3. Services, seats, deliveries, terminals, locks, claims, sessions
827
- // ---------------------------------------------------------------------------------------------------------------------
828
- /** Set a service's state; a change appends service_events (G5). */
829
- function setService(m, { name, kind, state, pid = undefined, port = undefined, url = undefined, probe = undefined, quarantinedUntil = undefined, action = null, actionId = null, probeMs = null, probeError = null }) {
830
- return m.transaction((db) => {
831
- const cur = db.prepare('SELECT state FROM services WHERE name=?').get(name);
832
- const at = m.now();
833
- upsertRow(db, 'services', { name, kind, state, since: cur?.state === state ? undefined : at, pid, port, url, last_probe_json: probe, quarantined_until: quarantinedUntil }, ['name']);
834
- if (!cur || cur.state !== state || (action && action !== 'none'))
835
- insertRow(db, 'service_events', { name, at, from_state: cur?.state ?? null, to_state: state, probe_ms: probeMs, probe_error: probeError, action, action_id: actionId });
836
- return { changed: !cur || cur.state !== state, from: cur?.state ?? null };
837
- });
838
- }
839
- const recordProbe = (m, { name, ok, latencyMs = null, detail = null }) => insertRow(m.db, 'service_probes', { name, at: m.now(), ok: ok ? 1 : 0, latency_ms: latencyMs, detail_json: detail });
840
- const services = (m) => m.db.prepare('SELECT * FROM v_services ORDER BY name').all().map((r) => ({ ...r, lastProbe: parse(r.last_probe_json) }));
841
- const serviceEvents = (m, { name = null, sinceMs = 86400000 } = {}) => m.db.prepare(`SELECT * FROM service_events WHERE at>? ${name ? 'AND name=?' : ''} ORDER BY seq`).all(m.now() - sinceMs, ...(name ? [name] : []));
842
-
843
- const upsertSeat = (m, seat) => upsertRow(m.db, 'seats', snake(seat), ['seat_id']);
844
- const seatOf = (m, seatId) => { const r = m.db.prepare('SELECT * FROM seats WHERE seat_id=?').get(seatId); return r ? { ...r, detail: parse(r.detail_json) } : null; };
845
- const seats = (m) => m.db.prepare('SELECT * FROM v_seats ORDER BY seat_id').all();
846
- /** One delivery attempt to a seat (G11, MB-02); the trigger counts consecutive input failures on the seat. */
847
- function recordDelivery(m, { messageKind, messageRef, ledgerId = null, seatId = null, terminalHandle = null, channel, outcome, turnId = null, detail = null }) {
848
- return Number(insertRow(m.db, 'deliveries', { message_kind: messageKind, message_ref: String(messageRef), ledger_id: ledgerId, seat_id: seatId, terminal_handle: terminalHandle,
849
- channel, attempted_at: m.now(), outcome, turn_id: turnId, detail: detail == null ? null : String(detail) }).lastInsertRowid);
850
- }
851
- /** A wake outcome (scripts/kernel/wake-delivery.mjs action) as a deliveries.outcome, or null when it says nothing about input. */
852
- const WAKE_OUTCOMES = Object.freeze({ 'kernel-woken': 'delivered', 'kernel-unwritable': 'unwritable', 'kernel-exited': 'exited', 'kernel-unavailable': 'unavailable',
853
- 'kernel-send-failed': 'failed', 'kernel-busy': 'busy-deferred' });
854
- /**
855
- * MB-05: count one wake of a seat. The outcome becomes a deliveries row; the deliveries trigger keeps
856
- * seats.input_failures_consecutive / _total (a refused input adds one, a delivered one resets). A seat whose
857
- * terminal changed starts from zero. Returns {failures, since, replace} with replace at `max` in a row.
858
- */
859
- function recordSeatInput(m, { seatId, terminal = null, action, messageKind = 'wake', messageRef = 'wake', channel = 'orca', role = 'supervisor', max = 3, detail = null }) {
860
- return m.transaction((db) => {
861
- const seat = db.prepare('SELECT * FROM seats WHERE seat_id=?').get(seatId);
862
- if (!seat) insertRow(db, 'seats', { seat_id: seatId, role, state: 'live', terminal_handle: terminal });
863
- else if (terminal && seat.terminal_handle !== terminal)
864
- db.prepare('UPDATE seats SET terminal_handle=?, input_failures_consecutive=0, last_input_failure_at=NULL WHERE seat_id=?').run(terminal, seatId);
865
- const outcome = WAKE_OUTCOMES[action] ?? null;
866
- // A busy seat is not an input answer: its row carries no seat_id, so the trigger leaves the run of failures as it is.
867
- if (outcome) recordDelivery(m, { messageKind, messageRef, seatId: outcome === 'busy-deferred' ? null : seatId, terminalHandle: terminal, channel, outcome, detail: detail ?? `${seatId} ${action}` });
868
- const row = db.prepare('SELECT input_failures_consecutive n, last_input_ok_at ok, last_input_failure_at bad FROM seats WHERE seat_id=?').get(seatId);
869
- const failures = Number(row.n) || 0;
870
- const since = failures ? db.prepare("SELECT min(attempted_at) at FROM (SELECT attempted_at FROM deliveries WHERE seat_id=? AND outcome IN ('unwritable','exited','unavailable','failed') ORDER BY delivery_id DESC LIMIT ?)").get(seatId, failures)?.at ?? row.bad : null;
871
- return { failures, since, replace: failures >= max };
872
- });
873
- }
874
- const startSeatTurn = (m, { seatId, wokenByDelivery = null, spanId = newSpanId() }) => Number(insertRow(m.db, 'seat_turns', { seat_id: seatId, woken_by_delivery: wokenByDelivery, started_at: m.now(), span_id: spanId }).lastInsertRowid);
875
- const endSeatTurn = (m, turnId, { endReason = 'idle', actionsCount = null } = {}) => m.db.prepare('UPDATE seat_turns SET ended_at=?, end_reason=?, actions_count=COALESCE(?,actions_count) WHERE turn_id=? AND ended_at IS NULL').run(m.now(), endReason, actionsCount, turnId).changes > 0;
876
- function seatTranscriptSnapshot(m, { seatId, terminalHandle = null, text }) {
877
- const sha = putMachineBlob(m, String(text), { mediaType: 'text/plain' });
878
- insertRow(m.db, 'seat_transcript_snapshots', { seat_id: seatId, terminal_handle: terminalHandle, at: m.now(), lines: String(text).split('\n').length, bytes: Buffer.byteLength(String(text)), sha256: sha }, { orIgnore: true });
879
- return sha;
880
- }
881
- const upsertTerminal = (m, terminal) => upsertRow(m.db, 'terminals', snake(terminal), ['handle']);
882
- const closeTerminal = (m, handle, { by = null, verified = false } = {}) => m.db.prepare('UPDATE terminals SET closed_at=COALESCE(closed_at,?), closed_by=COALESCE(closed_by,?), close_verified_at=CASE WHEN ? THEN ? ELSE close_verified_at END WHERE handle=?')
883
- .run(m.now(), by, verified ? 1 : 0, m.now(), handle).changes > 0;
884
-
885
- /**
886
- * Take a host lock (replaces connectors/*.lock and *.starting.json): succeeds when free, released, expired, or already
887
- * ours. Returns {ok, holder?}.
888
- */
889
- function acquireHostLock(m, { name, holder = null, pid = process.pid, ttlMs = 60000, processRunId = null, state = 'held' }) {
890
- return m.transaction((db) => {
891
- const at = m.now();
892
- const cur = db.prepare('SELECT * FROM host_locks WHERE name=?').get(name);
893
- if (cur && cur.state !== 'released' && cur.expires_at > at && cur.holder_pid !== pid) return { ok: false, holder: cur };
894
- upsertRow(db, 'host_locks', { name, holder_pid: pid, holder, process_run_id: processRunId, started_at: cur && cur.holder_pid === pid && cur.state !== 'released' ? cur.started_at : at,
895
- heartbeat_at: at, expires_at: at + ttlMs, handed_over_from: cur && cur.holder_pid !== pid && cur.state !== 'released' ? cur.holder_pid : null, state }, ['name']);
896
- return { ok: true };
897
- });
898
- }
899
- const renewHostLock = (m, { name, pid = process.pid, ttlMs = 60000, state = undefined }) => m.db.prepare(`UPDATE host_locks SET heartbeat_at=?, expires_at=? ${state ? ', state=?' : ''} WHERE name=? AND holder_pid=? AND state<>'released'`)
900
- .run(m.now(), m.now() + ttlMs, ...(state ? [state] : []), name, pid).changes > 0;
901
- const releaseHostLock = (m, { name, pid = process.pid, force = false }) => m.db.prepare(`UPDATE host_locks SET state='released', heartbeat_at=? WHERE name=? ${force ? '' : 'AND holder_pid=?'} AND state<>'released'`)
902
- .run(m.now(), name, ...(force ? [] : [pid])).changes > 0;
903
- const hostLock = (m, name) => m.db.prepare('SELECT * FROM host_locks WHERE name=?').get(name) ?? null;
904
- const hostLocks = (m) => m.db.prepare("SELECT * FROM host_locks WHERE state<>'released' ORDER BY name").all();
905
-
906
- function claimResource(m, { resourcePath, kind, ownerActionId = null, ownerPid = process.pid, ownerRunId = null, hasJunctions = false }) {
907
- return Number(insertRow(m.db, 'claims', { resource_path: path.resolve(resourcePath), kind, owner_action_id: ownerActionId, owner_pid: ownerPid, owner_run_id: ownerRunId,
908
- has_junctions: hasJunctions ? 1 : 0, created_at: m.now() }).lastInsertRowid);
909
- }
910
- const releaseClaim = (m, claimId) => m.db.prepare('UPDATE claims SET released_at=? WHERE claim_id=? AND released_at IS NULL').run(m.now(), claimId).changes > 0;
911
- const sweptClaim = (m, claimId, { error = null } = {}) => m.db.prepare('UPDATE claims SET swept_at=?, sweep_error=? WHERE claim_id=?').run(m.now(), error, claimId).changes > 0;
912
- const liveClaims = (m) => m.db.prepare('SELECT * FROM claims WHERE released_at IS NULL AND swept_at IS NULL ORDER BY claim_id').all();
913
- const upsertAgentSession = (m, session) => upsertRow(m.db, 'agent_sessions', snake(session), ['session_id']);
914
-
915
- // ---------------------------------------------------------------------------------------------------------------------
916
- // B4. Resources: throttle, samples, providers, pool backoff, quotas, guards, host leases, budgets
917
- // ---------------------------------------------------------------------------------------------------------------------
918
- const throttleState = (m) => { const r = m.db.prepare('SELECT * FROM throttle_state WHERE id=1').get(); return r ? { ...r, slotTargets: parse(r.slot_targets_json), priorities: parse(r.priorities_json) } : null; };
919
- /** Write the current throttle row (MB-15: writer + rev). A mode change appends throttle_events first (G7). */
920
- function setThrottle(m, { mode, effectiveCap = null, heavyCap = null, running = null, freeRamPct = null, freeRamMb = null, cpuPct = null, cpuHot = null, reason = null,
921
- writer, writerRev = runtimeRev(), slotTargets = null, priorities = null, sample = null }) {
922
- need(writer, 'setThrottle needs writer');
923
- return m.transaction((db) => {
924
- const at = m.now();
925
- const cur = db.prepare('SELECT mode, since, writer, writer_rev FROM throttle_state WHERE id=1').get();
926
- // MB-15: a process running an OLDER runtime never overwrites what a newer runtime wrote.
927
- if (cur && compareRevs(writerRev, cur.writer_rev) < 0)
928
- return { changed: false, refused: `stale-writer-rev: ${writerRev} is older than ${cur.writer_rev} (${cur.writer})`, from: cur.mode };
929
- const changed = !cur || cur.mode !== mode;
930
- if (changed) insertRow(db, 'throttle_events', { at, from_mode: cur?.mode ?? null, to_mode: mode, reason, free_ram_pct: freeRamPct, cpu_pct: cpuPct, effective_cap: effectiveCap, running, writer_rev: writerRev, sample_json: sample });
931
- upsertRow(db, 'throttle_state', { id: 1, mode, effective_cap: effectiveCap, heavy_cap: heavyCap, running, free_ram_pct: freeRamPct, free_ram_mb: freeRamMb, cpu_pct: cpuPct, cpu_hot: int(cpuHot),
932
- since: changed ? at : cur.since, updated_at: at, reason, writer, writer_rev: writerRev, slot_targets_json: slotTargets, priorities_json: priorities }, ['id']);
933
- return { changed, from: cur?.mode ?? null };
934
- });
935
- }
936
- const throttleEvents = (m, { sinceMs = 86400000 } = {}) => m.db.prepare('SELECT * FROM throttle_events WHERE at>? ORDER BY seq').all(m.now() - sinceMs);
937
- const recordThrottleDecision = (m, { ledgerId = null, workflowId = null, jobId = null, reason, waitedMs = null }) =>
938
- Number(insertRow(m.db, 'throttle_decisions', { at: m.now(), ledger_id: ledgerId, workflow_id: workflowId, job_id: jobId, reason, waited_ms: waitedMs }).lastInsertRowid);
939
- const releaseThrottleDecision = (m, seq) => m.db.prepare('UPDATE throttle_decisions SET released_at=?, waited_ms=?-at WHERE seq=? AND released_at IS NULL').run(m.now(), m.now(), seq).changes > 0;
940
- const recordHostSample = (m, sample) => Number(insertRow(m.db, 'host_samples', { at: m.now(), ...snake(sample) }).lastInsertRowid);
941
- const hostSamples = (m, { kind = 'host', sinceMs = 3600000 } = {}) => m.db.prepare('SELECT * FROM host_samples WHERE kind=? AND at>? ORDER BY seq').all(kind, m.now() - sinceMs);
942
- function setProviderHealth(m, { provider, status, failureKind = null, strikes = 0, strikeLimit = null, circuitOpenUntil = null, reason = null, ledgerId = null, attemptId = null, detail = null }) {
943
- return m.transaction((db) => {
944
- const cur = db.prepare('SELECT status FROM provider_health WHERE provider=?').get(provider);
945
- const at = m.now();
946
- upsertRow(db, 'provider_health', { provider, status, failure_kind: failureKind, strikes, strike_limit: strikeLimit, circuit_open_until: circuitOpenUntil,
947
- recovered_at: status === 'recovered' ? at : undefined, reason, updated_at: at, detail_json: detail }, ['provider']);
948
- if (!cur || cur.status !== status) insertRow(db, 'provider_health_events', { provider, at, from_status: cur?.status ?? null, to_status: status, failure_kind: failureKind, ledger_id: ledgerId, attempt_id: attemptId, detail_json: detail });
949
- return { changed: !cur || cur.status !== status };
950
- });
951
- }
952
- const providerHealth = (m) => m.db.prepare('SELECT * FROM provider_health ORDER BY provider').all();
953
- const poolBackoff = (m, pool = null) => (pool ? m.db.prepare('SELECT * FROM pool_backoff WHERE pool=?').get(pool) ?? null : m.db.prepare('SELECT * FROM pool_backoff ORDER BY pool').all());
954
- const setPoolBackoff = (m, { pool, untilAt = null, strikes = 0, reason = null }) => upsertRow(m.db, 'pool_backoff', { pool, until_at: untilAt, strikes, reason, updated_at: m.now() }, ['pool']);
955
- const clearPoolBackoff = (m, pool) => m.db.prepare('DELETE FROM pool_backoff WHERE pool=?').run(pool).changes > 0;
956
- const setQuota = (m, { provider, window, used = null, limitValue = null, resetAt = null, source = null }) => upsertRow(m.db, 'quotas', { provider, window, used, limit_value: limitValue, reset_at: resetAt, source, observed_at: m.now() }, ['provider', 'window']);
957
- const quotas = (m) => m.db.prepare('SELECT * FROM quotas ORDER BY provider, window').all();
958
-
959
- const { reserveProvider, providerReservations, providerReservationUsage, markProviderReservation, releaseProviderReservation } = providerReservationMethods({ need, parse, toJson, hex });
960
-
961
- const upsertGuardJob = (m, { jobId, ledgerId = null, workflowId = null, attemptId = null, allow, hooks = null, ttlMs = 86400000 }) =>
962
- upsertRow(m.db, 'guard_jobs', { job_id: jobId, ledger_id: ledgerId, workflow_id: workflowId, attempt_id: attemptId, allow_json: allow, hooks_json: hooks, created_at: m.now(), expires_at: m.now() + ttlMs, released_at: null }, ['job_id']);
963
- const guardJob = (m, jobId) => { const r = m.db.prepare('SELECT * FROM guard_jobs WHERE job_id=?').get(jobId); return r ? { ...r, allow: parse(r.allow_json), hooks: parse(r.hooks_json) } : null; };
964
- const releaseGuardJob = (m, jobId) => m.db.prepare('UPDATE guard_jobs SET released_at=? WHERE job_id=? AND released_at IS NULL').run(m.now(), jobId).changes > 0;
965
- const recordGuardRefusal = (m, refusal) => Number(insertRow(m.db, 'guard_refusals', { at: m.now(), ...snake(refusal) }).lastInsertRowid);
966
- /** Host leases (the machine side of a ledger's two-phase reserve): drop by token. */
967
- function releaseHostLeases(m, tokens) {
968
- const list = [...new Set([tokens].flat().map((t) => (typeof t === 'string' ? t : t?.token)).filter(Boolean))];
969
- let released = 0; for (const token of list) released += m.db.prepare('DELETE FROM host_leases WHERE token=?').run(token).changes;
970
- return { ok: true, released };
971
- }
972
- const hostLeases = (m) => m.db.prepare('SELECT * FROM host_leases ORDER BY resource_key').all();
973
- const setBudget = (m, { scopeKey, limitValue, window = null }) => m.db.prepare('INSERT INTO budgets(scope_key,limit_value,window,updated_at) VALUES(?,?,?,?) ON CONFLICT(scope_key) DO UPDATE SET limit_value=excluded.limit_value, window=excluded.window, updated_at=excluded.updated_at')
974
- .run(scopeKey, limitValue, window, m.now());
975
- /** Reserve units against a budget; refuses when used+reserved+units > limit. */
976
- function reserveBudget(m, { scopeKey, ledgerId, jobId, units }) {
977
- return m.transaction((db) => {
978
- const b = db.prepare('SELECT * FROM budgets WHERE scope_key=?').get(scopeKey);
979
- if (!b) return { ok: false, reason: 'no-budget' };
980
- const held = db.prepare('SELECT units FROM budget_reservations WHERE scope_key=? AND ledger_id=? AND job_id=?').get(scopeKey, ledgerId, jobId);
981
- if (held) return { ok: true, already: true };
982
- if (b.used_value + b.reserved_value + units > b.limit_value) return { ok: false, reason: 'budget-exhausted', budget: b };
983
- insertRow(db, 'budget_reservations', { scope_key: scopeKey, ledger_id: ledgerId, job_id: jobId, units, at: m.now() });
984
- db.prepare('UPDATE budgets SET reserved_value=reserved_value+?, updated_at=? WHERE scope_key=?').run(units, m.now(), scopeKey);
985
- return { ok: true };
986
- });
987
- }
988
- /** Settle a reservation: `used` units move to used_value, the rest is returned. */
989
- function settleBudget(m, { scopeKey, ledgerId, jobId, used = null }) {
990
- return m.transaction((db) => {
991
- const r = db.prepare('SELECT units FROM budget_reservations WHERE scope_key=? AND ledger_id=? AND job_id=?').get(scopeKey, ledgerId, jobId);
992
- if (!r) return false;
993
- db.prepare('DELETE FROM budget_reservations WHERE scope_key=? AND ledger_id=? AND job_id=?').run(scopeKey, ledgerId, jobId);
994
- db.prepare('UPDATE budgets SET reserved_value=max(0,reserved_value-?), used_value=used_value+?, updated_at=? WHERE scope_key=?').run(r.units, used ?? r.units, m.now(), scopeKey);
995
- return true;
996
- });
997
- }
998
- const budgets = (m) => m.db.prepare('SELECT * FROM budgets ORDER BY scope_key').all();
999
-
1000
- // ---------------------------------------------------------------------------------------------------------------------
1001
- // B5. GC, lanes, land queue, land runs, pushes, worktrees, env servers, UAT slots, connectors, asks
1002
- // ---------------------------------------------------------------------------------------------------------------------
1003
- const startGcRun = (m, { trigger = 'sweep', actionId = null, collectors = null, startedAt = m.now() } = {}) => Number(insertRow(m.db, 'gc_runs', { action_id: actionId, started_at: startedAt, trigger, collectors_json: collectors }).lastInsertRowid);
1004
- /** Finish a GC run; the full report is a blob (G4). */
1005
- function finishGcRun(m, runId, { freedBytes = null, counts = null, errors = null, report = null, finishedAt = m.now() } = {}) {
1006
- const reportSha = report == null ? null : putMachineBlob(m, JSON.stringify(report), { mediaType: 'application/json' });
1007
- return m.db.prepare('UPDATE gc_runs SET finished_at=?, freed_bytes=?, counts_json=?, errors_json=?, report_sha=? WHERE run_id=?')
1008
- .run(finishedAt, freedBytes, toJson(counts), toJson(errors), reportSha, runId).changes > 0;
1009
- }
1010
- /** One thing GC touched (G4, MB-14). `outcome` is the final fate; a retry sets nextTryAt past the grace window. */
1011
- const recordGcItem = (m, item) => Number(insertRow(m.db, 'gc_items', { at: m.now(), ...snake(item) }).lastInsertRowid);
1012
- const updateGcItem = (m, itemId, fields) => updateRow(m.db, 'gc_items', snake(fields), { item_id: itemId }).changes > 0;
1013
- const gcItems = (m, { runId = null, open = false, collector = null, limit = 500 } = {}) => m.db.prepare(`SELECT * FROM gc_items WHERE 1=1 ${runId != null ? 'AND run_id=?' : ''} ${open ? 'AND outcome IS NULL' : ''} ${collector ? 'AND collector=?' : ''} ORDER BY item_id DESC LIMIT ?`)
1014
- .all(...(runId != null ? [runId] : []), ...(collector ? [collector] : []), limit);
1015
- function gcMark(m, runId, entries) {
1016
- return m.transaction((db) => { const st = db.prepare('INSERT OR IGNORE INTO gc_marks(run_id,sha256,source,pinned) VALUES(?,?,?,?)'); for (const e of entries) st.run(runId, e.sha256, e.source, e.pinned ? 1 : 0); return entries.length; });
1017
- }
1018
- /** A blob whose bytes were archived (zip) before the sweep: archived_at + archive_ref on the machine's blobs row. */
1019
- const markMachineBlobArchived = (m, { sha256: sha, archivedAt = m.now(), archiveRef }) => m.db.prepare('UPDATE blobs SET archived_at=?, archive_ref=? WHERE sha256=?').run(archivedAt, archiveRef, sha).changes > 0;
1020
- /**
1021
- * Seat scrollback snapshots that are no longer needed (Q4): every snapshot taken before a seat session ended whose FINAL
1022
- * transcript is stored (agent_sessions.transcript_sha of that seat, ended_at set), plus any snapshot older than `seatMs`
1023
- * except each seat's newest one. Returns the rows deleted.
1024
- */
1025
- const pruneSeatSnapshots = (m, { now = m.now(), seatMs }) => m.transaction((db) => {
1026
- const final = db.prepare(`DELETE FROM seat_transcript_snapshots WHERE EXISTS (SELECT 1 FROM agent_sessions a
1027
- WHERE a.seat_id=seat_transcript_snapshots.seat_id AND a.transcript_sha IS NOT NULL AND a.ended_at IS NOT NULL
1028
- AND seat_transcript_snapshots.at<=a.ended_at)`).run().changes;
1029
- const aged = db.prepare(`DELETE FROM seat_transcript_snapshots WHERE at<? AND snapshot_id NOT IN
1030
- (SELECT max(snapshot_id) FROM seat_transcript_snapshots GROUP BY seat_id)`).run(now - seatMs).changes;
1031
- return final + aged;
1032
- });
1033
- const gcRuns = (m, { limit = 20 } = {}) => m.db.prepare('SELECT * FROM gc_runs ORDER BY run_id DESC LIMIT ?').all(limit);
1034
-
1035
- function upsertLane(m, { name, worktreePath, branch, baseSha = null, headSha = null, owner, supJobId = null, state = 'open' }) {
1036
- const cur = m.db.prepare('SELECT created_at FROM lanes WHERE name=?').get(name);
1037
- return upsertRow(m.db, 'lanes', { name, worktree_path: path.resolve(worktreePath), branch, base_sha: baseSha, head_sha: headSha, owner, sup_job_id: supJobId, state, created_at: cur?.created_at ?? m.now() }, ['name']);
1038
- }
1039
- function setLaneState(m, name, state, { reportText = null, headSha = null } = {}) {
1040
- const set = { state };
1041
- if (state === 'landed') set.landed_at = m.now();
1042
- if (state === 'removed') set.removed_at = m.now();
1043
- if (headSha) set.head_sha = headSha;
1044
- if (reportText) set.report_sha = textBlob(m, reportText, 'text/markdown');
1045
- return updateRow(m.db, 'lanes', set, { name }).changes > 0;
1046
- }
1047
- const laneOf = (m, name) => m.db.prepare('SELECT * FROM lanes WHERE name=?').get(name) ?? null;
1048
- const lanes = (m, { state = null } = {}) => m.db.prepare(`SELECT * FROM lanes ${state ? 'WHERE state=?' : ''} ORDER BY created_at`).all(...(state ? [state] : []));
1049
- /** Enqueue a land ticket (replaces the land queue dirs). The lane row is created when absent (FK). */
1050
- function enqueueLand(m, { ticketId = `land-${Date.now().toString(36)}-${hex(4)}`, lane = null, commitSha, commits = null, requestedBy = `pid:${process.pid}` }) {
1051
- return m.transaction((db) => {
1052
- if (lane && !db.prepare('SELECT 1 FROM lanes WHERE name=?').get(lane)) insertRow(db, 'lanes', { name: lane, worktree_path: '', branch: `lane/${lane}`, owner: requestedBy ?? 'unknown', state: 'open', created_at: m.now() });
1053
- insertRow(db, 'land_queue', { ticket_id: ticketId, lane, commit_sha: commitSha, commits, requested_by: requestedBy, state: 'queued', enqueued_at: m.now() });
1054
- return ticketId;
1055
- });
1056
- }
1057
- /** The head of the queue enters the gate when nothing is running. Returns the ticket, or {busy: running ticket}. */
1058
- /** requested_by 'pid:<n>' names the process that waits for and then runs the ticket. */
1059
- function claimLandGate(m, { ticketId, alive = pidAlive }) {
1060
- return m.transaction((db) => {
1061
- // A waiter or holder whose process is gone never blocks the queue: its ticket is cancelled.
1062
- for (const t of db.prepare("SELECT ticket_id, state, requested_by, busy_holder FROM land_queue WHERE state IN ('queued','running')").all()) {
1063
- const owner = /^pid:(\d+)$/.exec(String(t.requested_by ?? ''));
1064
- if (t.ticket_id !== ticketId && owner && !alive(Number(owner[1]))) db.prepare("UPDATE land_queue SET state='cancelled', finished_at=? WHERE ticket_id=?").run(m.now(), t.ticket_id);
1065
- }
1066
- const running = db.prepare("SELECT * FROM land_queue WHERE state='running' ORDER BY started_at LIMIT 1").get();
1067
- if (running && running.ticket_id !== ticketId) { db.prepare('UPDATE land_queue SET busy_holder=? WHERE ticket_id=?').run(running.ticket_id, ticketId); return { ok: false, busy: running }; }
1068
- const head = db.prepare("SELECT ticket_id FROM land_queue WHERE state='queued' ORDER BY enqueued_at, ticket_id LIMIT 1").get();
1069
- if (!running && head && head.ticket_id !== ticketId) return { ok: false, behind: head.ticket_id };
1070
- db.prepare("UPDATE land_queue SET state='running', gate_at=COALESCE(gate_at,?), started_at=COALESCE(started_at,?) WHERE ticket_id=? AND state IN ('queued','running')").run(m.now(), m.now(), ticketId);
1071
- return { ok: true, ticket: db.prepare('SELECT * FROM land_queue WHERE ticket_id=?').get(ticketId) };
1072
- });
1073
- }
1074
- const finishLandTicket = (m, ticketId, state) => m.db.prepare("UPDATE land_queue SET state=?, finished_at=? WHERE ticket_id=? AND state IN ('queued','running')").run(state, m.now(), ticketId).changes > 0;
1075
- const landQueue = (m, { open = true } = {}) => m.db.prepare(`SELECT * FROM land_queue ${open ? "WHERE state IN ('queued','running')" : ''} ORDER BY enqueued_at, ticket_id`).all();
1076
- /** One land-gate run with full stdout/stderr blobs. */
1077
- function recordLandRun(m, { ticketId = null, lane = null, spanId = newSpanId(), parentSpanId = null, commitSha, commits = null, landedSha = null, result, reason = null, pushId = null, specs = null, stdout = null, stderr = null, startedAt, finishedAt = m.now() }) {
1078
- return Number(insertRow(m.db, 'land_runs', { ticket_id: ticketId, lane, span_id: spanId, parent_span_id: parentSpanId, commit_sha: commitSha, commits_json: commits ?? (commitSha ? [commitSha] : null), landed_sha: landedSha, result, reason, push_id: pushId,
1079
- specs_json: specs, stdout_sha: textBlob(m, stdout), stderr_sha: textBlob(m, stderr), started_at: startedAt ?? m.now(), finished_at: finishedAt }).lastInsertRowid);
1080
- }
1081
- /**
1082
- * The core record of one land in ONE transaction, idempotent on spanId (a replay from the outbox or a second try never
1083
- * doubles it): the lane row when absent, the push row, the land_runs row, the lane head and the log line (its data gets
1084
- * runId). {runId, pushId, duplicate}.
1085
- */
1086
- function recordLandOutcome(m, { spanId, lane = null, push = null, run, laneHead = null, log: logRow = null }) {
1087
- need(spanId && run, 'recordLandOutcome needs spanId and run');
1088
- return m.transaction((db) => {
1089
- const hit = db.prepare('SELECT run_id, push_id FROM land_runs WHERE span_id=?').get(spanId);
1090
- if (hit) return { runId: Number(hit.run_id), pushId: hit.push_id == null ? null : Number(hit.push_id), duplicate: true };
1091
- if (lane && !db.prepare('SELECT 1 FROM lanes WHERE name=?').get(lane)) insertRow(db, 'lanes', { name: lane, worktree_path: '', branch: `lane/${lane}`, owner: 'land-gate', state: 'open', created_at: m.now() });
1092
- const pushId = push ? recordPush(m, push) : null;
1093
- const runId = recordLandRun(m, { ...run, lane, spanId, pushId });
1094
- if (lane && laneHead) updateRow(db, 'lanes', { head_sha: laneHead }, { name: lane });
1095
- if (logRow) log(m, { ...logRow, data: { ...(logRow.data ?? {}), runId } });
1096
- return { runId, pushId, duplicate: false };
1097
- });
1098
- }
1099
- const landRuns = (m, { lane = null, limit = 50 } = {}) => m.db.prepare(`SELECT * FROM land_runs ${lane ? 'WHERE lane=?' : ''} ORDER BY run_id DESC LIMIT ?`).all(...(lane ? [lane] : []), limit);
1100
- /** One push (G8, MB-03): a refusal needs a stable failure signature; logs are full blobs. */
1101
- function recordPush(m, { repoRoot, branch = null, head, fromSha = null, toSha = null, result, reason = null, failureSignature = null, ms = null, actionId = null, scan = null, stdout = null, stderr = null }) {
1102
- return Number(insertRow(m.db, 'pushes', { repo_root: repoKey(repoRoot), branch, head, from_sha: fromSha, to_sha: toSha, result, reason: reason == null ? null : redactText(String(reason)), failure_signature: failureSignature, ms, action_id: actionId,
1103
- scan_json: scan, stdout_sha: textBlob(m, stdout), stderr_sha: textBlob(m, stderr), at: m.now() }).lastInsertRowid);
1104
- }
1105
- const pushes = (m, { repoRoot = null, limit = 50 } = {}) => m.db.prepare(`SELECT * FROM pushes ${repoRoot ? 'WHERE repo_root=?' : ''} ORDER BY push_id DESC LIMIT ?`).all(...(repoRoot ? [repoKey(repoRoot)] : []), limit);
1106
- const upsertWorktree = (m, wt) => upsertRow(m.db, 'worktrees', { created_at: m.now(), ...snake(wt), path: path.resolve(wt.path) }, ['path']);
1107
- const removedWorktree = (m, wtPath, { error = null, archivedRef = null } = {}) => m.db.prepare('UPDATE worktrees SET removed_at=CASE WHEN ? IS NULL THEN ? ELSE removed_at END, remove_error=?, archived_ref=COALESCE(?,archived_ref) WHERE path=?')
1108
- .run(error, m.now(), error, archivedRef, path.resolve(wtPath)).changes > 0;
1109
- /**
1110
- * The worktree registry (scripts/machine/worktree-registry.mjs and the worktree api files are its callers): reserve a row for a worktree about to be created,
1111
- * atomically against the per-repo cap. BEGIN IMMEDIATE: two dispatches never both take the last slot. `cap` null: no cap.
1112
- * {ok, live} | {ok:false, reason:'worktree-cap', live, cap}
1113
- */
1114
- function reserveWorktree(m, wt, { cap = null } = {}) {
1115
- return m.transaction((db) => {
1116
- const repoRoot = repoKey(wt.repoRoot);
1117
- const target = path.resolve(wt.path);
1118
- const live = db.prepare('SELECT path FROM worktrees WHERE repo_root=? AND removed_at IS NULL').all(repoRoot).filter((r) => r.path !== target).length;
1119
- if (cap != null && live >= cap) return { ok: false, reason: 'worktree-cap', live, cap };
1120
- upsertRow(db, 'worktrees', { ...snake(wt), path: target, repo_root: repoRoot, created_at: m.now(), removed_at: null, remove_error: null }, ['path']);
1121
- return { ok: true, live: live + 1 };
1122
- });
1123
- }
1124
- /** Live (not removed) registry rows, optionally of one repo. */
1125
- const liveWorktrees = (m, { repoRoot = null } = {}) => m.db.prepare(`SELECT * FROM worktrees WHERE removed_at IS NULL ${repoRoot ? 'AND repo_root=?' : ''} ORDER BY created_at`)
1126
- .all(...(repoRoot ? [repoKey(repoRoot)] : []));
1127
- const worktreeRow = (m, wtPath) => m.db.prepare('SELECT * FROM worktrees WHERE path=?').get(path.resolve(wtPath)) ?? null;
1128
- /** Every repo the registry ever held a worktree of. */
1129
- const worktreeRepos = (m) => m.db.prepare('SELECT DISTINCT repo_root FROM worktrees ORDER BY repo_root').all().map((r) => r.repo_root);
1130
- /** A reservation whose `git worktree add` failed: the row goes, nothing was created. */
1131
- /** A GC collector's resume point (machine_meta gc_cursor:<name>): where its last bounded pass stopped, or null. */
1132
- const gcCursor = (m, name) => m.db.prepare('SELECT value FROM machine_meta WHERE key=?').get(`gc_cursor:${name}`)?.value ?? null;
1133
- const setGcCursor = (m, name, value) => (value == null
1134
- ? m.db.prepare('DELETE FROM machine_meta WHERE key=?').run(`gc_cursor:${name}`)
1135
- : m.db.prepare('INSERT INTO machine_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(`gc_cursor:${name}`, String(value))).changes > 0;
1136
- /** The worktree GC's stop mark (a removal changed the main checkout): {at, damage, path} or null. set(null) clears it. */
1137
- const worktreeGcStop = (m) => parse(m.db.prepare("SELECT value FROM machine_meta WHERE key='worktree_gc_stopped'").get()?.value ?? null);
1138
- const setWorktreeGcStop = (m, stop) => (stop
1139
- ? m.db.prepare("INSERT INTO machine_meta(key,value) VALUES('worktree_gc_stopped',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(stop))
1140
- : m.db.prepare("DELETE FROM machine_meta WHERE key='worktree_gc_stopped'").run()).changes > 0;
1141
- const dropWorktree = (m, wtPath) => m.db.prepare('DELETE FROM worktrees WHERE path=? AND removed_at IS NULL').run(path.resolve(wtPath)).changes > 0;
1142
- const upsertEnvServer = (m, server) => upsertRow(m.db, 'env_servers', snake(server), ['server_id']);
1143
- const envServer = (m, serverId) => m.db.prepare('SELECT * FROM env_servers WHERE server_id=?').get(serverId) ?? null;
1144
- const envServers = (m, { live = false } = {}) => m.db.prepare(`SELECT * FROM env_servers ${live ? "WHERE state IN ('starting','ready')" : ''} ORDER BY server_id`).all();
1145
- const upsertUatSlot = (m, slot) => upsertRow(m.db, 'uat_slots', snake(slot), ['slot_id']);
1146
- const uatSlots = (m, { live = true } = {}) => m.db.prepare(`SELECT * FROM uat_slots ${live ? 'WHERE released_at IS NULL' : ''} ORDER BY acquired_at`).all();
1147
- const releaseUatSlot = (m, slotId) => m.db.prepare('UPDATE uat_slots SET released_at=? WHERE slot_id=? AND released_at IS NULL').run(m.now(), slotId).changes > 0;
1148
- const upsertConnector = (m, { name, kind = null, state = null, pid = null, port = null, publicUrl = null, config = undefined, cursor = undefined }) =>
1149
- upsertRow(m.db, 'connectors', { name, kind, state, pid, port, public_url: publicUrl, config_json: config, cursor_json: cursor, updated_at: m.now() }, ['name']);
1150
- const connectorOf = (m, name) => { const r = m.db.prepare('SELECT * FROM connectors WHERE name=?').get(name); return r ? { ...r, config: parse(r.config_json), cursor: parse(r.cursor_json) } : null; };
1151
- const upsertAsk = (m, ask) => upsertRow(m.db, 'ask_requests', { asked_at: m.now(), ...snake(ask) }, ['ask_id']);
1152
-
1153
- // ---------------------------------------------------------------------------------------------------------------------
1154
- // B6. Observation: machine_logs (+FTS), metrics, notifications, archives
1155
- // ---------------------------------------------------------------------------------------------------------------------
1156
- const LOG_ACTORS = new Set(['reconciler', 'supervisor', 'worker', 'gc', 'host', 'land', 'watchdog', 'connector', 'harness', 'runtime']);
1157
- /**
1158
- * Append machine_logs rows ({actor, kind, msg, level?, controller?, ledgerId?, workflowId?, jobId?, attemptId?, actionId?,
1159
- * traceId?, spanId?, data?, refs?, src?, at?}). `src` is a unique idempotency key (a duplicate is skipped). data larger than
1160
- * 64 KiB goes to a blob. Returns the number written.
1161
- */
1162
- function log(m, rows) {
1163
- const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);
1164
- if (!list.length) return 0;
1165
- return m.transaction((db) => {
1166
- let n = 0;
1167
- for (const r of list) {
1168
- need(LOG_ACTORS.has(r.actor), `machine log: unknown actor ${r.actor}`);
1169
- const { json } = jsonOrBlob(m, r.data == null ? null : redactData(r.data));
1170
- n += insertRow(db, 'machine_logs', { at: r.at ?? m.now(), actor: r.actor, controller: r.controller ?? null, ledger_id: r.ledgerId ?? null, workflow_id: r.workflowId ?? null,
1171
- job_id: r.jobId ?? null, attempt_id: int(r.attemptId), action_id: r.actionId ?? null, trace_id: r.traceId ?? null, span_id: r.spanId ?? null, level: r.level ?? 'info',
1172
- kind: String(r.kind), msg: redactText(String(r.msg ?? '')), data_json: json, refs_json: r.refs == null ? null : redactData(r.refs), src: r.src ?? null }, { orIgnore: true }).changes;
1173
- }
1174
- return n;
1175
- });
1176
- }
1177
- function logs(m, { actor = null, kind = null, level = null, ledgerId = null, workflowId = null, jobId = null, since = null, search = null, limit = 200 } = {}) {
1178
- const where = [], args = [];
1179
- if (actor) { where.push('l.actor=?'); args.push(actor); }
1180
- if (kind) { where.push(kind.endsWith('*') ? 'l.kind LIKE ?' : 'l.kind=?'); args.push(kind.endsWith('*') ? `${kind.slice(0, -1)}%` : kind); }
1181
- if (level) { where.push('l.level=?'); args.push(level); }
1182
- if (ledgerId) { where.push('l.ledger_id=?'); args.push(ledgerId); }
1183
- if (workflowId) { where.push('l.workflow_id=?'); args.push(workflowId); }
1184
- if (jobId) { where.push('l.job_id=?'); args.push(jobId); }
1185
- if (since != null) { where.push('l.seq>?'); args.push(since); }
1186
- if (search) { where.push('l.seq IN (SELECT rowid FROM machine_logs_fts WHERE machine_logs_fts MATCH ?)'); args.push(search); }
1187
- return m.db.prepare(`SELECT l.* FROM machine_logs l ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY l.seq DESC LIMIT ?`).all(...args, limit)
1188
- .map((r) => ({ ...r, data: fullJson(parse(r.data_json)), refs: parse(r.refs_json) }));
1189
- }
1190
- /** Retention (DBTREE B6): debug rows older than 14 days, the rest older than 90 days. Returns rows deleted. */
1191
- function pruneLogs(m, { debugMs = 14 * 86400000, restMs = 90 * 86400000 } = {}) {
1192
- return m.transaction((db) => db.prepare("DELETE FROM machine_logs WHERE (level='debug' AND at<?) OR at<?").run(m.now() - debugMs, m.now() - restMs).changes);
1193
- }
1194
- function recordMetrics(m, { kind, ledgerId = null, workflowId = null, windowMs = null, subject = null, data }) {
1195
- const { json, sha } = jsonOrBlob(m, data);
1196
- return Number(insertRow(m.db, 'metrics_snapshots', { at: m.now(), kind, ledger_id: ledgerId, workflow_id: workflowId, window_ms: windowMs, subject, data_json: json, data_sha: sha }).lastInsertRowid);
1197
- }
1198
- const latestMetrics = (m, { kind, ledgerId = null, workflowId = null } = {}) => { const r = m.db.prepare('SELECT * FROM metrics_snapshots WHERE kind=? AND ledger_id IS ? AND workflow_id IS ? ORDER BY snap_id DESC LIMIT 1').get(kind, ledgerId, workflowId); return r ? { ...r, data: parse(r.data_json) } : null; };
1199
- /** A notification, deduplicated on dedupeKey (replaces telegram-sent.json). Returns {id, duplicate}. */
1200
- function recordNotification(m, { channel, kind, text, media = null, mediaType = 'image/png', sentAt = null, delivery = null, ref = null, dedupeKey = null }) {
1201
- if (dedupeKey) { const hit = m.db.prepare('SELECT notif_id FROM notifications WHERE dedupe_key=?').get(dedupeKey); if (hit) return { id: Number(hit.notif_id), duplicate: true }; }
1202
- const mediaSha = media ? putMachineBlob(m, media, { mediaType }) : null;
1203
- return { id: Number(insertRow(m.db, 'notifications', { channel, kind, text, media_sha: mediaSha, sent_at: sentAt, delivery, ref, dedupe_key: dedupeKey }).lastInsertRowid), duplicate: false };
1204
- }
1205
- const notificationSent = (m, dedupeKey) => Boolean(m.db.prepare('SELECT 1 FROM notifications WHERE dedupe_key=?').get(dedupeKey));
1206
- const markNotificationSent = (m, id, { delivery = 'sent' } = {}) => m.db.prepare('UPDATE notifications SET sent_at=?, delivery=? WHERE notif_id=?').run(m.now(), delivery, id).changes > 0;
1207
- const recordArchive = (m, archive) => upsertRow(m.db, 'archives', { created_at: m.now(), ...snake(archive), archive_path: path.resolve(archive.archivePath ?? archive.archive_path) }, ['archive_path']);
1208
-
1209
- // ---------------------------------------------------------------------------------------------------------------------
1210
- // Catalog projection (agents / models from modules/models/*.yaml, rewritten at engine start)
1211
- // ---------------------------------------------------------------------------------------------------------------------
1212
- function projectCatalog(m, { agents = [], models = [], sourceRev = runtimeRev() }) {
1213
- return m.transaction((db) => {
1214
- const at = m.now();
1215
- for (const a of agents) upsertRow(db, 'agents', { agent: a.agent, provider: a.provider ?? null, spawn_card: a.spawnCard ?? null, cli_name: a.cliName ?? null, source_rev: sourceRev, loaded_at: at }, ['agent']);
1216
- for (const p of models) upsertRow(db, 'models', { profile: p.profile, agent: p.agent ?? null, model: p.model ?? null, pool: p.pool ?? null, max_parallel: int(p.maxParallel), share_pct: p.sharePct ?? null,
1217
- roles_json: p.roles ?? null, cost_json: p.cost ?? null, source_rev: sourceRev, loaded_at: at }, ['profile']);
1218
- return { agents: agents.length, models: models.length };
1219
- });
1220
- }
1221
-
1222
- /**
1223
- * The ONE checkpoint of machine.sqlite: PASSIVE, on the checkpointer handle (openMachine({checkpointer:true}), the engine),
1224
- * and only while the engine_leader row `name` still names `holder` at `epoch` with a live lease. A standby, a draining or a
1225
- * superseded engine gets {skipped} and checkpoints nothing, so two engines never checkpoint back to back (WAL-reset bug).
1226
- * Returns {busy, log, checkpointed} or {skipped: reason}.
1227
- */
1228
- function checkpoint(m, { name = 'reconciler', holder, epoch } = {}) {
1229
- need(m.checkpointer, 'machine-db: only the checkpointer handle (the reconciler engine leader) checkpoints machine.sqlite', 'STARCI_MACHINE_NOT_CHECKPOINTER');
1230
- need(holder && Number.isInteger(Number(epoch)), 'machine-db: checkpoint needs the leader {name, holder, epoch}');
1231
- const row = m.db.prepare('SELECT holder, epoch, expires_at FROM engine_leader WHERE name=?').get(name);
1232
- if (!row || row.holder !== holder || Number(row.epoch) !== Number(epoch)) return { skipped: `not the ${name} leader at epoch ${epoch}` };
1233
- if (Number(row.expires_at) <= m.now()) return { skipped: `the ${name} lease of epoch ${epoch} expired` };
1234
- return m.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
1235
- }
1236
- /** machine_meta as an object. */
1237
- const meta = (m) => Object.fromEntries(m.db.prepare('SELECT key, value FROM machine_meta').all().map((r) => [r.key, r.value]));
1238
-
1239
- // ---------------------------------------------------------------------------------------------------------------------
1240
- // Outbox: a typed write the store refused waits here until the next flush (the land gate flushes it).
1241
- // Why a file: when machine.sqlite itself refuses the write (a persistent SQLITE_CORRUPT, a lock held past busy_timeout)
1242
- // the store cannot hold the record, so the only durable place left is beside it. It is append-only JSONL (one line per
1243
- // write, appendFileSync = one write call), owned by this module, holds only DEFERRABLE (idempotent) writes, and is empty
1244
- // in the normal case; flushOutbox renames it before applying, so appends during a flush start a fresh file.
1245
- // ---------------------------------------------------------------------------------------------------------------------
1246
- const outboxFileFor = (machineFile) => `${path.resolve(machineFile)}.outbox.jsonl`;
1247
- /** The writes that may wait in the outbox: each is idempotent (recordLandOutcome on spanId, log on src). */
1248
- const DEFERRABLE = Object.freeze({ recordLandOutcome, log });
1249
- /** Append one deferred write; returns its id. */
1250
- function deferWrite({ op, args = [], file = null, env = process.env, error = null }) {
1251
- need(Object.hasOwn(DEFERRABLE, op), `machine-db: ${op} is not a deferrable write (${Object.keys(DEFERRABLE).join(', ')})`);
1252
- const id = `ob-${Date.now().toString(36)}-${hex(4)}`;
1253
- const list = op === 'log' ? args.map((a, i) => (i === 0 ? (Array.isArray(a) ? a : [a]).map((r) => ({ ...r, src: r.src ?? `outbox:${id}` })) : a)) : args;
1254
- const target = outboxFileFor(file ?? machineFileFor(env));
1255
- fs.mkdirSync(path.dirname(target), { recursive: true });
1256
- fs.appendFileSync(target, `${JSON.stringify({ id, at: Date.now(), pid: process.pid, op, args: list, error: error ? errText(error) : null })}\n`);
1257
- process.stderr.write(`[machine-db] deferred ${op} ${id} to ${target}${error ? `: ${errText(error)}` : ''}\n`);
1258
- return id;
1259
- }
1260
- /** withMachine(API[op](m, ...args)); when the store refuses it, the write goes to the outbox. {ok, value} | {ok:false, deferred, error}. */
1261
- export function writeOrDefer(op, args = [], { env = process.env, file = null } = {}) {
1262
- need(Object.hasOwn(DEFERRABLE, op), `machine-db: ${op} is not a deferrable write`);
1263
- try { return { ok: true, value: withMachine((m) => DEFERRABLE[op](m, ...args), { env, file }) }; }
1264
- catch (error) { return { ok: false, deferred: deferWrite({ op, args, file, env, error }), error: errText(error) }; }
1265
- }
1266
- /**
1267
- * Apply every outbox line through its typed writer (each in its own transaction). A line that fails again goes back to
1268
- * the outbox; a claimed file of a dead flusher is taken over. {flushed, failed, pending}.
1269
- */
1270
- function flushOutbox(m) {
1271
- need(!m.readOnly, 'machine-db: flushOutbox needs a writer');
1272
- const base = outboxFileFor(m.file), dir = path.dirname(base), stem = path.basename(base);
1273
- const claimed = [];
1274
- try { const to = `${base}.${process.pid}.${Date.now()}.flushing`; fs.renameSync(base, to); claimed.push(to); }
1275
- catch (error) { if (error.code !== 'ENOENT') return { flushed: 0, failed: 0, pending: base, busy: errText(error) }; }
1276
- let names = [];
1277
- try { names = fs.readdirSync(dir); } catch { /* none */ }
1278
- for (const n of names) {
1279
- const hit = n.startsWith(`${stem}.`) && /^(\d+)\.\d+\.flushing$/.exec(n.slice(stem.length + 1));
1280
- const full = path.join(dir, n);
1281
- if (hit && !claimed.includes(full) && Number(hit[1]) !== process.pid && !pidAlive(Number(hit[1]))) claimed.push(full);
1282
- }
1283
- let flushed = 0, failed = 0;
1284
- for (const f of claimed) {
1285
- const back = [];
1286
- for (const line of fs.readFileSync(f, 'utf8').split(/\r?\n/).filter(Boolean)) {
1287
- let item = null;
1288
- try { item = JSON.parse(line); } catch { back.push(line); failed += 1; continue; }
1289
- try {
1290
- need(Object.hasOwn(DEFERRABLE, item.op), `unknown op ${item.op}`);
1291
- m.transaction(() => DEFERRABLE[item.op](m, ...(item.args ?? [])));
1292
- flushed += 1;
1293
- } catch (error) {
1294
- back.push(JSON.stringify({ ...item, error: errText(error), tries: (item.tries ?? 1) + 1 })); failed += 1;
1295
- }
1296
- }
1297
- if (back.length) fs.appendFileSync(base, `${back.join('\n')}\n`);
1298
- fs.rmSync(f, { force: true });
1299
- }
1300
- if (flushed) log(m, { actor: 'harness', kind: 'machine-db.outbox-flushed', level: failed ? 'warn' : 'info', msg: `outbox: ${flushed} deferred write(s) applied${failed ? `, ${failed} still pending` : ''}`, data: { flushed, failed } });
1301
- return { flushed, failed, pending: failed ? base : null };
1302
- }
1303
-
1304
- // camelCase → snake_case keys for the pass-through writers (seat, terminal, worktree ...).
1305
- function snake(obj) {
1306
- return Object.fromEntries(Object.entries(obj ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => [k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`), v]));
1307
- }
1308
-
1309
- const API = {
1310
- insert: (m, table, row, opts) => insertRow(m.db, table, row, opts), upsert: (m, table, row, keys) => upsertRow(m.db, table, row, keys), update: (m, table, set, where) => updateRow(m.db, table, set, where),
1311
- putMachineBlob, jsonOrBlob, meta, checkpoint,
1312
- registerLedger, resolveLedger, listLedgers, touchLedger, setLedgerState, upsertRepository, forEachLedger, attachLedgers,
1313
- supEvent, supEvents, newestSupEvent, upsertSupJob, setSupJobStatus, supJob, listSupJobs, acquireSupLeases, releaseSupLeases, supLeases,
1314
- startSupAttempt, updateSupAttempt, latestSupAttempt, recordSupReport, supReports, consumeSupReport,
1315
- openSupDecision, setSupDecision, markSupDecisionDelivered, listSupDecisions, openOwed, ackOwed, closeOwed, listOwed,
1316
- upsertLearning, listLearning, recordOwnerRuling, upsertBridge, recordSupMessage, supMessages, markSupMessagesRead,
1317
- setSupSignal, supSignal, clearSupSignal, recordMachineLlmUsage,
1318
- startProcessRun, heartbeatProcessRun, endProcessRun, openProcessRuns, processRuns,
1319
- leaderOf, acquireLeader, renewLeader, releaseLeader, leaderHistory, cursorOf, setCursor, cursors,
1320
- enqueue, dueQueue, queueRows, dequeue, requeue, ensureSchedule, claimSchedule, finishSchedule, schedules,
1321
- actionIntent, actionRunning, actionFinish, markStaleActionsUnknown, actionOf, actions, actionStep,
1322
- controllerModes, setControllerMode, modeChanges, openSlaEpisode, markSlaViolated, markSlaReported, clearSla, openSla, recordViolation, clearViolation,
1323
- setService, recordProbe, services, serviceEvents, upsertSeat, seatOf, seats, recordDelivery, recordSeatInput, startSeatTurn, endSeatTurn, seatTranscriptSnapshot,
1324
- upsertTerminal, closeTerminal, acquireHostLock, renewHostLock, releaseHostLock, hostLock, hostLocks,
1325
- claimResource, releaseClaim, sweptClaim, liveClaims, upsertAgentSession,
1326
- throttleState, setThrottle, throttleEvents, recordThrottleDecision, releaseThrottleDecision, recordHostSample, hostSamples,
1327
- setProviderHealth, providerHealth, poolBackoff, setPoolBackoff, clearPoolBackoff, setQuota, quotas,
1328
- reserveProvider, providerReservations, providerReservationUsage, markProviderReservation, releaseProviderReservation,
1329
- upsertGuardJob, guardJob, releaseGuardJob, recordGuardRefusal, releaseHostLeases, release: releaseHostLeases, hostLeases, setBudget, reserveBudget, settleBudget, budgets,
1330
- startGcRun, finishGcRun, recordGcItem, addGcItem: recordGcItem, updateGcItem, gcItems, gcMark, addGcMarks: gcMark, gcRuns, markMachineBlobArchived, pruneSeatSnapshots,
1331
- upsertLane, setLaneState, laneOf, lanes, enqueueLand, claimLandGate, finishLandTicket, landQueue, recordLandRun, recordLandOutcome, landRuns, recordPush, pushes, flushOutbox,
1332
- upsertWorktree, removedWorktree, reserveWorktree, liveWorktrees, worktreeRow, worktreeRepos, dropWorktree, worktreeGcStop, setWorktreeGcStop, gcCursor, setGcCursor, upsertEnvServer, envServer, envServers, upsertUatSlot, uatSlots, releaseUatSlot, upsertConnector, connectorOf, upsertAsk,
1333
- log, logs, pruneLogs, recordMetrics, latestMetrics, recordNotification, notificationSent, markNotificationSent, recordArchive, projectCatalog,
1334
- };
1335
-
1336
- /** Every typed function at module level too: fn(handle, ...args) — blob-gc and callers holding a handle. */
1337
- export { putMachineBlob, meta, checkpoint, registerLedger, resolveLedger, listLedgers, setLedgerState, upsertRepository, forEachLedger, attachLedgers, supEvent, supEvents, newestSupEvent, upsertSupJob, setSupJobStatus, supJob, listSupJobs, acquireSupLeases, releaseSupLeases, supLeases, startSupAttempt, updateSupAttempt, latestSupAttempt, recordSupReport, supReports, consumeSupReport, openSupDecision, setSupDecision, markSupDecisionDelivered, listSupDecisions, ackOwed, upsertLearning, recordSupMessage, supMessages, setSupSignal, supSignal, recordMachineLlmUsage, startProcessRun, heartbeatProcessRun, endProcessRun, openProcessRuns, leaderOf, acquireLeader, renewLeader, releaseLeader, leaderHistory, cursorOf, setCursor, cursors, enqueue, requeue, ensureSchedule, claimSchedule, finishSchedule, schedules, actionIntent, actionRunning, actionFinish, actionOf, actions, controllerModes, setControllerMode, modeChanges, openSlaEpisode, markSlaViolated, markSlaReported, clearSla, openSla, recordViolation, setService, recordProbe, services, serviceEvents, upsertSeat, seatOf, seats, recordDelivery, recordSeatInput, seatTranscriptSnapshot, upsertTerminal, closeTerminal, acquireHostLock, renewHostLock, releaseHostLock, hostLock, claimResource, releaseClaim, throttleState, setThrottle, recordThrottleDecision, releaseThrottleDecision, recordHostSample, hostSamples, setProviderHealth, providerHealth, poolBackoff, setPoolBackoff, clearPoolBackoff, setQuota, quotas, setBudget, budgets, startGcRun, finishGcRun, recordGcItem, gcRuns, upsertLane, laneOf, lanes, enqueueLand, claimLandGate, finishLandTicket, landQueue, recordLandRun, recordLandOutcome, landRuns, recordPush, pushes, flushOutbox, removedWorktree, reserveWorktree, liveWorktrees, worktreeRow, worktreeRepos, dropWorktree, worktreeGcStop, setWorktreeGcStop, gcCursor, setGcCursor, upsertEnvServer, envServer, envServers, upsertUatSlot, uatSlots, releaseUatSlot, connectorOf, upsertAsk, log, logs, recordMetrics };
1338
- export { reserveProvider, providerReservations, providerReservationUsage, markProviderReservation, releaseProviderReservation };
1339
-
1340
- /** Best-effort machine log line from anywhere (never throws): opens, appends, closes. */
1341
- export function machineLog(row, { env = process.env } = {}) {
1342
- try { return withMachine((m) => m.log(row), { env }); } catch { return 0; }
1343
- }
1344
-
1345
- // ---------------------------------------------------------------------------------------------------------------------
1346
- // CLI: starci runtime machine-db <init|status|ledgers|register|resolve> [--file <machine.sqlite>] [--json]
1347
- // ---------------------------------------------------------------------------------------------------------------------
1348
- if (isMain(import.meta.url)) {
1349
- const args = process.argv.slice(2);
1350
- const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
1351
- const cmd = args[0] ?? 'status';
1352
- const file = flag('file');
1353
- const out = (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1354
- try {
1355
- if (cmd === 'init' || cmd === 'status') {
1356
- withMachine((m) => out({ ok: true, file: m.file, meta: m.meta(), userVersion: Number(pragma(m.db, 'user_version')), journalMode: pragma(m.db, 'journal_mode'),
1357
- modes: m.controllerModes(), ledgers: m.listLedgers().length, integrity: m.db.prepare('PRAGMA quick_check').get()?.quick_check,
1358
- foreignKeyViolations: m.db.prepare('PRAGMA foreign_key_check').all().length }), { file });
1359
- } else if (cmd === 'ledgers') {
1360
- withMachine((m) => out(m.listLedgers({ includeRetired: args.includes('--all') })), { file });
1361
- } else if (cmd === 'register') {
1362
- withMachine((m) => out(m.registerLedger({ ledgerId: flag('ledger-id'), name: flag('name'), repoRoot: flag('repo'), file: flag('ledger-file'), product: flag('product') })), { file });
1363
- } else if (cmd === 'resolve') {
1364
- withMachine((m) => out(m.resolveLedger({ ledgerId: flag('ledger-id'), name: flag('name'), repoRoot: flag('repo'), create: args.includes('--create') })), { file });
1365
- } else { throw Error(`unknown command ${cmd}: init | status | ledgers [--all] | register --ledger-id --name --repo | resolve (--repo|--name|--ledger-id) [--create]`); }
1366
- } catch (error) { process.stderr.write(`${error.code ?? 'error'}: ${error.message}\n`); process.exit(2); }
1367
- }