akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.4

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 (343) hide show
  1. package/CHANGELOG.md +756 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +363 -679
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -427
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -4,93 +4,70 @@
4
4
  /**
5
5
  * state.db — Durable SQLite database for non-regenerable akm state.
6
6
  *
7
- * This module OWNS the state database's shared infrastructure: path resolution,
8
- * the managed-db open/loan wrappers, the `BEGIN IMMEDIATE` transaction helper,
9
- * and schema introspection. The table-specific query helpers live by domain in
7
+ * This module OWNS the state database's shared infrastructure: path
8
+ * resolution, the open/loan wrappers and schema introspection. The
9
+ * table-specific query helpers live by domain in
10
10
  * `src/storage/repositories/*-repository.ts` (events, proposals, task-history,
11
- * improve-runs, extract-sessions, consolidation, embeddings, canaries);
12
- * importers reference those modules directly. The migration engine
13
- * lives in `./state/migrations`.
14
- *
15
- * The state DB stores non-regenerable events, proposals, task history, workflow
16
- * runs, and improve-pipeline ledgers.
11
+ * improve-runs, improve-ledger, extract-sessions, embeddings); the
12
+ * migration registry lives in `./state/migrations` and the runner in
13
+ * `storage/sqlite-migrations`. The `BEGIN IMMEDIATE` helpers are
14
+ * re-exported from `storage/sqlite-transaction`.
17
15
  *
18
16
  * ## Why a separate database from index.db
19
17
  *
20
18
  * index.db is a derived cache built by an idempotent baseline schema; it is fully
21
19
  * regenerable from the stash on disk, so a corrupt index is recovered by deleting
22
- * it and re-running `akm index` (no destructive version-bump rebuild). Events,
23
- * proposals, and task history are NON-REGENERABLE — losing them is data loss. They
24
- * live in a database whose released migration ledger is immutable and whose
25
- * application policy is explicit.
26
- *
27
- * ## Migration-safety contract
28
- *
29
- * The `schema_migrations` table records every applied migration by a stable string
30
- * ID. New installs run all migrations in order. Existing exact-prefix ledgers
31
- * automatically apply additive migrations and the verified data-preserving 002
32
- * table rebuild. Released migration 018 contains destructive cleanup DDL and is
33
- * never applied by an ordinary managed open. The successful `akm upgrade` path
34
- * must first create and verify a sibling `VACUUM INTO` snapshot, then supplies
35
- * the narrow explicit intent that admits 018. A pre-existing file with no
36
- * applied migration IDs (whether the ledger table is absent or empty) is also
37
- * rejected without writes; explicit upgrade snapshots its exact inode before
38
- * creating the ledger or applying migration 001, then retains the same writer
39
- * lock through migration 002's rebuild. Unknown and divergent ledgers fail
40
- * closed.
41
- *
42
- * Normal automatic schema evolution uses:
43
- * - ALTER TABLE … ADD COLUMN <name> <type> DEFAULT <value>
44
- * - CREATE INDEX IF NOT EXISTS …
45
- * - CREATE TABLE IF NOT EXISTS … (additive new tables)
20
+ * it and re-running `akm index`. Events, proposals, task history, workflow runs
21
+ * and improve-pipeline ledgers are NON-REGENERABLE — losing them is data loss.
22
+ * They live in a database whose released migration ledger is append-only.
23
+ *
24
+ * ## Open sequence
25
+ *
26
+ * {@link openStateDatabase} opens ONE connection: mkdir the parent, open, apply
27
+ * the standard pragmas (`busy_timeout` 30 s first, `journal_mode` WAL with the
28
+ * network-filesystem fallback, `foreign_keys` ON — `storage/sqlite-pragmas`),
29
+ * read the `schema_migrations` ledger on that connection, and run every
30
+ * migration not yet applied inside one `BEGIN IMMEDIATE` transaction. A
31
+ * missing, empty or table-less file is simply a fresh database and gets the
32
+ * whole registry. A ledger carrying ids this akm does not know was written by
33
+ * a newer akm: the open continues with the schema this version knows and warns
34
+ * once ({@link warnNewerStateLedger}). Before a released migration that drops
35
+ * schema (`018-drop-dead-lane-schema`, `028-improve-ledger`; see
36
+ * `STATE_MIGRATION_SAFETY_BY_ID`) runs against an existing database, the
37
+ * database is copied beside itself to `state.db.pre-<id of the first such
38
+ * pending migration>.bak` with `VACUUM INTO` — a plain sibling copy the
39
+ * operator can open, nothing more. A database this open created from scratch is
40
+ * compacted once after its migrations, since the registry creates tables that
41
+ * later migrations drop. Only a ledger that DIVERGES from this akm's registry
42
+ * is refused (`assertMigrationLedger`).
46
43
  *
47
44
  * ## Schema design: indexed columns vs. metadata_json
48
45
  *
49
46
  * Each table holds only the columns needed for indexed queries as first-class
50
- * columns. All other fields live in a `metadata_json TEXT` column (a JSON object).
51
- * New fields can be appended to the JSON blob at any time without touching the
52
- * DDL. This is the same pattern used by `usage_events.metadata` in index.db and
53
- * by the original events.jsonl format (the `metadata` field was always free-form
54
- * JSON).
47
+ * columns. All other fields live in a `metadata_json TEXT` column (a JSON
48
+ * object), so new fields can be appended without touching the DDL.
55
49
  *
56
- * ## WAL mode
50
+ * ## WAL mode and writer contention
57
51
  *
58
- * SQLite WAL mode allows concurrent readers while a writer is active and makes
59
- * crashes safe (the WAL is replayed on next open). The O_APPEND multi-writer model
60
- * of events.jsonl is replaced by WAL-mode serialised writes — acceptable because
61
- * CLI commands are almost always single-writer.
62
- *
63
- * ## Writer contention (#948)
64
- *
65
- * Every open already carries a 30s `busy_timeout` (`sqlite-pragmas.ts`), so a
66
- * single blocked statement waits before failing. `withImmediateTransaction`
67
- * additionally retries `BEGIN IMMEDIATE` itself
68
- * ({@link WITH_IMMEDIATE_TX_MAX_ATTEMPTS} attempts) for the rarer case of two
69
- * writers racing the BEGIN statement back-to-back. If every attempt is still
70
- * contention-shaped ({@link isSqliteContentionError} — SQLITE_BUSY/LOCKED, the
71
- * matching message text, or the phantom-BEGIN marker), the exhaustion throw is
72
- * reclassified into `TransientError("STATE_DB_CONTENDED")` (exit 75, #948
73
- * addendum) instead of surfacing the raw driver text: another akm process (an
74
- * unrelated `improve`, `workflow run`, or task run — not necessarily
75
- * contending for the same row) is writing state.db right now. A genuinely
76
- * unrelated error (real corruption, a body-thrown failure) is never
77
- * reclassified and rethrows exactly as raised.
52
+ * WAL lets readers proceed while a writer is active and replays after a crash;
53
+ * CLI commands are almost always single-writer. Writer contention that
54
+ * outlasts every `BEGIN IMMEDIATE` retry surfaces as
55
+ * `TransientError("STATE_DB_CONTENDED")`, exit 75 (#948) — the one place that
56
+ * mapping lives is `storage/sqlite-transaction`.
78
57
  *
79
58
  * @module state-db
80
59
  */
