akm-cli 0.9.1 → 0.9.2-alpha.2

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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -183,10 +183,8 @@ export function proposalRowToProposal(row) {
183
183
  throw new Error("Proposal row has invalid metadata_json.", { cause: error });
184
184
  }
185
185
  validatePresentMetadata(meta);
186
- const changes = Object.hasOwn(meta, "changes")
187
- ? storedToChanges(meta.changes, row.content)
188
- : [{ path: "", op: "update", after: row.content }];
189
- const proposedTarget = Object.hasOwn(meta, "proposedTarget") ? currentProposalTarget(meta.proposedTarget) : undefined;
186
+ const changes = storedToChanges(meta.changes, row.content);
187
+ const proposedTarget = currentProposalTarget(meta.proposedTarget);
190
188
  return {
191
189
  id: row.id,
192
190
  ref: currentProposalRef(row.ref),
@@ -200,7 +198,7 @@ export function proposalRowToProposal(row) {
200
198
  ...(frontmatter !== undefined ? { frontmatter } : {}),
201
199
  },
202
200
  changes,
203
- ...(proposedTarget !== undefined ? { proposedTarget } : {}),
201
+ proposedTarget,
204
202
  ...(typeof meta.beforeHash === "string" ? { beforeHash: meta.beforeHash } : {}),
205
203
  ...(meta.review !== undefined ? { review: meta.review } : {}),
206
204
  ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
@@ -212,110 +210,6 @@ export function proposalRowToProposal(row) {
212
210
  : {}),
213
211
  };
214
212
  }
