@cleocode/core 2026.9.8 → 2026.9.9

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 (561) hide show
  1. package/dist/audit/reconstruct.d.ts +13 -43
  2. package/dist/audit/reconstruct.d.ts.map +1 -1
  3. package/dist/audit/reconstruct.js +295 -290
  4. package/dist/audit/reconstruct.js.map +1 -1
  5. package/dist/bootstrap.d.ts.map +1 -1
  6. package/dist/bootstrap.js +8 -2
  7. package/dist/bootstrap.js.map +1 -1
  8. package/dist/dispatch/mutate-projection.d.ts.map +1 -1
  9. package/dist/dispatch/mutate-projection.js +12 -0
  10. package/dist/dispatch/mutate-projection.js.map +1 -1
  11. package/dist/dispatch/mvi-projection.d.ts +29 -6
  12. package/dist/dispatch/mvi-projection.d.ts.map +1 -1
  13. package/dist/dispatch/mvi-projection.js +109 -139
  14. package/dist/dispatch/mvi-projection.js.map +1 -1
  15. package/dist/docs/docs-read-model.d.ts +3 -2
  16. package/dist/docs/docs-read-model.d.ts.map +1 -1
  17. package/dist/docs/docs-read-model.js +4 -3
  18. package/dist/docs/docs-read-model.js.map +1 -1
  19. package/dist/docs/export-document.js +5100 -2297
  20. package/dist/docs/export-document.js.map +3 -3
  21. package/dist/docs/projection.d.ts +67 -0
  22. package/dist/docs/projection.d.ts.map +1 -0
  23. package/dist/docs/projection.js +370 -0
  24. package/dist/docs/projection.js.map +1 -0
  25. package/dist/doctor/acceptance-drift.d.ts +6 -2
  26. package/dist/doctor/acceptance-drift.d.ts.map +1 -1
  27. package/dist/doctor/acceptance-drift.js +43 -5
  28. package/dist/doctor/acceptance-drift.js.map +1 -1
  29. package/dist/doctor/knowledge-summary.d.ts +41 -0
  30. package/dist/doctor/knowledge-summary.d.ts.map +1 -0
  31. package/dist/doctor/knowledge-summary.js +81 -0
  32. package/dist/doctor/knowledge-summary.js.map +1 -0
  33. package/dist/doctor/knowledge.d.ts +192 -0
  34. package/dist/doctor/knowledge.d.ts.map +1 -0
  35. package/dist/doctor/knowledge.js +1864 -0
  36. package/dist/doctor/knowledge.js.map +1 -0
  37. package/dist/git/work-tree.d.ts +64 -0
  38. package/dist/git/work-tree.d.ts.map +1 -0
  39. package/dist/git/work-tree.js +111 -0
  40. package/dist/git/work-tree.js.map +1 -0
  41. package/dist/hooks/handlers/task-hooks.d.ts +8 -5
  42. package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
  43. package/dist/hooks/handlers/task-hooks.js +15 -60
  44. package/dist/hooks/handlers/task-hooks.js.map +1 -1
  45. package/dist/identity/cleo-identity.d.ts +13 -0
  46. package/dist/identity/cleo-identity.d.ts.map +1 -1
  47. package/dist/identity/cleo-identity.js +20 -6
  48. package/dist/identity/cleo-identity.js.map +1 -1
  49. package/dist/injection.d.ts.map +1 -1
  50. package/dist/injection.js +29 -13
  51. package/dist/injection.js.map +1 -1
  52. package/dist/internal.d.ts +2 -0
  53. package/dist/internal.d.ts.map +1 -1
  54. package/dist/internal.js +2 -0
  55. package/dist/internal.js.map +1 -1
  56. package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
  57. package/dist/lifecycle/verification/satisfies-validator.js +124 -120
  58. package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
  59. package/dist/llm/plugin-facade.js +4018 -1726
  60. package/dist/llm/plugin-facade.js.map +3 -3
  61. package/dist/memory/brain-backfill.d.ts +27 -12
  62. package/dist/memory/brain-backfill.d.ts.map +1 -1
  63. package/dist/memory/brain-backfill.js +325 -134
  64. package/dist/memory/brain-backfill.js.map +1 -1
  65. package/dist/memory/brain-doctor.d.ts +21 -0
  66. package/dist/memory/brain-doctor.d.ts.map +1 -1
  67. package/dist/memory/brain-doctor.js +111 -53
  68. package/dist/memory/brain-doctor.js.map +1 -1
  69. package/dist/memory/brain-links.d.ts.map +1 -1
  70. package/dist/memory/brain-links.js +31 -4
  71. package/dist/memory/brain-links.js.map +1 -1
  72. package/dist/memory/brain-reasoning.d.ts.map +1 -1
  73. package/dist/memory/brain-reasoning.js +28 -2
  74. package/dist/memory/brain-reasoning.js.map +1 -1
  75. package/dist/memory/brain-reconciler.d.ts +14 -19
  76. package/dist/memory/brain-reconciler.d.ts.map +1 -1
  77. package/dist/memory/brain-reconciler.js +70 -97
  78. package/dist/memory/brain-reconciler.js.map +1 -1
  79. package/dist/memory/brain-search.d.ts +2 -0
  80. package/dist/memory/brain-search.d.ts.map +1 -1
  81. package/dist/memory/brain-search.js +59 -29
  82. package/dist/memory/brain-search.js.map +1 -1
  83. package/dist/memory/brain-similarity.d.ts.map +1 -1
  84. package/dist/memory/brain-similarity.js +5 -4
  85. package/dist/memory/brain-similarity.js.map +1 -1
  86. package/dist/memory/brain-stub-prune.d.ts +31 -9
  87. package/dist/memory/brain-stub-prune.d.ts.map +1 -1
  88. package/dist/memory/brain-stub-prune.js +65 -18
  89. package/dist/memory/brain-stub-prune.js.map +1 -1
  90. package/dist/memory/brain-writer-handlers.d.ts +10 -1
  91. package/dist/memory/brain-writer-handlers.d.ts.map +1 -1
  92. package/dist/memory/brain-writer-handlers.js +23 -3
  93. package/dist/memory/brain-writer-handlers.js.map +1 -1
  94. package/dist/memory/brain-writer-thread.d.ts +11 -1
  95. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  96. package/dist/memory/brain-writer-thread.js +87 -20
  97. package/dist/memory/brain-writer-thread.js.map +1 -1
  98. package/dist/memory/brain-writer-worker.js +8 -1
  99. package/dist/memory/brain-writer-worker.js.map +1 -1
  100. package/dist/memory/decision-cross-link.d.ts +39 -0
  101. package/dist/memory/decision-cross-link.d.ts.map +1 -1
  102. package/dist/memory/decision-cross-link.js +316 -0
  103. package/dist/memory/decision-cross-link.js.map +1 -1
  104. package/dist/memory/decisions.d.ts +4 -1
  105. package/dist/memory/decisions.d.ts.map +1 -1
  106. package/dist/memory/decisions.js +7 -24
  107. package/dist/memory/decisions.js.map +1 -1
  108. package/dist/memory/dispatch-trace.d.ts +12 -5
  109. package/dist/memory/dispatch-trace.d.ts.map +1 -1
  110. package/dist/memory/dispatch-trace.js +17 -7
  111. package/dist/memory/dispatch-trace.js.map +1 -1
  112. package/dist/memory/eligibility.d.ts +51 -0
  113. package/dist/memory/eligibility.d.ts.map +1 -0
  114. package/dist/memory/eligibility.js +77 -0
  115. package/dist/memory/eligibility.js.map +1 -0
  116. package/dist/memory/engine-compat.d.ts +2 -0
  117. package/dist/memory/engine-compat.d.ts.map +1 -1
  118. package/dist/memory/engine-compat.js +10 -0
  119. package/dist/memory/engine-compat.js.map +1 -1
  120. package/dist/memory/extraction-gate.d.ts +21 -7
  121. package/dist/memory/extraction-gate.d.ts.map +1 -1
  122. package/dist/memory/extraction-gate.js +178 -216
  123. package/dist/memory/extraction-gate.js.map +1 -1
  124. package/dist/memory/graph-auto-populate.d.ts +38 -5
  125. package/dist/memory/graph-auto-populate.d.ts.map +1 -1
  126. package/dist/memory/graph-auto-populate.js +182 -60
  127. package/dist/memory/graph-auto-populate.js.map +1 -1
  128. package/dist/memory/graph-memory-bridge.d.ts +14 -1
  129. package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
  130. package/dist/memory/graph-memory-bridge.js +66 -28
  131. package/dist/memory/graph-memory-bridge.js.map +1 -1
  132. package/dist/memory/index.d.ts +1 -19
  133. package/dist/memory/index.d.ts.map +1 -1
  134. package/dist/memory/index.js +1 -50
  135. package/dist/memory/index.js.map +1 -1
  136. package/dist/memory/learnings.d.ts.map +1 -1
  137. package/dist/memory/learnings.js +2 -23
  138. package/dist/memory/learnings.js.map +1 -1
  139. package/dist/memory/manifest-filter.d.ts +21 -0
  140. package/dist/memory/manifest-filter.d.ts.map +1 -0
  141. package/dist/memory/manifest-filter.js +51 -0
  142. package/dist/memory/manifest-filter.js.map +1 -0
  143. package/dist/memory/manifest-ingestion.d.ts +28 -30
  144. package/dist/memory/manifest-ingestion.d.ts.map +1 -1
  145. package/dist/memory/manifest-ingestion.js +104 -235
  146. package/dist/memory/manifest-ingestion.js.map +1 -1
  147. package/dist/memory/observer-reflector.d.ts +2 -0
  148. package/dist/memory/observer-reflector.d.ts.map +1 -1
  149. package/dist/memory/observer-reflector.js +6 -49
  150. package/dist/memory/observer-reflector.js.map +1 -1
  151. package/dist/memory/patterns.d.ts +10 -0
  152. package/dist/memory/patterns.d.ts.map +1 -1
  153. package/dist/memory/patterns.js +25 -33
  154. package/dist/memory/patterns.js.map +1 -1
  155. package/dist/memory/pipeline-manifest-sqlite.d.ts +72 -9
  156. package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
  157. package/dist/memory/pipeline-manifest-sqlite.js +508 -412
  158. package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
  159. package/dist/memory/retrieval/build-retrieval-bundle.d.ts.map +1 -1
  160. package/dist/memory/retrieval/build-retrieval-bundle.js +16 -15
  161. package/dist/memory/retrieval/build-retrieval-bundle.js.map +1 -1
  162. package/dist/memory/retrieval/fetch.d.ts +7 -0
  163. package/dist/memory/retrieval/fetch.d.ts.map +1 -1
  164. package/dist/memory/retrieval/fetch.js +14 -11
  165. package/dist/memory/retrieval/fetch.js.map +1 -1
  166. package/dist/memory/retrieval/observe.d.ts +7 -1
  167. package/dist/memory/retrieval/observe.d.ts.map +1 -1
  168. package/dist/memory/retrieval/observe.js +58 -4
  169. package/dist/memory/retrieval/observe.js.map +1 -1
  170. package/dist/memory/retrieval/search.d.ts +14 -1
  171. package/dist/memory/retrieval/search.d.ts.map +1 -1
  172. package/dist/memory/retrieval/search.js +133 -69
  173. package/dist/memory/retrieval/search.js.map +1 -1
  174. package/dist/memory/retrieval/telemetry.d.ts +31 -0
  175. package/dist/memory/retrieval/telemetry.d.ts.map +1 -0
  176. package/dist/memory/retrieval/telemetry.js +53 -0
  177. package/dist/memory/retrieval/telemetry.js.map +1 -0
  178. package/dist/nexus/analyze-orchestrator.d.ts +41 -9
  179. package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
  180. package/dist/nexus/analyze-orchestrator.js +257 -58
  181. package/dist/nexus/analyze-orchestrator.js.map +1 -1
  182. package/dist/nexus/api-contracts.d.ts +2 -6
  183. package/dist/nexus/api-contracts.d.ts.map +1 -1
  184. package/dist/nexus/api-contracts.js +11 -2
  185. package/dist/nexus/api-contracts.js.map +1 -1
  186. package/dist/nexus/context.d.ts.map +1 -1
  187. package/dist/nexus/context.js +14 -37
  188. package/dist/nexus/context.js.map +1 -1
  189. package/dist/nexus/diff.d.ts.map +1 -1
  190. package/dist/nexus/diff.js +2 -1
  191. package/dist/nexus/diff.js.map +1 -1
  192. package/dist/nexus/identity.d.ts +29 -3
  193. package/dist/nexus/identity.d.ts.map +1 -1
  194. package/dist/nexus/identity.js +63 -12
  195. package/dist/nexus/identity.js.map +1 -1
  196. package/dist/nexus/impact.d.ts +21 -19
  197. package/dist/nexus/impact.d.ts.map +1 -1
  198. package/dist/nexus/impact.js +96 -192
  199. package/dist/nexus/impact.js.map +1 -1
  200. package/dist/nexus/knowledge.d.ts +80 -0
  201. package/dist/nexus/knowledge.d.ts.map +1 -0
  202. package/dist/nexus/knowledge.js +786 -0
  203. package/dist/nexus/knowledge.js.map +1 -0
  204. package/dist/nexus/living-brain.d.ts +8 -5
  205. package/dist/nexus/living-brain.d.ts.map +1 -1
  206. package/dist/nexus/living-brain.js +252 -188
  207. package/dist/nexus/living-brain.js.map +1 -1
  208. package/dist/nexus/registry.d.ts +11 -2
  209. package/dist/nexus/registry.d.ts.map +1 -1
  210. package/dist/nexus/registry.js +144 -128
  211. package/dist/nexus/registry.js.map +1 -1
  212. package/dist/nexus/source-roots.d.ts +16 -0
  213. package/dist/nexus/source-roots.d.ts.map +1 -0
  214. package/dist/nexus/source-roots.js +174 -0
  215. package/dist/nexus/source-roots.js.map +1 -0
  216. package/dist/nexus/task-evidence.d.ts +22 -0
  217. package/dist/nexus/task-evidence.d.ts.map +1 -0
  218. package/dist/nexus/task-evidence.js +210 -0
  219. package/dist/nexus/task-evidence.js.map +1 -0
  220. package/dist/nexus/tasks-bridge.d.ts +14 -7
  221. package/dist/nexus/tasks-bridge.d.ts.map +1 -1
  222. package/dist/nexus/tasks-bridge.js +81 -40
  223. package/dist/nexus/tasks-bridge.js.map +1 -1
  224. package/dist/orchestrate/lifecycle-ops.d.ts.map +1 -1
  225. package/dist/orchestrate/lifecycle-ops.js +170 -120
  226. package/dist/orchestrate/lifecycle-ops.js.map +1 -1
  227. package/dist/orchestrate/query-ops.d.ts +6 -7
  228. package/dist/orchestrate/query-ops.d.ts.map +1 -1
  229. package/dist/orchestrate/query-ops.js +509 -532
  230. package/dist/orchestrate/query-ops.js.map +1 -1
  231. package/dist/orchestration/bootstrap.d.ts +15 -1
  232. package/dist/orchestration/bootstrap.d.ts.map +1 -1
  233. package/dist/orchestration/bootstrap.js +21 -10
  234. package/dist/orchestration/bootstrap.js.map +1 -1
  235. package/dist/orchestration/index.d.ts +35 -3
  236. package/dist/orchestration/index.d.ts.map +1 -1
  237. package/dist/orchestration/index.js +64 -35
  238. package/dist/orchestration/index.js.map +1 -1
  239. package/dist/orchestration/lead-rollup.d.ts +24 -1
  240. package/dist/orchestration/lead-rollup.d.ts.map +1 -1
  241. package/dist/orchestration/lead-rollup.js +31 -11
  242. package/dist/orchestration/lead-rollup.js.map +1 -1
  243. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  244. package/dist/orchestration/spawn-prompt.js +16 -41
  245. package/dist/orchestration/spawn-prompt.js.map +1 -1
  246. package/dist/orchestration/unblock.d.ts +15 -1
  247. package/dist/orchestration/unblock.d.ts.map +1 -1
  248. package/dist/orchestration/unblock.js +23 -12
  249. package/dist/orchestration/unblock.js.map +1 -1
  250. package/dist/orchestration/validate-spawn.d.ts.map +1 -1
  251. package/dist/orchestration/validate-spawn.js +3 -3
  252. package/dist/orchestration/validate-spawn.js.map +1 -1
  253. package/dist/orchestration/waves.d.ts +29 -13
  254. package/dist/orchestration/waves.d.ts.map +1 -1
  255. package/dist/orchestration/waves.js +43 -55
  256. package/dist/orchestration/waves.js.map +1 -1
  257. package/dist/paths.d.ts +17 -163
  258. package/dist/paths.d.ts.map +1 -1
  259. package/dist/paths.js +145 -496
  260. package/dist/paths.js.map +1 -1
  261. package/dist/project-info.d.ts +2 -11
  262. package/dist/project-info.d.ts.map +1 -1
  263. package/dist/project-info.js +3 -31
  264. package/dist/project-info.js.map +1 -1
  265. package/dist/project-scope.d.ts +239 -0
  266. package/dist/project-scope.d.ts.map +1 -0
  267. package/dist/project-scope.js +473 -0
  268. package/dist/project-scope.js.map +1 -0
  269. package/dist/release/pr-evidence.d.ts +16 -3
  270. package/dist/release/pr-evidence.d.ts.map +1 -1
  271. package/dist/release/pr-evidence.js +53 -7
  272. package/dist/release/pr-evidence.js.map +1 -1
  273. package/dist/render/nexus/graph/index.d.ts.map +1 -1
  274. package/dist/render/nexus/graph/index.js +4 -2
  275. package/dist/render/nexus/graph/index.js.map +1 -1
  276. package/dist/render/orchestration/audit-reconstruct.d.ts +11 -0
  277. package/dist/render/orchestration/audit-reconstruct.d.ts.map +1 -1
  278. package/dist/render/orchestration/audit-reconstruct.js +21 -6
  279. package/dist/render/orchestration/audit-reconstruct.js.map +1 -1
  280. package/dist/render/tasks/show.js +2 -1
  281. package/dist/render/tasks/show.js.map +1 -1
  282. package/dist/resources/spawn-wrapper.d.ts +82 -14
  283. package/dist/resources/spawn-wrapper.d.ts.map +1 -1
  284. package/dist/resources/spawn-wrapper.js +585 -27
  285. package/dist/resources/spawn-wrapper.js.map +1 -1
  286. package/dist/selfimprove/fix-gen.js +4026 -1734
  287. package/dist/selfimprove/fix-gen.js.map +3 -3
  288. package/dist/sequence/index.d.ts.map +1 -1
  289. package/dist/sequence/index.js +76 -68
  290. package/dist/sequence/index.js.map +1 -1
  291. package/dist/session/canon-lint.d.ts +24 -3
  292. package/dist/session/canon-lint.d.ts.map +1 -1
  293. package/dist/session/canon-lint.js +72 -31
  294. package/dist/session/canon-lint.js.map +1 -1
  295. package/dist/session/engine-ops.d.ts +1 -4
  296. package/dist/session/engine-ops.d.ts.map +1 -1
  297. package/dist/session/engine-ops.js +6 -2
  298. package/dist/session/engine-ops.js.map +1 -1
  299. package/dist/sessions/briefing.d.ts +11 -1
  300. package/dist/sessions/briefing.d.ts.map +1 -1
  301. package/dist/sessions/briefing.js +76 -8
  302. package/dist/sessions/briefing.js.map +1 -1
  303. package/dist/sessions/handoff.d.ts +3 -1
  304. package/dist/sessions/handoff.d.ts.map +1 -1
  305. package/dist/sessions/handoff.js +2 -0
  306. package/dist/sessions/handoff.js.map +1 -1
  307. package/dist/shutdown-deadline.d.ts +24 -33
  308. package/dist/shutdown-deadline.d.ts.map +1 -1
  309. package/dist/shutdown-deadline.js +71 -14
  310. package/dist/shutdown-deadline.js.map +1 -1
  311. package/dist/shutdown.d.ts +21 -10
  312. package/dist/shutdown.d.ts.map +1 -1
  313. package/dist/shutdown.js +89 -42
  314. package/dist/shutdown.js.map +1 -1
  315. package/dist/skills/index.d.ts +1 -1
  316. package/dist/skills/index.d.ts.map +1 -1
  317. package/dist/skills/index.js +1 -1
  318. package/dist/skills/index.js.map +1 -1
  319. package/dist/skills/orchestrator/validator.d.ts +87 -20
  320. package/dist/skills/orchestrator/validator.d.ts.map +1 -1
  321. package/dist/skills/orchestrator/validator.js +330 -241
  322. package/dist/skills/orchestrator/validator.js.map +1 -1
  323. package/dist/store/agent-resolver.d.ts.map +1 -1
  324. package/dist/store/agent-resolver.js +16 -9
  325. package/dist/store/agent-resolver.js.map +1 -1
  326. package/dist/store/attachment-store.d.ts +4 -1
  327. package/dist/store/attachment-store.d.ts.map +1 -1
  328. package/dist/store/attachment-store.js +20 -3
  329. package/dist/store/attachment-store.js.map +1 -1
  330. package/dist/store/background-jobs.d.ts +426 -105
  331. package/dist/store/background-jobs.d.ts.map +1 -1
  332. package/dist/store/background-jobs.js +1208 -252
  333. package/dist/store/background-jobs.js.map +1 -1
  334. package/dist/store/background-ops.d.ts +145 -8
  335. package/dist/store/background-ops.d.ts.map +1 -1
  336. package/dist/store/background-ops.js +459 -15
  337. package/dist/store/background-ops.js.map +1 -1
  338. package/dist/store/backup-inspect.d.ts +48 -4
  339. package/dist/store/backup-inspect.d.ts.map +1 -1
  340. package/dist/store/backup-inspect.js +384 -4
  341. package/dist/store/backup-inspect.js.map +1 -1
  342. package/dist/store/converters.d.ts.map +1 -1
  343. package/dist/store/converters.js +19 -2
  344. package/dist/store/converters.js.map +1 -1
  345. package/dist/store/data-accessor.d.ts +9 -1
  346. package/dist/store/data-accessor.d.ts.map +1 -1
  347. package/dist/store/data-accessor.js +22 -5
  348. package/dist/store/data-accessor.js.map +1 -1
  349. package/dist/store/data-safety-central.d.ts.map +1 -1
  350. package/dist/store/data-safety-central.js +272 -232
  351. package/dist/store/data-safety-central.js.map +1 -1
  352. package/dist/store/db-helpers.d.ts +19 -3
  353. package/dist/store/db-helpers.d.ts.map +1 -1
  354. package/dist/store/db-helpers.js +42 -58
  355. package/dist/store/db-helpers.js.map +1 -1
  356. package/dist/store/dual-scope-db.d.ts +63 -4
  357. package/dist/store/dual-scope-db.d.ts.map +1 -1
  358. package/dist/store/dual-scope-db.js +166 -19
  359. package/dist/store/dual-scope-db.js.map +1 -1
  360. package/dist/store/exodus/archive.d.ts +21 -7
  361. package/dist/store/exodus/archive.d.ts.map +1 -1
  362. package/dist/store/exodus/archive.js +60 -18
  363. package/dist/store/exodus/archive.js.map +1 -1
  364. package/dist/store/exodus/migrate.d.ts.map +1 -1
  365. package/dist/store/exodus/migrate.js +27 -23
  366. package/dist/store/exodus/migrate.js.map +1 -1
  367. package/dist/store/exodus/on-open.d.ts +16 -27
  368. package/dist/store/exodus/on-open.d.ts.map +1 -1
  369. package/dist/store/exodus/on-open.js +121 -169
  370. package/dist/store/exodus/on-open.js.map +1 -1
  371. package/dist/store/exodus/recovery.d.ts +90 -0
  372. package/dist/store/exodus/recovery.d.ts.map +1 -0
  373. package/dist/store/exodus/recovery.js +434 -0
  374. package/dist/store/exodus/recovery.js.map +1 -0
  375. package/dist/store/exodus/types.d.ts +15 -0
  376. package/dist/store/exodus/types.d.ts.map +1 -1
  377. package/dist/store/memory-accessor.d.ts +52 -3
  378. package/dist/store/memory-accessor.d.ts.map +1 -1
  379. package/dist/store/memory-accessor.js +122 -7
  380. package/dist/store/memory-accessor.js.map +1 -1
  381. package/dist/store/nexus-sqlite.d.ts +24 -6
  382. package/dist/store/nexus-sqlite.d.ts.map +1 -1
  383. package/dist/store/nexus-sqlite.js +39 -20
  384. package/dist/store/nexus-sqlite.js.map +1 -1
  385. package/dist/store/ports/domain-binding.d.ts.map +1 -1
  386. package/dist/store/ports/domain-binding.js +27 -5
  387. package/dist/store/ports/domain-binding.js.map +1 -1
  388. package/dist/store/schema/background-jobs.d.ts +181 -2
  389. package/dist/store/schema/background-jobs.d.ts.map +1 -1
  390. package/dist/store/schema/background-jobs.js +27 -3
  391. package/dist/store/schema/background-jobs.js.map +1 -1
  392. package/dist/store/schema/cleo-global/skills.d.ts +2 -2
  393. package/dist/store/schema/cleo-project/lifecycle.d.ts +2 -2
  394. package/dist/store/schema/cleo-project/runtime.d.ts +1 -1
  395. package/dist/store/schema/cleo-project/tasks-core.d.ts +1 -1
  396. package/dist/store/schema/lifecycle.d.ts +2 -2
  397. package/dist/store/schema/skills-schema.d.ts +2 -2
  398. package/dist/store/schema/tasks.d.ts +1 -1
  399. package/dist/store/session-store.d.ts +3 -1
  400. package/dist/store/session-store.d.ts.map +1 -1
  401. package/dist/store/session-store.js +252 -207
  402. package/dist/store/session-store.js.map +1 -1
  403. package/dist/store/sqlite-data-accessor.d.ts +39 -4
  404. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  405. package/dist/store/sqlite-data-accessor.js +689 -527
  406. package/dist/store/sqlite-data-accessor.js.map +1 -1
  407. package/dist/store/tasks-sqlite.d.ts +3 -1
  408. package/dist/store/tasks-sqlite.d.ts.map +1 -1
  409. package/dist/store/tasks-sqlite.js +309 -279
  410. package/dist/store/tasks-sqlite.js.map +1 -1
  411. package/dist/store/validation-schemas.d.ts +8 -8
  412. package/dist/store/writer-lease.d.ts +80 -8
  413. package/dist/store/writer-lease.d.ts.map +1 -1
  414. package/dist/store/writer-lease.js +503 -134
  415. package/dist/store/writer-lease.js.map +1 -1
  416. package/dist/system/audit.d.ts +2 -1
  417. package/dist/system/audit.d.ts.map +1 -1
  418. package/dist/system/audit.js +118 -71
  419. package/dist/system/audit.js.map +1 -1
  420. package/dist/system/backup.d.ts.map +1 -1
  421. package/dist/system/backup.js +5 -5
  422. package/dist/system/backup.js.map +1 -1
  423. package/dist/system/inject-generate.js +1 -1
  424. package/dist/task-work/index.d.ts +3 -0
  425. package/dist/task-work/index.d.ts.map +1 -1
  426. package/dist/task-work/index.js +34 -37
  427. package/dist/task-work/index.js.map +1 -1
  428. package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
  429. package/dist/tasks/ac-coverage-gate.js +4 -0
  430. package/dist/tasks/ac-coverage-gate.js.map +1 -1
  431. package/dist/tasks/ac-immutability.d.ts +7 -4
  432. package/dist/tasks/ac-immutability.d.ts.map +1 -1
  433. package/dist/tasks/ac-immutability.js +19 -17
  434. package/dist/tasks/ac-immutability.js.map +1 -1
  435. package/dist/tasks/ac-table.d.ts +5 -5
  436. package/dist/tasks/ac-table.d.ts.map +1 -1
  437. package/dist/tasks/ac-table.js +26 -12
  438. package/dist/tasks/ac-table.js.map +1 -1
  439. package/dist/tasks/acceptance-input.d.ts +52 -0
  440. package/dist/tasks/acceptance-input.d.ts.map +1 -0
  441. package/dist/tasks/acceptance-input.js +259 -0
  442. package/dist/tasks/acceptance-input.js.map +1 -0
  443. package/dist/tasks/add.d.ts +67 -29
  444. package/dist/tasks/add.d.ts.map +1 -1
  445. package/dist/tasks/add.js +255 -97
  446. package/dist/tasks/add.js.map +1 -1
  447. package/dist/tasks/complete.d.ts +16 -1
  448. package/dist/tasks/complete.d.ts.map +1 -1
  449. package/dist/tasks/complete.js +152 -55
  450. package/dist/tasks/complete.js.map +1 -1
  451. package/dist/tasks/decompose.d.ts +103 -0
  452. package/dist/tasks/decompose.d.ts.map +1 -0
  453. package/dist/tasks/decompose.js +224 -0
  454. package/dist/tasks/decompose.js.map +1 -0
  455. package/dist/tasks/delete.d.ts +5 -8
  456. package/dist/tasks/delete.d.ts.map +1 -1
  457. package/dist/tasks/delete.js +4 -3
  458. package/dist/tasks/delete.js.map +1 -1
  459. package/dist/tasks/dependency-check.d.ts +59 -0
  460. package/dist/tasks/dependency-check.d.ts.map +1 -1
  461. package/dist/tasks/dependency-check.js +73 -0
  462. package/dist/tasks/dependency-check.js.map +1 -1
  463. package/dist/tasks/deps-ready.d.ts +13 -1
  464. package/dist/tasks/deps-ready.d.ts.map +1 -1
  465. package/dist/tasks/deps-ready.js +16 -5
  466. package/dist/tasks/deps-ready.js.map +1 -1
  467. package/dist/tasks/engine-converters.d.ts +7 -0
  468. package/dist/tasks/engine-converters.d.ts.map +1 -1
  469. package/dist/tasks/engine-converters.js +8 -1
  470. package/dist/tasks/engine-converters.js.map +1 -1
  471. package/dist/tasks/evidence.d.ts +54 -26
  472. package/dist/tasks/evidence.d.ts.map +1 -1
  473. package/dist/tasks/evidence.js +292 -40
  474. package/dist/tasks/evidence.js.map +1 -1
  475. package/dist/tasks/find.d.ts +16 -12
  476. package/dist/tasks/find.d.ts.map +1 -1
  477. package/dist/tasks/find.js +107 -93
  478. package/dist/tasks/find.js.map +1 -1
  479. package/dist/tasks/gate-runner.d.ts +88 -14
  480. package/dist/tasks/gate-runner.d.ts.map +1 -1
  481. package/dist/tasks/gate-runner.js +596 -253
  482. package/dist/tasks/gate-runner.js.map +1 -1
  483. package/dist/tasks/hierarchy-policy.d.ts +8 -0
  484. package/dist/tasks/hierarchy-policy.d.ts.map +1 -1
  485. package/dist/tasks/hierarchy-policy.js +9 -2
  486. package/dist/tasks/hierarchy-policy.js.map +1 -1
  487. package/dist/tasks/hierarchy.d.ts +67 -1
  488. package/dist/tasks/hierarchy.d.ts.map +1 -1
  489. package/dist/tasks/hierarchy.js +81 -1
  490. package/dist/tasks/hierarchy.js.map +1 -1
  491. package/dist/tasks/index.d.ts +3 -0
  492. package/dist/tasks/index.d.ts.map +1 -1
  493. package/dist/tasks/index.js +3 -0
  494. package/dist/tasks/index.js.map +1 -1
  495. package/dist/tasks/infer-add-params.d.ts +1 -20
  496. package/dist/tasks/infer-add-params.d.ts.map +1 -1
  497. package/dist/tasks/infer-add-params.js +2 -203
  498. package/dist/tasks/infer-add-params.js.map +1 -1
  499. package/dist/tasks/list.d.ts +7 -2
  500. package/dist/tasks/list.d.ts.map +1 -1
  501. package/dist/tasks/list.js +24 -36
  502. package/dist/tasks/list.js.map +1 -1
  503. package/dist/tasks/ops.d.ts +6 -61
  504. package/dist/tasks/ops.d.ts.map +1 -1
  505. package/dist/tasks/ops.js +5 -53
  506. package/dist/tasks/ops.js.map +1 -1
  507. package/dist/tasks/plan.d.ts +15 -1
  508. package/dist/tasks/plan.d.ts.map +1 -1
  509. package/dist/tasks/plan.js +23 -25
  510. package/dist/tasks/plan.js.map +1 -1
  511. package/dist/tasks/population.d.ts +46 -0
  512. package/dist/tasks/population.d.ts.map +1 -0
  513. package/dist/tasks/population.js +88 -0
  514. package/dist/tasks/population.js.map +1 -0
  515. package/dist/tasks/reconcile-scope.d.ts +157 -0
  516. package/dist/tasks/reconcile-scope.d.ts.map +1 -0
  517. package/dist/tasks/reconcile-scope.js +242 -0
  518. package/dist/tasks/reconcile-scope.js.map +1 -0
  519. package/dist/tasks/req.d.ts +51 -12
  520. package/dist/tasks/req.d.ts.map +1 -1
  521. package/dist/tasks/req.js +125 -69
  522. package/dist/tasks/req.js.map +1 -1
  523. package/dist/tasks/session-scope.d.ts +6 -31
  524. package/dist/tasks/session-scope.d.ts.map +1 -1
  525. package/dist/tasks/session-scope.js +3 -17
  526. package/dist/tasks/session-scope.js.map +1 -1
  527. package/dist/tasks/severity-attestation.d.ts +39 -10
  528. package/dist/tasks/severity-attestation.d.ts.map +1 -1
  529. package/dist/tasks/severity-attestation.js +75 -36
  530. package/dist/tasks/severity-attestation.js.map +1 -1
  531. package/dist/tasks/task-next.d.ts +5 -0
  532. package/dist/tasks/task-next.d.ts.map +1 -1
  533. package/dist/tasks/task-next.js +10 -9
  534. package/dist/tasks/task-next.js.map +1 -1
  535. package/dist/tasks/task-reparent.d.ts.map +1 -1
  536. package/dist/tasks/task-reparent.js +3 -13
  537. package/dist/tasks/task-reparent.js.map +1 -1
  538. package/dist/tasks/update.d.ts +20 -43
  539. package/dist/tasks/update.d.ts.map +1 -1
  540. package/dist/tasks/update.js +64 -49
  541. package/dist/tasks/update.js.map +1 -1
  542. package/dist/validation/engine-ops.d.ts +17 -1
  543. package/dist/validation/engine-ops.d.ts.map +1 -1
  544. package/dist/validation/engine-ops.js +149 -11
  545. package/dist/validation/engine-ops.js.map +1 -1
  546. package/dist/workgraph/reparent-preflight.d.ts +1 -1
  547. package/dist/workgraph/reparent-preflight.d.ts.map +1 -1
  548. package/dist/workgraph/reparent-preflight.js +6 -1
  549. package/dist/workgraph/reparent-preflight.js.map +1 -1
  550. package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
  551. package/dist/workgraph/scaffold-apply.js +81 -76
  552. package/dist/workgraph/scaffold-apply.js.map +1 -1
  553. package/dist/workgraph/structure-validation.d.ts +1 -1
  554. package/dist/workgraph/structure-validation.d.ts.map +1 -1
  555. package/dist/workgraph/structure-validation.js +6 -1
  556. package/dist/workgraph/structure-validation.js.map +1 -1
  557. package/migrations/drizzle-tasks/20260919180000_t12263-job-ownership-fences/migration.sql +34 -0
  558. package/migrations/drizzle-tasks/20260919184500_t12265-durable-pending-proposals/migration.sql +4 -0
  559. package/package.json +18 -13
  560. package/schemas/config.schema.json +2 -2
  561. package/templates/CLEO-INJECTION.md +103 -101