81
- import { randomUUID } from "node:crypto";
82
60
  import fs from "node:fs";
83
61
  import path from "node:path";
84
- import { sleepSync } from "../runtime.js";
85
62
  import { openDatabase } from "../storage/database.js";
86
- import { assertMigrationLedger } from "../storage/engines/sqlite-migrations.js";
87
63
  import { openManagedDatabase, withManagedDb } from "../storage/managed-db.js";
64
+ import { assertMigrationLedger, runMigrations, } from "../storage/sqlite-migrations.js";
65
+ import { applyReadonlyPragmas } from "../storage/sqlite-pragmas.js";
88
66
  import { pkgVersion } from "../version.js";
89
- import { TransientError } from "./errors.js";
90
- import { acquireMaintenanceActivitySync } from "./maintenance-barrier.js";
91
67
  import { getDataDir } from "./paths.js";
92
- import { runMigrations, STATE_MIGRATIONS } from "./state/migrations.js";
68
+ import { getStateMigrationSafety, STATE_MIGRATIONS } from "./state/migrations.js";
93
69
  import { warnOnce } from "./warn.js";
70
+ export { beginImmediateTransaction, isSqliteContentionError, withImmediateTransaction, } from "../storage/sqlite-transaction.js";
94
71
  // ── Path helper ──────────────────────────────────────────────────────────────