215
- /**
216
- * Lenient counterpart to {@link proposalRowToProposal} for the terminal-status
217
- * (reject/archive) read paths (repository.ts `archiveProposal` /
218
- * `rejectProposalDurably`). Those paths must succeed even on legacy rows
219
- * minted before a field was required — e.g. 0.8-era rows missing
220
- * `proposedTarget` and/or carrying empty/missing `changes[].path` metadata —
221
- * so an operator can reject them instead of resorting to manual SQL.
222
- *
223
- * Per-field decode failures are swallowed: the offending field degrades to a
224
- * safe placeholder (matching the strict decoder's own "no changes recorded"
225
- * fallback) and the failure is appended to `decodeWarnings` instead of
226
- * throwing. `id` / `status` / timestamps / `source` are trusted verbatim (the
227
- * schema guarantees they're non-null strings). The strict decoder remains the
228
- * default everywhere else, including every other read of a `pending` or
229
- * `accepted` proposal (accept/revert must keep failing exactly as today on a
230
- * malformed row).
231
- */
232
- export function proposalRowToProposalLenient(row) {
233
- const warnings = [];
234
- let meta = {};
235
- try {
236
- const parsed = JSON.parse(row.metadata_json);
237
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
238
- throw new Error("metadata_json must contain an object.");
239
- }
240
- meta = parsed;
241
- validatePresentMetadata(meta);
242
- }
243
- catch (error) {
244
- warnings.push(`metadata_json is invalid (${error instanceof Error ? error.message : String(error)}); optional fields dropped.`);
245
- meta = {};
246
- }
247
- let frontmatter;
248
- if (row.frontmatter_json !== null) {
249
- try {
250
- const parsed = JSON.parse(row.frontmatter_json);
251
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
252
- throw new Error("frontmatter_json must contain an object.");
253
- }
254
- frontmatter = parsed;
255
- }
256
- catch (error) {
257
- warnings.push(`frontmatter_json is invalid (${error instanceof Error ? error.message : String(error)}); frontmatter dropped.`);
258
- }
259
- }
260
- let ref;
261
- try {
262
- ref = currentProposalRef(row.ref);
263
- }
264
- catch (error) {
265
- warnings.push(`ref is invalid (${error instanceof Error ? error.message : String(error)}); keeping the raw stored value.`);
266
- ref = row.ref;
267
- }
268
- let changes;
269
- try {
270
- changes = Object.hasOwn(meta, "changes")
271
- ? storedToChanges(meta.changes, row.content)
272
- : [{ path: "", op: "update", after: row.content }];
273
- }
274
- catch (error) {
275
- warnings.push(`changes metadata is invalid (${error instanceof Error ? error.message : String(error)}); degraded to a single placeholder change.`);
276
- changes = [{ path: "", op: "update", after: row.content }];
277
- }
278
- let proposedTarget;
279
- if (Object.hasOwn(meta, "proposedTarget")) {
280
- try {
281
- proposedTarget = currentProposalTarget(meta.proposedTarget);
282
- }
283
- catch (error) {
284
- warnings.push(`proposedTarget is invalid (${error instanceof Error ? error.message : String(error)}); omitted.`);
285
- }
286
- }
287
- else {
288
- warnings.push("proposedTarget is missing (pre-WI-6.2 legacy row).");
289
- }
290
- if (!["pending", "accepted", "rejected", "reverted"].includes(row.status)) {
291
- warnings.push(`status "${row.status}" is not a recognized value.`);
292
- }
293
- return {
294
- id: row.id,
295
- ref,
296
- status: row.status,
297
- source: row.source,
298
- ...(typeof meta.sourceRun === "string" ? { sourceRun: meta.sourceRun } : {}),
299
- createdAt: row.created_at,
300
- updatedAt: row.updated_at,
301
- payload: {
302
- content: row.content,
303
- ...(frontmatter !== undefined ? { frontmatter } : {}),
304
- },
305
- changes,
306
- ...(proposedTarget !== undefined ? { proposedTarget } : {}),
307
- ...(typeof meta.beforeHash === "string" ? { beforeHash: meta.beforeHash } : {}),
308
- ...(meta.review !== undefined ? { review: meta.review } : {}),
309
- ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
310
- ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
311
- ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
312
- ...(meta.acceptedTarget !== undefined ? { acceptedTarget: meta.acceptedTarget } : {}),
313
- ...(typeof meta.eligibilitySource === "string"
314
- ? { eligibilitySource: meta.eligibilitySource }
315
- : {}),
316
- ...(warnings.length > 0 ? { decodeWarnings: warnings } : {}),
317
- };
318
- }
319
213
  /**
320
214
  * Convert a public `Proposal` to column values ready for an INSERT/UPDATE.
321
215
  * The `stash_dir` comes from the call site (proposals.ts has it in scope).
@@ -413,12 +307,7 @@ export function listStateProposals(db, options = {}) {
413
307
  content, frontmatter_json, metadata_json
414
308
  FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
415
309
  .all(...params);
416
- // Archived rows (accepted/rejected/reverted) decode leniently so a
417
- // terminal-status row that was rejected via the lenient path (or predates
418
- // a required field) remains listable — a strict throw here would defeat
419
- // the point of tolerating it at reject/archive time. Pending rows keep the
420
- // strict decoder; nothing about the live queue changes here.
421
- return rows.map((row) => (row.status === "pending" ? proposalRowToProposal(row) : proposalRowToProposalLenient(row)));
310
+ return rows.map(proposalRowToProposal);
422
311
  }
423
312
  /**
424
313
  * Look up a single proposal by id, optionally scoped to one stash root.
@@ -431,18 +320,6 @@ export function getStateProposal(db, id, stashDir) {
431
320
  const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
432
321
  return row ? proposalRowToProposal(row) : undefined;
433
322
  }
434
- /**
435
- * Lenient counterpart to {@link getStateProposal} — used ONLY by the
436
- * terminal-status (reject/archive) read paths in repository.ts that must
437
- * tolerate a malformed row (see {@link proposalRowToProposalLenient}).
438
- */
439
- export function getStateProposalLenient(db, id, stashDir) {
440
- const sql = `SELECT id, stash_dir, ref, status, source, created_at, updated_at,
441
- content, frontmatter_json, metadata_json
442
- FROM proposals WHERE id = ?${stashDir ? " AND stash_dir = ?" : ""}`;
443
- const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
444
- return row ? proposalRowToProposalLenient(row) : undefined;
445
- }
446
323
  /**
447
324
  * Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
448
325
  * Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
@@ -2,6 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { rethrowIfTestIsolationError } from "../../core/errors.js";
5
+ import { formatRegistryError } from "../../core/registry-url.js";
5
6
  import { warn } from "../../core/warn.js";
6
7
  import { closeDatabase, openIndexDatabase } from "./index-connection.js";
7
8
  import { getRegistryIndexCache, upsertRegistryIndexCache } from "./registry-index-cache-repository.js";
@@ -96,7 +97,7 @@ export async function fetchCachedJson(opts) {
96
97
  if (expiredRow) {
97
98
  const stale = parseCache(expiredRow.indexJson, { stale: true });
98
99
  if (stale !== undefined) {
99
- warn(`Registry fetch failed (${err instanceof Error ? err.message : String(err)}); ` +
100
+ warn(`Registry fetch failed (${formatRegistryError(err)}); ` +
100
101
  "serving the last cached index, which is past its refresh interval.");
101
102
  return stale;
102
103
  }
@@ -24,7 +24,7 @@ function validateDetail(value) {
24
24
  metadataError("detail.exitCode must be a number or null");
25
25
  }
26
26
  }
27
- /** Decode supported historical and current task-history metadata. */
27
+ /** Decode the current task-history metadata shape. */
28
28
  export function decodeTaskHistoryMetadata(input) {
29
29
  let parsed = input;
30
30
  if (typeof input === "string") {
@@ -37,46 +37,26 @@ export function decodeTaskHistoryMetadata(input) {
37
37
  }
38
38
  if (!isRecord(parsed))
39
39
  metadataError("root must be an object");
40
- if (parsed.metadataVersion === undefined || parsed.metadataVersion === 1) {
41
- const allowed = new Set(["metadataVersion", "durationMs", "detail", "profile"]);
42
- const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
43
- if (unknown.length > 0)
44
- metadataError(`unknown v1 fields: ${unknown.sort().join(", ")}`);
45
- if (parsed.durationMs !== undefined && typeof parsed.durationMs !== "number") {
46
- metadataError("durationMs must be a number");
47
- }
48
- if (parsed.profile !== undefined && typeof parsed.profile !== "string") {
49
- metadataError("profile must be a string");
50
- }
51
- validateDetail(parsed.detail);
52
- return {
53
- metadataVersion: 1,
54
- durationMs: parsed.durationMs ?? 0,
55
- detail: parsed.detail ?? null,
56
- ...(parsed.profile !== undefined ? { legacyProfile: parsed.profile } : {}),
57
- };
58
- }
59
- if (parsed.metadataVersion === 2) {
60
- const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine"]);
61
- const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
62
- if (unknown.length > 0)
63
- metadataError(`unknown v2 fields: ${unknown.sort().join(", ")}`);
64
- if (typeof parsed.durationMs !== "number")
65
- metadataError("durationMs must be a number");
66
- if (!("detail" in parsed))
67
- metadataError("detail is required in v2");
68
- if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
69
- metadataError("engine must be a string or null");
70
- }
71
- validateDetail(parsed.detail);
72
- return {
73
- metadataVersion: 2,
74
- durationMs: parsed.durationMs,
75
- detail: parsed.detail ?? null,
76
- ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
77
- };
40
+ if (parsed.metadataVersion !== 2)
41
+ metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
42
+ const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine"]);
43
+ const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
44
+ if (unknown.length > 0)
45
+ metadataError(`unknown fields: ${unknown.sort().join(", ")}`);
46
+ if (typeof parsed.durationMs !== "number")
47
+ metadataError("durationMs must be a number");
48
+ if (!("detail" in parsed))
49
+ metadataError("detail is required");
50
+ if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
51
+ metadataError("engine must be a string or null");
78
52
  }
79
- metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
53
+ validateDetail(parsed.detail);
54
+ return {
55
+ metadataVersion: 2,
56
+ durationMs: parsed.durationMs,
57
+ detail: parsed.detail ?? null,
58
+ ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
59
+ };
80
60
  }
