akm-cli 0.9.0-beta.9 → 0.9.0-rc.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 (325) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -21
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +156 -155
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +3 -3
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1091
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1295 -1277
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +228 -605
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +54 -3
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +157 -10
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +28 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +485 -2764
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +37 -35
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +206 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +2 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -895
  138. package/dist/commands/read/curate.js +410 -111
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +19 -39
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +382 -62
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +18 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +132 -1126
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +259 -769
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +36 -92
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +18 -11
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +182 -204
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +34 -11
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2661 -2369
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +883 -596
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/website.js +9 -5
  286. package/dist/sources/website-ingest.js +187 -29
  287. package/dist/sources/wiki-fetchers/registry.js +53 -0
  288. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  289. package/dist/storage/database.js +45 -10
  290. package/dist/storage/managed-db.js +82 -0
  291. package/dist/storage/repositories/canaries-repository.js +107 -0
  292. package/dist/storage/repositories/consolidation-repository.js +38 -0
  293. package/dist/storage/repositories/embeddings-repository.js +72 -0
  294. package/dist/storage/repositories/events-repository.js +187 -0
  295. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  296. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  297. package/dist/storage/repositories/index-db.js +14 -8
  298. package/dist/storage/repositories/proposals-repository.js +220 -0
  299. package/dist/storage/repositories/recombine-repository.js +213 -0
  300. package/dist/storage/repositories/registry-cache.js +93 -0
  301. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  302. package/dist/storage/repositories/task-history-repository.js +93 -0
  303. package/dist/storage/sqlite-pragmas.js +146 -0
  304. package/dist/tasks/backends/cron.js +1 -1
  305. package/dist/tasks/backends/index.js +9 -0
  306. package/dist/tasks/backends/launchd.js +1 -1
  307. package/dist/tasks/backends/schtasks.js +1 -1
  308. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  309. package/dist/tasks/runner.js +15 -13
  310. package/dist/text-import-hook.mjs +0 -0
  311. package/dist/wiki/wiki.js +52 -11
  312. package/dist/workflows/cli.js +1 -0
  313. package/dist/workflows/db.js +3 -4
  314. package/dist/workflows/runtime/runs.js +43 -118
  315. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  316. package/dist/workflows/validate-summary.js +2 -7
  317. package/docs/README.md +69 -18
  318. package/docs/data-and-telemetry.md +5 -4
  319. package/docs/migration/release-notes/0.7.0.md +1 -1
  320. package/docs/migration/release-notes/0.9.0.md +39 -0
  321. package/package.json +10 -10
  322. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  323. package/dist/commands/db-cli.js +0 -23
  324. package/dist/indexer/db/db-backup.js +0 -376
  325. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -4,20 +4,26 @@
4
4
  /**
5
5
  * state.db — Durable SQLite database for non-regenerable akm state.
6
6
  *
7
- * This module owns THREE tables that replace flat-file storage:
8
- *
9
- * events — replaces events.jsonl (append-only event bus)
10
- * proposals — replaces per-uuid JSON directories under .akm/proposals/
11
- * task_history replaces per-task JSONL files under <cacheDir>/tasks/history/
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
10
+ * `src/storage/repositories/*-repository.ts` (events, proposals, task-history,
11
+ * improve-runs, extract-sessions, consolidation, recombine, embeddings,
12
+ * canaries); importers reference those modules directly. The migration engine
13
+ * lives in `./state/migrations`.
14
+ *
15
+ * The state DB replaces flat-file storage for data that is NON-REGENERABLE —
16
+ * events (events.jsonl), proposals (per-uuid JSON directories), task history
17
+ * (per-task JSONL), and the improve-pipeline ledgers.
12
18
  *
13
19
  * ## Why a separate database from index.db
14
20
  *
15
- * index.db uses a single DB_VERSION integer: when the version changes it drops
16
- * ALL tables and recreates them. That is acceptable for the search index because
17
- * every entry is fully regenerable from the stash on disk. Events, proposals, and
18
- * task history are NON-REGENERABLE — losing them is data loss. They must live in
19
- * a database whose schema evolves via incremental, additive migrations that never
20
- * drop rows.
21
+ * index.db is a derived cache built by an idempotent baseline schema; it is fully
22
+ * regenerable from the stash on disk, so a corrupt index is recovered by deleting
23
+ * it and re-running `akm index` (no destructive version-bump rebuild). Events,
24
+ * proposals, and task history are NON-REGENERABLE — losing them is data loss. They
25
+ * must live in a database whose schema evolves via incremental, additive migrations
26
+ * that never drop rows.
21
27
  *
22
28
  * ## Migration-safety contract
23
29
  *
@@ -49,13 +55,9 @@
49
55
  *
50
56
  * @module state-db
51
57
  */
52
- import fs from "node:fs";
53
58
  import path from "node:path";
54
- import { openDatabase } from "../storage/database.js";
55
- import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
56
- import { classifyImproveAction } from "./improve-types.js";
59
+ import { openManagedDatabase, withManagedDb, withManagedDbAsync } from "../storage/managed-db.js";
57
60
  import { getDataDir } from "./paths.js";
58
- import { error } from "./warn.js";
59
61
  // ── Path helper ──────────────────────────────────────────────────────────────
60
62
  /**
61
63
  * Default path: `<dataDir>/state.db`.
@@ -94,716 +96,33 @@ export function getStateDbPath() {
94
96
  * narrow when a post-inference reindex overlapped a parallel event write.
95
97
  */