95
72
  /**
96
73
  * Default path: `<dataDir>/state.db`.
@@ -101,294 +78,7 @@ import { warnOnce } from "./warn.js";
101
78
  export function getStateDbPath() {
102
79
  return path.join(getDataDir(), "state.db");
103
80
  }
104
- function safetyCopyTimestamp() {
105
- return new Date().toISOString().replaceAll(/[^0-9]/g, "");
106
- }
107
- function noFollowFlag() {
108
- return process.platform !== "win32" && typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
109
- }
110
- function samePhysicalFile(left, right) {
111
- if (left.dev !== 0n || left.ino !== 0n || right.dev !== 0n || right.ino !== 0n) {
112
- return left.dev === right.dev && left.ino === right.ino;
113
- }
114
- return left.birthtimeNs === right.birthtimeNs && left.rdev === right.rdev;
115
- }
116
- function assertOwnedFileReservation(reservation, label) {
117
- const descriptorStat = fs.fstatSync(reservation.fd, { bigint: true });
118
- let pathStat;
119
- try {
120
- pathStat = fs.lstatSync(reservation.path, { bigint: true });
121
- }
122
- catch (error) {
123
- const detail = error instanceof Error ? error.message : String(error);
124
- throw new Error(`${label} ownership/inode verification failed because its path disappeared: ${detail}`);
125
- }
126
- if (pathStat.isSymbolicLink() ||
127
- !pathStat.isFile() ||
128
- !descriptorStat.isFile() ||
129
- !samePhysicalFile(reservation.identity, descriptorStat) ||
130
- !samePhysicalFile(descriptorStat, pathStat)) {
131
- throw new Error(`${label} ownership/inode verification failed: its path is a symlink or was replaced.`);
132
- }
133
- if (process.platform !== "win32" && typeof process.geteuid === "function") {
134
- const expectedUid = BigInt(process.geteuid());
135
- if (descriptorStat.uid !== expectedUid || pathStat.uid !== expectedUid) {
136
- throw new Error(`${label} ownership verification failed: the reserved file is not owned by the current user.`);
137
- }
138
- }
139
- return pathStat;
140
- }
141
- function assertStateDatabaseSource(source) {
142
- const descriptorStat = fs.fstatSync(source.fd, { bigint: true });
143
- let pathStat;
144
- try {
145
- pathStat = fs.lstatSync(source.path, { bigint: true });
146
- }
147
- catch (error) {
148
- const detail = error instanceof Error ? error.message : String(error);
149
- throw new Error(`state.db source inode verification failed because its path disappeared: ${detail}`);
150
- }
151
- if (pathStat.isSymbolicLink() ||
152
- !pathStat.isFile() ||
153
- !descriptorStat.isFile() ||
154
- !samePhysicalFile(source.identity, descriptorStat) ||
155
- !samePhysicalFile(descriptorStat, pathStat) ||
156
- descriptorStat.uid !== source.identity.uid ||
157
- pathStat.uid !== source.identity.uid) {
158
- throw new Error("state.db source ownership/inode verification failed: its path is a symlink or was replaced.");
159
- }
160
- return descriptorStat;
161
- }
162
- function descriptorAlias(handle) {
163
- const candidates = process.platform === "linux"
164
- ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
165
- : process.platform === "win32"
166
- ? []
167
- : [`/dev/fd/${handle.fd}`];
168
- for (const candidate of candidates) {
169
- try {
170
- const stat = fs.statSync(candidate, { bigint: true });
171
- if (samePhysicalFile(handle.identity, stat))
172
- return candidate;
173
- }
174
- catch {
175
- // The caller verifies the pathname immediately around SQLite open on
176
- // platforms without a SQLite-openable descriptor alias.
177
- }
178
- }
179
- return undefined;
180
- }
181
- function sqliteBoundFilePath(handle) {
182
- // A descriptor-backed alias (/proc/self/fd, /dev/fd) lets SQLite open the
183
- // exact held inode even if its path gets swapped out from under it. It is
184
- // an optimization, not the actual protection: every caller re-verifies the
185
- // held identity (dev/ino/uid) immediately before and after every open that
186
- // uses this path, so a plain path is safe whenever no alias is available —
187
- // Windows never has one, and macOS's /dev/fd is a small fixed-size devfs
188
- // table that a process holding higher fd numbers (as a bundled standalone
189
- // binary routinely does) can miss entirely. Either way, a swap in that
190
- // window is still caught by the surrounding identity checks.
191
- return descriptorAlias(handle) ?? handle.path;
192
- }
193
- function closeFileIdentity(handle) {
194
- try {
195
- fs.closeSync(handle.fd);
196
- }
197
- catch {
198
- // Preserve the authoritative operation failure.
199
- }
200
- }
201
- function reserveFreshStateDatabase(dbPath) {
202
- let fd;
203
- try {
204
- fd = fs.openSync(dbPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR | noFollowFlag(), 0o666);
205
- }
206
- catch (error) {
207
- if (error?.code === "EEXIST")
208
- return undefined;
209
- throw error;
210
- }
211
- try {
212
- return {
213
- path: dbPath,
214
- fd,
215
- identity: fs.fstatSync(fd, { bigint: true }),
216
- };
217
- }
218
- catch (error) {
219
- try {
220
- fs.closeSync(fd);
221
- }
222
- catch {
223
- // Preserve the fstat failure.
224
- }
225
- throw error;
226
- }
227
- }
228
- function openExistingStateDatabaseSource(dbPath) {
229
- let fd;
230
- try {
231
- fd = fs.openSync(dbPath, fs.constants.O_RDONLY | noFollowFlag());
232
- }
233
- catch (error) {
234
- const detail = error instanceof Error ? error.message : String(error);
235
- throw new Error(`Could not bind the existing state.db source inode: ${detail}`);
236
- }
237
- try {
238
- return {
239
- path: dbPath,
240
- fd,
241
- identity: fs.fstatSync(fd, { bigint: true }),
242
- };
243
- }
244
- catch (error) {
245
- try {
246
- fs.closeSync(fd);
247
- }
248
- catch {
249
- // Preserve the fstat failure.
250
- }
251
- throw error;
252
- }
253
- }
254
- function reserveHistoricalSafetyCopy(source, migrationId) {
255
- const sourceStat = assertStateDatabaseSource(source);
256
- const finalMode = Number(sourceStat.mode & 384n);
257
- const prefix = `${source.path}.pre-${migrationId}.${safetyCopyTimestamp()}`;
258
- for (let attempt = 0; attempt < 32; attempt += 1) {
259
- const candidate = `${prefix}.${randomUUID()}.bak`;
260
- let fd;
261
- try {
262
- fd = fs.openSync(candidate, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR | noFollowFlag(), 0o600);
263
- }
264
- catch (error) {
265
- if (error?.code === "EEXIST")
266
- continue;
267
- throw error;
268
- }
269
- let reservation;
270
- try {
271
- reservation = {
272
- path: candidate,
273
- fd,
274
- identity: fs.fstatSync(fd, { bigint: true }),
275
- };
276
- // Keep recovery bytes owner-only throughout creation. The source-derived
277
- // (never broader) final mode is restored only after verification.
278
- fs.fchmodSync(fd, 0o600);
279
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
280
- return { reservation, finalMode };
281
- }
282
- catch (error) {
283
- try {
284
- fs.closeSync(fd);
285
- }
286
- catch {
287
- // Preserve the reservation/ownership failure.
288
- }
289
- const detail = error instanceof Error ? error.message : String(error);
290
- throw new Error(`Could not secure reserved state.db safety-copy path ${candidate}: ${detail}. ` +
291
- "The reserved pathname was not removed.");
292
- }
293
- }
294
- throw new Error("Could not reserve a unique randomized state.db safety-copy path after 32 attempts.");
295
- }
296
- function fsyncDirectory(directory) {
297
- if (process.platform === "win32")
298
- return;
299
- const fd = fs.openSync(directory, fs.constants.O_RDONLY);
300
- try {
301
- fs.fsyncSync(fd);
302
- }
303
- finally {
304
- fs.closeSync(fd);
305
- }
306
- }
307
- /**
308
- * Create one verified, standalone SQLite snapshot immediately before released
309
- * migration 018 removes its retired tables/column. `VACUUM INTO` includes
310
- * committed WAL content in one consistent sibling database; a raw file copy
311
- * would not.
312
- */
313
- function createHistoricalStateSafetyCopy(source, migrationId) {
314
- const { reservation, finalMode } = reserveHistoricalSafetyCopy(source, migrationId);
315
- let reader;
316
- try {
317
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
318
- assertStateDatabaseSource(source);
319
- // The migration connection already holds BEGIN IMMEDIATE. A distinct
320
- // read-only connection bound to the held source inode can snapshot the
321
- // committed WAL view without trying to VACUUM from inside that transaction.
322
- reader = openDatabase(sqliteBoundFilePath(source), { readonly: true });
323
- assertStateDatabaseSource(source);
324
- reader.prepare("VACUUM INTO ?").run(sqliteBoundFilePath(reservation));
325
- assertStateDatabaseSource(source);
326
- reader.close();
327
- reader = undefined;
328
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
329
- fs.fsyncSync(reservation.fd);
330
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
331
- const verified = openDatabase(sqliteBoundFilePath(reservation), {
332
- readonly: true,
333
- });
334
- try {
335
- const quickCheck = verified.prepare("PRAGMA quick_check").get();
336
- if (!quickCheck || Object.values(quickCheck)[0] !== "ok") {
337
- throw new Error("SQLite quick_check did not report ok");
338
- }
339
- assertMigrationLedger(verified, STATE_MIGRATIONS);
340
- }
341
- finally {
342
- verified.close();
343
- }
344
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
345
- fs.fchmodSync(reservation.fd, finalMode);
346
- fs.fsyncSync(reservation.fd);
347
- assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
348
- fsyncDirectory(path.dirname(reservation.path));
349
- closeFileIdentity(reservation);
350
- return reservation.path;
351
- }
352
- catch (error) {
353
- try {
354
- reader?.close();
355
- }
356
- catch {
357
- // Preserve the snapshot/verification failure below.
358
- }
359
- closeFileIdentity(reservation);
360
- const detail = error instanceof Error ? error.message : String(error);
361
- throw new Error(`Could not create a verified state.db safety copy before ${migrationId}: ${detail}. ` +
362
- `The reserved safety-copy pathname was not removed: ${reservation.path}`);
363
- }
364
- }
365
81
  // ── Database open ────────────────────────────────────────────────────────────
