@bevel-software/platform-core-backend 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -4,6 +4,7 @@ import { logger } from '../../shared/logging.js';
4
4
  const log = logger('tools');
5
5
  import type { IExternalApiKeyService } from './external-api-key.interface.js';
6
6
  import type { AuthService } from '../auth/auth.service.js';
7
+ import { ACCOUNT_DEACTIVATED_MESSAGE, AuthBackendError } from '../auth/account-admission.js';
7
8
  import { InternalTokenService } from './internal-token.service.js';
8
9
  import { INVALID_CONNECTION_KEY_CHALLENGE, INVALID_CONNECTION_KEY_MESSAGE } from './connection-key-rejection.js';
9
10
 
@@ -79,10 +80,10 @@ export const requireInternalSource: RequestHandler = (req, res, next) => {
79
80
 
80
81
  /**
81
82
  * Mirror of `requireInternalSource` for surfaces that must admit ONLY external
82
- * callers. `start_session` mints a fresh chat thread as the caller's
83
- * ontology-session id; an internal (in-process agent) token already carries
84
- * its run's `sessionId`, so letting it mint a new thread mid-run would
85
- * silently reset the one-ontology-per-conversation boundary.
83
+ * callers. `start_session` mints a fresh chat thread as the caller's session
84
+ * id; an internal (in-process agent) token already carries its run's
85
+ * `sessionId`, so letting it mint a new thread mid-run would silently split
86
+ * one conversation in two.
86
87
  *
87
88
  * "External" is decided by the VERIFIER, not by which credential shape
88
89
  * authenticated: a `bevel_…` connection key resolves external, and so does an
@@ -122,6 +123,15 @@ export type VerifyResult =
122
123
  export function createTokenVerifier(
123
124
  externalApiKeyService: IExternalApiKeyService,
124
125
  internalTokenService: InternalTokenService,
126
+ /**
127
+ * Whether the account an internal token was minted for is still on. An
128
+ * internal token carries only a user id and lives up to an hour (longer
129
+ * for the MCP loopback), so without this a switched-off account's running
130
+ * agent would keep working until it expired. Connection keys need no
131
+ * second look: their lookup reads the account. Every deployment passes
132
+ * it; absent, internal tokens are judged on their signature alone.
133
+ */
134
+ accounts?: Pick<AuthService, 'isActive'>,
125
135
  ): (token: string | undefined) => Promise<VerifyResult> {
126
136
  const missing = { ok: false as const, status: 401, message: 'A connection key or internal token is required' };
127
137
  return async (token) => {
@@ -136,6 +146,9 @@ export function createTokenVerifier(
136
146
  if (internalTokenService.looksLikeInternalToken(token)) {
137
147
  const claim = internalTokenService.verify(token);
138
148
  if (!claim) return { ok: false, status: 401, message: 'Invalid or expired internal token' };
149
+ if (accounts && !(await accounts.isActive(claim.userId))) {
150
+ return { ok: false, status: 401, message: ACCOUNT_DEACTIVATED_MESSAGE };
151
+ }
139
152
  return claim.externalProxy
140
153
  ? {
141
154
  ok: true,
@@ -170,8 +183,9 @@ export function createTokenVerifier(
170
183
  export function createToolAuthMiddleware(
171
184
  externalApiKeyService: IExternalApiKeyService,
172
185
  internalTokenService: InternalTokenService,
186
+ accounts?: Pick<AuthService, 'isActive'>,
173
187
  ): RequestHandler {
174
- const verify = createTokenVerifier(externalApiKeyService, internalTokenService);
188
+ const verify = createTokenVerifier(externalApiKeyService, internalTokenService, accounts);
175
189
  return async (req: Request, res: Response, next: NextFunction) => {
176
190
  const header = req.headers.authorization;
177
191
  if (!header || !header.toLowerCase().startsWith('bearer ')) {
@@ -210,9 +224,9 @@ export function createToolAuthMiddleware(
210
224
  export function createManualAuthMiddleware(
211
225
  externalApiKeyService: IExternalApiKeyService,
212
226
  internalTokenService: InternalTokenService,
213
- authService: AuthService,
227
+ authService: Pick<AuthService, 'isActive' | 'resolveSession'>,
214
228
  ): RequestHandler {
215
- const verify = createTokenVerifier(externalApiKeyService, internalTokenService);
229
+ const verify = createTokenVerifier(externalApiKeyService, internalTokenService, authService);
216
230
  return async (req: Request, res: Response, next: NextFunction) => {
217
231
  const header = req.headers.authorization;
218
232
  if (!header || !header.toLowerCase().startsWith('bearer ')) {
@@ -241,13 +255,22 @@ export function createManualAuthMiddleware(
241
255
  }
242
256
 
243
257
  // Otherwise treat the bearer as a browser session JWT.
258
+ let userId: string;
244
259
  try {
245
- const { userId } = authService.verifyToken(token);
246
- req.toolAuth = { source: 'session', userId, scope: 'write' };
247
- next();
248
- } catch {
260
+ ({ userId } = await authService.resolveSession(token));
261
+ } catch (err) {
262
+ // The account could not be looked up: an outage, not a verdict on the
263
+ // session, and a 401 would sign a valid caller out over it.
264
+ if (err instanceof AuthBackendError) {
265
+ log.error('session account lookup failed:', { err: err.cause });
266
+ res.status(500).json({ error: 'Authentication backend unavailable' });
267
+ return;
268
+ }
249
269
  res.setHeader('WWW-Authenticate', WWW_AUTH);
250
270
  res.status(401).json({ error: 'Invalid or expired token' });
271
+ return;
251
272
  }
273
+ req.toolAuth = { source: 'session', userId, scope: 'write' };
274
+ next();
252
275
  };
253
276
  }
@@ -12,6 +12,7 @@ import { createToolHandlerFactory } from '../tool-handler.js';
12
12
  import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
13
13
  import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
14
14
  import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
15
+ import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
15
16
  import { SpillStore } from '../../workspace/spill-store.js';
16
17
  import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
17
18
  import { NEW_ROLE_GUIDANCE } from '../../access-model/roles-yaml-guard.js';
@@ -68,13 +69,14 @@ async function start(): Promise<string> {
68
69
  canOwner: async () => true,
69
70
  canWriteBatchAtRef: async () => null,
70
71
  canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
72
+ // The `after` half of a move's or copy's preview — allowing everything, like the gates above.
73
+ previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
71
74
  } as never;
72
75
  registerWorkspaceTools(registry, router, toolAuth, createToolHandlerFactory(resolve), new SpillStore(path.join(os.tmpdir(), 'bevel-test-spills')), new DocExtractService(docCache), allowAll, testKbContext({ kbDirName: KB }), {
73
- service: {} as never,
74
- enabled: false, // ontology boundary not under test here
75
- kb: testKbContext({ kbDirName: KB }),
76
+ // No hook registered: the agent-access gate refuses nothing here.
76
77
  recoveryBotEmail: 'recovery-bot@bevel.local',
77
78
  hooks: new WorkflowHooks(),
79
+ notes: new ToolDescriptionNotes(),
78
80
  }, new RoutineWritePolicyService(), {} as never);
79
81
  const app = express();
80
82
  app.use(express.json());
@@ -10,6 +10,7 @@ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
10
10
  import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
11
11
  import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
12
12
  import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
13
+ import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
13
14
  import { SpillStore } from '../../workspace/spill-store.js';
14
15
  import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
15
16
  import { testKbContext } from '../../../__tests__/kb-context.js';
@@ -65,11 +66,10 @@ async function start(): Promise<string> {
65
66
  // and their web_search / submit_feedback / cite_kb_node cases — moved to
66
67
  // the enterprise repo with their modules; core registers only core tools.
67
68
  registerWorkspaceTools(registry, router, toolAuth, th, new SpillStore('/tmp/bevel-test-spills'), new DocExtractService('/tmp/bevel-test-doc-extract'), accessControl, testKbContext(), {
68
- service: {} as never,
69
- enabled: false, // ontology boundary not under test here
70
- kb: testKbContext(),
69
+ // No hook registered: the agent-access gate refuses nothing here.
71
70
  recoveryBotEmail: 'recovery-bot@bevel.local',
72
71
  hooks: new WorkflowHooks(),
72
+ notes: new ToolDescriptionNotes(),
73
73
  }, new RoutineWritePolicyService(), {} as never /* sessionSink — start_session not exercised here */);
74
74
  router.use(createManualRoutes(registry, toolAuth));
75
75
 
@@ -18,7 +18,11 @@ const externalApiKeyService = {
18
18
  verifyAndLoadToken: async (t: string) =>
19
19
  t === 'bevel_ok' ? { user: { id: 'u-ext', email: 'e@x', name: 'Ext' }, tokenId: 'tk-1' } : null,
20
20
  } as never;
21
- const authService = { getUserById: async (id: string) => ({ id, email: 'e@x', name: 'N' }) } as never;
21
+ // Every account is on except `u-off`, which an admin switched off.
22
+ const authService = {
23
+ getUserById: async (id: string) => ({ id, email: 'e@x', name: 'N' }),
24
+ isActive: async (id: string) => id !== 'u-off',
25
+ } as never;
22
26
 
23
27
  let tempDir = '';
24
28
  const workspaceService = {
@@ -66,6 +70,12 @@ describe('validateToken (tool-author SDK entry)', () => {
66
70
  expect((await fs.readFile('a.md')).toString()).toBe('hi');
67
71
  });
68
72
 
73
+ it('throws a 401 ToolError for a valid internal token whose account was switched off', async () => {
74
+ const err = await validateToken(internalToken.mint({ userId: 'u-off' })).catch((e) => e);
75
+ expect(hasHttpStatus(err) && err.status).toBe(401);
76
+ expect((err as Error).message).toContain('switched off');
77
+ });
78
+
69
79
  it('throws a 401 ToolError on a bad token and an empty token', async () => {
70
80
  for (const bad of ['garbage', 'bevel_nope', '', undefined]) {
71
81
  const err = await validateToken(bad as string).catch((e) => e);
@@ -6,6 +6,7 @@ import { hasHttpStatus, ToolError, type ToolHandler } from './tool.contract.js';
6
6
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
7
7
  import { domainErrorBody } from '../../shared/http-errors.js';
8
8
  import type { ResolveToolContext } from './tool-context.js';
9
+ import { alwaysWritable, READ_ONLY_CODE, refuseWriteTool, type IWriteAccess } from '../write-access/write-access.js';
9
10
  import '../tool-auth/tool-auth.middleware.js'; // Express Request.toolAuth augmentation
10
11
 
11
12
  function isAsyncIterable(v: unknown): v is AsyncIterable<unknown> {
@@ -29,7 +30,7 @@ export interface ToolHandlerOptions {
29
30
  * The handler receives `req.body` as the flat args (UTCP's `body_field` already
30
31
  * delivered the inner body as the request body).
31
32
  */
32
- export function createToolHandlerFactory(resolve: ResolveToolContext) {
33
+ export function createToolHandlerFactory(resolve: ResolveToolContext, writeAccess: IWriteAccess = alwaysWritable) {
33
34
  return function toolHandler(handler: ToolHandler, opts: ToolHandlerOptions = {}) {
34
35
  return async (req: Request, res: Response): Promise<void> => {
35
36
  const auth = req.toolAuth;
@@ -41,10 +42,29 @@ export function createToolHandlerFactory(resolve: ResolveToolContext) {
41
42
  res.status(403).json({ error: 'This tool requires write access.' });
42
43
  return;
43
44
  }
45
+ // Before anything is awaited: a client that goes away during the
46
+ // write-access check below must still abort the call.
47
+ //
48
+ // Asked of the RESPONSE. The request closes as soon as its body has
49
+ // been read, with the client still there and waiting, so its `close`
50
+ // says nothing about the client; the response closes when it has been
51
+ // sent or the connection is gone, and `writableEnded` tells the two
52
+ // apart.
44
53
  const abort = new AbortController();
45
- req.on('close', () => {
54
+ res.on('close', () => {
46
55
  if (!res.writableEnded) abort.abort();
47
56
  });
57
+ // The tool layer's half of the read-only gate: the HTTP gate lets every
58
+ // tool call through, since only here is a write tool told from a read.
59
+ if (opts.write) {
60
+ const refusal = await refuseWriteTool(writeAccess);
61
+ if (refusal !== null) {
62
+ res.status(403).json({ error: refusal, code: READ_ONLY_CODE });
63
+ return;
64
+ }
65
+ // The client left while the verdict was awaited: nobody to answer.
66
+ if (abort.signal.aborted) return;
67
+ }
48
68
  const body: unknown = req.body;
49
69
  if (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) {
50
70
  res.status(400).json({ error: 'Request body must be a JSON object.' });
@@ -53,8 +73,8 @@ export function createToolHandlerFactory(resolve: ResolveToolContext) {
53
73
  const args = (body ?? {}) as Record<string, unknown>;
54
74
  // `sessionId` rides the tool body like `branch` does: the external MCP
55
75
  // proxy injects it (ask-tool continuity convention) and the in-process
56
- // agent passes its thread id. Surfaced on the context so the ontology
57
- // gate can scope to one run without each handler re-reading args.
76
+ // agent passes its thread id. Surfaced on the context so the
77
+ // agent-access gate can name the run without each handler re-reading args.
58
78
  const sessionId = typeof args.sessionId === 'string' && args.sessionId.length > 0
59
79
  ? args.sessionId
60
80
  : undefined;
@@ -34,8 +34,9 @@ export interface ToolContext {
34
34
  * The agent run / conversation id this call belongs to (the agent thread id).
35
35
  * Supplied as a `sessionId` field on the tool's input body — the external MCP
36
36
  * proxy injects it via the `ask`-tool continuity convention, and the
37
- * in-process agent passes its thread id. Used to scope the ontology-session
38
- * boundary to one run. Absent for non-agent or unthreaded calls.
37
+ * in-process agent passes its thread id. Handed to the agent-access hooks,
38
+ * which is how a deployment scopes a rule to one conversation. Absent for
39
+ * non-agent or unthreaded calls.
39
40
  */
40
41
  sessionId?: string;
41
42
  /**
@@ -35,7 +35,7 @@ export type ValidateToken = (
35
35
  ) => Promise<ToolContext>;
36
36
 
37
37
  export function createToolValidator(deps: ToolValidatorDeps): ValidateToken {
38
- const verify = createTokenVerifier(deps.externalApiKeyService, deps.internalTokenService);
38
+ const verify = createTokenVerifier(deps.externalApiKeyService, deps.internalTokenService, deps.authService);
39
39
  const resolve = createToolContextResolver(deps);
40
40
  return async (token, signal) => {
41
41
  let result;
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { SQL } from 'drizzle-orm';
3
+ import { PgDialect } from 'drizzle-orm/pg-core';
4
+ import { PendingCommitsService } from '../pending-commits.service.js';
5
+ import type { Database } from '../../database/connection.js';
6
+
7
+ /**
8
+ * Commits still queued for a branch when the knowledge-base repository is
9
+ * REPLACED.
10
+ *
11
+ * A release enqueues the bytes and only then drops its lock, so the moment the
12
+ * locks on a branch are dropped there may be rows already waiting — written
13
+ * against a working copy of the repository that is gone. Left `pending`, the
14
+ * worker picks them up after the re-clone and commits them onto a same-named
15
+ * branch of a DIFFERENT repository: somebody else's file, overwritten by a
16
+ * change nobody made to it.
17
+ *
18
+ * So they are escalated, never dropped: the bytes stay in the row, the reason
19
+ * is on it, and a person decides.
20
+ */
21
+ function makeFakeDb(moved: Array<{ id: string }>) {
22
+ const sets: Record<string, unknown>[] = [];
23
+ const db = {
24
+ update: vi.fn(() => ({
25
+ set: (values: Record<string, unknown>) => {
26
+ sets.push(values);
27
+ return { where: () => ({ returning: async () => moved }) };
28
+ },
29
+ })),
30
+ // Queued bytes are never thrown away over this.
31
+ delete: () => {
32
+ throw new Error('a queued commit must never be deleted');
33
+ },
34
+ } as unknown as Database;
35
+ return { db, sets };
36
+ }
37
+
38
+ describe('PendingCommitsService.markNeedsAttentionOnBranch', () => {
39
+ it('escalates every queued commit on the branch, with the reason, and answers how many', async () => {
40
+ const { db, sets } = makeFakeDb([{ id: 'a' }, { id: 'b' }]);
41
+ const svc = new PendingCommitsService(db);
42
+
43
+ await expect(
44
+ svc.markNeedsAttentionOnBranch('alice/draft', 'The repository was replaced.'),
45
+ ).resolves.toBe(2);
46
+
47
+ expect(sets).toHaveLength(1);
48
+ // `needs_attention` is the status the admin surface reads — the row is out
49
+ // of the worker's reach and in front of a person.
50
+ expect(sets[0]?.status).toBe('needs_attention');
51
+ expect(sets[0]?.lastError).toBe('The repository was replaced.');
52
+ expect(sets[0]?.lastAttemptedAt).toBeInstanceOf(Date);
53
+ });
54
+
55
+ it('answers zero when nothing was queued for that branch', async () => {
56
+ const { db } = makeFakeDb([]);
57
+ const svc = new PendingCommitsService(db);
58
+ await expect(svc.markNeedsAttentionOnBranch('quiet/branch', 'whatever')).resolves.toBe(0);
59
+ });
60
+ });
61
+
62
+ /**
63
+ * The same, keyed on the WORKING COPY that was set aside. Closing change
64
+ * requests covers their branches and nothing else; a commit queued on the
65
+ * default branch was written against the repository that was left all the
66
+ * same, and the path it names is about to hold a clone of another one.
67
+ */
68
+ describe('PendingCommitsService.markNeedsAttentionInWorkspace', () => {
69
+ it('escalates every queued commit for the working copy, with the reason, and answers how many', async () => {
70
+ const { db, sets } = makeFakeDb([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
71
+ const svc = new PendingCommitsService(db);
72
+
73
+ await expect(svc.markNeedsAttentionInWorkspace('main', 'The repository was replaced.')).resolves.toBe(3);
74
+
75
+ expect(sets).toHaveLength(1);
76
+ expect(sets[0]?.status).toBe('needs_attention');
77
+ expect(sets[0]?.lastError).toBe('The repository was replaced.');
78
+ expect(sets[0]?.lastAttemptedAt).toBeInstanceOf(Date);
79
+ });
80
+
81
+ /**
82
+ * Rows are stored under the encoded id, whichever spelling the caller
83
+ * enqueued with. The predicate is rendered and read, since the stub does
84
+ * not evaluate it: the id it compares must be the stored spelling, and it
85
+ * must match only the two statuses a worker could still write, `pending`
86
+ * and `running`.
87
+ */
88
+ it('looks for the working copy under the id the queue stores, and only for commits still queued', async () => {
89
+ const wheres: SQL[] = [];
90
+ const db = {
91
+ update: () => ({
92
+ set: () => ({
93
+ where: (predicate: SQL) => {
94
+ wheres.push(predicate);
95
+ return { returning: async () => [] };
96
+ },
97
+ }),
98
+ }),
99
+ } as unknown as Database;
100
+ await new PendingCommitsService(db).markNeedsAttentionInWorkspace('alice/feature', 'whatever');
101
+
102
+ const { sql: text, params } = new PgDialect().sqlToQuery(wheres[0]!);
103
+ expect(text).toContain('"workspace_id" =');
104
+ expect(params).toEqual(['alice%2Ffeature', 'pending', 'running']);
105
+ });
106
+
107
+ it('answers zero when nothing was queued for it', async () => {
108
+ const { db } = makeFakeDb([]);
109
+ await expect(new PendingCommitsService(db).markNeedsAttentionInWorkspace('main', 'whatever')).resolves.toBe(0);
110
+ });
111
+ });
@@ -8,6 +8,10 @@ import type { AuthService } from '../../auth/auth.service.js';
8
8
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
9
  import type { WorkflowEventBus } from '../event-bus.js';
10
10
  import { createWorkflowRoutes } from '../workflow.routes.js';
11
+ import {
12
+ VersionNotOnBranchError,
13
+ VERSION_NOT_ON_BRANCH_MESSAGE,
14
+ } from '../../../shared/domain-errors.js';
11
15
 
12
16
  // A file's history is its content with a time axis: the same default-deny
13
17
  // read model that hides a file must hide its commit list, its diffs, and its
@@ -129,6 +133,27 @@ describe('history routes enforce the read model', () => {
129
133
  expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
130
134
  });
131
135
 
136
+ it.each([
137
+ ['/show-file?sha=abc1234&path=%s', 'showFileAtChange'],
138
+ ['/file-at-change?sha=abc1234&path=%s', 'fileAtChange'],
139
+ ] as const)(
140
+ '%s: a save off this branch is refused with the history message',
141
+ async (route, method) => {
142
+ // The service raises it (the git layer knows the branch); the route has
143
+ // to pass the typed refusal through with its own status and sentence,
144
+ // because a reader who is told "not found" about a file that is plainly
145
+ // there learns the wrong thing. The bytes route carries the same rule,
146
+ // so neither can be read around through the other.
147
+ h = await makeHarness();
148
+ h.workflow[method].mockRejectedValueOnce(new VersionNotOnBranchError());
149
+ const res = await get(route.replace('%s', OPEN));
150
+ expect(res.status).toBe(404);
151
+ expect(((await res.json()) as { error: string }).error).toBe(
152
+ VERSION_NOT_ON_BRANCH_MESSAGE,
153
+ );
154
+ },
155
+ );
156
+
132
157
  it('an access-model error fails closed, not open', async () => {
133
158
  h = await makeHarness();
134
159
  h.canRead.mockRejectedValueOnce(new Error('access tree unreadable'));
@@ -0,0 +1,249 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { testKbContext } from '../../../__tests__/kb-context.js';
3
+ import type { GitService } from '../git/git.service.js';
4
+ import type { PullRequestService } from '../git/pull-request.service.js';
5
+ import type { IReviewWorkflowService } from '../review-workflow/review-workflow.interface.js';
6
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import type { IAccessControl } from '../../access/access-control.interface.js';
8
+ import type { FileLockService } from '../file-lock.service.js';
9
+ import type { PendingCommitsService } from '../pending-commits.service.js';
10
+ import type { Database } from '../../database/connection.js';
11
+ import { WorkflowService } from '../workflow.service.js';
12
+ import { openChangeGate } from '../../../__tests__/open-change-gate.js';
13
+
14
+ /**
15
+ * The CLOSE half of the admin's choice when the knowledge-base repository is
16
+ * replaced: the requests point at branches that are not in the new
17
+ * repository, so they are closed — with a reason, releasing the locks held on
18
+ * those branches and taking any commit still queued for them off the worker,
19
+ * and without deleting a single row.
20
+ */
21
+
22
+ interface ClosedRow {
23
+ number: number;
24
+ sourceBranch: string;
25
+ }
26
+
27
+ /**
28
+ * DB stub shaped like the real close: ONE `update … where state = 'open'
29
+ * returning` per pass, answering with the rows that pass actually closed.
30
+ *
31
+ * `passes` is therefore what the statement RETURNS, pass by pass — which is
32
+ * how a request already closed by a merge or a withdrawal is expressed (it is
33
+ * simply not in the returned set) and how a request opened BETWEEN passes is
34
+ * expressed (it turns up in a later one). A pass past the end answers nothing,
35
+ * which is what stops the loop.
36
+ */
37
+ function makeDb(passes: ClosedRow[][], sets: Record<string, unknown>[], openCount: number) {
38
+ let pass = 0;
39
+ const db = {
40
+ // The count is a SQL aggregate: one row, one integer.
41
+ select: () => ({ from: () => ({ where: async () => [{ open: openCount }] }) }),
42
+ update: () => ({
43
+ set: (values: Record<string, unknown>) => {
44
+ sets.push(values);
45
+ return { where: () => ({ returning: async () => passes[pass++] ?? [] }) };
46
+ },
47
+ }),
48
+ // Nothing here may ever delete a change request.
49
+ delete: () => {
50
+ throw new Error('a change request must never be deleted');
51
+ },
52
+ };
53
+ return db as unknown as Database;
54
+ }
55
+
56
+ function makeService(passes: ClosedRow[][], openCount = passes.flat().length) {
57
+ const sets: Record<string, unknown>[] = [];
58
+ const unlocked: string[] = [];
59
+ const dequeued: string[] = [];
60
+ const invalidated: number[] = [];
61
+ const emitted: unknown[] = [];
62
+ const fileLocks = {
63
+ releaseAllOnBranch: vi.fn(async (branch: string) => {
64
+ unlocked.push(branch);
65
+ return 2;
66
+ }),
67
+ } as unknown as FileLockService;
68
+ const pendingCommits = {
69
+ markNeedsAttentionOnBranch: vi.fn(async (branch: string) => {
70
+ dequeued.push(branch);
71
+ return 1;
72
+ }),
73
+ } as unknown as PendingCommitsService;
74
+ const prs = {
75
+ invalidateDetailCache: vi.fn((n: number) => invalidated.push(n)),
76
+ } as unknown as PullRequestService;
77
+ const svc = new WorkflowService(
78
+ makeDb(passes, sets, openCount),
79
+ {} as GitService,
80
+ prs,
81
+ {} as IReviewWorkflowService,
82
+ {} as WorkspaceService,
83
+ {} as IAccessControl,
84
+ fileLocks,
85
+ pendingCommits,
86
+ testKbContext(),
87
+ openChangeGate(),
88
+ );
89
+ (svc as unknown as { events?: { emit(e: unknown): void } }).events = {
90
+ emit: (e: unknown) => emitted.push(e),
91
+ };
92
+ return { svc, sets, unlocked, dequeued, invalidated, emitted, fileLocks, pendingCommits };
93
+ }
94
+
95
+ describe('closeOpenChangeRequestsAsRepositoryReplaced', () => {
96
+ it('closes every open request with the reason, and deletes nothing', async () => {
97
+ const { svc, sets, invalidated, emitted } = makeService([
98
+ [
99
+ { number: 7, sourceBranch: 'alice/draft' },
100
+ { number: 9, sourceBranch: 'bob/draft' },
101
+ ],
102
+ ]);
103
+
104
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(2);
105
+
106
+ // ONE statement closed both: a read-then-write loop would have missed any
107
+ // request opened while it ran.
108
+ expect(sets).toHaveLength(2); // the closing pass, then the pass that found nothing
109
+ expect(sets[0]?.state).toBe('closed');
110
+ // The reason outlives the admin who chose it — that is the whole point
111
+ // of a column rather than a log line.
112
+ expect(sets[0]?.closedReason).toBe('repository-replaced');
113
+ expect(sets[0]?.closedAt).toBeInstanceOf(Date);
114
+ expect(invalidated).toEqual([7, 9]);
115
+ expect(emitted).toEqual([
116
+ { kind: 'change-request-rejected', number: 7 },
117
+ { kind: 'change-request-rejected', number: 9 },
118
+ ]);
119
+ });
120
+
121
+ it('closes a request opened while it was closing the others', async () => {
122
+ // The deployment is still live — the address has not been saved yet — so
123
+ // `POST /change-requests` is still being served. The first pass closed two;
124
+ // #11 arrived after it and is closed by the next one.
125
+ const { svc, invalidated } = makeService([
126
+ [
127
+ { number: 7, sourceBranch: 'alice/draft' },
128
+ { number: 9, sourceBranch: 'bob/draft' },
129
+ ],
130
+ [{ number: 11, sourceBranch: 'carol/draft' }],
131
+ ]);
132
+
133
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(3);
134
+ expect(invalidated).toEqual([7, 9, 11]);
135
+ });
136
+
137
+ it('gives up after a bounded number of passes rather than holding the save open', async () => {
138
+ // A client opening requests in a loop must not be able to keep the save
139
+ // from ever returning. What survives the last pass was opened after the
140
+ // repository was replaced, and the deleted-branch sweep owns it.
141
+ const endless = Array.from({ length: 50 }, (_, i) => [
142
+ { number: i + 1, sourceBranch: `writer-${i}/draft` },
143
+ ]);
144
+ const { svc } = makeService(endless, 50);
145
+ const closed = await svc.closeOpenChangeRequestsAsRepositoryReplaced();
146
+ expect(closed).toBeGreaterThan(0);
147
+ expect(closed).toBeLessThan(50);
148
+ });
149
+
150
+ it('skips a request a merge or a withdrawal closed first', async () => {
151
+ // The guard is the statement's own `where state = 'open'`: #9 was closed by
152
+ // somebody else between the count and this call, so it is not in the
153
+ // returned set — and nothing is done on its behalf.
154
+ const { svc, unlocked, dequeued, invalidated, emitted } = makeService(
155
+ [[{ number: 7, sourceBranch: 'alice/draft' }]],
156
+ 2,
157
+ );
158
+
159
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
160
+
161
+ expect(unlocked).toEqual(['alice/draft']);
162
+ expect(dequeued).toEqual(['alice/draft']);
163
+ expect(invalidated).toEqual([7]);
164
+ expect(emitted).toEqual([{ kind: 'change-request-rejected', number: 7 }]);
165
+ });
166
+
167
+ it('releases the file locks held on the branches it closes', async () => {
168
+ const { svc, unlocked } = makeService([
169
+ [
170
+ { number: 7, sourceBranch: 'alice/draft' },
171
+ { number: 9, sourceBranch: 'bob/draft' },
172
+ ],
173
+ ]);
174
+
175
+ await svc.closeOpenChangeRequestsAsRepositoryReplaced();
176
+
177
+ // Nobody can publish those bytes or release the lock by finishing: the
178
+ // branch belongs to a repository this deployment no longer has.
179
+ expect(unlocked).toEqual(['alice/draft', 'bob/draft']);
180
+ });
181
+
182
+ it('takes the commits already queued for those branches off the worker', async () => {
183
+ const { svc, dequeued, pendingCommits } = makeService([
184
+ [{ number: 7, sourceBranch: 'alice/draft' }],
185
+ ]);
186
+
187
+ await svc.closeOpenChangeRequestsAsRepositoryReplaced();
188
+
189
+ // A release ENQUEUES the bytes and only then drops its lock, so dropping
190
+ // the locks can leave rows waiting. Left pending, the worker would commit
191
+ // them onto a same-named branch of a different repository after the
192
+ // re-clone.
193
+ expect(dequeued).toEqual(['alice/draft']);
194
+ expect(vi.mocked(pendingCommits.markNeedsAttentionOnBranch).mock.calls[0]?.[1]).toMatch(
195
+ /repository was replaced/,
196
+ );
197
+ });
198
+
199
+ it('still closes the requests when a lock release fails', async () => {
200
+ const { svc, fileLocks } = makeService([[{ number: 7, sourceBranch: 'alice/draft' }]]);
201
+ vi.mocked(fileLocks.releaseAllOnBranch).mockRejectedValueOnce(new Error('lock table is down'));
202
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
203
+ try {
204
+ // The close is the decision the admin made; a lock expires on its own
205
+ // within the minute, so it is not worth half-closing the set over.
206
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
207
+ } finally {
208
+ vi.restoreAllMocks();
209
+ }
210
+ });
211
+
212
+ it('still closes the requests when the queued commits cannot be set aside', async () => {
213
+ const { svc, pendingCommits, unlocked } = makeService([
214
+ [{ number: 7, sourceBranch: 'alice/draft' }],
215
+ ]);
216
+ vi.mocked(pendingCommits.markNeedsAttentionOnBranch).mockRejectedValueOnce(
217
+ new Error('pending_commits is down'),
218
+ );
219
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
220
+ try {
221
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
222
+ expect(unlocked).toEqual(['alice/draft']);
223
+ } finally {
224
+ vi.restoreAllMocks();
225
+ }
226
+ });
227
+
228
+ it('closes nothing, and asks nothing of the lock table, when no request is open', async () => {
229
+ const { svc, unlocked, dequeued, emitted } = makeService([]);
230
+ await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(0);
231
+ expect(unlocked).toEqual([]);
232
+ expect(dequeued).toEqual([]);
233
+ expect(emitted).toEqual([]);
234
+ });
235
+ });
236
+
237
+ describe('countOpenChangeRequests', () => {
238
+ it('answers how many are open, so the confirmation can say what it is about', async () => {
239
+ const { svc } = makeService([], 2);
240
+ // A SQL aggregate: the question is an integer, and materializing one row
241
+ // per open request to answer it would scale with the wrong thing.
242
+ await expect(svc.countOpenChangeRequests()).resolves.toBe(2);
243
+ });
244
+
245
+ it('answers zero when the aggregate comes back empty', async () => {
246
+ const { svc } = makeService([], 0);
247
+ await expect(svc.countOpenChangeRequests()).resolves.toBe(0);
248
+ });
249
+ });