81
61
  /**
82
62
  * Atomically reserve one task-attempt identity.
@@ -1,16 +1,30 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { randomUUID } from "node:crypto";
5
+ import { UsageError } from "../../core/errors.js";
4
6
  import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
5
7
  import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
6
8
  import { resolveStorageLocations } from "../locations.js";
9
+ import { insertEventOnce, insertEventStrict } from "./events-repository.js";
10
+ function assertAttemptReservationLease(input, run) {
11
+ if (input.leaseMode === "direct") {
12
+ if (run.engine_lease_holder === null)
13
+ return;
14
+ throw new UsageError(`Workflow run ${input.runId} is leased by another engine; direct durable dispatch reservation is forbidden.`, "RESOURCE_ALREADY_EXISTS");
15
+ }
16
+ if (run.engine_lease_holder !== input.claimHolder) {
17
+ throw new UsageError(`Workflow run ${input.runId} lease holder changed; refusing a stale durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
18
+ }
19
+ if (run.engine_lease_until === null || run.engine_lease_until < input.now) {
20
+ throw new UsageError(`Workflow run ${input.runId} engine lease expired before durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
21
+ }
22
+ }
7
23
  /**
8
24
  * Repository owning every raw SQL statement against `workflow_runs` and
9
25
  * `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper
10
- * {@link withWorkflowRunsRepo} binds it to {@link StorageLocations.stateDb}
11
- * (the three-DB cutover folded workflow.db into state.db; the tables exist via
12
- * state migration `020-three-db-cutover`) so a future storage move (#489)
13
- * changes only `locations.ts`.
26
+ * {@link withWorkflowRunsRepo} binds it to {@link StorageLocations.stateDb}, so
27
+ * a future storage move changes only `locations.ts`.
14
28
  *
15
29
  * ## Connection-lifetime contract (WS5)
16
30
  *
@@ -44,7 +58,8 @@ export class WorkflowRunsRepository {
44
58
  .get(...refs, scopeKey);
45
59
  }
46
60
  getRunById(runId) {
47
- return this.db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(runId);
61
+ return (this.db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(runId) ??
62
+ undefined);
48
63
  }
49
64
  getActiveRunRowForScope(workflowRefs, scopeKey) {
50
65
  const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs];
@@ -157,16 +172,34 @@ export class WorkflowRunsRepository {
157
172
  this.db.prepare("UPDATE workflow_runs SET checkin_armed_at = ? WHERE id = ?").run(checkinArmedAt, runId);
158
173
  }
159
174
  /**
160
- * Freeze the compiled plan on the run row (migration 006, redesign addendum
161
- * R1): `planJson` is the CANONICAL plan JSON (`ir/plan-hash.ts`), `planHash`
162
- * its sha256. Called by `startWorkflowRun` inside the same transaction as
163
- * `insertRun`, so a run row never exists without its frozen plan. Read back
164
- * via {@link getRunById} (`plan_json` / `plan_hash` on the row).
175
+ * Atomically publish the entire durable-v4 run spine after the final source
176
+ * CAS. No run row, partial spine, plan attachment, or started event can
177
+ * escape independently across a crash or statement failure.
165
178
  */