366
- /**
367
- * Open (and initialise / migrate) the state database.
368
- *
369
- * @param dbPath - Override the database file path. Pass a tmpdir path in tests
370
- * to avoid touching the real user cache. Mirrors the `filePath` test seam
371
- * on `EventsContext`.
372
- *
373
- * PRAGMA rationale:
374
- *
375
- * journal_mode = WAL
376
- * Write-Ahead Logging: readers never block writers and vice-versa. Crashes
377
- * are safe — the WAL is replayed on next open. Required for concurrent CLI
378
- * invocations that may read while another writes.
379
- *
380
- * foreign_keys = ON
381
- * Enforces FK constraints at runtime. SQLite disables them by default for
382
- * backwards compatibility; enabling them prevents orphaned rows in tables
383
- * that reference each other (not used in v1 schema but guards future ones).
384
- *
385
- * busy_timeout = 30000
386
- * When another connection holds a write lock, SQLite retries for up to
387
- * 30 000 ms before returning SQLITE_BUSY. Without this, the default timeout
388
- * is 0 ms — any concurrent writer causes an immediate error. 30 s (#589)
389
- * matches the value used in openDatabase() for index.db; 5 s proved too
390
- * narrow when a post-inference reindex overlapped a parallel event write.
391
- */
392
82
  /**
393
83
  * Tell the operator once when state.db was migrated by a newer akm than the
394
84
  * one running. The open proceeds: every migration this binary knows is already
@@ -402,193 +92,102 @@ function warnNewerStateLedger(ledger) {
402
92
  warnOnce("state-db-newer-ledger", `[state.db] This akm (v${pkgVersion}) is older than the state database: ${ledger.detail}. ` +
403
93
  "Continuing with the schema this version knows; upgrade akm if its output looks incomplete.");
404
94
  }
405
- function unversionedDatabaseHasNoTables(db) {
406
- const tables = db
407
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != ?")
408
- .get("schema_migrations");
409
- return !tables;
95
+ /**
96
+ * Copy the database beside itself before the first pending migration that
97
+ * drops schema runs. `VACUUM INTO` writes one consistent standalone file
98
+ * (committed WAL content included — a raw file copy would miss it) through
99
+ * the same connection, into a temporary name that is renamed over the final
100
+ * one only once the copy is complete, so a crash never leaves a half-written
101
+ * `.bak` behind. Returns the copy's path, or undefined when nothing pending
102
+ * is destructive.
103
+ */
104
+ function backupBeforeDestructiveMigration(db, dbPath, pending) {
105
+ const destructive = pending.find((migration) => getStateMigrationSafety(migration.id) === "historical-destructive");
106
+ if (!destructive)
107
+ return undefined;
108
+ const backupPath = `${dbPath}.pre-${destructive.id}.bak`;
109
+ const partialPath = `${backupPath}.tmp`;
110
+ fs.rmSync(partialPath, { force: true });
111
+ db.prepare("VACUUM INTO ?").run(partialPath);
112
+ fs.renameSync(partialPath, backupPath);
113
+ return backupPath;
114
+ }
115
+ /** Read the ledger, warn on a newer one, back up before destructive DDL, then run what is pending. */
116
+ function migrateStateDatabase(db, dbPath) {
117
+ const ledger = assertMigrationLedger(db, STATE_MIGRATIONS);
118
+ warnNewerStateLedger(ledger);
119
+ const pending = STATE_MIGRATIONS.slice(ledger.migrationIds.length);
120
+ if (pending.length === 0)
121
+ return { applied: [] };
122
+ // A database with no applied migration holds nothing akm wrote: a fresh
123
+ // file, `:memory:`, or an empty file left by an interrupted first open.
124
+ const fresh = ledger.migrationIds.length === 0;
125
+ const backupPath = fresh ? undefined : backupBeforeDestructiveMigration(db, dbPath, pending);
126
+ const applied = runMigrations(db, STATE_MIGRATIONS);
127
+ // The registry creates tables that later migrations drop (018, 028), which
128
+ // leaves free pages behind; a database created by this open starts compact.
129
+ if (fresh && applied.length > 0)
130
+ db.exec("VACUUM");
131
+ return backupPath ? { applied, backupPath } : { applied };
132
+ }
133
+ function openAndMigrate(dbPath) {
134
+ let report = { applied: [] };
135
+ const db = openManagedDatabase({
136
+ path: dbPath,
137
+ pragmas: { dataDir: path.dirname(dbPath) },
138
+ init: (handle) => {
139
+ report = migrateStateDatabase(handle, dbPath);
140
+ },
141
+ });
142
+ return { db, ...report };
410
143
  }