96
98
  export function openStateDatabase(dbPath) {
97
- const resolvedPath = dbPath ?? getStateDbPath();
98
- const dir = path.dirname(resolvedPath);
99
- if (!fs.existsSync(dir)) {
100
- fs.mkdirSync(dir, { recursive: true });
101
- }
102
- const db = openDatabase(resolvedPath);
103
- // PRAGMAs must run before any DDL or DML.
104
- db.exec("PRAGMA journal_mode = WAL");
105
- db.exec("PRAGMA foreign_keys = ON");
106
- db.exec("PRAGMA busy_timeout = 30000");
107
- runMigrations(db);
108
- return db;
109
- }
110
- // ── Migration engine ─────────────────────────────────────────────────────────
111
- //
112
- // The runner itself (ensureMigrationsTable + runMigrations) lives in the shared
113
- // engine at src/storage/engines/sqlite-migrations.ts. This module owns only its
114
- // own MIGRATIONS array and delegates application to that shared runner. The
115
- // {@link Migration} interface is imported from there.
116
- /**
117
- * All migrations in application order. New migrations are APPENDED to this
118
- * array — never inserted in the middle or reordered.
119
- *
120
- * @see Migration
121
- */
122
- const MIGRATIONS = [
123
- // ── Migration 001 — initial schema ──────────────────────────────────────────
124
- {
125
- id: "001-initial-schema",
126
- up: `
127
- -- ── events ──────────────────────────────────────────────────────────────
128
- --
129
- -- Replaces events.jsonl. Indexed (query) columns:
130
- -- id INTEGER PK — monotonic rowid; replaces byte-offset cursor.
131
- -- Callers store this as "sinceId" for resume.
132
- -- event_type TEXT — indexed; replaces the type filter in readEvents().
133
- -- ts TEXT — ISO-8601 UTC ms; indexed for range queries.
134
- -- ref TEXT — nullable asset ref; indexed for ref-scoped queries.
135
- --
136
- -- Extensible (metadata_json) columns:
137
- -- metadata_json TEXT — JSON object storing all non-indexed payload
138
- -- fields (tags, any future structured fields).
139
- -- Maps directly to EventEnvelope.metadata.
140
- --
141
- -- schema_version mirrors EventEnvelope.schemaVersion — always 1 for v1
142
- -- rows. Stored as a column (not in the JSON blob) so future schema
143
- -- changes can be detected and migrated row-by-row if ever needed.
144
- --
145
- -- TTL: rows where ts < NOW() - 90 days can be deleted by a maintenance job.
146
- -- No automatic deletion occurs here — callers call purgeOldEvents().
147
- --
148
- -- ADD COLUMN extension points (future migrations):
149
- -- ALTER TABLE events ADD COLUMN stash_dir TEXT DEFAULT NULL;
150
- -- ALTER TABLE events ADD COLUMN correlation_id TEXT DEFAULT NULL;
151
- -- ALTER TABLE events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;
152
- --
153
- CREATE TABLE IF NOT EXISTS events (
154
- id INTEGER PRIMARY KEY AUTOINCREMENT,
155
- event_type TEXT NOT NULL,
156
- ts TEXT NOT NULL,
157
- ref TEXT,
158
- metadata_json TEXT NOT NULL DEFAULT '{}'
159
- );
160
-
161
- -- Query patterns supported by these indexes:
162
- -- SELECT … WHERE event_type = ? → idx_events_type
163
- -- SELECT … WHERE ref = ? → idx_events_ref
164
- -- SELECT … WHERE ts >= ? AND ts <= ? → idx_events_ts
165
- -- SELECT … WHERE event_type = ? AND ref = ? → idx_events_type (prefix scan) + filter
166
- -- SELECT … WHERE id > ? → PK (rowid) — no extra index needed
167
- CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
168
- CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
169
- CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
170
-
171
- -- ── proposals ────────────────────────────────────────────────────────────
172
- --
173
- -- Replaces per-uuid JSON directories under <stashDir>/.akm/proposals/.
174
- --
175
- -- Indexed (query) columns:
176
- -- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
177
- -- stash_dir TEXT — absolute stash root; multi-stash installs need
178
- -- this to partition proposal lists per stash.
179
- -- ref TEXT — target asset ref (e.g. "lesson:alpha");
180
- -- indexed for ref-scoped queue views.
181
- -- status TEXT — "pending" | "accepted" | "rejected"; indexed
182
- -- so pending-queue queries are fast.
183
- -- source TEXT — human-readable origin tag (e.g. "reflect").
184
- -- created_at TEXT — ISO-8601; used for ORDER BY created_at ASC.
185
- -- updated_at TEXT — ISO-8601; updated on accept/reject.
186
- --
187
- -- Large payload columns (NOT indexed):
188
- -- content TEXT — full markdown text; the proposal payload body.
189
- -- frontmatter_json TEXT — JSON of parsed frontmatter (may be NULL when
190
- -- the content has no frontmatter block).
191
- --
192
- -- Extensible (metadata_json) columns:
193
- -- metadata_json TEXT — JSON object for future proposal fields.
194
- -- Current fields stored here: sourceRun,
195
- -- review, confidence, gateDecision (#577),
196
- -- backupContent, eligibilitySource.
197
- --
198
- -- ADD COLUMN extension points (future migrations):
199
- -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
200
- -- ALTER TABLE proposals ADD COLUMN review_outcome TEXT DEFAULT NULL;
201
- -- ALTER TABLE proposals ADD COLUMN review_reason TEXT DEFAULT NULL;
202
- -- ALTER TABLE proposals ADD COLUMN review_decided_at TEXT DEFAULT NULL;
203
- -- ALTER TABLE proposals ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
204
- --
205
- CREATE TABLE IF NOT EXISTS proposals (
206
- id TEXT PRIMARY KEY,
207
- stash_dir TEXT NOT NULL,
208
- ref TEXT NOT NULL,
209
- status TEXT NOT NULL DEFAULT 'pending',
210
- source TEXT NOT NULL,
211
- created_at TEXT NOT NULL,
212
- updated_at TEXT NOT NULL,
213
- content TEXT NOT NULL DEFAULT '',
214
- frontmatter_json TEXT,
215
- metadata_json TEXT NOT NULL DEFAULT '{}'
216
- );
217
-
218
- -- Query patterns:
219
- -- SELECT … WHERE stash_dir = ? AND status = ? → idx_proposals_stash_status
220
- -- SELECT … WHERE ref = ? AND status = ? → idx_proposals_ref_status
221
- -- SELECT … WHERE id = ? → PK
222
- CREATE INDEX IF NOT EXISTS idx_proposals_stash_status
223
- ON proposals(stash_dir, status);
224
- CREATE INDEX IF NOT EXISTS idx_proposals_ref_status
225
- ON proposals(ref, status);
226
-
227
- -- ── task_history ─────────────────────────────────────────────────────────
228
- --
229
- -- Replaces per-task JSONL files under <cacheDir>/tasks/history/.
230
- --
231
- -- Indexed (query) columns:
232
- -- task_id TEXT PK — stable task identifier string.
233
- -- status TEXT — terminal status (e.g. "completed", "failed",
234
- -- "cancelled"); indexed for status-scoped queries.
235
- -- started_at TEXT — ISO-8601; indexed for time-range queries.
236
- -- target_kind TEXT — kind of the target entity (e.g. "issue",
237
- -- "workflow", "agent"); indexed for kind-scoped queries.
238
- -- target_ref TEXT — stable ref of the target entity; indexed for
239
- -- per-target history lookups.
240
- --
241
- -- Non-indexed time columns:
242
- -- completed_at TEXT — ISO-8601 or NULL if still running.
243
- -- failed_at TEXT — ISO-8601 or NULL.
244
- --
245
- -- Non-indexed diagnostic columns:
246
- -- log_path TEXT — absolute path to the task log file, if any.
247
- --
248
- -- Extensible (metadata_json) columns:
249
- -- metadata_json TEXT — JSON object for future task fields (exit_code,
250
- -- runner, priority, parent_task_id, …).
251
- --
252
- -- ADD COLUMN extension points (future migrations):
253
- -- ALTER TABLE task_history ADD COLUMN exit_code INTEGER DEFAULT NULL;
254
- -- ALTER TABLE task_history ADD COLUMN runner TEXT DEFAULT NULL;
255
- -- ALTER TABLE task_history ADD COLUMN parent_task_id TEXT DEFAULT NULL;
256
- -- ALTER TABLE task_history ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
257
- --
258
- CREATE TABLE IF NOT EXISTS task_history (
259
- task_id TEXT PRIMARY KEY,
260
- status TEXT NOT NULL,
261
- started_at TEXT NOT NULL,
262
- completed_at TEXT,
263
- failed_at TEXT,
264
- log_path TEXT,
265
- target_kind TEXT,
266
- target_ref TEXT,
267
- metadata_json TEXT NOT NULL DEFAULT '{}'
268
- );
269
-
270
- -- Query patterns:
271
- -- SELECT … WHERE task_id = ? → PK
272
- -- SELECT … WHERE started_at >= ? AND started_at <= ? → idx_task_history_started
273
- -- SELECT … WHERE target_kind = ? AND target_ref = ? → idx_task_history_target
274
- -- SELECT … WHERE status = ? → idx_task_history_status
275
- CREATE INDEX IF NOT EXISTS idx_task_history_started
276
- ON task_history(started_at);
277
- CREATE INDEX IF NOT EXISTS idx_task_history_target
278
- ON task_history(target_kind, target_ref);
279
- CREATE INDEX IF NOT EXISTS idx_task_history_status
280
- ON task_history(status);
281
- `,
282
- },
283
- // Migration 002 — fix task_history to be a true per-run log.
284
- //
285
- // Migration 001 used task_id as PRIMARY KEY, meaning each task had exactly
286
- // one row and every new run overwrote the previous one. This silently
287
- // discarded all historical runs — the opposite of a history table.
288
- //
289
- // This migration recreates the table with an AUTOINCREMENT id so each run
290
- // appends a new row. The old single-row table is renamed to _old, the new
291
- // table is created, data is copied, and the old table is dropped.
292
- {
293
- id: "002-task-history-per-run",
294
- up: `
295
- ALTER TABLE task_history RENAME TO task_history_v1;
296
-
297
- CREATE TABLE task_history (
298
- id INTEGER PRIMARY KEY AUTOINCREMENT,
299
- task_id TEXT NOT NULL,
300
- status TEXT NOT NULL,
301
- started_at TEXT NOT NULL,
302
- completed_at TEXT,
303
- failed_at TEXT,
304
- log_path TEXT,
305
- target_kind TEXT,
306
- target_ref TEXT,
307
- metadata_json TEXT NOT NULL DEFAULT '{}'
308
- );
309
-
310
- INSERT INTO task_history
311
- (task_id, status, started_at, completed_at, failed_at,
312
- log_path, target_kind, target_ref, metadata_json)
313
- SELECT task_id, status, started_at, completed_at, failed_at,
314
- log_path, target_kind, target_ref, metadata_json
315
- FROM task_history_v1;
316
-
317
- DROP TABLE task_history_v1;
318
-
319
- -- Unique constraint: same task cannot have two runs with the same start time.
320
- CREATE UNIQUE INDEX IF NOT EXISTS idx_task_history_run
321
- ON task_history(task_id, started_at);
322
- CREATE INDEX IF NOT EXISTS idx_task_history_task_id
323
- ON task_history(task_id);
324
- CREATE INDEX IF NOT EXISTS idx_task_history_started
325
- ON task_history(started_at);
326
- CREATE INDEX IF NOT EXISTS idx_task_history_target
327
- ON task_history(target_kind, target_ref);
328
- CREATE INDEX IF NOT EXISTS idx_task_history_status
329
- ON task_history(status);
330
- `,
331
- },
332
- // ── Migration 003 — improve_runs ────────────────────────────────────────────
333
- //
334
- // Records every `akm improve` invocation as a durable row, replacing the
335
- // legacy `<stash>/.akm/runs/<runId>/improve-result.json` artifact files.
336
- //
337
- // The `dry_run` column is FIRST-CLASS and indexed so productivity audits can
338
- // cleanly filter dry-run probes out of real-run analyses without parsing
339
- // `result_json`. The dry-run/real-run artifact-trap (recorded in
340
- // feedback_akm_dryrun_artifact_trap) was the specific motivating bug.
341
- //
342
- // Indexed (query) columns:
343
- // id TEXT PK — runId (`buildImproveRunId()` output).
344
- // started_at TEXT — ISO-8601; indexed for time-range queries.
345
- // stash_dir TEXT — absolute stash root; multi-stash scoping.
346
- // dry_run INTEGER — 0/1; indexed for productivity audits.
347
- // scope_mode TEXT — "all" | "type" | "ref"; indexed via composite
348
- // with stash_dir for stash-scoped scope queries.
349
- //
350
- // Non-indexed payload:
351
- // completed_at TEXT — ISO-8601 or NULL if interrupted.
352
- // profile TEXT — improve profile name (nullable).
353
- // scope_value TEXT — type name or asset ref (nullable).
354
- // guidance TEXT — user-provided guidance text, if any.
355
- // ok INTEGER — 0/1; whether the run produced ok=true.
356
- // result_json TEXT — full AkmImproveResult JSON.
357
- // metrics_json TEXT — aggregate counts extracted from result, cheap
358
- // to query without parsing result_json.
359
- //
360
- // Extensible (metadata_json) columns:
361
- // metadata_json TEXT — JSON object for future improve-run fields.
362
- //
363
- // ADD COLUMN extension points (future migrations):
364
- // ALTER TABLE improve_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL;
365
- // ALTER TABLE improve_runs ADD COLUMN host TEXT DEFAULT NULL;
366
- //
367
- // TTL: rows where started_at < NOW() - 90 days can be deleted by
368
- // `purgeOldImproveRuns()`. No automatic deletion occurs here.
369
- {
370
- id: "003-improve-runs",
371
- up: `
372
- CREATE TABLE IF NOT EXISTS improve_runs (
373
- id TEXT PRIMARY KEY,
374
- started_at TEXT NOT NULL,
375
- completed_at TEXT,
376
- stash_dir TEXT NOT NULL,
377
- dry_run INTEGER NOT NULL DEFAULT 0,
378
- profile TEXT,
379
- scope_mode TEXT NOT NULL,
380
- scope_value TEXT,
381
- guidance TEXT,
382
- ok INTEGER NOT NULL,
383
- result_json TEXT NOT NULL,
384
- metrics_json TEXT,
385
- metadata_json TEXT NOT NULL DEFAULT '{}'
386
- );
387
-
388
- -- Query patterns supported:
389
- -- SELECT … WHERE started_at >= ? AND started_at <= ?
390
- -- → idx_improve_runs_started
391
- -- SELECT … WHERE dry_run = 0
392
- -- → idx_improve_runs_dry_run (productivity audits filter trap)
393
- -- SELECT … WHERE stash_dir = ? AND scope_mode = ?
394
- -- → idx_improve_runs_stash_scope
395
- CREATE INDEX IF NOT EXISTS idx_improve_runs_started
396
- ON improve_runs(started_at);
397
- CREATE INDEX IF NOT EXISTS idx_improve_runs_dry_run
398
- ON improve_runs(dry_run);
399
- CREATE INDEX IF NOT EXISTS idx_improve_runs_stash_scope
400
- ON improve_runs(stash_dir, scope_mode);
401
- `,
402
- },
403
- // ── Migration 004 — extract_sessions_seen ───────────────────────────────────
404
- //
405
- // Tracks which platform sessions the extractor has processed, so the discovery
406
- // pass in `akm extract --since <window>` skips sessions whose content hasn't
407
- // changed since the last successful run. Replaces the akm-plugin
408
- // session-checkpoint hook's implicit "write-once" memory of what's been
409
- // captured — but persistent and queryable.
410
- //
411
- // Indexed (query) columns:
412
- // harness TEXT — harness name (claude-code, opencode, ...).
413
- // session_id TEXT — platform-native session identifier.
414
- // processed_at TEXT — ISO-8601 UTC; when extract last ran on this session.
415
- // session_ended_at TEXT — session.endedAt at processing time. When a
416
- // later listSessions reports a *newer* endedAt
417
- // for the same session_id, the extractor
418
- // re-processes the appended events.
419
- // outcome TEXT — "candidates_queued" | "no_candidates" |
420
- // "skipped" | "failed".
421
- //
422
- // Non-indexed columns:
423
- // candidate_count INTEGER — number of candidates the LLM produced.
424
- // proposal_count INTEGER — number of proposals actually queued
425
- // (candidates may fail downstream validation).
426
- // rationale TEXT — for "no_candidates", the LLM's explanation.
427
- // source_run TEXT — sourceRun id for PROV-DM traceability.
428
- // metadata_json TEXT — future-proofing (pre-filter stats, LLM
429
- // model+version, prompt token count, etc.).
430
- //
431
- // PK: (harness, session_id) — one row per session per harness. A re-extract
432
- // updates the row in place via INSERT OR REPLACE.
433
- //
434
- // TTL: no automatic deletion. Sessions stay tracked as long as the source
435
- // session files exist on disk. Operator can `DELETE FROM extract_sessions_seen
436
- // WHERE processed_at < ?` for cleanup if desired.
437
- {
438
- id: "004-extract-sessions-seen",
439
- up: `
440
- CREATE TABLE IF NOT EXISTS extract_sessions_seen (
441
- harness TEXT NOT NULL,
442
- session_id TEXT NOT NULL,
443
- processed_at TEXT NOT NULL,
444
- session_ended_at TEXT,
445
- outcome TEXT NOT NULL,
446
- candidate_count INTEGER NOT NULL DEFAULT 0,
447
- proposal_count INTEGER NOT NULL DEFAULT 0,
448
- rationale TEXT,
449
- source_run TEXT,
450
- metadata_json TEXT NOT NULL DEFAULT '{}',
451
- PRIMARY KEY (harness, session_id)
452
- );
453
-
454
- -- Query patterns:
455
- -- SELECT … WHERE harness = ? → idx_extract_sessions_harness
456
- -- SELECT … WHERE processed_at >= ? → idx_extract_sessions_processed
457
- -- SELECT … WHERE harness = ? AND session_id = ? → PK
458
- CREATE INDEX IF NOT EXISTS idx_extract_sessions_harness
459
- ON extract_sessions_seen(harness);
460
- CREATE INDEX IF NOT EXISTS idx_extract_sessions_processed
461
- ON extract_sessions_seen(processed_at);
462
- `,
463
- },
464
- // ── Migration 005 — proposal_fs_imports ─────────────────────────────────────
465
- //
466
- // One-shot ledger for the legacy filesystem→SQLite proposal import (#578).
467
- //
468
- // Before 0.9.0 the proposal queue lived as per-uuid JSON directories under
469
- // `<stashDir>/.akm/proposals/` and the `proposals` table (created in 001) was
470
- // dead weight. 0.9.0 makes the table canonical; the first proposal operation
471
- // against a stash imports any legacy `proposal.json` files it finds (INSERT
472
- // OR IGNORE, so re-runs never duplicate) and records the stash here so later
473
- // invocations skip the directory walk entirely.
474
- //
475
- // Indexed (query) columns:
476
- // stash_dir TEXT PK — absolute stash root the import ran against.
477
- //
478
- // Non-indexed columns:
479
- // imported_at TEXT — ISO-8601 UTC; when the import completed.
480
- // imported_count INTEGER — rows actually inserted by the import.
481
- {
482
- id: "005-proposal-fs-imports",
483
- up: `
484
- CREATE TABLE IF NOT EXISTS proposal_fs_imports (
485
- stash_dir TEXT PRIMARY KEY,
486
- imported_at TEXT NOT NULL,
487
- imported_count INTEGER NOT NULL DEFAULT 0
488
- );
489
- `,
490
- },
491
- // ── Migration 006 — pending proposal lookup index ──────────────────────────
492
- //
493
- // Supports the transaction-scoped dedup / queue-mutation hardening added in
494
- // 0.9.x. The queue now acquires an IMMEDIATE write transaction before it
495
- // reads pending proposals, so the hot path is a stash-scoped `status='pending'
496
- // AND ref=?` probe followed by an update/insert. This composite index keeps
497
- // that lookup index-covered under contention.
498
- {
499
- id: "006-proposals-pending-ref-source",
500
- up: `
501
- CREATE INDEX IF NOT EXISTS idx_proposals_stash_status_ref_source
502
- ON proposals(stash_dir, status, ref, source);
503
- `,
504
- },
505
- ];
506
- /**
507
- * Apply every pending migration in a single transaction per migration.
508
- *
509
- * Delegates to the shared SQLite migration engine; state.db has no
510
- * pre-versioning bootstrap step, so no `bootstrap` hook is passed.
511
- *
512
- * Called automatically by `openStateDatabase()`.
513
- */
514
- export function runMigrations(db) {
515
- runSqliteMigrations(db, MIGRATIONS);
516
- }
517
- /**
518
- * Convert a raw `EventRow` from the database to the public `EventEnvelope`
519
- * interface used throughout the events module.
520
- */
521
- export function eventRowToEnvelope(row) {
522
- let metadata;
523
- try {
524
- const parsed = JSON.parse(row.metadata_json);
525
- // Only attach metadata when the JSON blob is non-empty so downstream
526
- // consumers that check `envelope.metadata !== undefined` keep working.
527
- if (Object.keys(parsed).length > 0) {
528
- metadata = parsed;
529
- }
530
- }
531
- catch {
532
- // Corrupt JSON in the DB — treat as no metadata.
533
- }
534
- return {
535
- schemaVersion: 1,
536
- id: row.id,
537
- ts: row.ts,
538
- eventType: row.event_type,
539
- ...(row.ref !== null ? { ref: row.ref } : {}),
540
- ...(metadata !== undefined ? { metadata } : {}),
541
- };
542
- }
543
- /**
544
- * Convert a raw `ProposalRow` to the public `Proposal` shape.
545
- */
546
- export function proposalRowToProposal(row) {
547
- let frontmatter;
548
- if (row.frontmatter_json) {
549
- try {
550
- frontmatter = JSON.parse(row.frontmatter_json);
551
- }
552
- catch {
553
- /* ignore corrupt frontmatter JSON */
554
- }
555
- }
556
- let meta = {};
557
- try {
558
- meta = JSON.parse(row.metadata_json);
559
- }
560
- catch {
561
- /* ignore */
562
- }
563
- return {
564
- id: row.id,
565
- ref: row.ref,
566
- status: row.status,
567
- source: row.source,
568
- ...(typeof meta.sourceRun === "string" ? { sourceRun: meta.sourceRun } : {}),
569
- createdAt: row.created_at,
570
- updatedAt: row.updated_at,
571
- payload: {
572
- content: row.content,
573
- ...(frontmatter !== undefined ? { frontmatter } : {}),
574
- },
575
- ...(meta.review !== undefined ? { review: meta.review } : {}),
576
- ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
577
- ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
578
- ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
579
- ...(typeof meta.eligibilitySource === "string"
580
- ? { eligibilitySource: meta.eligibilitySource }
581
- : {}),
582
- };
583
- }
584
- /**
585
- * Convert a public `Proposal` to column values ready for an INSERT/UPDATE.
586
- * The `stash_dir` comes from the call site (proposals.ts has it in scope).
587
- */
588
- export function proposalToRowValues(proposal, stashDir) {
589
- // Fields that have no dedicated column live in metadata_json.
590
- const metaObj = {};
591
- if (proposal.sourceRun !== undefined)
592
- metaObj.sourceRun = proposal.sourceRun;
593
- if (proposal.review !== undefined)
594
- metaObj.review = proposal.review;
595
- if (proposal.confidence !== undefined)
596
- metaObj.confidence = proposal.confidence;
597
- if (proposal.gateDecision !== undefined)
598
- metaObj.gateDecision = proposal.gateDecision;
599
- if (proposal.backupContent !== undefined)
600
- metaObj.backupContent = proposal.backupContent;
601
- if (proposal.eligibilitySource !== undefined)
602
- metaObj.eligibilitySource = proposal.eligibilitySource;
603
- return {
604
- id: proposal.id,
605
- stash_dir: stashDir,
606
- ref: proposal.ref,
607
- status: proposal.status,
608
- source: proposal.source,
609
- created_at: proposal.createdAt,
610
- updated_at: proposal.updatedAt,
611
- content: proposal.payload.content,
612
- frontmatter_json: proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null,
613
- metadata_json: JSON.stringify(metaObj),
614
- };
615
- }
616
- // ── events table helpers ─────────────────────────────────────────────────────
617
- /**
618
- * Insert a single event. Returns the auto-assigned monotonic rowid, which
619
- * callers can store as a "sinceId" cursor for future `readEventsSince` calls.
620
- *
621
- * Best-effort: mirrors the behaviour of the old `appendEvent` — errors are
622
- * caught and logged to stderr rather than propagated so observability never
623
- * breaks mutation.
624
- */
625
- export function insertEvent(db, input) {
626
- try {
627
- const result = db
628
- .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
629
- VALUES (?, ?, ?, ?)
630
- RETURNING id`)
631
- .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {}));
632
- return result?.id;
633
- }
634
- catch (err) {
635
- const message = err instanceof Error ? err.message : String(err);
636
- error(`akm: state.db event insert failed (${message})`);
637
- return undefined;
638
- }
639
- }
640
- /**
641
- * Read events from the database matching the filter. Returns events in
642
- * ascending id order so consumers can process them in emission order.
643
- *
644
- * The returned `nextId` is the maximum id seen (or `sinceId` when no rows
645
- * match), suitable as the next `sinceId` cursor value.
646
- */
647
- export function readStateEvents(db, options = {}) {
648
- const conditions = [];
649
- const params = [];
650
- if (options.sinceId !== undefined && options.sinceId > 0) {
651
- conditions.push("id > ?");
652
- params.push(options.sinceId);
653
- }
654
- if (options.since) {
655
- conditions.push("ts >= ?");
656
- params.push(options.since);
657
- }
658
- if (options.type) {
659
- conditions.push("event_type = ?");
660
- params.push(options.type);
661
- }
662
- if (options.ref) {
663
- conditions.push("ref = ?");
664
- params.push(options.ref);
665
- }
666
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
667
- const rows = db
668
- .prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ORDER BY id ASC`)
669
- .all(...params);
670
- const events = rows.map(eventRowToEnvelope);
671
- const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0);
672
- return { events, nextId };
673
- }
674
- /**
675
- * Delete events older than `retentionDays` (default: 90). Safe to call from
676
- * a maintenance cron; uses a single DELETE with an index-covered ts predicate.
677
- *
678
- * Returns the number of rows actually deleted so callers can emit an
679
- * `events_purged` observability event. A non-positive or non-finite
680
- * `retentionDays` is treated as "disabled" and returns 0 without scanning.
681
- */
682
- export function purgeOldEvents(db, retentionDays = 90) {
683
- if (!Number.isFinite(retentionDays) || retentionDays <= 0)
684
- return 0;
685
- const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
686
- const result = db.prepare("DELETE FROM events WHERE ts < ?").run(cutoff);
687
- // bun:sqlite's run() returns { changes, lastInsertRowid }. `changes` may be
688
- // a number or bigint depending on the underlying lib; coerce to number for
689
- // the metadata payload.
690
- const changes = result.changes ?? 0;
691
- return typeof changes === "bigint" ? Number(changes) : changes;
692
- }
693
- // ── proposals table helpers ──────────────────────────────────────────────────
694
- /**
695
- * Upsert a proposal row. Called by the proposal write path when state.db is
696
- * the active backend.
697
- */
698
- export function upsertProposal(db, proposal, stashDir) {
699
- const v = proposalToRowValues(proposal, stashDir);
700
- db.prepare(`
701
- INSERT INTO proposals
702
- (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
703
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
704
- ON CONFLICT(id) DO UPDATE SET
705
- stash_dir = excluded.stash_dir,
706
- ref = excluded.ref,
707
- status = excluded.status,
708
- source = excluded.source,
709
- updated_at = excluded.updated_at,
710
- content = excluded.content,
711
- frontmatter_json = excluded.frontmatter_json,
712
- metadata_json = excluded.metadata_json
713
- `).run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
714
- }
715
- /**
716
- * List proposals, optionally filtered by stashDir, status, and/or ref.
717
- *
718
- * Results are ordered by `created_at ASC` (matching the historical
719
- * `listProposals()` sort), with `rowid` as a deterministic tiebreak so two
720
- * proposals created in the same millisecond list in insertion order.
721
- */
722
- export function listStateProposals(db, options = {}) {
723
- const conditions = [];
724
- const params = [];
725
- if (options.stashDir) {
726
- conditions.push("stash_dir = ?");
727
- params.push(options.stashDir);
728
- }
729
- if (options.status) {
730
- conditions.push("status = ?");
731
- params.push(options.status);
732
- }
733
- if (options.ref) {
734
- conditions.push("ref = ?");
735
- params.push(options.ref);
736
- }
737
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
738
- const rows = db
739
- .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
740
- content, frontmatter_json, metadata_json
741
- FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
742
- .all(...params);
743
- return rows.map(proposalRowToProposal);
744
- }
745
- /**
746
- * Look up a single proposal by id, optionally scoped to one stash root.
747
- * Returns undefined when not found.
748
- */
749
- export function getStateProposal(db, id, stashDir) {
750
- const sql = `SELECT id, stash_dir, ref, status, source, created_at, updated_at,
751
- content, frontmatter_json, metadata_json
752
- FROM proposals WHERE id = ?${stashDir ? " AND stash_dir = ?" : ""}`;
753
- const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
754
- return row ? proposalRowToProposal(row) : undefined;
755
- }
756
- /**
757
- * Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
758
- * Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
759
- * prefix resolution is deliberately scoped to the live (pending) queue,
760
- * mirroring the historical behaviour of scanning only the live directory.
761
- *
762
- * `%` / `_` / `\` in the prefix are escaped so the LIKE pattern is literal.
763
- */
764
- export function listStateProposalIdsByPrefix(db, stashDir, idPrefix) {
765
- const escaped = idPrefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
766
- const rows = db
767
- .prepare(`SELECT id FROM proposals
768
- WHERE stash_dir = ? AND status = 'pending' AND id LIKE ? ESCAPE '\\'
769
- ORDER BY id ASC`)
770
- .all(stashDir, `${escaped}%`);
771
- return rows.map((r) => r.id);
772
- }
773
- /**
774
- * Whether the legacy filesystem proposal import has already run for `stashDir`.
775
- * See migration 005 (`proposal_fs_imports`).
776
- */
777
- export function hasImportedFsProposals(db, stashDir) {
778
- // Drivers disagree on the no-row sentinel (bun:sqlite → null,
779
- // better-sqlite3 → undefined) — Boolean() covers both.
780
- return Boolean(db.prepare("SELECT 1 FROM proposal_fs_imports WHERE stash_dir = ?").get(stashDir));
99
+ return openManagedDatabase({ path: dbPath ?? getStateDbPath(), init: runMigrations });
781
100
  }