166
- setRunPlan(runId, planJson, planHash, planIrVersion = 3) {
167
- this.db
168
- .prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = ? WHERE id = ?")
169
- .run(planJson, planHash, planIrVersion, runId);
179
+ publishWorkflowRunV4(input) {
180
+ this.immediateTransaction((db) => {
181
+ input.revalidateSources();
182
+ if (!input.force) {
183
+ const existing = this.findActiveRunForScope(input.workflowRefs, input.run.scopeKey);
184
+ if (existing) {
185
+ throw new UsageError(`Workflow ${input.run.workflowRef} already has an active run in this scope ` +
186
+ `(id=${existing.id}, step=${existing.current_step_id ?? "—"}). ` +
187
+ `Use 'akm workflow run ${input.run.workflowRef}' to resume it or ` +
188
+ `'akm workflow abandon ${existing.id}' to give up on it.`, "RESOURCE_ALREADY_EXISTS");
189
+ }
190
+ }
191
+ this.insertRun(input.run);
192
+ this.insertSteps(input.steps);
193
+ db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = 4 WHERE id = ?").run(input.planJson, input.planHash, input.run.id);
194
+ insertEventOnce(db, {
195
+ eventType: "workflow_started",
196
+ ts: input.run.createdAt,
197
+ ref: input.run.workflowRef,
198
+ metadata: { runId: input.run.id, status: "active" },
199
+ idempotencyKey: input.run.id,
200
+ idempotencyMetadataKey: "runId",
201
+ });
202
+ });
170
203
  }
171
204
  // ── engine run lease (migration 006 columns, R2 enforcement) ──────────────
172
205
  //
@@ -211,7 +244,186 @@ export class WorkflowRunsRepository {
211
244
  .prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ? AND status <> 'failed'")
212
245
  .run(runId, holder);
213
246
  }
214
- // ── unit rows (migration 004) ──────────────────────────────────────────────
247
+ // ── durable v4 append-only dispatch attempts (migration 022) ─────────────
248
+ getUnitAttempts(runId, unitId) {
249
+ return this.db
250
+ .prepare(`SELECT * FROM workflow_run_unit_attempts
251
+ WHERE run_id = ? AND unit_id = ?
252
+ ORDER BY attempt`)
253
+ .all(runId, unitId);
254
+ }
255
+ getAttemptAccounting(runId) {
256
+ const row = this.db
257
+ .prepare(`SELECT
258
+ COUNT(*) AS total_attempts,
259
+ COALESCE(SUM(tokens), 0) AS total_tokens,
260
+ COALESCE(SUM(CASE WHEN phase = 'unit' THEN 1 ELSE 0 END), 0) AS dispatch_attempts,
261
+ COALESCE(SUM(CASE WHEN phase = 'unit' THEN COALESCE(tokens, 0) ELSE 0 END), 0) AS dispatch_tokens,
262
+ COALESCE(SUM(CASE WHEN phase = 'gate' THEN 1 ELSE 0 END), 0) AS gate_attempts,
263
+ COALESCE(SUM(CASE WHEN phase = 'gate' THEN COALESCE(tokens, 0) ELSE 0 END), 0) AS gate_tokens
264
+ FROM workflow_run_unit_attempts
265
+ WHERE run_id = ?`)
266
+ .get(runId);
267
+ return {
268
+ totalAttempts: Number(row.total_attempts),
269
+ totalTokens: Number(row.total_tokens),
270
+ dispatchAttempts: Number(row.dispatch_attempts),
271
+ dispatchTokens: Number(row.dispatch_tokens),
272
+ gateAttempts: Number(row.gate_attempts),
273
+ gateTokens: Number(row.gate_tokens),
274
+ };
275
+ }
276
+ /**
277
+ * Reserve or reclaim one v4 external dispatch. The attempt row, legacy
278
+ * projection, and directly-paired started event share one IMMEDIATE
279
+ * transaction. Reclaim keeps the stable dispatch id and emits no duplicate
280
+ * start event: the external effect remains explicitly at-least-once.
281
+ */
282
+ reserveUnitAttempt(input) {
283
+ return this.immediateTransaction((db) => {
284
+ const run = db
285
+ .prepare("SELECT workflow_ref, status, engine_lease_holder, engine_lease_until FROM workflow_runs WHERE id = ?")
286
+ .get(input.runId);
287
+ if (!run || run.status !== "active") {
288
+ throw new UsageError(`Workflow run ${input.runId} is not active; refusing to reserve a durable dispatch attempt.`, "RESOURCE_ALREADY_EXISTS");
289
+ }
290
+ assertAttemptReservationLease(input, run);
291
+ const latest = db
292
+ .prepare(`SELECT * FROM workflow_run_unit_attempts
293
+ WHERE run_id = ? AND unit_id = ?
294
+ ORDER BY attempt DESC
295
+ LIMIT 1`)
296
+ .get(input.runId, input.unitId);
297
+ if (latest?.status === "running") {
298
+ if (latest.claim_holder === input.claimHolder) {
299
+ return { kind: "existing", attempt: latest };
300
+ }
301
+ const currentRunLeaseDisplacedClaim = run.engine_lease_holder === input.claimHolder;
302
+ const expired = latest.claim_expires_at < input.now;
303
+ if (!expired && !currentRunLeaseDisplacedClaim) {
304
+ return { kind: "busy", attempt: latest };
305
+ }
306
+ const reclaimed = db
307
+ .prepare(`UPDATE workflow_run_unit_attempts
308
+ SET claim_holder = ?, claim_expires_at = ?
309
+ WHERE run_id = ? AND unit_id = ? AND attempt = ?
310
+ AND status = 'running' AND dispatch_id = ? AND claim_holder = ?
311
+ AND claim_expires_at = ?`)
312
+ .run(input.claimHolder, input.claimExpiresAt, input.runId, input.unitId, latest.attempt, latest.dispatch_id, latest.claim_holder, latest.claim_expires_at);
313
+ if (Number(reclaimed.changes) !== 1) {
314
+ throw new Error(`Durable attempt ${input.unitId} changed while its displaced claim was reclaimed.`);
315
+ }
316
+ db.prepare(`UPDATE workflow_run_units
317
+ SET claim_holder = ?, claim_expires_at = ?, last_checkin_at = ?
318
+ WHERE run_id = ? AND unit_id = ? AND status = 'running'`).run(input.claimHolder, input.claimExpiresAt, input.now, input.runId, input.unitId);
319
+ const attempt = db
320
+ .prepare(`SELECT * FROM workflow_run_unit_attempts
321
+ WHERE run_id = ? AND unit_id = ? AND attempt = ?`)
322
+ .get(input.runId, input.unitId, latest.attempt);
323
+ return { kind: "reclaimed", attempt };
324
+ }
325
+ const attemptNumber = (latest?.attempt ?? 0) + 1;
326
+ const dispatchId = randomUUID();
327
+ db.prepare(`INSERT INTO workflow_run_unit_attempts (
328
+ run_id, unit_id, attempt, dispatch_id, step_id, node_id, phase,
329
+ runner, engine, model, input_hash, status, worktree_path, started_at,
330
+ claim_holder, claim_expires_at
331
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(input.runId, input.unitId, attemptNumber, dispatchId, input.stepId, input.nodeId, input.phase, input.runner, input.engine, input.model, input.inputHash, input.worktreePath ?? null, input.now, input.claimHolder, input.claimExpiresAt);
332
+ db.prepare(`INSERT INTO workflow_run_units (
333
+ run_id, unit_id, step_id, node_id, parent_unit_id, phase, runner, engine, model,
334
+ status, input_hash, worktree_path, started_at, claim_holder, claim_expires_at, attempts
335
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?)
336
+ ON CONFLICT(run_id, unit_id) DO UPDATE SET
337
+ step_id = excluded.step_id,
338
+ node_id = excluded.node_id,
339
+ parent_unit_id = excluded.parent_unit_id,
340
+ phase = excluded.phase,
341
+ runner = excluded.runner,
342
+ engine = excluded.engine,
343
+ model = excluded.model,
344
+ status = 'running',
345
+ input_hash = excluded.input_hash,
346
+ worktree_path = excluded.worktree_path,
347
+ started_at = excluded.started_at,
348
+ claim_holder = excluded.claim_holder,
349
+ claim_expires_at = excluded.claim_expires_at,
350
+ result_json = NULL,
351
+ tokens = NULL,
352
+ failure_reason = NULL,
353
+ session_id = NULL,
354
+ finished_at = NULL,
355
+ last_checkin_at = NULL,
356
+ attempts = excluded.attempts`).run(input.runId, input.unitId, input.stepId, input.nodeId, input.parentUnitId ?? null, input.phase, input.runner, input.engine, input.model, input.inputHash, input.worktreePath ?? null, input.now, input.claimHolder, input.claimExpiresAt, attemptNumber);
357
+ insertEventStrict(db, {
358
+ eventType: "workflow_unit_started",
359
+ ts: input.now,
360
+ ref: run.workflow_ref,
361
+ metadata: {
362
+ runId: input.runId,
363
+ stepId: input.stepId,
364
+ unitId: input.unitId,
365
+ attempt: attemptNumber,
366
+ dispatchId,
367
+ phase: input.phase,
368
+ status: "running",
369
+ },
370
+ });
371
+ const attempt = db
372
+ .prepare(`SELECT * FROM workflow_run_unit_attempts
373
+ WHERE run_id = ? AND unit_id = ? AND attempt = ?`)
374
+ .get(input.runId, input.unitId, attemptNumber);
375
+ return { kind: "reserved", attempt };
376
+ });
377
+ }
378
+ /** Commit one CAS-valid v4 terminal result, known usage, and finish event. */
379
+ finishUnitAttempt(input) {
380
+ return this.immediateTransaction((db) => {
381
+ const changed = db
382
+ .prepare(`UPDATE workflow_run_unit_attempts
383
+ SET status = ?, result_json = ?, tokens = ?, failure_reason = ?,
384
+ session_id = ?, finished_at = ?
385
+ WHERE run_id = ? AND unit_id = ? AND attempt = ? AND dispatch_id = ?
386
+ AND claim_holder = ? AND status = 'running'`)
387
+ .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.attempt, input.dispatchId, input.claimHolder);
388
+ if (Number(changed.changes) !== 1)
389
+ return false;
390
+ const attempt = db
391
+ .prepare(`SELECT * FROM workflow_run_unit_attempts
392
+ WHERE run_id = ? AND unit_id = ? AND attempt = ?`)
393
+ .get(input.runId, input.unitId, input.attempt);
394
+ const projection = db
395
+ .prepare(`UPDATE workflow_run_units
396
+ SET status = ?, result_json = ?, tokens = ?, failure_reason = ?,
397
+ session_id = ?, finished_at = ?
398
+ WHERE run_id = ? AND unit_id = ? AND status = 'running'
399
+ AND attempts = ? AND claim_holder = ?`)
400
+ .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.attempt, input.claimHolder);
401
+ if (Number(projection.changes) !== 1) {
402
+ throw new Error(`Durable attempt ${input.unitId} has no matching live workflow_run_units projection.`);
403
+ }
404
+ const run = db.prepare("SELECT workflow_ref FROM workflow_runs WHERE id = ?").get(input.runId);
405
+ if (!run)
406
+ throw new Error(`Durable attempt ${input.unitId} has no owning workflow run.`);
407
+ insertEventStrict(db, {
408
+ eventType: "workflow_unit_finished",
409
+ ts: input.finishedAt,
410
+ ref: run.workflow_ref,
411
+ metadata: {
412
+ runId: input.runId,
413
+ stepId: attempt.step_id,
414
+ unitId: input.unitId,
415
+ attempt: input.attempt,
416
+ dispatchId: input.dispatchId,
417
+ phase: attempt.phase,
418
+ status: input.status,
419
+ ...(input.failureReason ? { failureReason: input.failureReason } : {}),
420
+ ...(input.tokens !== null ? { tokens: input.tokens } : {}),
421
+ },
422
+ });
423
+ return true;
424
+ });
425
+ }
426
+ // ── current unit status projection ─────────────────────────────────────────
215
427
  //
216
428
  // Writes to `workflow_run_units` should go through the serialized writer
217
429
  // queue (`src/workflows/exec/unit-writer.ts`) when N units may complete
@@ -229,123 +441,10 @@ export class WorkflowRunsRepository {
229
441
  .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND step_id = ? ORDER BY started_at ASC, unit_id ASC")
230
442
  .all(runId, stepId);
231
443
  }
232
- /** One unit row by primary key, or undefined. Used for guarded read-then-write
233
- * (idempotency / replay-divergence / claim checks). */
444
+ /** One unit row by primary key, or undefined. */
234
445
  getUnit(runId, unitId) {
235
446
  return this.db.prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND unit_id = ?").get(runId, unitId);
236
447
  }
237
- /**
238
- * Insert a unit row in `running` state (a dispatch is starting now).
239
- *
240
- * Upsert on the (run_id, unit_id) primary key: durable-row resume
241
- * re-dispatches units whose previous attempt never reached a terminal status
242
- * (a crash mid-step leaves `running` rows). The fresh dispatch REPLACES the
243
- * stale row — value columns (`result_json`/`tokens`/`failure_reason`/
244
- * `session_id`/`finished_at`/`last_checkin_at`) are reset exactly as the old
245
- * `INSERT OR REPLACE` reset them, and the dispatch metadata is overwritten —
246
- * but `attempts` is INCREMENTED rather than reset (migration 008). A first
247
- * insert lands `attempts = 1` (column default); each re-dispatch bumps it, so
248
- * budget/lifetime seeds that sum `attempts` charge every crash-retried
249
- * dispatch instead of collapsing them into one row. Using `ON CONFLICT DO
250
- * UPDATE` (not `INSERT OR REPLACE`, which deletes+reinserts) is what lets the
251
- * increment read the prior row's counter.
252
- */
253
- insertUnit(input) {
254
- this.db
255
- .prepare(`INSERT INTO workflow_run_units (
256
- run_id, unit_id, step_id, node_id, parent_unit_id, phase, runner, engine, model,
257
- status, input_hash, worktree_path, started_at, claim_holder, claim_expires_at
258
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)
259
- ON CONFLICT(run_id, unit_id) DO UPDATE SET
260
- step_id = excluded.step_id,
261
- node_id = excluded.node_id,
262
- parent_unit_id = excluded.parent_unit_id,
263
- phase = excluded.phase,
264
- runner = excluded.runner,
265
- engine = excluded.engine,
266
- model = excluded.model,
267
- status = excluded.status,
268
- input_hash = excluded.input_hash,
269
- worktree_path = excluded.worktree_path,
270
- started_at = excluded.started_at,
271
- claim_holder = excluded.claim_holder,
272
- claim_expires_at = excluded.claim_expires_at,
273
- result_json = NULL,
274
- tokens = NULL,
275
- failure_reason = NULL,
276
- session_id = NULL,
277
- finished_at = NULL,
278
- last_checkin_at = NULL,
279
- attempts = workflow_run_units.attempts + 1`)
280
- .run(input.runId, input.unitId, input.stepId, input.nodeId, input.parentUnitId, input.phase, input.runner, input.engine ?? null, input.model, input.inputHash, input.worktreePath ?? null, input.startedAt, input.claimHolder ?? null, input.claimExpiresAt ?? null);
281
- }
282
- /**
283
- * Stamp a `running` claim + heartbeat on a unit row (migration 009, PR #714
284
- * review round 2). Sets `status = 'running'`, refreshes the heartbeat
285
- * (`last_checkin_at`), and (re)writes the claim owner + expiry. Must be called
286
- * inside a transaction that has ALREADY validated the claim is free / expired
287
- * / already held by this holder, so the write is the final step of a checked
288
- * reclaim. Never touches `started_at` (the first-claim marker set by
289
- * {@link insertUnit}).
290
- */
291
- updateUnitClaim(runId, unitId, holder, expiresAt, lastCheckinAt) {
292
- const result = this.db
293
- .prepare(`UPDATE workflow_run_units
294
- SET status = 'running', last_checkin_at = ?, claim_holder = ?, claim_expires_at = ?
295
- WHERE run_id = ? AND unit_id = ?
296
- AND EXISTS (SELECT 1 FROM workflow_runs WHERE id = ? AND status = 'active')`)
297
- .run(lastCheckinAt, holder, expiresAt, runId, unitId, runId);
298
- return Number(result.changes) === 1;
299
- }
300
- /**
301
- * Record a unit's terminal state (completed / failed / skipped).
302
- *
303
- * ## Loud contract (must update exactly one row)
304
- *
305
- * A finish ALWAYS targets a row a prior dispatch/claim inserted: every caller
306
- * ({@link insertUnit} in the executor and the report path,
307
- * {@link journalGateEvaluationStart} for gate rows) writes the `running` row
308
- * before finishing it, inside the same writer-queue task or SQLite
309
- * transaction. If the UPDATE matches NO `(run_id, unit_id)` row the journal is
310
- * inconsistent — a finish against a missing or mismatched unit id — and the
311
- * unit's terminal state would silently vanish (the row stays `running`, or no
312
- * row exists at all). That is a journaling BUG, so we throw loudly at the
313
- * source instead of no-oping: a stuck-`running` unit would wedge resume
314
- * (durable-row reuse never matches it) or corrupt budget/gate accounting.
315
- */
316
- finishUnit(input) {
317
- const result = this.db
318
- .prepare(`UPDATE workflow_run_units
319
- SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ?
320
- WHERE run_id = ? AND unit_id = ?`)
321
- .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId);
322
- if (Number(result.changes) === 0) {
323
- throw new Error(`finishUnit updated no row: no unit "${input.unitId}" exists for run "${input.runId}". ` +
324
- `A unit must be inserted (dispatched or claimed) before it can be finished — a finish that ` +
325
- `matches no row indicates a journaling bug (mismatched unit id or a lost dispatch row), not a ` +
326
- `recoverable state.`);
327
- }
328
- }
329
- /**
330
- * Finish a unit row ONLY while it is still the exact row a specific dispatch
331
- * inserted: `running`, with that dispatch's `started_at`. The native
332
- * executor's guarded finish (single-driver invariant): a run stolen by
333
- * another engine re-dispatches the unit through {@link insertUnit}, which
334
- * REPLACES the row (fresh `started_at`, bumped `attempts`) — the stale
335
- * driver's finish then matches NOTHING instead of clobbering the new
336
- * driver's live dispatch. Returns whether the row was finished; a zero-row
337
- * match is a caller-classified outcome here (the executor distinguishes
338
- * "replaced by another driver" from "row vanished"), unlike
339
- * {@link finishUnit}'s loud throw, whose callers guarantee their row exists.
340
- */
341
- finishUnitFromDispatch(input) {
342
- const result = this.db
343
- .prepare(`UPDATE workflow_run_units
344
- SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ?
345
- WHERE run_id = ? AND unit_id = ? AND status = 'running' AND started_at = ?`)
346
- .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.dispatchStartedAt);
347
- return Number(result.changes) === 1;
348
- }
349
448
  }
350
449
  /**
351
450
  * Run `fn` against a {@link WorkflowRunsRepository} bound to state.db