411
- export function openStateDatabase(dbPath, options) {
412
- const canonicalPath = getStateDbPath();
413
- const resolvedPath = dbPath ?? canonicalPath;
414
- // `:memory:` is a SQLite connection identity, not a filesystem pathname.
415
- // Never pass it through the durable-file reservation/inode/snapshot path:
416
- // doing so creates a literal `:memory:` file and makes later in-process
417
- // opens look like an unversioned durable database. Each in-memory handle is
418
- // fresh and cannot be path-swapped, so the ownership proof is intrinsically
419
- // satisfied for this explicit test/internal seam.
420
- if (resolvedPath === ":memory:") {
421
- return openManagedDatabase({
422
- path: resolvedPath,
423
- pragmas: { dataDir: path.dirname(resolvedPath) },
424
- init: (db) => runMigrations(db, { freshDatabase: true }),
425
- });
426
- }
427
- const isCanonical = path.resolve(resolvedPath) === path.resolve(canonicalPath);
428
- const releaseActivity = isCanonical ? acquireMaintenanceActivitySync("state-db") : undefined;
429
- let freshReservation;
430
- let existingSource;
431
- let openedDb;
432
- let existingUnversionedDatabase = false;
433
- let treatUnversionedAsFresh = false;
434
- let stateSafetyCopyCreated = false;
435
- try {
436
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
437
- freshReservation = reserveFreshStateDatabase(resolvedPath);
438
- if (!freshReservation) {
439
- existingSource = openExistingStateDatabaseSource(resolvedPath);
440
- const preflight = openDatabase(sqliteBoundFilePath(existingSource), {
441
- readonly: true,
442
- });
443
- try {
444
- preflight.exec("PRAGMA busy_timeout = 30000");
445
- const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
446
- warnNewerStateLedger(ledger);
447
- existingUnversionedDatabase = ledger.migrationIds.length === 0;
448
- if (existingUnversionedDatabase && unversionedDatabaseHasNoTables(preflight)) {
449
- existingUnversionedDatabase = false;
450
- treatUnversionedAsFresh = true;
451
- }
452
- if (existingUnversionedDatabase && !options?.allowHistoricalDestructiveStateUpgrade) {
453
- throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
454
- "Run `akm upgrade` (or `akm migrate apply`) to create a verified snapshot before migration 001 " +
455
- "and apply it deliberately.");
456
- }
457
- }
458
- finally {
459
- preflight.close();
460
- }
461
- }
462
- const boundSource = existingSource;
463
- openedDb = openManagedDatabase({
464
- path: boundSource ? sqliteBoundFilePath(boundSource) : resolvedPath,
465
- pragmas: { dataDir: path.dirname(resolvedPath) },
466
- init: (db) => {
467
- runMigrations(db, {
468
- freshDatabase: !!freshReservation || treatUnversionedAsFresh,
469
- existingUnversionedDatabase,
470
- allowHistoricalDestructiveStateUpgrade: options?.allowHistoricalDestructiveStateUpgrade,
471
- beforeExistingUnversionedStateMigration: options?.allowHistoricalDestructiveStateUpgrade
472
- ? (migration) => {
473
- if (!boundSource)
474
- throw new Error("An existing unversioned state.db has no bound source inode.");
475
- const safetyCopyPath = createHistoricalStateSafetyCopy(boundSource, migration.id);
476
- stateSafetyCopyCreated = true;
477
- options.onHistoricalStateSafetyCopy?.(safetyCopyPath);
478
- }
479
- : undefined,
480
- beforeHistoricalDestructiveMigration: options?.allowHistoricalDestructiveStateUpgrade
481
- ? (migration) => {
482
- if (stateSafetyCopyCreated)
483
- return;
484
- if (!boundSource)
485
- throw new Error("Historical state migration has no bound source inode.");
486
- const safetyCopyPath = createHistoricalStateSafetyCopy(boundSource, migration.id);
487
- stateSafetyCopyCreated = true;
488
- options.onHistoricalStateSafetyCopy?.(safetyCopyPath);
489
- }
490
- : undefined,
491
- });
492
- },
493
- });
494
- if (existingSource) {
495
- closeFileIdentity(existingSource);
496
- existingSource = undefined;
497
- }
498
- if (freshReservation) {
499
- closeFileIdentity(freshReservation);
500
- freshReservation = undefined;
501
- }
502
- const db = openedDb;
503
- if (!releaseActivity)
504
- return db;
505
- let closed = false;
506
- return {
507
- prepare: db.prepare.bind(db),
508
- exec: db.exec.bind(db),
509
- run: db.run.bind(db),
510
- transaction: db.transaction.bind(db),
511
- loadExtension: db.loadExtension.bind(db),
512
- get inTransaction() {
513
- return db.inTransaction;
514
- },
515
- close() {
516
- if (closed)
517
- return;
518
- closed = true;
519
- try {
520
- db.close();
521
- }
522
- finally {
523
- releaseActivity();
524
- }
525
- },
526
- };
527
- }
528
- catch (error) {
529
- if (openedDb) {
530
- try {
531
- openedDb.close();
532
- }
533
- catch {
534
- // Preserve the open/migration ownership failure.
535
- }
536
- }
537
- if (existingSource)
538
- closeFileIdentity(existingSource);
539
- if (freshReservation)
540
- closeFileIdentity(freshReservation);
541
- releaseActivity?.();
542
- throw error;
543
- }
144
+ /**
145
+ * Open (and initialise / migrate) the state database on one connection — see
146
+ * the module header for the sequence.
147
+ *
148
+ * @param dbPath - Override the database file path. Pass a tmpdir path (or
149
+ * `:memory:`) in tests to avoid touching the real user data dir.
150
+ */
151
+ export function openStateDatabase(dbPath = getStateDbPath()) {
152
+ return openAndMigrate(dbPath).db;
153
+ }
154
+ /** {@link openStateDatabase}, also reporting which migrations this open applied (`akm health`). */
155
+ export function openStateDatabaseWithReport(dbPath = getStateDbPath()) {
156
+ return openAndMigrate(dbPath);
544
157
  }