782
101
  /**
783
- * Record that the legacy filesystem proposal import completed for `stashDir`
784
- * so subsequent invocations skip the directory walk. INSERT OR REPLACE keeps
785
- * the call idempotent.
102
+ * Run `fn` against state.db, owning the handle unless one is borrowed. The loan
103
+ * helper for state.db, mirroring `withIndexDb` / `withWorkflowRunsRepo`. Pass
104
+ * `{ borrowed: ctx?.db }` to reuse an already-open run-scoped handle rather than
105
+ * opening + closing a fresh one — this replaces the hand-rolled
106
+ * `ctx?.db ?? open()` + `ownsDb` flag + `finally`/close idiom at call sites.
786
107
  */
787
- export function recordFsProposalsImport(db, stashDir, importedCount) {
788
- db.prepare("INSERT OR REPLACE INTO proposal_fs_imports (stash_dir, imported_at, imported_count) VALUES (?, ?, ?)").run(stashDir, new Date().toISOString(), importedCount);
108
+ export function withStateDb(fn, opts) {
109
+ return withManagedDb(() => openStateDatabase(opts?.path), fn, opts);
789
110
  }
790
111
  /**
791
- * Insert a proposal row ONLY when the id is not already present (used by the
792
- * legacy filesystem import so re-runs never clobber rows that have since been
793
- * mutated through the canonical store). Returns true when a row was inserted.
112
+ * Async sibling of {@link withStateDb} for `fn`s that hold the handle across
113
+ * an `await` (the sync version would close it too early). Same borrow/own +
114
+ * optional `path` override semantics.
794
115
  */
795
- export function insertProposalIfAbsent(db, proposal, stashDir) {
796
- const v = proposalToRowValues(proposal, stashDir);
797
- const result = db
798
- .prepare(`
799
- INSERT OR IGNORE INTO proposals
800
- (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
801
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
802
- `)
803
- .run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
804
- const changes = result.changes ?? 0;
805
- return Number(changes) > 0;
116
+ export function withStateDbAsync(fn, opts) {
117
+ return withManagedDbAsync(() => openStateDatabase(opts?.path), fn, opts);
806
118
  }
119
+ // ── Migration engine ─────────────────────────────────────────────────────────
120
+ //
121
+ // The MIGRATIONS registry + runMigrations live in ./state/migrations (the single
122
+ // append-only ordered source of truth). Imported for internal use by
123
+ // openStateDatabase.
124
+ import { runMigrations } from "./state/migrations.js";
125
+ // ── BEGIN IMMEDIATE transaction helper ───────────────────────────────────────
807
126
  /**
808
127
  * Run `fn` inside a `BEGIN IMMEDIATE` transaction.
809
128
  *
@@ -813,113 +132,105 @@ export function insertProposalIfAbsent(db, proposal, stashDir) {
813
132
  * need the write lock BEFORE those reads so concurrent processes serialize on
814
133
  * the live queue state rather than clobbering each other.
815
134
  */
135
+ /**
136
+ * Errors `BEGIN IMMEDIATE` can throw under concurrent-writer contention that are
137
+ * transient (the statement did NOT start a usable transaction) and safe to
138
+ * retry:
139
+ * - "database is locked" / SQLITE_BUSY — another writer holds the lock.
140
+ * These are start-of-transaction failures only; an error thrown by `fn` is a
141
+ * real failure and is NEVER retried.
142
+ *
143
+ * "cannot start a transaction within a transaction" is deliberately NOT
144
+ * retryable: it means a transaction is already open on this connection (a
145
+ * re-entrant call — handled by the entry guard in withImmediateTransaction),
146
+ * and "retrying" it with a ROLLBACK would destroy the caller's transaction
147
+ * (issue #686).
148
+ */
149
+ function isRetryableBeginError(err) {
150
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
151
+ return (msg.includes("database is locked") ||
152
+ msg.includes("database table is locked") ||
153
+ // Phantom BEGIN (see below) — synthesized when BEGIN IMMEDIATE returns
154
+ // without opening a transaction. Safe to retry: fn() has not run.
155
+ msg.includes("did not open a transaction"));
156
+ }
157
+ const WITH_IMMEDIATE_TX_MAX_ATTEMPTS = 5;
158
+ /** Portable synchronous sleep (works under both Bun and Node). */
159
+ function sleepSyncMs(ms) {
160
+ if (ms <= 0)
161
+ return;
162
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
163
+ }
816
164
  export function withImmediateTransaction(db, fn) {
817
- db.exec("BEGIN IMMEDIATE");
818
- try {
819
- const result = fn();
820
- db.exec("COMMIT");
821
- return result;
822
- }
823
- catch (err) {
165
+ // Re-entrancy guard (issue #686): if a transaction is already open on this
166
+ // connection (e.g. a nested withImmediateTransaction call inside an outer
167
+ // frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
168
+ // our own. Without this, the nested BEGIN throws "cannot start a transaction
169
+ // within a transaction", which the old retry path answered with an
170
+ // unconditional ROLLBACK — destroying the OUTER transaction and leaving its
171
+ // COMMIT to fail with "cannot commit - no transaction is active".
172
+ if (db.inTransaction) {
173
+ return fn();
174
+ }
175
+ let lastBeginErr;
176
+ for (let attempt = 1; attempt <= WITH_IMMEDIATE_TX_MAX_ATTEMPTS; attempt++) {
824
177
  try {
825
- db.exec("ROLLBACK");
178
+ db.exec("BEGIN IMMEDIATE");
179
+ // bun:sqlite can return from BEGIN IMMEDIATE under writer contention WITHOUT
180
+ // actually opening a transaction (no throw). That phantom state otherwise
181
+ // surfaces as "cannot commit - no transaction is active" at COMMIT — AFTER
182
+ // fn() has already run in autocommit, so its writes escaped the intended
183
+ // serialization (the concurrent proposal-queue race). Detect it here, before
184
+ // fn(), and route it through the same retry path as a contended BEGIN.
185
+ if (!db.inTransaction) {
186
+ throw new Error("BEGIN IMMEDIATE did not open a transaction (phantom contention state)");
187
+ }
826
188
  }
827
- catch {
828
- // Ignore rollback failures so the original error is preserved.
189
+ catch (err) {
190
+ lastBeginErr = err;
191
+ if (isRetryableBeginError(err) && attempt < WITH_IMMEDIATE_TX_MAX_ATTEMPTS) {
192
+ // Only roll back a transaction we can see — never blind-ROLLBACK, since
193
+ // that could destroy a transaction this frame does not own.
194
+ if (db.inTransaction) {
195
+ try {
196
+ db.exec("ROLLBACK");
197
+ }
198
+ catch {
199
+ // Transaction already gone — fine.
200
+ }
201
+ }
202
+ sleepSyncMs(2 ** (attempt - 1));
203
+ continue;
204
+ }
205
+ throw err;
206
+ }
207
+ try {
208
+ const result = fn();
209
+ if (!db.inTransaction) {
210
+ // The transaction we opened vanished while fn() ran (e.g. an
211
+ // auto-rollback or a stray ROLLBACK inside fn). fn's writes may have
212
+ // escaped serialization, so retrying is unsafe — fail loudly instead of
213
+ // letting COMMIT throw the opaque "cannot commit - no transaction is
214
+ // active" SQLiteError.
215
+ 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)");
216
+ }
217
+ db.exec("COMMIT");
218
+ return result;
219
+ }
220
+ catch (err) {
221
+ if (db.inTransaction) {
222
+ try {
223
+ db.exec("ROLLBACK");
224
+ }
225
+ catch {
226
+ // Ignore rollback failures so the original error is preserved.
227
+ }
228
+ }
229
+ throw err; // a real error inside the transaction body — never retried.
829
230
  }
830
- throw err;
831
- }
832
- }
833
- // ── task_history table helpers ───────────────────────────────────────────────
834
- /**
835
- * Upsert a task history row.
836
- */
837
- export function upsertTaskHistory(db, row) {
838
- // INSERT OR IGNORE: if a run with the same (task_id, started_at) was already
839
- // imported (e.g. by the migration script), skip it silently.
840
- db.prepare(`
841
- INSERT OR IGNORE INTO task_history
842
- (task_id, status, started_at, completed_at, failed_at, log_path,
843
- target_kind, target_ref, metadata_json)
844
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
845
- `).run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json);
846
- }
847
- /**
848
- * Look up a task history row by task_id. Returns undefined when not found.
849
- */
850
- /**
851
- * Return the most recent run for a given task_id, or undefined if no runs exist.
852
- */
853
- export function getTaskHistory(db, taskId) {
854
- return db
855
- .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
856
- target_kind, target_ref, metadata_json
857
- FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`)
858
- .get(taskId);
859
- }
860
- /**
861
- * Return all runs for a given task_id, newest first.
862
- */
863
- export function getTaskHistoryRuns(db, taskId, limit = 50) {
864
- return db
865
- .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
866
- target_kind, target_ref, metadata_json
867
- FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`)
868
- .all(taskId, limit);
869
- }
870
- /**
871
- * Query task history rows by started_at range and/or status.
872
- */
873
- export function queryTaskHistory(db, options = {}) {
874
- const conditions = [];
875
- const params = [];
876
- if (options.since) {
877
- conditions.push("started_at >= ?");
878
- params.push(options.since);
879
- }
880
- if (options.until) {
881
- conditions.push("started_at <= ?");
882
- params.push(options.until);
883
- }
884
- if (options.status) {
885
- conditions.push("status = ?");
886
- params.push(options.status);
887
- }
888
- if (options.targetKind) {
889
- conditions.push("target_kind = ?");
890
- params.push(options.targetKind);
891
- }
892
- if (options.targetRef) {
893
- conditions.push("target_ref = ?");
894
- params.push(options.targetRef);
895
231
  }
896
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
897
- return db
898
- .prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
899
- target_kind, target_ref, metadata_json
900
- FROM task_history ${where} ORDER BY started_at DESC`)
901
- .all(...params);
902
- }
903
- /**
904
- * Read COMPLETED `akm-improve` task_history runs whose `started_at` falls in
905
- * `[since, until)` (or `started_at >= since` when `until` is omitted), ordered
906
- * oldest-first by `started_at`. Only rows with a non-null `completed_at` are
907
- * returned (in-flight runs are excluded). The `task_id = 'akm-improve'`
908
- * predicate is fixed because the only caller (commands/health.ts
909
- * `loadTaskIntervals`) builds wall-time intervals for the improve cron task.
910
- *
911
- * Owns the SQL formerly inlined in commands/health.ts. Note the bound is
912
- * EXCLUSIVE on the upper end (`started_at < ?`) — callers pass an already
913
- * widened window; this helper does not widen.
914
- *
915
- * Connection-lifetime rule (WS5): `.all()` materializes a plain array before
916
- * returning.
917
- */
918
- export function queryCompletedTaskIntervals(db, since, until) {
919
- const sql = until
920
- ? "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND started_at < ? AND completed_at IS NOT NULL ORDER BY started_at"
921
- : "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND completed_at IS NOT NULL ORDER BY started_at";
922
- return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since));
232
+ // Exhausted retries on transient begin failures.
233
+ throw lastBeginErr;
923
234
  }
924
235
  // ── schema introspection ─────────────────────────────────────────────────────
925
236
  /**
@@ -941,308 +252,3 @@ export function listExistingTableNames(db, names) {
941
252
  .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders}) ORDER BY name`)
942
253
  .all(...names);
943
254
  }
