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