545
158
  /**
546
159
  * Read-only: the state migration IDs the running akm would apply to `dbPath`,
547
- * in ledger order. Empty when the database is missing or current. Throws on a
548
- * ledger this akm cannot own (newer, or not an exact ordered prefix) -- the
549
- * same refusal a managed open makes.
160
+ * in ledger order. Empty when the database is missing, current, or was
161
+ * migrated by a newer akm. Never applies anything. A ledger that diverges from
162
+ * this akm's registry throws the same refusal the open does.
550
163
  */
551
164
  export function listPendingStateMigrations(dbPath = getStateDbPath()) {
552
165
  if (!fs.existsSync(dbPath))
553
166
  return [];
554
- const preflight = openDatabase(dbPath, { readonly: true });
167
+ const db = openDatabase(dbPath, { readonly: true, create: false });
555
168
  try {
556
- preflight.exec("PRAGMA busy_timeout = 30000");
557
- const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
169
+ applyReadonlyPragmas(db);
170
+ const ledger = assertMigrationLedger(db, STATE_MIGRATIONS);
558
171
  return STATE_MIGRATIONS.slice(ledger.migrationIds.length).map((migration) => migration.id);
559
172
  }
560
173
  finally {
561
- preflight.close();
174
+ db.close();
562
175
  }
563
176
  }
564
177
  /**
565
- * Apply every pending state migration, historical-destructive ones included:
566
- * the one step `akm upgrade` and `akm migrate apply` share, and the only
567
- * caller that may admit migration 018 (an ordinary managed open refuses it by
568
- * design). Missing/current databases are no-ops. A pre-018 exact ledger, or an
569
- * unversioned database, is snapshotted beside state.db and verified before the
570
- * immutable released migration runs.
178
+ * Apply every pending state migration now and report what ran — the state
179
+ * step of `akm migrate apply`. Any open does exactly the same work; this one
180
+ * just says what happened. Missing and current databases are no-ops.
571
181
  */