944
- // ── events.jsonl import ──────────────────────────────────────────────────────
945
- /**
946
- * Import all events from an `events.jsonl` file into the `events` table.
947
- *
948
- * The old byte-offset `id` is NOT preserved — the database assigns new
949
- * monotonic integer ids. Callers that persisted a byte-offset cursor must
950
- * discard it after migration and use the returned `maxId` as the new cursor.
951
- *
952
- * **Idempotency**: each line is pre-checked against the `events` table using
953
- * `(event_type, ts, ref, metadata_json)` as the duplicate key. Lines whose
954
- * exact tuple is already present are skipped and reported as `skipped` in the
955
- * return value. This makes the migration safe to re-run (the v0.7→v0.8
956
- * migration guide recommends re-running the script as a recovery path; without
957
- * this guard, every re-run would double-import the entire event log).
958
- *
959
- * Duplicate detection is per-import-tuple, not a table-wide UNIQUE constraint:
960
- * the events table has no UNIQUE constraint at runtime so that
961
- * `appendEvent` can write multiple events with the same ts (sub-millisecond
962
- * bursts produce identical `(event_type, ts, ref)` triples in practice). The
963
- * SELECT-first check is scoped to the import path only.
964
- *
965
- * The import is wrapped in a single transaction for atomicity.
966
- *
967
- * @param db - Open state.db connection.
968
- * @param jsonlPath - Absolute path to the events.jsonl file to import.
969
- * @returns Number of rows inserted, the max id assigned, and the
970
- * count of rows skipped because an identical event already
971
- * existed in the table.
972
- */
973
- export async function importEventsJsonl(db, jsonlPath) {
974
- const { readFileSync, existsSync } = await import("node:fs");
975
- if (!existsSync(jsonlPath)) {
976
- return { imported: 0, maxId: 0, skipped: 0 };
977
- }
978
- const text = readFileSync(jsonlPath, "utf8");
979
- const lines = text.split("\n").filter((l) => l.trim().length > 0);
980
- let imported = 0;
981
- let maxId = 0;
982
- let skipped = 0;
983
- const insertStmt = db.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
984
- VALUES (?, ?, ?, ?)
985
- RETURNING id`);
986
- // Dedup pre-check: matches by the full tuple including metadata_json so an
987
- // import is idempotent over identical rows but does not collide with two
988
- // genuinely different events that happen to share (event_type, ts, ref).
989
- //
990
- // Uses IS for ref so two NULL refs compare equal (a plain `=` would treat
991
- // NULL = NULL as NULL and the row would be re-inserted on every run).
992
- const existsStmt = db.prepare(`SELECT 1 FROM events
993
- WHERE event_type = ?
994
- AND ts = ?
995
- AND ref IS ?
996
- AND metadata_json = ?
997
- LIMIT 1`);
998
- db.transaction(() => {
999
- for (const line of lines) {
1000
- let parsed;
1001
- try {
1002
- parsed = JSON.parse(line);
1003
- }
1004
- catch {
1005
- continue; // skip malformed lines — same behaviour as readEvents()
1006
- }
1007
- const eventType = typeof parsed.eventType === "string" ? parsed.eventType : "unknown";
1008
- const ts = typeof parsed.ts === "string" ? parsed.ts : new Date().toISOString();
1009
- const ref = typeof parsed.ref === "string" ? parsed.ref : null;
1010
- const metadata = parsed.metadata !== undefined && typeof parsed.metadata === "object" ? JSON.stringify(parsed.metadata) : "{}";
1011
- const duplicate = existsStmt.get(eventType, ts, ref, metadata);
1012
- if (duplicate) {
1013
- skipped++;
1014
- continue;
1015
- }
1016
- const result = insertStmt.get(eventType, ts, ref, metadata);
1017
- if (result) {
1018
- imported++;
1019
- if (result.id > maxId)
1020
- maxId = result.id;
1021
- }
1022
- }
1023
- })();
1024
- return { imported, maxId, skipped };
1025
- }
1026
- /**
1027
- * Compute the cheap aggregate metrics blob from a full improve result.
1028
- *
1029
- * Pure function — no I/O. Used by {@link recordImproveRun} to populate
1030
- * `metrics_json`. Exposed for tests and for any future call site that wants
1031
- * the same aggregation logic without hitting state.db.
1032
- */
1033
- export function computeImproveRunMetrics(result) {
1034
- const plannedCount = Array.isArray(result.plannedRefs) ? result.plannedRefs.length : 0;
1035
- const actions = Array.isArray(result.actions) ? result.actions : [];
1036
- const actionsCount = actions.length;
1037
- let acceptedCount = 0;
1038
- let rejectedCount = 0;
1039
- let autoAcceptedCount = 0;
1040
- let errorCount = 0;
1041
- for (const action of actions) {
1042
- // Bucketing delegated to the shared classifyImproveAction so this aggregate
1043
- // and the improve_completed event in improve.ts can never disagree, and so a
1044
- // new union variant is a compile error rather than a silent drop. Note:
1045
- // `reflect-guard-rejected` now counts as "rejected" (previously this switch
1046
- // omitted it entirely — a data-integrity miscount). "noop" (memory-prune) is
1047
- // intentionally counted in none of the three numeric buckets.
1048
- switch (classifyImproveAction(action.mode)) {
1049
- case "accepted":
1050
- acceptedCount++;
1051
- break;
1052
- case "rejected":
1053
- rejectedCount++;
1054
- break;
1055
- case "error":
1056
- errorCount++;
1057
- break;
1058
- case "noop":
1059
- break;
1060
- }
1061
- // Legacy: pre-gate action results may carry autoAccepted: true (reflect path).
1062
- const r = action.result;
1063
- if (r && r.autoAccepted === true)
1064
- autoAcceptedCount++;
1065
- }
1066
- // Add gate-promoted count from the unified PostPhaseAutoAcceptGate (all phases).
1067
- autoAcceptedCount += result.gateAutoAcceptedCount ?? 0;
1068
- return { plannedCount, actionsCount, acceptedCount, rejectedCount, autoAcceptedCount, errorCount };
1069
- }
1070
- /**
1071
- * Insert a single improve-run row into `improve_runs`. Uses parameterised SQL.
1072
- *
1073
- * Idempotency: the table's PRIMARY KEY is `id`, so re-running with the same
1074
- * runId would error. Callers mint a fresh runId per invocation via
1075
- * {@link buildImproveRunId} so this is not a concern in practice — but the
1076
- * default behaviour is INSERT (not REPLACE) so accidental dupes surface as
1077
- * a SQLite constraint error rather than silently overwriting a prior record.
1078
- *
1079
- * The `metrics` parameter defaults to the output of
1080
- * {@link computeImproveRunMetrics} when not supplied. Pass an explicit
1081
- * `metrics` object to override the derivation (e.g. tests).
1082
- */
1083
- export function recordImproveRun(db, input) {
1084
- const metricsObj = input.metrics ?? computeImproveRunMetrics(input.result);
1085
- db.prepare(`
1086
- INSERT INTO improve_runs
1087
- (id, started_at, completed_at, stash_dir, dry_run, profile,
1088
- scope_mode, scope_value, guidance, ok, result_json, metrics_json, metadata_json)
1089
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1090
- `).run(input.id, input.startedAt, input.completedAt, input.stashDir, input.dryRun ? 1 : 0, input.profile, input.scopeMode, input.scopeValue, input.guidance, input.ok ? 1 : 0, JSON.stringify(input.result), JSON.stringify(metricsObj), JSON.stringify(input.metadata ?? {}));
1091
- }
1092
- /**
1093
- * Read real (non-dry-run) improve_runs rows whose `started_at` falls in the
1094
- * window `[since, until)`. When `until` is omitted the window is open-ended
1095
- * (`started_at >= since`). Rows are returned newest-first (`ORDER BY
1096
- * started_at DESC`).
1097
- *
1098
- * Owns the SQL formerly inlined in commands/health.ts (`loadImproveRunRows`).
1099
- * The `dry_run = 0` filter is first-class so dry-run probes never pollute
1100
- * productivity audits.
1101
- *
1102
- * Connection-lifetime rule (WS5): `.all()` fully materializes the result set
1103
- * into a plain array before returning — no live cursor escapes the caller's
1104
- * `openStateDatabase` scope.
1105
- */
1106
- export function queryImproveRuns(db, since, until) {
1107
- const sql = until
1108
- ? "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND started_at < ? AND dry_run = 0 ORDER BY started_at DESC"
1109
- : "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND dry_run = 0 ORDER BY started_at DESC";
1110
- return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since));
1111
- }
1112
- /**
1113
- * Delete improve_runs rows older than `retentionDays` (default: 90). Mirrors
1114
- * {@link purgeOldEvents} — same default, same return shape (number of rows
1115
- * actually deleted), same disabled-when-non-finite semantics.
1116
- *
1117
- * Safe to call from the improve post-loop maintenance pass alongside
1118
- * `purgeOldEvents(db, retentionDays)`.
1119
- */
1120
- export function purgeOldImproveRuns(db, retentionDays = 90) {
1121
- if (!Number.isFinite(retentionDays) || retentionDays <= 0)
1122
- return 0;
1123
- const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
1124
- const result = db.prepare("DELETE FROM improve_runs WHERE started_at < ?").run(cutoff);
1125
- const changes = result.changes ?? 0;
1126
- return typeof changes === "bigint" ? Number(changes) : changes;
1127
- }
1128
- /**
1129
- * Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
1130
- * row reflects the most recent run; downstream skip-logic compares
1131
- * `session_ended_at` against the live session metadata to decide if anything
1132
- * new arrived since `processed_at`.
1133
- */
1134
- export function upsertExtractedSession(db, input) {
1135
- const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
1136
- ? new Date(input.sessionEndedAt).toISOString()
1137
- : null;
1138
- db.prepare(`
1139
- INSERT OR REPLACE INTO extract_sessions_seen
1140
- (harness, session_id, processed_at, session_ended_at, outcome,
1141
- candidate_count, proposal_count, rationale, source_run, metadata_json)
1142
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1143
- `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}));
1144
- }
1145
- /**
1146
- * Fetch a single session's last extract record, or `undefined` when the
1147
- * session has never been processed.
1148
- */
1149
- export function getExtractedSession(db, harness, sessionId) {
1150
- // bun:sqlite returns null (not undefined) when no row matches — normalize so
1151
- // callers can rely on `if (!row)` and `toBeUndefined()` equivalently.
1152
- const row = db
1153
- .prepare("SELECT * FROM extract_sessions_seen WHERE harness = ? AND session_id = ?")
1154
- .get(harness, sessionId);
1155
- return row ?? undefined;
1156
- }
1157
- /**
1158
- * Bulk-fetch session-extract status for a list of sessionIds in one harness.
1159
- * Returns a Map keyed by sessionId so callers can do O(1) lookups while
1160
- * iterating the discovery list.
1161
- */
1162
- export function getExtractedSessionsMap(db, harness, sessionIds) {
1163
- const out = new Map();
1164
- if (sessionIds.length === 0)
1165
- return out;
1166
- // SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that.
1167
- const CHUNK = 500;
1168
- for (let i = 0; i < sessionIds.length; i += CHUNK) {
1169
- const chunk = sessionIds.slice(i, i + CHUNK);
1170
- const placeholders = chunk.map(() => "?").join(",");
1171
- const rows = db
1172
- .prepare(`SELECT * FROM extract_sessions_seen
1173
- WHERE harness = ? AND session_id IN (${placeholders})`)
1174
- .all(harness, ...chunk);
1175
- for (const row of rows)
1176
- out.set(row.session_id, row);
1177
- }
1178
- return out;
1179
- }
1180
- /**
1181
- * Decide whether a session should be skipped because the extractor has
1182
- * already processed it AND nothing has changed since. The "anything new since
1183
- * last extract?" rule is: the live `sessionEndedAtMs` is strictly later than
1184
- * the recorded `session_ended_at`. Same-or-earlier endedAt means we'd be
1185
- * re-processing the exact same content for no gain.
1186
- *
1187
- * Returns:
1188
- * - `false` — no prior row, or session has new content since last extract.
1189
- * The caller should process it.
1190
- * - `true` — the session was already processed and hasn't been updated.
1191
- * The caller should skip.
1192
- */
1193
- export function shouldSkipAlreadyExtractedSession(prior, liveSessionEndedAtMs) {
1194
- if (!prior)
1195
- return false;
1196
- // No live timestamp → can't tell if anything's new. Be conservative and
1197
- // skip — the operator can pass --force later if we add it.
1198
- if (typeof liveSessionEndedAtMs !== "number" || !Number.isFinite(liveSessionEndedAtMs)) {
1199
- return true;
1200
- }
1201
- const priorMs = prior.session_ended_at ? Date.parse(prior.session_ended_at) : Number.NaN;
1202
- if (!Number.isFinite(priorMs))
1203
- return false;
1204
- // Re-process when there's new content; skip when the session is unchanged.
1205
- return liveSessionEndedAtMs <= priorMs;
1206
- }
1207
- // ── registry_index_cache (goes in index.db, not state.db) ───────────────────
1208
- /**
1209
- * DDL for the `registry_index_cache` table that lives in the EXISTING index.db
1210
- * (managed by src/indexer/db/db.ts).
1211
- *
1212
- * Design: uses the same migration-safe ADD COLUMN approach. The table is
1213
- * created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
1214
- * ensureSchema() or as a standalone migration.
1215
- *
1216
- * Purpose: caches the result of resolving and fetching remote registry stash
1217
- * indexes so `akm search` does not hit the network on every invocation.
1218
- *
1219
- * Indexed (query) columns:
1220
- * registry_url TEXT PK — canonical URL of the registry; cache key.
1221
- * fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
1222
- * etag TEXT — HTTP ETag for conditional GET (If-None-Match).
1223
- * last_modified TEXT — HTTP Last-Modified for conditional GET.
1224
- *
1225
- * Non-indexed payload:
1226
- * index_json TEXT — JSON blob of the fetched registry index document.
1227
- *
1228
- * ADD COLUMN extension points (future migrations in db.ts):
1229
- * ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
1230
- * ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
1231
- * ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
1232
- *
1233
- * To add this table to index.db, call ensureRegistryIndexCacheSchema(db) from
1234
- * within ensureSchema() in src/indexer/db/db.ts, or add it as a new CREATE TABLE
1235
- * IF NOT EXISTS block inside the existing ensureSchema() call.
1236
- */
1237
- export const REGISTRY_INDEX_CACHE_DDL = `
1238
- CREATE TABLE IF NOT EXISTS registry_index_cache (
1239
- registry_url TEXT PRIMARY KEY,
1240
- fetched_at TEXT NOT NULL,
1241
- etag TEXT,
1242
- last_modified TEXT,
1243
- index_json TEXT NOT NULL DEFAULT '{}'
1244
- );
1245
-
1246
- CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
1247
- ON registry_index_cache(fetched_at);
1248
- `;