@@ -1,370 +1,1326 @@
1
1
  /**
2
- * Background Job Manager for Long-Running Operations
2
+ * Existing durable job store and executor facade.
3
3
  *
4
- * Operations that may take >30s support async execution. This module
5
- * provides a job manager backed by a SQLite DurableJobStore so that job
6
- * state survives process restart. Any job recorded as `running` in the
7
- * database at construction time is immediately transitioned to `orphaned`
8
- * so that humans/agents can triage it — no silent retry.
4
+ * Opening a client never changes ownership. Each mutation uses a SQLite write
5
+ * transaction and an expiring owner/epoch fence. Legacy unowned rows remain
6
+ * inspectable; they require explicit recovery rather than guessed ownership.
9
7
  *
10
- * @remarks
11
- * Relocated from `packages/cleo/src/dispatch/lib/background-jobs.ts` into
12
- * `@cleocode/core/store` (R3-K1 · T11455 · SG-RUNTIME-UNIFICATION). This is
13
- * Drizzle DB-access logic, so it belongs in `@cleocode/core` — the package that
14
- * owns the consolidated SQLite schema, the single Drizzle instance, and the DB
15
- * handle types. Hosting it here means there is exactly ONE `drizzle-orm`
16
- * instance backing the query builders (no dual peer-hashed instance / `tsc -b`
17
- * `SQL<unknown>` nominal mismatch). `@cleocode/runtime/gateway` imports the
18
- * accessor from here and declares no `drizzle-orm` dependency of its own.
8
+ * The active table is background_jobs (epoch-ms timestamps). The separate
9
+ * tasks_background_jobs history is retained without rebinding or conversion.
10
+ * Job fences protect job metadata, not arbitrary mutations inside an executor.
19
11
  *
20
- * @task T641
21
- * @task T11455
12
+ * @task T12263
22
13
  */