572
182
  export function upgradeHistoricalStateDatabase(dbPath = getStateDbPath()) {
573
183
  const pending = listPendingStateMigrations(dbPath);
574
184
  if (pending.length === 0)
575
185
  return { upgraded: false, applied: [] };
576
- let safetyCopyPath;
577
- try {
578
- const db = openStateDatabase(dbPath, {
579
- allowHistoricalDestructiveStateUpgrade: true,
580
- onHistoricalStateSafetyCopy(copyPath) {
581
- safetyCopyPath = copyPath;
582
- },
583
- });
584
- db.close();
585
- }
586
- catch (error) {
587
- const detail = error instanceof Error ? error.message : String(error);
588
- const recovery = safetyCopyPath ? ` Verified safety copy: ${safetyCopyPath}.` : "";
589
- throw new Error(`${detail}${recovery}`);
590
- }
591
- return safetyCopyPath ? { upgraded: true, applied: pending, safetyCopyPath } : { upgraded: true, applied: pending };
186
+ const { db, backupPath } = openAndMigrate(dbPath);
187
+ db.close();
188
+ return backupPath
189
+ ? { upgraded: true, applied: pending, safetyCopyPath: backupPath }
190
+ : { upgraded: true, applied: pending };
592
191
  }
593
192
  /**
594
193
  * Run `fn` against state.db, owning the handle unless one is borrowed. The loan
@@ -601,14 +200,14 @@ export function withStateDb(fn, opts) {
601
200
  return withManagedDb(() => openStateDatabase(opts?.path), fn, opts);
602
201
  }
603
202
  /**
604
- * Fire-and-forget telemetry write to state.db (Chunk-8 WI-8.3: usage_events'
605
- * durable home). Skips entirely when state.db does not exist yet (never
606
- * fabricates an un-migrated DB); otherwise opens the migrated DB and lowers
607
- * `busy_timeout` to a short window so a contended state.db (e.g. a reindex
608
- * finalize holding the write lock while relinking usage_events) never stalls a
609
- * hot path — mirrors `withIndexDb`'s `TELEMETRY_BUSY_TIMEOUT_MS`. WAL mode lets
610
- * the read-only migration-preflight run concurrently with a writer, so the open
611
- * itself does not block. Callers wrap this in their own try/catch.
203
+ * Fire-and-forget telemetry write to state.db (usage_events' durable home).
204
+ * Skips entirely when state.db does not exist yet (never fabricates an
205
+ * un-migrated DB); otherwise opens it and lowers `busy_timeout` to a short
206
+ * window so a contended state.db (e.g. a reindex finalize holding the write
207
+ * lock) never stalls a hot path — mirrors `withIndexDb`'s
208
+ * `TELEMETRY_BUSY_TIMEOUT_MS`. On a current database the open itself only
209
+ * reads, so it does not wait behind a writer. Callers wrap this in their own
210
+ * try/catch.
612
211
  */
613
212
  export function withStateDbTelemetry(fn, busyTimeoutMs = 250) {
614
213
  if (!fs.existsSync(getStateDbPath()))
@@ -622,171 +221,6 @@ export function withStateDbTelemetry(fn, busyTimeoutMs = 250) {
622
221
  db.close();
623
222
  }
624
223
  }
625
- // ── Migration engine ─────────────────────────────────────────────────────────
626
- //
627
- // The MIGRATIONS registry + runMigrations live in ./state/migrations (the single
628
- // append-only ordered source of truth). Imported for internal use by
629
- // openStateDatabase.
630
- // ── BEGIN IMMEDIATE transaction helper ───────────────────────────────────────
631
- /**
632
- * Run `fn` inside a `BEGIN IMMEDIATE` transaction.
633
- *
634
- * `db.transaction()` is DEFERRED by default on both Bun and better-sqlite3,
635
- * which means two writers can both perform stale preflight reads and only race
636
- * when they finally attempt the write. Proposal creation and queue mutation
637
- * need the write lock BEFORE those reads so concurrent processes serialize on
638
- * the live queue state rather than clobbering each other.
639
- */
640
- /**
641
- * Whether `err` is one of the SQLite conditions a concurrent-writer race can
642
- * throw that are transient — the statement did NOT corrupt anything, another
643
- * writer just holds the lock right now — and therefore safe to retry or
644
- * reclassify as ordinary contention rather than a genuine failure:
645
- * - `SQLITE_BUSY` / `SQLITE_LOCKED` (either driver's `.code`).
646
- * - "database is locked" / "database table is locked" message text.
647
- * - the phantom-BEGIN marker synthesized below when `BEGIN IMMEDIATE`
648
- * returns without actually opening a transaction.
649
- *
650
- * This is the single shared classifier for "is this ordinary state.db
651
- * contention" (#948) — `isRetryableBeginError` below delegates to it, and so
652
- * does {@link WorkflowRunsRepository}'s lease-contention classifier (which
653
- * additionally matches a couple of corruption-shaped texts specific to its
654
- * own narrower cross-process race and keeps its own live-lease confirmation
655
- * before reclassifying). Matching this set alone is never sufficient to
656
- * declare something DEFINITELY contention when a caller needs corroborating
657
- * evidence (see the lease path); for `beginImmediateTransaction`'s own
658
- * exhaustion case there is no such evidence available, so the five 30s
659
- * `busy_timeout` waits already spent stand as the evidence instead.
660
- */
661
- export function isSqliteContentionError(err) {
662
- const code = err?.code;
663
- if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
664
- return true;
665
- const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
666
- return (msg.includes("database is locked") ||
667
- msg.includes("database table is locked") ||
668
- // Phantom BEGIN (see below) — synthesized when BEGIN IMMEDIATE returns
669
- // without opening a transaction. Safe to retry: fn() has not run.
670
- msg.includes("did not open a transaction"));
671
- }
672
- /**
673
- * Errors `BEGIN IMMEDIATE` can throw under concurrent-writer contention that
674
- * are transient (the statement did NOT start a usable transaction) and safe
675
- * to retry. An error thrown by `fn` is a real failure and is NEVER retried.
676
- *
677
- * "cannot start a transaction within a transaction" is deliberately NOT
678
- * retryable: it means a transaction is already open on this connection (a
679
- * re-entrant call — handled by the entry guard in withImmediateTransaction),
680
- * and "retrying" it with a ROLLBACK would destroy the caller's transaction
681
- * (issue #686).
682
- */
683
- function isRetryableBeginError(err) {
684
- return isSqliteContentionError(err);
685
- }
686
- const WITH_IMMEDIATE_TX_MAX_ATTEMPTS = 5;
687
- /** Portable synchronous sleep (works under both Bun and Node). Delegates to the runtime boundary's `sleepSync`. */
688
- function sleepSyncMs(ms) {
689
- if (ms <= 0)
690
- return;
691
- sleepSync(ms);
692
- }
693
- /**
694
- * Open, but deliberately do not finish, an immediate transaction.
695
- *
696
- * This is the split-phase counterpart to {@link withImmediateTransaction} for
697
- * the source-update coordinator: index finalization must mutate state.db in a
698
- * transaction that remains pending until content, lockfile, and index
699
- * publication have all succeeded. The caller that asked for this split phase
700
- * owns the matching COMMIT/ROLLBACK.
701
- */
702
- /**
703
- * Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
704
- * (#948) into a `TransientError("STATE_DB_CONTENDED")`, mirroring the
705
- * RUN_LEASE_HELD precedent (`WorkflowRunsRepository.acquireEngineLease`): the
706
- * driver text is accurate but unhelpful (`{"ok":false,"error":"database is
707
- * locked"}`, exit 70/INTERNAL) — this instead reads as a retryable-shortly
708
- * signal (exit 75, sysexits EX_TEMPFAIL — #948 addendum) with the original
709
- * error preserved as `cause` for `--verbose`/debugging. A genuinely unrelated
710
- * error (not contention-shaped) is rethrown exactly as raised, never
711
- * reclassified.
712
- */
713
- function throwBeginFailure(err) {
714
- if (isSqliteContentionError(err)) {
715
- const contended = new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
716
- contended.cause = err;
717
- throw contended;
718
- }
719
- throw err;
720
- }
721
- export function beginImmediateTransaction(db) {
722
- if (db.inTransaction) {
723
- throw new Error("beginImmediateTransaction requires a connection with no active transaction");
724
- }
725
- let lastBeginErr;
726
- for (let attempt = 1; attempt <= WITH_IMMEDIATE_TX_MAX_ATTEMPTS; attempt++) {
727
- try {
728
- db.exec("BEGIN IMMEDIATE");
729
- if (!db.inTransaction) {
730
- throw new Error("BEGIN IMMEDIATE did not open a transaction (phantom contention state)");
731
- }
732
- return;
733
- }
734
- catch (err) {
735
- lastBeginErr = err;
736
- if (isRetryableBeginError(err) && attempt < WITH_IMMEDIATE_TX_MAX_ATTEMPTS) {
737
- if (db.inTransaction) {
738
- try {
739
- db.exec("ROLLBACK");
740
- }
741
- catch {
742
- // Transaction already gone — safe to retry BEGIN.
743
- }
744
- }
745
- sleepSyncMs(2 ** (attempt - 1));
746
- continue;
747
- }
748
- throwBeginFailure(err);
749
- }
750
- }
751
- throwBeginFailure(lastBeginErr);
752
- }
753
- export function withImmediateTransaction(db, fn) {
754
- // Re-entrancy guard (issue #686): if a transaction is already open on this
755
- // connection (e.g. a nested withImmediateTransaction call inside an outer
756
- // frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
757
- // our own. Without this, the nested BEGIN throws "cannot start a transaction
758
- // within a transaction", which the old retry path answered with an
759
- // unconditional ROLLBACK — destroying the OUTER transaction and leaving its
760
- // COMMIT to fail with "cannot commit - no transaction is active".
761
- if (db.inTransaction) {
762
- return fn();
763
- }
764
- beginImmediateTransaction(db);
765
- try {
766
- const result = fn();
767
- if (!db.inTransaction) {
768
- // The transaction we opened vanished while fn() ran (e.g. an
769
- // auto-rollback or a stray ROLLBACK inside fn). fn's writes may have
770
- // escaped serialization, so retrying is unsafe — fail loudly instead of
771
- // letting COMMIT throw the opaque "cannot commit - no transaction is
772
- // active" SQLiteError.
773
- throw new Error("withImmediateTransaction invariant violated: transaction opened by BEGIN IMMEDIATE was no longer active after the transaction body ran; refusing to COMMIT (writes may have escaped serialization)");
774
- }
775
- db.exec("COMMIT");
776
- return result;
777
- }
778
- catch (err) {
779
- if (db.inTransaction) {
780
- try {
781
- db.exec("ROLLBACK");
782
- }
783
- catch {
784
- // Ignore rollback failures so the original error is preserved.
785
- }
786
- }
787
- throw err;
788
- }
789
- }
790
224
  // ── schema introspection ─────────────────────────────────────────────────────
791
225
  /**
792
226
  * Return the subset of `names` that exist as TABLEs in this database, ordered