23
- import { randomUUID } from 'node:crypto';
24
- import { eq, inArray } from 'drizzle-orm';
25
- import { backgroundJobs, } from './tasks-schema.js';
26
- // ---------------------------------------------------------------------------
27
- // Row ↔ domain model helpers
28
- // ---------------------------------------------------------------------------
29
- /** Convert a {@link BackgroundJobRow} (from DB) to the public {@link BackgroundJob}. */
14
+ import { createHash, randomUUID } from 'node:crypto';
15
+ import { and, asc, eq, getTableColumns, gt, inArray, isNull, ne, or, sql } from 'drizzle-orm';
16
+ import { BACKGROUND_JOB_STATUSES, backgroundJobs, } from './tasks-schema.js';
17
+ /**
18
+ * Stable refusal of an unsafe ownership, retry, or transaction operation.
19
+ * @remarks A refusal preserves the current owner's work and reports no committed transition.
20
+ */
21
+ export class BackgroundJobError extends Error {
22
+ /** Machine-readable refusal reason. */
23
+ code;
24
+ /**
25
+ * Construct an explicit job-store refusal.
26
+ * @param code - Stable failure category.
27
+ * @param message - Human-readable reason and required correction.
28
+ * @remarks This error never establishes successful job execution.
29
+ * @example
30
+ * ```ts
31
+ * throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Reclaim expired work first');
32
+ * ```
33
+ */
34
+ constructor(code, message) {
35
+ super(message);
36
+ this.name = 'BackgroundJobError';
37
+ this.code = code;
38
+ }
39
+ }
30
40
  function rowToJob(row) {
41
+ const terminal = row.status !== 'running' && row.status !== 'pending';
31
42
  const job = {
32
43
  id: row.id,
33
44
  operation: row.operation,
34
45
  status: row.status,
35
46
  startedAt: new Date(row.startedAt).toISOString(),
47
+ projectId: row.projectId,
48
+ ownerId: row.ownerId,
49
+ leaseExpiresAt: row.leaseExpiresAt,
50
+ heartbeatAt: row.heartbeatAt,
51
+ fencingEpoch: row.fencingEpoch,
52
+ attempts: row.attempts,
53
+ cancellationRequestedAt: row.cancellationRequestedAt,
54
+ checkpointJson: row.checkpointJson,
55
+ checkpointAt: row.checkpointAt,
56
+ idempotencyKey: row.idempotencyKey,
57
+ proposalHash: row.proposalHash,
58
+ proposalJson: row.proposalJson,
59
+ ownership: terminal
60
+ ? 'terminal'
61
+ : row.status === 'pending' && pendingProposalProblem(row) === null
62
+ ? 'unclaimed'
63
+ : row.ownerId === null || row.leaseExpiresAt === null
64
+ ? 'legacy-unknown'
65
+ : row.leaseExpiresAt <= Date.now()
66
+ ? 'expired'
67
+ : 'current',
36
68
  };
37
- if (row.completedAt !== null && row.completedAt !== undefined) {
69
+ if (row.status === 'pending')
70
+ job.diagnosticError = pendingProposalProblem(row) ?? undefined;
71
+ if (row.completedAt !== null)
38
72
  job.completedAt = new Date(row.completedAt).toISOString();
39
- }
40
- if (row.result !== null && row.result !== undefined) {
73
+ if (row.result !== null) {
41
74
  try {
42
75
  job.result = JSON.parse(row.result);
43
76
  }
44
77
  catch {
45
78
  job.result = row.result;
79
+ job.diagnosticError = 'Stored job result is not valid JSON';
46
80
  }
47
81
  }
48
- if (row.error !== null && row.error !== undefined) {
82
+ if (row.error !== null)
49
83
  job.error = row.error;
50
- }
51
- if (row.progress !== null && row.progress !== undefined) {
84
+ if (row.progress !== null)
52
85
  job.progress = row.progress;
53
- }
54
- if (row.claimedBy !== null && row.claimedBy !== undefined) {
86
+ if (row.claimedBy !== null)
55
87
  job.claimedBy = row.claimedBy;
56
- }
57
88
  return job;
58
89
  }
90
+ function pendingProposalProblem(row) {
91
+ if (row.ownerId !== null ||
92
+ row.leaseExpiresAt !== null ||
93
+ row.fencingEpoch !== 0 ||
94
+ row.attempts !== 0)
95
+ return 'Pending job contains inconsistent execution ownership; explicit recovery is required';
96
+ if (!row.projectId ||
97
+ !row.idempotencyKey ||
98
+ row.proposalJson === null ||
99
+ row.proposalHash === null)
100
+ return 'Pending job has no authentic scoped proposal payload; explicit recovery is required';
101
+ try {
102
+ requireJson(row.proposalJson);
103
+ }
104
+ catch {
105
+ return 'Pending job proposal is not valid JSON';
106
+ }
107
+ if (createHash('sha256').update(row.proposalJson).digest('hex') !== row.proposalHash)
108
+ return 'Pending job proposal bytes do not match their immutable hash';
109
+ return null;
110
+ }
111
+ function requireText(value, field) {
112
+ if (typeof value !== 'string' || value.trim().length === 0) {
113
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', `${field} must be a nonempty string`);
114
+ }
115
+ }
116
+ function requireJson(value) {
117
+ if (typeof value !== 'string')
118
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Proposal/checkpoint must contain serialized JSON bytes');
119
+ try {
120
+ JSON.parse(value);
121
+ }
122
+ catch {
123
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Proposal/checkpoint must contain valid JSON bytes');
124
+ }
125
+ }
126
+ function requireRunningLimit(maxRunning) {
127
+ if (maxRunning !== undefined && (!Number.isSafeInteger(maxRunning) || maxRunning < 1))
128
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Running-job limit must be a positive safe integer');
129
+ }
59
130
  /**
60
- * Drizzle-backed persistence layer for background jobs.
61
- *
62
- * On construction the store scans for `running` rows left by a prior
63
- * process and marks them `orphaned`. All reads and writes go through
64
- * Drizzle — no raw SQL strings.
65
- *
66
- * @task T641
131
+ * Revalidate captured job ownership inside the domain's existing write transaction.
132
+ * @param db - The same transaction handle that will perform the domain mutation.
133
+ * @param execution - Captured lifetime and optional immutable job fence.
134
+ * @throws BackgroundJobError when ownership, scope, proposal, cancellation or file identity changed.
135
+ * @remarks This read does not start a transaction or authorize arbitrary work. Callers
136
+ * must own the surrounding write transaction and independently validate their domain
137
+ * preconditions. Keeping this check inside that transaction fences concurrent claims
138
+ * and permits domain mutations and receipts to compose in one atomic unit.
139
+ * @example
140
+ * ```ts
141
+ * db.transaction(tx => { assertOperationWriteFence(tx, execution); writePreparedRows(tx); });
142
+ * ```
143
+ */
144
+ export function assertOperationWriteFence(db, execution) {
145
+ assertOperationJobState(db, execution, 'running');
146
+ }
147
+ function assertOperationJobState(db, execution, status, resultJson, bookkeeping = false, outcome) {
148
+ if (!bookkeeping)
149
+ execution.assertActive();
150
+ const fence = execution.writeFence;
151
+ if (!fence)
152
+ return;
153
+ const row = db
154
+ .select({
155
+ proposalJson: sql `${backgroundJobs.proposalJson}`,
156
+ result: sql `${backgroundJobs.result}`,
157
+ error: sql `${backgroundJobs.error}`,
158
+ })
159
+ .from(sql `main.${backgroundJobs}`)
160
+ .where(and(eq(backgroundJobs.id, fence.lease.jobId), eq(backgroundJobs.status, status), eq(backgroundJobs.ownerId, fence.lease.ownerId), eq(backgroundJobs.fencingEpoch, fence.lease.epoch), gt(backgroundJobs.leaseExpiresAt, Date.now()), bookkeeping ? undefined : isNull(backgroundJobs.cancellationRequestedAt), eq(backgroundJobs.projectId, execution.identity.projectId), eq(backgroundJobs.operation, execution.identity.operation), eq(backgroundJobs.idempotencyKey, execution.identity.idempotencyKey), eq(backgroundJobs.proposalHash, fence.proposalHash), sql `EXISTS (SELECT 1 FROM pragma_database_list WHERE name = 'main' AND file = ${fence.dbPath})`))
161
+ .get();
162
+ if (!row?.proposalJson ||
163
+ createHash('sha256').update(row.proposalJson).digest('hex') !== fence.proposalHash ||
164
+ (resultJson !== undefined && row.result !== resultJson) ||
165
+ (outcome !== undefined && row.error !== outcome.message)) {
166
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Domain write refused: job authority or proposal no longer matches the captured attempt');
167
+ }
168
+ }
169
+ // Coordination only: no connection lifetime or domain cache is owned here.
170
+ const boundedTransactionHandles = new WeakSet();
171
+ /**
172
+ * Drizzle-backed ownership of the established active job table.
173
+ * @remarks Sync methods refuse an already-owned native transaction before writing.
174
+ * No method infers a missing legacy lease to be expired. Rows are never age-pruned.
175
+ * @example
176
+ * ```ts
177
+ * const jobs = new DurableJobStore(db, { projectId: 'project-uuid' });
178
+ * const lease = jobs.insert('job-uuid', 'doctor.knowledge', Date.now());
179
+ * if (lease) jobs.complete(lease.jobId, { verified: true }, Date.now());
180
+ * ```
67
181
  */
68
182
  export class DurableJobStore {
69
183
  #db;
70
- constructor(db) {
184
+ #ownerId = randomUUID();
185
+ #options;
186
+ #grants = new Map();
187
+ #committedCleanupFailures = new Map();
188
+ /** Positive lease duration used for claims and heartbeats. */
189
+ leaseMs;
190
+ /**
191
+ * Open a client without changing persisted work.
192
+ * @param db - Existing canonical database handle; no new connection is opened.
193
+ * @param options - Explicit scope, actor, and lease duration.
194
+ * @remarks Unscoped legacy callers retain visible null project identity.
195
+ * @example
196
+ * ```ts
197
+ * const store = new DurableJobStore(db, { projectId: 'project-uuid', leaseMs: 30000 });
198
+ * ```
199
+ */
200
+ constructor(db, options = {}) {
71
201
  this.#db = db;
72
- this.#orphanStaleJobs();
202
+ this.#options = Object.freeze({ ...options });
203
+ this.leaseMs = options.leaseMs ?? 30_000;
204
+ if (!Number.isSafeInteger(this.leaseMs) || this.leaseMs <= 0) {
205
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'leaseMs must be a positive safe integer');
206
+ }
207
+ if (options.projectId !== undefined)
208
+ requireText(options.projectId, 'projectId');
209
+ }
210
+ #scope() {
211
+ return this.#options.projectId === undefined
212
+ ? undefined
213
+ : eq(backgroundJobs.projectId, this.#options.projectId);
214
+ }
215
+ #row(id) {
216
+ const columns = getTableColumns(backgroundJobs);
217
+ return this.#db
218
+ .select({
219
+ id: sql `${columns.id}`.mapWith(columns.id),
220
+ operation: sql `${columns.operation}`.mapWith(columns.operation),
221
+ status: sql `${columns.status}`.mapWith(columns.status),
222
+ startedAt: sql `${columns.startedAt}`.mapWith(columns.startedAt),
223
+ completedAt: sql `${columns.completedAt}`.mapWith(columns.completedAt),
224
+ result: sql `${columns.result}`.mapWith(columns.result),
225
+ error: sql `${columns.error}`.mapWith(columns.error),
226
+ progress: sql `${columns.progress}`.mapWith(columns.progress),
227
+ heartbeatAt: sql `${columns.heartbeatAt}`.mapWith(columns.heartbeatAt),
228
+ claimedBy: sql `${columns.claimedBy}`.mapWith(columns.claimedBy),
229
+ projectId: sql `${columns.projectId}`.mapWith(columns.projectId),
230
+ ownerId: sql `${columns.ownerId}`.mapWith(columns.ownerId),
231
+ leaseExpiresAt: sql `${columns.leaseExpiresAt}`.mapWith(columns.leaseExpiresAt),
232
+ fencingEpoch: sql `${columns.fencingEpoch}`.mapWith(columns.fencingEpoch),
233
+ attempts: sql `${columns.attempts}`.mapWith(columns.attempts),
234
+ cancellationRequestedAt: sql `${columns.cancellationRequestedAt}`.mapWith(columns.cancellationRequestedAt),
235
+ checkpointJson: sql `${columns.checkpointJson}`.mapWith(columns.checkpointJson),
236
+ checkpointAt: sql `${columns.checkpointAt}`.mapWith(columns.checkpointAt),
237
+ idempotencyKey: sql `${columns.idempotencyKey}`.mapWith(columns.idempotencyKey),
238
+ proposalHash: sql `${columns.proposalHash}`.mapWith(columns.proposalHash),
239
+ proposalJson: sql `${columns.proposalJson}`.mapWith(columns.proposalJson),
240
+ })
241
+ .from(sql `main.${backgroundJobs}`)
242
+ .where(and(eq(backgroundJobs.id, id), this.#scope()))
243
+ .get();
244
+ }
245
+ #write(operation, deadlineAt, onCommittedCleanupFailure, assertCommitAllowed, readOnly = false) {
246
+ let outcome;
247
+ let failure;
248
+ let handle;
249
+ let previousTimeout;
250
+ let installedTimeout;
251
+ if (deadlineAt !== undefined) {
252
+ if (Date.now() >= deadlineAt)
253
+ throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'No budget remains for job bookkeeping');
254
+ if (!('$client' in this.#db) ||
255
+ typeof this.#db.$client !== 'object' ||
256
+ this.#db.$client === null)
257
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Bounded writes require the actual native handle identity');
258
+ handle = this.#db.$client;
259
+ if (boundedTransactionHandles.has(handle))
260
+ throw new BackgroundJobError('E_JOB_TRANSACTION_OWNED', 'Another caller owns this bounded native transaction');
261
+ boundedTransactionHandles.add(handle);
262
+ }
263
+ try {
264
+ if (deadlineAt !== undefined) {
265
+ previousTimeout = this.#db.get(sql `PRAGMA busy_timeout`).timeout;
266
+ installedTimeout = Math.max(0, Math.min(previousTimeout, Math.floor(deadlineAt - Date.now())));
267
+ this.#db.run(sql.raw(`PRAGMA busy_timeout=${installedTimeout}`));
268
+ if (Date.now() >= deadlineAt)
269
+ throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed before lock acquisition');
270
+ }
271
+ // BEGIN outside rollback scope: failure must not roll back another caller.
272
+ try {
273
+ this.#db.run(readOnly ? sql `BEGIN DEFERRED` : sql `BEGIN IMMEDIATE`);
274
+ }
275
+ catch (error) {
276
+ if (error instanceof Error && /within a transaction/i.test(String(error.cause ?? error)))
277
+ throw new BackgroundJobError('E_JOB_TRANSACTION_OWNED', 'Job write refused: another caller owns the native transaction');
278
+ throw error;
279
+ }
280
+ try {
281
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt)
282
+ throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed during lock acquisition');
283
+ const value = operation();
284
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt)
285
+ throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed before commit');
286
+ if (installedTimeout !== undefined &&
287
+ this.#db.get(sql `PRAGMA busy_timeout`).timeout !== installedTimeout)
288
+ throw new BackgroundJobError('E_JOB_LOCK_POLICY_CONFLICT', 'Another caller changed the native lock policy');
289
+ assertCommitAllowed?.();
290
+ this.#db.run(sql `COMMIT`);
291
+ outcome = { value };
292
+ }
293
+ catch (error) {
294
+ this.#db.run(sql `ROLLBACK`);
295
+ throw error;
296
+ }
297
+ }
298
+ catch (error) {
299
+ failure = error instanceof Error ? error : new Error(String(error));
300
+ }
301
+ finally {
302
+ try {
303
+ // Do not overwrite a later caller's independently changed timeout value.
304
+ if (previousTimeout !== undefined &&
305
+ installedTimeout !== undefined &&
306
+ this.#db.get(sql `PRAGMA busy_timeout`).timeout === installedTimeout)
307
+ this.#db.run(sql.raw(`PRAGMA busy_timeout=${previousTimeout}`));
308
+ }
309
+ catch (error) {
310
+ if (outcome && onCommittedCleanupFailure)
311
+ onCommittedCleanupFailure(String(error));
312
+ else {
313
+ const cleanupFailure = error instanceof Error ? error : new Error(String(error));
314
+ failure = failure
315
+ ? new AggregateError([failure, cleanupFailure], 'Transaction and timeout cleanup failed')
316
+ : cleanupFailure;
317
+ }
318
+ }
319
+ finally {
320
+ if (handle)
321
+ boundedTransactionHandles.delete(handle);
322
+ }
323
+ }
324
+ if (failure)
325
+ throw failure;
326
+ if (!outcome)
327
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Transaction produced no outcome');
328
+ return outcome.value;
329
+ }
330
+ #owned(id) {
331
+ const grant = this.#grants.get(id);
332
+ const row = this.#row(id);
333
+ if (!grant ||
334
+ !row ||
335
+ row.status !== 'running' ||
336
+ row.ownerId !== this.#ownerId ||
337
+ row.ownerId !== grant.ownerId ||
338
+ row.fencingEpoch !== grant.epoch ||
339
+ row.leaseExpiresAt === null ||
340
+ row.leaseExpiresAt <= Date.now()) {
341
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', `Job ${id} is not owned by this unexpired attempt`);
342
+ }
343
+ return row;
344
+ }
345
+ #grant(row) {
346
+ if (row.ownerId === null || row.leaseExpiresAt === null) {
347
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Cannot issue a grant for unowned legacy work');
348
+ }
349
+ return Object.freeze({
350
+ jobId: row.id,
351
+ ownerId: row.ownerId,
352
+ epoch: row.fencingEpoch,
353
+ expiresAt: row.leaseExpiresAt,
354
+ });
73
355
  }
74
356
  /**
75
- * Transition all `running` rows to `orphaned`.
76
- *
77
- * Called once at startup so abandoned jobs surface via {@link listJobs}
78
- * rather than being silently retried.
357
+ * Create a leased job, or return the existing immutable scoped submission.
358
+ * @param id - Proposed UUID for a new job.
359
+ * @param operation - Supported operation identifier.
360
+ * @param now - Event timestamp in epoch milliseconds.
361
+ * @param submission - Optional scoped immutable retry contract.
362
+ * @param maxRunning - Optional running-job limit checked atomically with creation.
363
+ * @returns Grant for new work, or null when an identical submission already exists.
364
+ * @remarks A repeated submission never acquires ownership or re-executes by itself.
365
+ * @example
366
+ * ```ts
367
+ * const grant = store.insert(id, 'doctor.knowledge', Date.now(), submission);
368
+ * ```
79
369
  */
80
- #orphanStaleJobs() {
81
- const now = Date.now();
82
- this.#db
83
- .update(backgroundJobs)
84
- .set({
85
- status: 'orphaned',
86
- completedAt: now,
87
- error: 'process-exited-before-completion',
88
- })
89
- .where(eq(backgroundJobs.status, 'running'))
90
- .run();
370
+ insert(id, operation, now, submission, maxRunning) {
371
+ const { row, created } = this.#submit(id, operation, now, submission, 'running', maxRunning);
372
+ if (!created)
373
+ return null;
374
+ const grant = this.#grant(row);
375
+ this.#grants.set(id, grant);
376
+ return grant;
91
377
  }
92
378
  /**
93
- * Insert a new job row with `running` status.
94
- *
95
- * @param id - UUID for the job
96
- * @param operation - Operation name
97
- * @param now - Current timestamp in ms
379
+ * Find an existing immutable submission without claiming it.
380
+ * @param operation - Exact supported operation.
381
+ * @param submission - Project and retry identity.
382
+ * @returns Persisted job, or undefined.
383
+ * @remarks Input conflict validation is performed by insert before coalescing.
384
+ * @example
385
+ * ```ts
386
+ * const prior = store.findSubmission('doctor.knowledge', submission);
387
+ * ```
98
388
  */
99
- insert(id, operation, now) {
100
- const row = {
101
- id,
102
- operation,
103
- status: 'running',
104
- startedAt: now,
105
- heartbeatAt: now,
106
- };
107
- this.#db.insert(backgroundJobs).values(row).run();
389
+ findSubmission(operation, submission) {
390
+ const row = this.#db
391
+ .select()
392
+ .from(backgroundJobs)
393
+ .where(and(this.#scope(), eq(backgroundJobs.projectId, submission.projectId), eq(backgroundJobs.operation, operation), eq(backgroundJobs.idempotencyKey, submission.idempotencyKey)))
394
+ .get();
395
+ return row ? rowToJob(row) : undefined;
396
+ }
397
+ /** Check the project running limit while the caller owns the write transaction. */
398
+ #requireCapacity(maxRunning, excludeId) {
399
+ requireRunningLimit(maxRunning);
400
+ if (maxRunning === undefined)
401
+ return;
402
+ const count = this.#db
403
+ .select({ count: sql `count(*)` })
404
+ .from(backgroundJobs)
405
+ .where(and(this.#scope(), eq(backgroundJobs.status, 'running'), excludeId ? ne(backgroundJobs.id, excludeId) : undefined))
406
+ .get()?.count ?? 0;
407
+ if (count >= maxRunning)
408
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', `Maximum concurrent jobs reached (${maxRunning})`);
409
+ }
410
+ /** Check an optional invocation budget and immutable submission scope without granting authority. */
411
+ #assertInvocation(execution, operation, projectId, idempotencyKey) {
412
+ if (!execution)
413
+ return;
414
+ execution.assertActive();
415
+ if (execution.identity.projectId !== projectId ||
416
+ execution.identity.projectId !== this.#options.projectId ||
417
+ execution.identity.operation !== operation ||
418
+ execution.identity.idempotencyKey !== idempotencyKey)
419
+ throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Invocation differs from immutable job scope');
420
+ }
421
+ /** Atomically coalesce or insert complete immutable proposal state. */
422
+ #submit(id, operation, now, submission, status, maxRunning, execution) {
423
+ this.#assertInvocation(execution, operation, submission?.projectId ?? null, submission?.idempotencyKey ?? null);
424
+ requireRunningLimit(maxRunning);
425
+ requireText(id, 'id');
426
+ if (!Number.isSafeInteger(now))
427
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Job event timestamp must be a safe integer');
428
+ requireText(operation, 'operation');
429
+ if (submission) {
430
+ requireText(submission.projectId, 'projectId');
431
+ requireText(submission.idempotencyKey, 'idempotencyKey');
432
+ requireJson(submission.proposalJson);
433
+ if (this.#options.projectId !== undefined &&
434
+ this.#options.projectId !== submission.projectId) {
435
+ throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Submission project differs from the store scope');
436
+ }
437
+ }
438
+ const proposalHash = submission
439
+ ? createHash('sha256').update(submission.proposalJson).digest('hex')
440
+ : null;
441
+ let committedId = id;
442
+ return this.#write(() => {
443
+ this.#assertInvocation(execution, operation, submission?.projectId ?? null, submission?.idempotencyKey ?? null);
444
+ if (submission) {
445
+ const prior = this.#db
446
+ .select()
447
+ .from(backgroundJobs)
448
+ .where(and(eq(backgroundJobs.projectId, submission.projectId), eq(backgroundJobs.operation, operation), eq(backgroundJobs.idempotencyKey, submission.idempotencyKey)))
449
+ .get();
450
+ if (prior) {
451
+ if (prior.proposalHash !== proposalHash ||
452
+ (prior.proposalJson !== null && prior.proposalJson !== submission.proposalJson))
453
+ throw new BackgroundJobError('E_JOB_IDEMPOTENCY_CONFLICT', 'Retry key was already used with different immutable proposal bytes');
454
+ committedId = prior.id;
455
+ return { row: prior, created: false };
456
+ }
457
+ }
458
+ if (status === 'running')
459
+ this.#requireCapacity(maxRunning);
460
+ const row = this.#db
461
+ .insert(backgroundJobs)
462
+ .values({
463
+ id,
464
+ operation,
465
+ status,
466
+ startedAt: now,
467
+ heartbeatAt: now,
468
+ claimedBy: this.#options.actor ?? null,
469
+ projectId: submission?.projectId ?? this.#options.projectId ?? null,
470
+ ownerId: status === 'running' ? this.#ownerId : null,
471
+ leaseExpiresAt: status === 'running' ? Date.now() + this.leaseMs : null,
472
+ fencingEpoch: status === 'running' ? 1 : 0,
473
+ attempts: status === 'running' ? 1 : 0,
474
+ proposalHash,
475
+ proposalJson: submission?.proposalJson ?? null,
476
+ idempotencyKey: submission?.idempotencyKey ?? null,
477
+ })
478
+ .returning()
479
+ .get();
480
+ return { row, created: true };
481
+ }, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(committedId, message), () => execution?.assertActive());
482
+ }
483
+ /**
484
+ * Persist authentic pending work without claiming or starting an executor.
485
+ * @param id - Proposed job identity; matching retries retain the original identity.
486
+ * @param operation - Exact operation whose supported inputs the domain service validates.
487
+ * @param now - Submission timestamp in epoch milliseconds.
488
+ * @param submission - Required project-scoped immutable serialized proposal.
489
+ * @param execution - Optional original invocation budget; omitted legacy callers retain existing behavior.
490
+ * @returns Persisted pending job, or unchanged prior matching submission.
491
+ * @remarks Input and its hash commit in one native transaction. Existing history
492
+ * without payload is retained, never reconstructed from a hash or overwritten.
493
+ * @example
494
+ * ```ts
495
+ * const pending = store.defer(id, 'docs.projection', Date.now(), submission);
496
+ * ```
497
+ */
498
+ defer(id, operation, now, submission, execution) {
499
+ if (!submission)
500
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Pending work requires a scoped proposal');
501
+ return rowToJob(this.#submit(id, operation, now, submission, 'pending', undefined, execution).row);
108
502
  }
109
503
  /**
110
- * Retrieve a single job by ID. Returns `undefined` if not found.
504
+ * Read a job with scope and ownership disclosed.
505
+ * @param id - Job identity.
506
+ * @returns Job or undefined.
507
+ * @remarks A missing or expired lease is never represented as current ownership.
508
+ * @example
509
+ * ```ts
510
+ * const job = store.get(id);
511
+ * ```
111
512
  */
112
513
  get(id) {
113
- const rows = this.#db.select().from(backgroundJobs).where(eq(backgroundJobs.id, id)).all();
114
- const row = rows[0];
115
- return row === undefined ? undefined : rowToJob(row);
514
+ const row = this.#row(id);
515
+ if (!row)
516
+ return undefined;
517
+ const job = rowToJob(row);
518
+ const cleanupError = this.#committedCleanupFailures.get(id);
519
+ return cleanupError ? { ...job, diagnosticError: cleanupError } : job;
116
520
  }
117
521
  /**
118
- * Retrieve all jobs, optionally filtered by status.
522
+ * List visible jobs.
523
+ * @param status - Optional lifecycle filter.
524
+ * @returns Matching scoped jobs.
525
+ * @remarks Project-scoped clients exclude other projects and unscoped legacy rows.
526
+ * @example
527
+ * ```ts
528
+ * const jobs = store.list('running');
529
+ * ```
119
530
  */
120
531
  list(status) {
121
- const rows = status
122
- ? this.#db
123
- .select()
532
+ if (status !== undefined &&
533
+ !BACKGROUND_JOB_STATUSES.some((candidate) => candidate === status)) {
534
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', `Unsupported job status: ${status}`);
535
+ }
536
+ return this.#db
537
+ .select()
538
+ .from(backgroundJobs)
539
+ .where(and(this.#scope(), status ? eq(backgroundJobs.status, status) : undefined))
540
+ .all()
541
+ .map(rowToJob);
542
+ }
543
+ /**
544
+ * Read a bounded candidate page without a runtime manager or ownership mutation.
545
+ * @param query - Exact operation, optional status, payload cap and bound cursor.
546
+ * @param execution - Original captured caller and deadline; never renewed by this read.
547
+ * @returns Candidate rows and a last-scanned cursor; the domain must validate principals.
548
+ * @throws BackgroundJobError for ambiguous scope, mismatched cursors, oversized rows or borrowed transactions.
549
+ * @remarks SQL limits candidates before payload materialization or JSON parsing. Mutable
550
+ * claimedBy is not used as principal authority. Existing transaction cleanup bounds lock waits;
551
+ * synchronous SQLite work is cooperative and cannot be preempted by a JavaScript timer.
552
+ * @example
553
+ * ```ts
554
+ * const page = store.listPage({ operation: 'doctor.knowledge', limit: 20 }, execution);
555
+ * ```
556
+ */
557
+ listPage(query, execution) {
558
+ execution.assertActive();
559
+ const projectId = this.#options.projectId;
560
+ const actor = this.#options.actor;
561
+ if (!projectId ||
562
+ !actor?.trim() ||
563
+ execution.identity.projectId !== projectId ||
564
+ execution.identity.actor !== actor ||
565
+ execution.identity.operation !== query.operation)
566
+ throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Candidate reads require an exact project, explicit caller and operation');
567
+ requireText(query.operation, 'operation');
568
+ const limit = query.limit ?? 25;
569
+ const maxPayloadBytes = query.maxPayloadBytes ?? 262144;
570
+ if (!Number.isSafeInteger(limit) ||
571
+ limit < 1 ||
572
+ limit > 100 ||
573
+ !Number.isSafeInteger(maxPayloadBytes) ||
574
+ maxPayloadBytes < 1 ||
575
+ maxPayloadBytes > 1048576 ||
576
+ (query.status !== undefined &&
577
+ !BACKGROUND_JOB_STATUSES.some((status) => status === query.status)))
578
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Invalid bounded candidate query');
579
+ const scope = {
580
+ version: 1,
581
+ projectId,
582
+ projectRoot: execution.identity.projectRoot,
583
+ actor,
584
+ operation: query.operation,
585
+ status: query.status ?? null,
586
+ limit,
587
+ maxPayloadBytes,
588
+ };
589
+ const after = query.after;
590
+ if (after !== undefined &&
591
+ (!after ||
592
+ typeof after !== 'object' ||
593
+ !Number.isSafeInteger(after.startedAt) ||
594
+ typeof after.id !== 'string' ||
595
+ !after.id.trim() ||
596
+ after.version !== scope.version ||
597
+ after.projectId !== scope.projectId ||
598
+ after.projectRoot !== scope.projectRoot ||
599
+ after.actor !== scope.actor ||
600
+ after.operation !== scope.operation ||
601
+ after.status !== scope.status ||
602
+ after.limit !== scope.limit ||
603
+ after.maxPayloadBytes !== scope.maxPayloadBytes))
604
+ throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Candidate cursor belongs to a different caller or query');
605
+ return this.#write(() => {
606
+ execution.assertActive();
607
+ const columns = getTableColumns(backgroundJobs);
608
+ const payloadBytes = sql `${sql.join(Object.values(columns)
609
+ .filter((column) => column.dataType === 'string' || column.dataType === 'string enum')
610
+ .map((column) => sql `length(CAST(coalesce(${column}, '') AS BLOB))`), sql ` + `)}`;
611
+ // The first query never returns opaque payloads. An over-limit ID is withheld too.
612
+ const selected = this.#db
613
+ .select({
614
+ id: sql `CASE WHEN ${payloadBytes} <= ${maxPayloadBytes} THEN ${columns.id} ELSE '' END`,
615
+ startedAt: columns.startedAt,
616
+ payloadBytes,
617
+ })
124
618
  .from(backgroundJobs)
125
- .where(eq(backgroundJobs.status, status))
126
- .all()
127
- : this.#db.select().from(backgroundJobs).all();
128
- return rows.map(rowToJob);
619
+ .where(and(this.#scope(), eq(columns.operation, query.operation), query.status ? eq(columns.status, query.status) : undefined, after
620
+ ? or(gt(columns.startedAt, after.startedAt), and(eq(columns.startedAt, after.startedAt), gt(columns.id, after.id)))
621
+ : undefined))
622
+ .orderBy(asc(columns.startedAt), asc(columns.id))
623
+ .limit(limit + 1)
624
+ .all();
625
+ execution.assertActive();
626
+ const scanned = selected.slice(0, limit);
627
+ if (scanned.some((row) => row.payloadBytes > maxPayloadBytes))
628
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Candidate payload exceeds the explicit byte cap; no complete inventory is available');
629
+ execution.consume({
630
+ items: scanned.length,
631
+ bytes: scanned.reduce((total, row) => total + row.payloadBytes, 0),
632
+ });
633
+ const rows = scanned.length
634
+ ? this.#db
635
+ .select()
636
+ .from(backgroundJobs)
637
+ .where(and(this.#scope(), eq(columns.operation, query.operation), inArray(columns.id, scanned.map((row) => row.id))))
638
+ .orderBy(asc(columns.startedAt), asc(columns.id))
639
+ .all()
640
+ : [];
641
+ const candidates = rows.map((row) => {
642
+ execution.assertActive();
643
+ return rowToJob(row);
644
+ });
645
+ const last = scanned.at(-1);
646
+ return {
647
+ candidates,
648
+ scope,
649
+ scannedCount: scanned.length,
650
+ hasMoreCandidates: selected.length > limit,
651
+ nextCursor: selected.length > limit && last
652
+ ? { ...scope, startedAt: last.startedAt, id: last.id }
653
+ : null,
654
+ matchingTotal: null,
655
+ observation: 'per-page-snapshot',
656
+ principalValidation: 'domain-required',
657
+ };
658
+ }, execution.deadlineAt, undefined, () => execution.assertActive(), true);
129
659
  }
130
660
  /**
131
- * Mark a job as `complete` with an optional result payload.
661
+ * Claim authentic unstarted work or reclaim an explicitly expired lease.
662
+ * @param id - Existing job identity.
663
+ * @param now - Claim timestamp.
664
+ * @param maxRunning - Optional capacity limit enforced atomically with the claim.
665
+ * @param execution - Optional original invocation budget; supplying it adds scoped cancellation and deadline checks.
666
+ * @returns New attempt's lease.
667
+ * @remarks Preserves checkpoint and cancellation request. Pending work must have
668
+ * authentic hash-matching inputs and no prior execution; ambiguous legacy rows refuse.
669
+ * @example
670
+ * ```ts
671
+ * const lease = store.claim(expiredJob.id, Date.now());
672
+ * ```
132
673
  */
133
- complete(id, result, now) {
134
- this.#db
674
+ claim(id, now, maxRunning, execution) {
675
+ execution?.assertActive();
676
+ const row = this.#write(() => {
677
+ const prior = this.#row(id);
678
+ const pending = prior?.status === 'pending';
679
+ const problem = pending && prior ? pendingProposalProblem(prior) : null;
680
+ if (!prior ||
681
+ (pending
682
+ ? problem !== null
683
+ : prior.status !== 'running' ||
684
+ prior.ownerId === null ||
685
+ prior.leaseExpiresAt === null ||
686
+ prior.leaseExpiresAt > Date.now())) {
687
+ throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', problem ?? 'Only explicitly expired ownership or authentic pending work may be claimed');
688
+ }
689
+ this.#assertInvocation(execution, prior.operation, prior.projectId, prior.idempotencyKey);
690
+ this.#requireCapacity(maxRunning, id);
691
+ if (!Number.isSafeInteger(prior.fencingEpoch + 1) ||
692
+ !Number.isSafeInteger(prior.attempts + 1)) {
693
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Claim counter exhausted; explicit recovery is required');
694
+ }
695
+ return this.#claimRow(prior, now);
696
+ }, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution?.assertActive());
697
+ if (!row)
698
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Claimed job disappeared');
699
+ const grant = this.#grant(row);
700
+ this.#grants.set(id, grant);
701
+ return grant;
702
+ }
703
+ /** Assign the next owned epoch; terminal retry clears its retained previous outcome from the active row. */
704
+ #claimRow(prior, now, terminalRetry = false) {
705
+ return this.#db
135
706
  .update(backgroundJobs)
136
707
  .set({
137
- status: 'complete',
138
- completedAt: now,
139
- result: result !== undefined ? JSON.stringify(result) : null,
140
- progress: 100,
708
+ status: 'running',
709
+ ownerId: this.#ownerId,
710
+ claimedBy: this.#options.actor ?? null,
711
+ fencingEpoch: prior.fencingEpoch + 1,
712
+ attempts: prior.attempts + 1,
141
713
  heartbeatAt: now,
714
+ leaseExpiresAt: Date.now() + this.leaseMs,
715
+ ...(terminalRetry
716
+ ? {
717
+ completedAt: null,
718
+ result: null,
719
+ error: null,
720
+ cancellationRequestedAt: null,
721
+ progress: 0,
722
+ }
723
+ : {}),
142
724
  })
143
- .where(eq(backgroundJobs.id, id))
144
- .run();
725
+ .where(eq(backgroundJobs.id, prior.id))
726
+ .returning()
727
+ .get();
728
+ }
729
+ /**
730
+ * Start an explicit new attempt after retaining terminal or expired owned work atomically.
731
+ * @param id - Authentic failed, cancelled or explicitly expired owned job with the same immutable proposal.
732
+ * @param now - New attempt timestamp.
733
+ * @param execution - Fresh bounded invocation; expired contexts or old fences cannot be renewed.
734
+ * @param retainOutcome - Trusted synchronous core callback that rechecks domain preconditions,
735
+ * appends the complete previous row image, and verifies its receipt before returning.
736
+ * @param maxRunning - Optional capacity limit enforced inside the same transaction.
737
+ * @returns New owned lease, without reapplying any domain operation.
738
+ * @throws BackgroundJobError when scope, terminal status, authentic input, counters, or receipt is invalid.
739
+ * @remarks Complete jobs are never reopened. Expired running rows retain their authentic
740
+ * uncertain outcome; expiry does not invent failure. Explicit resume acknowledges a retained
741
+ * previous cancellation request and clears it only with the new claim. Callback failure, cancellation, or claim failure
742
+ * rolls back both historical bookkeeping and the new claim. The caller must independently
743
+ * preserve its append-only history in the same database; this is not an arbitrary callback sandbox.
744
+ * @example
745
+ * ```ts
746
+ * const lease = store.retryAtomically(id, Date.now(), context, retainPreviousAttempt);
747
+ * ```
748
+ */
749
+ retryAtomically(id, now, execution, retainOutcome, maxRunning) {
750
+ execution.assertActive();
751
+ if (execution.writeFence)
752
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Explicit retry requires a fresh invocation without an old attempt fence');
753
+ requireRunningLimit(maxRunning);
754
+ if (!Number.isSafeInteger(now))
755
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry timestamp must be a safe integer');
756
+ const row = this.#write(() => {
757
+ const prior = this.#row(id);
758
+ const expiredOwned = prior?.status === 'running' &&
759
+ prior.ownerId !== null &&
760
+ prior.leaseExpiresAt !== null &&
761
+ prior.leaseExpiresAt <= Date.now();
762
+ if (!prior || (prior.status !== 'failed' && prior.status !== 'cancelled' && !expiredOwned))
763
+ throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', 'Explicit retry requires terminal or expired owned work; committed effects cannot be reopened');
764
+ this.#assertInvocation(execution, prior.operation, prior.projectId, prior.idempotencyKey);
765
+ if (!prior.proposalJson ||
766
+ !prior.proposalHash ||
767
+ createHash('sha256').update(prior.proposalJson).digest('hex') !== prior.proposalHash)
768
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Terminal retry requires authentic immutable proposal bytes');
769
+ if (!Number.isSafeInteger(prior.fencingEpoch + 1) ||
770
+ !Number.isSafeInteger(prior.attempts + 1))
771
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry counter exhausted');
772
+ this.#requireCapacity(maxRunning, id);
773
+ const receipt = retainOutcome(JSON.stringify(prior));
774
+ if (typeof receipt !== 'string')
775
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry bookkeeping must return synchronous JSON receipt bytes');
776
+ requireJson(receipt);
777
+ if (JSON.stringify(this.#row(id)) !== JSON.stringify(prior))
778
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Retry bookkeeping changed the original job before its new claim');
779
+ execution.assertActive();
780
+ return this.#claimRow(prior, now, true);
781
+ }, execution.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution.assertActive());
782
+ if (!row)
783
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Retried job disappeared');
784
+ const grant = this.#grant(row);
785
+ this.#grants.set(id, grant);
786
+ return grant;
787
+ }
788
+ /**
789
+ * Renew an owned lease and observe persisted cancellation.
790
+ * @param id - Owned job identity.
791
+ * @param now - Heartbeat timestamp.
792
+ * @returns Current job including cancellation request.
793
+ * @remarks Expired attempts cannot revive themselves through heartbeat.
794
+ * @example
795
+ * ```ts
796
+ * const job = store.heartbeat(id, Date.now());
797
+ * ```
798
+ */
799
+ heartbeat(id, now) {
800
+ return this.#write(() => {
801
+ this.#owned(id);
802
+ const row = this.#db
803
+ .update(backgroundJobs)
804
+ .set({ heartbeatAt: now, leaseExpiresAt: Date.now() + this.leaseMs })
805
+ .where(eq(backgroundJobs.id, id))
806
+ .returning()
807
+ .get();
808
+ if (!row)
809
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Heartbeat target disappeared');
810
+ return rowToJob(row);
811
+ });
812
+ }
813
+ /**
814
+ * Persist a checkpoint under the current attempt fence.
815
+ * @param id - Owned job identity.
816
+ * @param valueJson - Valid serialized checkpoint bytes.
817
+ * @param now - Checkpoint timestamp.
818
+ * @remarks Writes atomically with ownership verification; does not authorize domain writes.
819
+ * @example
820
+ * ```ts
821
+ * store.checkpoint(id, JSON.stringify({ nextFile: 8 }), Date.now());
822
+ * ```
823
+ */
824
+ checkpoint(id, valueJson, now) {
825
+ requireJson(valueJson);
826
+ this.#write(() => {
827
+ this.#owned(id);
828
+ this.#db
829
+ .update(backgroundJobs)
830
+ .set({ checkpointJson: valueJson, checkpointAt: now })
831
+ .where(eq(backgroundJobs.id, id))
832
+ .run();
833
+ });
834
+ }
835
+ /**
836
+ * Request cancellation without claiming that execution has stopped.
837
+ * @param id - Visible job identity.
838
+ * @param now - Request timestamp.
839
+ * @param execution - Optional original invocation context; never renews its deadline.
840
+ * @returns Whether pending work was cancelled or running work has a persisted request.
841
+ * @remarks Terminal results are preserved when cancellation arrives after commit.
842
+ * @example
843
+ * ```ts
844
+ * const requested = store.requestCancel(id, Date.now());
845
+ * ```
846
+ */
847
+ requestCancel(id, now, execution) {
848
+ execution?.assertActive();
849
+ return this.#write(() => {
850
+ const row = this.#row(id);
851
+ if (!row)
852
+ return false;
853
+ this.#assertInvocation(execution, row.operation, row.projectId, row.idempotencyKey);
854
+ if (row.status !== 'running' && row.status !== 'pending')
855
+ return false;
856
+ if (row.status === 'pending') {
857
+ const problem = pendingProposalProblem(row);
858
+ if (problem)
859
+ throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', problem);
860
+ this.#db
861
+ .update(backgroundJobs)
862
+ .set({ status: 'cancelled', cancellationRequestedAt: now, completedAt: now })
863
+ .where(eq(backgroundJobs.id, id))
864
+ .run();
865
+ return true;
866
+ }
867
+ if (row.cancellationRequestedAt === null)
868
+ this.#db
869
+ .update(backgroundJobs)
870
+ .set({ cancellationRequestedAt: now })
871
+ .where(eq(backgroundJobs.id, id))
872
+ .run();
873
+ return true;
874
+ }, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution?.assertActive());
875
+ }
876
+ /**
877
+ * Commit actual executor success under ownership.
878
+ * @param id - Job identity.
879
+ * @param result - Actual outcome.
880
+ * @param now - Completion timestamp.
881
+ * @remarks An earlier cancellation request does not erase work already committed.
882
+ * @example
883
+ * ```ts
884
+ * store.complete(id, { verified: true }, Date.now());
885
+ * ```
886
+ */
887
+ complete(id, result, now) {
888
+ const resultJson = result === undefined ? null : JSON.stringify(result);
889
+ this.#write(() => {
890
+ this.#owned(id);
891
+ this.#completeRow(id, resultJson, now);
892
+ });
893
+ this.#grants.delete(id);
894
+ }
895
+ /**
896
+ * Persist terminal data inside the already-owned transaction.
897
+ * @param id - Job whose ownership was checked by the caller.
898
+ * @param resultJson - Serialized outcome, or null for the legacy empty result.
899
+ * @param now - Completion time in epoch milliseconds.
900
+ */
901
+ #completeRow(id, resultJson, now) {
902
+ this.#db.run(sql `UPDATE main.background_jobs SET status='complete',
903
+ completed_at=${now}, result=${resultJson}, progress=100, heartbeat_at=${now}
904
+ WHERE id=${id}`);
905
+ }
906
+ /**
907
+ * Commit a synchronous domain mutation, its receipt and job completion atomically.
908
+ * @param execution - Captured context carrying this client's current claimed fence.
909
+ * @param mutation - Trusted synchronous domain operation returning JSON receipt bytes.
910
+ * @returns Exact receipt bytes committed with the domain changes and terminal job row.
911
+ * @throws BackgroundJobError when the transaction is borrowed, authority changed,
912
+ * cancellation was observed before commit, or receipt bytes are invalid.
913
+ * @remarks The store acquires its own write transaction; an ambient BEGIN confers
914
+ * no authority. Domain code must independently validate its proposal and operate
915
+ * only on the same SQLite database without transaction control or external effects.
916
+ * Guards are cooperative boundaries, not preemption. Cancellation observed after
917
+ * COMMIT cannot erase the committed result. Arbitrary async callbacks are unsupported.
918
+ * @example
919
+ * ```ts
920
+ * const receiptJson = store.completeAtomically(execution, () => applyPreparedRepair());
921
+ * ```
922
+ */
923
+ completeAtomically(execution, mutation) {
924
+ execution.assertActive();
925
+ const fence = execution.writeFence;
926
+ if (!fence) {
927
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Atomic mutation requires a claimed job fence');
928
+ }
929
+ const resultJson = this.#write(() => {
930
+ this.#owned(fence.lease.jobId);
931
+ assertOperationWriteFence(this.#db, execution);
932
+ const receipt = mutation(execution);
933
+ requireJson(receipt);
934
+ assertOperationWriteFence(this.#db, execution);
935
+ this.#completeRow(fence.lease.jobId, receipt, Date.now());
936
+ assertOperationJobState(this.#db, execution, 'complete', receipt);
937
+ execution.assertActive();
938
+ return receipt;
939
+ }, execution.deadlineAt, (message) => this.#committedCleanupFailures.set(fence.lease.jobId, message));
940
+ this.#grants.delete(fence.lease.jobId);
941
+ return resultJson;
942
+ }
943
+ /**
944
+ * Finalize trusted receipt/event metadata and a failed or cancelled attempt atomically.
945
+ * @param execution - Original bounded attempt identity and current ownership fence.
946
+ * @param outcome - Sourced observed outcome; expiry alone cannot supply this fact.
947
+ * @param bookkeeping - Synchronous metadata-only service callback returning JSON receipt bytes.
948
+ * @returns Committed receipt or explicit pending finalization with actual elapsed time.
949
+ * @remarks Cancellation allows bookkeeping only; domain repair remains forbidden.
950
+ * An exhausted deadline never acquires a lock. Failed finalization preserves the
951
+ * lease/checkpoint for inspection and a later explicit recovery attempt. No budget
952
+ * is renewed, and synchronous SQLite completion is not preempted by a timer.
953
+ * @example
954
+ * ```ts
955
+ * const result = store.finalizeAtomically(context, outcome, () => appendAttemptReceipt());
956
+ * ```
957
+ */
958
+ finalizeAtomically(execution, outcome, bookkeeping) {
959
+ const startedAt = Date.now();
960
+ let cleanupError;
961
+ try {
962
+ if (outcome.status !== 'failed' && outcome.status !== 'cancelled')
963
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Unsupported terminal attempt outcome');
964
+ requireText(outcome.message, 'outcome.message');
965
+ const fence = execution.writeFence;
966
+ if (!fence)
967
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Finalization requires a claimed job fence');
968
+ const resultJson = this.#write(() => {
969
+ const row = this.#owned(fence.lease.jobId);
970
+ assertOperationJobState(this.#db, execution, 'running', undefined, true);
971
+ if (outcome.status === 'cancelled' &&
972
+ row.cancellationRequestedAt === null &&
973
+ !execution.signal.aborted)
974
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Cancellation has not been observed or requested');
975
+ const receipt = bookkeeping();
976
+ requireJson(receipt);
977
+ this.#owned(fence.lease.jobId);
978
+ assertOperationJobState(this.#db, execution, 'running', undefined, true);
979
+ const now = Date.now();
980
+ this.#db.run(sql `UPDATE main.background_jobs SET status=${outcome.status},
981
+ completed_at=${now}, result=${receipt}, error=${outcome.message}, heartbeat_at=${now}
982
+ WHERE id=${fence.lease.jobId}`);
983
+ assertOperationJobState(this.#db, execution, outcome.status, receipt, true, outcome);
984
+ return receipt;
985
+ }, execution.deadlineAt, (message) => {
986
+ cleanupError = message;
987
+ this.#committedCleanupFailures.set(fence.lease.jobId, message);
988
+ });
989
+ this.#grants.delete(fence.lease.jobId);
990
+ return {
991
+ state: 'finalized',
992
+ resultJson,
993
+ ...(cleanupError ? { cleanupError } : {}),
994
+ elapsedMs: Date.now() - startedAt,
995
+ deadlineExceeded: Date.now() >= execution.deadlineAt,
996
+ };
997
+ }
998
+ catch (error) {
999
+ return {
1000
+ state: 'pending-finalization',
1001
+ reason: String(error),
1002
+ elapsedMs: Date.now() - startedAt,
1003
+ deadlineExceeded: Date.now() >= execution.deadlineAt,
1004
+ };
1005
+ }
145
1006
  }
146
1007
  /**
147
- * Mark a job as `failed` with an error message.
1008
+ * Commit actual executor failure under ownership.
1009
+ * @param id - Job identity.
1010
+ * @param error - Failure message.
1011
+ * @param now - Failure timestamp.
1012
+ * @remarks Stale owners cannot replace a newer attempt's outcome.
1013
+ * @example
1014
+ * ```ts
1015
+ * store.fail(id, 'Verification failed', Date.now());
1016
+ * ```
148
1017
  */
149
1018
  fail(id, error, now) {
150
- this.#db
151
- .update(backgroundJobs)
152
- .set({
153
- status: 'failed',
154
- completedAt: now,
155
- error,
156
- heartbeatAt: now,
157
- })
158
- .where(eq(backgroundJobs.id, id))
159
- .run();
1019
+ this.#write(() => {
1020
+ this.#owned(id);
1021
+ this.#db
1022
+ .update(backgroundJobs)
1023
+ .set({ status: 'failed', completedAt: now, error, heartbeatAt: now })
1024
+ .where(eq(backgroundJobs.id, id))
1025
+ .run();
1026
+ });
1027
+ this.#grants.delete(id);
160
1028
  }
161
1029
  /**
162
- * Mark a job as `cancelled`.
1030
+ * Acknowledge an executor's cancellation.
1031
+ * @param id - Owned job identity.
1032
+ * @param now - Acknowledgement timestamp.
1033
+ * @remarks Requires a persisted cancellation request and current ownership.
1034
+ * @example
1035
+ * ```ts
1036
+ * store.cancel(id, Date.now());
1037
+ * ```
163
1038
  */
164
1039
  cancel(id, now) {
165
- this.#db
166
- .update(backgroundJobs)
167
- .set({
168
- status: 'cancelled',
169
- completedAt: now,
170
- heartbeatAt: now,
171
- })
172
- .where(eq(backgroundJobs.id, id))
173
- .run();
1040
+ this.#write(() => {
1041
+ const row = this.#owned(id);
1042
+ if (row.cancellationRequestedAt === null)
1043
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Cancellation must be requested before acknowledgement');
1044
+ this.#db
1045
+ .update(backgroundJobs)
1046
+ .set({ status: 'cancelled', completedAt: now, heartbeatAt: now })
1047
+ .where(eq(backgroundJobs.id, id))
1048
+ .run();
1049
+ });
1050
+ this.#grants.delete(id);
174
1051
  }
175
1052
  /**
176
- * Update the progress (0-100) and heartbeat of a running job.
1053
+ * Update an owned attempt's progress.
1054
+ * @param id - Job identity.
1055
+ * @param progress - Finite percentage, clamped to zero through one hundred.
1056
+ * @param now - Observation timestamp.
1057
+ * @remarks Progress does not renew an expired lease or overwrite another owner.
1058
+ * @example
1059
+ * ```ts
1060
+ * store.progress(id, 50, Date.now());
1061
+ * ```
177
1062
  */
178
1063
  progress(id, progress, now) {
179
- this.#db
180
- .update(backgroundJobs)
181
- .set({
182
- progress: Math.max(0, Math.min(100, progress)),
183
- heartbeatAt: now,
184
- })
185
- .where(eq(backgroundJobs.id, id))
186
- .run();
1064
+ if (!Number.isFinite(progress))
1065
+ throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Progress must be finite');
1066
+ this.#write(() => {
1067
+ this.#owned(id);
1068
+ this.#db
1069
+ .update(backgroundJobs)
1070
+ .set({ progress: Math.max(0, Math.min(100, progress)), heartbeatAt: now })
1071
+ .where(eq(backgroundJobs.id, id))
1072
+ .run();
1073
+ });
187
1074
  }
188
1075
  /**
189
- * Delete terminal (complete/failed/cancelled/orphaned) jobs whose
190
- * `completedAt` is older than `cutoffMs`.
191
- *
192
- * @returns Number of rows deleted.
1076
+ * Retain historical job evidence regardless of age.
1077
+ * @param _cutoffMs - Former deletion threshold, retained for API compatibility.
1078
+ * @returns Zero; any future deletion requires explicit evidence-aware recovery policy.
1079
+ * @remarks No automatic or age-only pruning is performed.
1080
+ * @example
1081
+ * ```ts
1082
+ * const removed = store.purgeOlderThan(Date.now()); // 0
1083
+ * ```
193
1084
  */
194
- purgeOlderThan(cutoffMs) {
195
- const terminalStatuses = ['complete', 'failed', 'cancelled', 'orphaned'];
196
- const rows = this.#db
197
- .select({ id: backgroundJobs.id, completedAt: backgroundJobs.completedAt })
198
- .from(backgroundJobs)
199
- .where(inArray(backgroundJobs.status, terminalStatuses))
200
- .all();
201
- const staleIds = rows
202
- .filter((r) => r.completedAt !== null && r.completedAt !== undefined && r.completedAt < cutoffMs)
203
- .map((r) => r.id);
204
- if (staleIds.length === 0) {
205
- return 0;
206
- }
207
- this.#db.delete(backgroundJobs).where(inArray(backgroundJobs.id, staleIds)).run();
208
- return staleIds.length;
1085
+ purgeOlderThan(_cutoffMs) {
1086
+ return 0;
209
1087
  }
210
1088
  }
211
- // ---------------------------------------------------------------------------
212
- // BackgroundJobManager — public façade (same interface as before T641)
213
- // ---------------------------------------------------------------------------
214
1089
  /**
215
- * Manages background jobs for long-running operations.
216
- *
217
- * Backed by a {@link DurableJobStore} so job state persists across process
218
- * restarts. The public interface is identical to the previous in-memory
219
- * implementation so the job-manager accessor does not change.
220
- *
221
- * @task T641
1090
+ * Existing executor facade with persisted ownership and cooperative cancellation.
1091
+ * @remarks Executors must use a separately authorized transactional service for
1092
+ * domain changes and receipts; this class fences only job metadata/checkpoints.
1093
+ * @example
1094
+ * ```ts
1095
+ * const manager = new BackgroundJobManager(db, { projectId: 'project-uuid' });
1096
+ * const id = await manager.startJob('inspect', async ({ signal }) => ({ cancelled: signal.aborted }));
1097
+ * ```
222
1098
  */
223
1099
  export class BackgroundJobManager {
224
1100
  #store;
225
- #abortControllers;
1101
+ #abortControllers = new Map();
1102
+ #heartbeatTimers = new Map();
1103
+ #diagnostics = new Map();
226
1104
  #maxJobs;
227
- #retentionMs;
228
- #cleanupTimer;
1105
+ /**
1106
+ * Open the existing facade.
1107
+ * @param db - Canonical store.
1108
+ * @param config - Scope and lease configuration.
1109
+ * @remarks Construction does not orphan or execute persisted work.
1110
+ * @example
1111
+ * ```ts
1112
+ * const manager = new BackgroundJobManager(db, { projectId: 'project-uuid' });
1113
+ * ```
1114
+ */
229
1115
  constructor(db, config) {
230
- this.#store = new DurableJobStore(db);
231
- this.#abortControllers = new Map();
1116
+ this.#store = new DurableJobStore(db, config);
232
1117
  this.#maxJobs = config?.maxJobs ?? 10;
233
- this.#retentionMs = config?.retentionMs ?? 3_600_000; // 1 hour default
234
- this.#cleanupTimer = null;
235
- // Start periodic cleanup every 5 minutes
236
- this.#cleanupTimer = setInterval(() => this.cleanup(), 300_000);
237
- // Don't prevent process exit
238
- if (this.#cleanupTimer.unref) {
239
- this.#cleanupTimer.unref();
240
- }
241
1118
  }
242
1119
  /**
243
- * Start a new background job.
244
- *
245
- * @param operation - The operation identifier (e.g. "nexus.analyze")
246
- * @param executor - Async function to execute in the background
247
- * @returns The job ID
248
- * @throws Error if the maximum number of concurrent running jobs is reached
1120
+ * Start new work or coalesce an identical immutable submission.
1121
+ * @param operation - Supported operation.
1122
+ * @param executor - Existing executor, now given cancellation and checkpoint capabilities.
1123
+ * @param submission - Optional scoped retry identity.
1124
+ * @returns New or already persisted job identity.
1125
+ * @remarks Repeated submissions never invoke a second executor.
1126
+ * @example
1127
+ * ```ts
1128
+ * const id = await manager.startJob('inspect', async () => ({ verified: true }), submission);
1129
+ * ```
249
1130
  */
250
- async startJob(operation, executor) {
251
- // Check concurrent job limit (only count running jobs)
252
- const runningCount = this.listJobs('running').length;
253
- if (runningCount >= this.#maxJobs) {
254
- throw new Error(`Maximum concurrent jobs reached (${this.#maxJobs}). Cancel or wait for existing jobs to complete.`);
1131
+ async startJob(operation, executor, submission) {
1132
+ const id = randomUUID();
1133
+ const lease = this.#store.insert(id, operation, Date.now(), submission, this.#maxJobs);
1134
+ if (!lease) {
1135
+ const existing = submission && this.#store.findSubmission(operation, submission);
1136
+ if (!existing)
1137
+ throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Coalesced job is unavailable');
1138
+ return existing.id;
255
1139
  }
256
- const jobId = randomUUID();
257
- const abortController = new AbortController();
258
- const now = Date.now();
259
- this.#store.insert(jobId, operation, now);
260
- this.#abortControllers.set(jobId, abortController);
261
- // Execute async — do not await
262
- void this.#executeJob(jobId, executor, abortController.signal);
263
- return jobId;
1140
+ this.#launch(lease, executor);
1141
+ return id;
1142
+ }
1143
+ /**
1144
+ * Record optional work durably without scheduling an executor or model.
1145
+ * @param operation - Supported action validated by the calling domain service.
1146
+ * @param submission - Authentic scoped proposal bytes for explicit later resume.
1147
+ * @returns Existing or newly persisted job identity.
1148
+ * @remarks This does not establish completion of the optional projection or a
1149
+ * domain repair receipt. Explicit resume must validate current prerequisites.
1150
+ * @example
1151
+ * ```ts
1152
+ * const id = manager.deferJob('docs.projection', submission);
1153
+ * ```
1154
+ */
1155
+ deferJob(operation, submission) {
1156
+ return this.#store.defer(randomUUID(), operation, Date.now(), submission).id;
1157
+ }
1158
+ /**
1159
+ * Explicitly start pending work or resume expired ownership using the existing facade.
1160
+ * @param id - Expired job identity.
1161
+ * @param executor - Executor capable of resuming its persisted checkpoint.
1162
+ * @returns Identity of the resumed job.
1163
+ * @remarks The earlier owner is fenced before a replacement executor can run.
1164
+ * @example
1165
+ * ```ts
1166
+ * await manager.resumeJob(id, async ({ checkpoint }) => { checkpoint('{}'); });
1167
+ * ```
1168
+ */
1169
+ async resumeJob(id, executor) {
1170
+ const lease = this.#store.claim(id, Date.now(), this.#maxJobs);
1171
+ this.#launch(lease, executor);
1172
+ return id;
1173
+ }
1174
+ #launch(lease, executor) {
1175
+ const controller = new AbortController();
1176
+ this.#abortControllers.set(lease.jobId, controller);
1177
+ if (this.#store.get(lease.jobId)?.cancellationRequestedAt !== null)
1178
+ controller.abort();
1179
+ const timer = setInterval(() => {
1180
+ try {
1181
+ const job = this.#store.heartbeat(lease.jobId, Date.now());
1182
+ if (job.cancellationRequestedAt !== null)
1183
+ controller.abort();
1184
+ }
1185
+ catch (error) {
1186
+ this.#diagnostics.set(lease.jobId, error instanceof Error ? error.message : String(error));
1187
+ controller.abort();
1188
+ }
1189
+ }, Math.max(1, Math.floor(this.#store.leaseMs / 3)));
1190
+ timer.unref();
1191
+ this.#heartbeatTimers.set(lease.jobId, timer);
1192
+ void this.#executeJob(lease, executor, controller.signal);
264
1193
  }
265
1194
  /**
266
- * Get a specific job by ID.
267
- *
268
- * @returns The job or `undefined` if not found.
1195
+ * Read persisted state and local diagnostic failures.
1196
+ * @param id - Job identity.
1197
+ * @returns Job or undefined.
1198
+ * @remarks Persistence errors are disclosed separately from a committed outcome.
1199
+ * @example
1200
+ * ```ts
1201
+ * const job = manager.getJob(id);
1202
+ * ```
269
1203
  */
270
- getJob(jobId) {
271
- return this.#store.get(jobId);
1204
+ getJob(id) {
1205
+ const job = this.#store.get(id);
1206
+ const error = this.#diagnostics.get(id);
1207
+ if (job && error)
1208
+ job.diagnosticError = error;
1209
+ return job;
272
1210
  }
273
1211
  /**
274
- * List all jobs, optionally filtered by status.
275
- *
276
- * @param status - Optional status filter string.
277
- * @returns Array of matching jobs.
1212
+ * List persisted scoped jobs.
1213
+ * @param status - Optional lifecycle filter.
1214
+ * @returns Matching jobs.
1215
+ * @remarks Includes cancellation requests and local diagnostic failures.
1216
+ * @example
1217
+ * ```ts
1218
+ * const jobs = manager.listJobs('running');
1219
+ * ```
278
1220
  */
279
1221
  listJobs(status) {
280
- return this.#store.list(status);
1222
+ return this.#store.list(status).map((job) => this.getJob(job.id) ?? job);
281
1223
  }
282
1224
  /**
283
- * Cancel a running job.
284
- *
285
- * @returns `true` if the job was cancelled; `false` if not found or not running.
1225
+ * Request cancellation of visible running work.
1226
+ * @param id - Job identity.
1227
+ * @returns Whether the request was recorded, not proof of termination.
1228
+ * @remarks Other owners observe the durable request on heartbeat.
1229
+ * @example
1230
+ * ```ts
1231
+ * const requested = manager.cancelJob(id);
1232
+ * ```
286
1233
  */
287
- cancelJob(jobId) {
288
- const job = this.#store.get(jobId);
289
- if (!job || job.status !== 'running') {
290
- return false;
291
- }
292
- const controller = this.#abortControllers.get(jobId);
293
- if (controller) {
294
- controller.abort();
295
- this.#abortControllers.delete(jobId);
296
- }
297
- this.#store.cancel(jobId, Date.now());
298
- return true;
1234
+ cancelJob(id) {
1235
+ const requested = this.#store.requestCancel(id, Date.now());
1236
+ if (requested)
1237
+ this.#abortControllers.get(id)?.abort();
1238
+ return requested;
299
1239
  }
300
1240
  /**
301
- * Update the progress of a running job (0-100).
302
- *
303
- * @returns `true` if updated; `false` if the job is not found or not running.
1241
+ * Report progress from an owned executor.
1242
+ * @param id - Job identity.
1243
+ * @param progress - Finite percentage.
1244
+ * @returns Whether the job exists and is running.
1245
+ * @remarks A visible job owned by another attempt is explicitly refused.
1246
+ * @example
1247
+ * ```ts
1248
+ * manager.updateProgress(id, 50);
1249
+ * ```
304
1250
  */
305
- updateProgress(jobId, progress) {
306
- const job = this.#store.get(jobId);
307
- if (!job || job.status !== 'running') {
1251
+ updateProgress(id, progress) {
1252
+ const job = this.#store.get(id);
1253
+ if (!job || job.status !== 'running')
308
1254
  return false;
309
- }
310
- this.#store.progress(jobId, progress, Date.now());
1255
+ this.#store.progress(id, progress, Date.now());
311
1256
  return true;
312
1257
  }
313
1258
  /**
314
- * Delete completed/failed/cancelled/orphaned jobs past the retention window.
315
- *
316
- * @returns Number of jobs removed.
1259
+ * Preserve historical evidence.
1260
+ * @returns Zero rows removed.
1261
+ * @remarks Age alone never authorizes deletion of job evidence.
1262
+ * @example
1263
+ * ```ts
1264
+ * const removed = manager.cleanup(); // 0
1265
+ * ```
317
1266
  */
318
1267
  cleanup() {
319
- const cutoff = Date.now() - this.#retentionMs;
320
- return this.#store.purgeOlderThan(cutoff);
1268
+ return 0;
321
1269
  }
322
1270
  /**
323
- * Destroy the manager: cancel all in-process running jobs and stop the
324
- * cleanup timer. Does NOT purge DB rows — orphaned jobs must be reviewed.
1271
+ * Request cancellation only for this manager's executors and stop its timers.
1272
+ * @remarks Does not claim non-cooperative executors stopped or alter another owner.
1273
+ * @example
1274
+ * ```ts
1275
+ * manager.destroy(); // inspect persisted cancellation/completion separately
1276
+ * ```
325
1277
  */
326
1278
  destroy() {
327
- const now = Date.now();
328
- for (const runningJob of this.listJobs('running')) {
329
- const controller = this.#abortControllers.get(runningJob.id);
330
- if (controller) {
331
- controller.abort();
1279
+ for (const [id, controller] of this.#abortControllers) {
1280
+ try {
1281
+ this.#store.requestCancel(id, Date.now());
332
1282
  }
333
- // Persist orphaned status for any jobs still running in this process
334
- this.#store.fail(runningJob.id, 'manager-destroyed', now);
335
- }
336
- this.#abortControllers.clear();
337
- if (this.#cleanupTimer) {
338
- clearInterval(this.#cleanupTimer);
339
- this.#cleanupTimer = null;
1283
+ catch (error) {
1284
+ this.#diagnostics.set(id, error instanceof Error ? error.message : String(error));
1285
+ }
1286
+ controller.abort();
340
1287
  }
1288
+ for (const timer of this.#heartbeatTimers.values())
1289
+ clearInterval(timer);
1290
+ this.#heartbeatTimers.clear();
341
1291
  }
342
- /**
343
- * Execute a job's executor function and persist the outcome.
344
- */
345
- async #executeJob(jobId, executor, signal) {
346
- const job = this.#store.get(jobId);
347
- if (!job) {
348
- return;
349
- }
1292
+ async #executeJob(lease, executor, signal) {
350
1293
  try {
351
- const result = await executor();
352
- // Check if cancelled during execution
353
1294
  if (signal.aborted) {
1295
+ this.#store.cancel(lease.jobId, Date.now());
354
1296
  return;
355
1297
  }
356
- this.#store.complete(jobId, result, Date.now());
1298
+ const result = await executor({
1299
+ signal,
1300
+ lease,
1301
+ checkpoint: (value) => this.#store.checkpoint(lease.jobId, value, Date.now()),
1302
+ });
1303
+ // A resolved executor reports actual completion, even if cancellation arrived
1304
+ // after its commit. Only an acknowledged abort establishes cancelled status.
1305
+ this.#store.complete(lease.jobId, result, Date.now());
357
1306
  }
358
1307
  catch (error) {
359
- // Check if this was a cancellation
360
- if (signal.aborted) {
361
- return;
1308
+ try {
1309
+ if (signal.aborted && error instanceof Error && error.name === 'AbortError')
1310
+ this.#store.cancel(lease.jobId, Date.now());
1311
+ else
1312
+ this.#store.fail(lease.jobId, error instanceof Error ? error.message : String(error), Date.now());
1313
+ }
1314
+ catch (persistenceError) {
1315
+ this.#diagnostics.set(lease.jobId, persistenceError instanceof Error ? persistenceError.message : String(persistenceError));
362
1316
  }
363
- const message = error instanceof Error ? error.message : String(error);
364
- this.#store.fail(jobId, message, Date.now());
365
1317
  }
366
1318
  finally {
367
- this.#abortControllers.delete(jobId);
1319
+ const timer = this.#heartbeatTimers.get(lease.jobId);
1320
+ if (timer)
1321
+ clearInterval(timer);
1322
+ this.#heartbeatTimers.delete(lease.jobId);
1323
+ this.#abortControllers.delete(lease.jobId);
368
1324
  }
369
1325
  }
370
1326
  }