@bevel-software/platform-core-backend 0.21.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 (434) 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 +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -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-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -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'));
@@ -17,6 +17,7 @@ import { PullRebaseConflictError, WorkflowDomainError } from '../../../shared/do
17
17
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
18
18
  import type { Database } from '../../database/connection.js';
19
19
  import { openChangeGate } from '../../../__tests__/open-change-gate.js';
20
+ import { hashEmail } from '../../../shared/email-identity.js';
20
21
 
21
22
  // `deleteBranch`'s open-request guard is the only DB touch these tests
22
23
  // exercise. The stub ANSWERS FROM THE WHERE-CLAUSE rather than returning the
@@ -375,7 +376,7 @@ describe('WorkflowService — change request delegation + cache invalidation', (
375
376
  expect(outcome).toEqual({ kind: 'merged', result: mergeResult });
376
377
  // The target branch's workspace is pulled so it doesn't fall behind origin.
377
378
  expect(workspaceService.getOrCreateForBranch).toHaveBeenCalledWith('main');
378
- expect(git.pull).toHaveBeenCalledWith('main');
379
+ expect(git.pull).toHaveBeenCalledWith('main', {});
379
380
  expect(reviewWorkflow.mergePr).toHaveBeenCalledWith(
380
381
  4,
381
382
  expect.objectContaining({ email: 'alice@example.com' }),
@@ -784,8 +785,13 @@ describe('WorkflowService — revertChangeRequestFile / closeEmptyChangeRequest'
784
785
  });
785
786
  });
786
787
 
787
- describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () => {
788
- function makeDeleteHarness(opts: { isAdmin?: boolean; prState?: string } = {}) {
788
+ describe('WorkflowService — deleteChangeRequest (the author’s and the admin’s verb)', () => {
789
+ const AUTHOR_EMAIL = 'alice@example.com'; // makeUser()'s default caller
790
+ const STRANGER_AUTHOR = 'mallory@example.com';
791
+
792
+ function makeDeleteHarness(
793
+ opts: { isAdmin?: boolean; prState?: string; authorEmail?: string | null } = {},
794
+ ) {
789
795
  const git = Object.assign(makeGit(), {
790
796
  changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md']),
791
797
  }) as unknown as GitService;
@@ -795,26 +801,83 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
795
801
  base: 'main',
796
802
  branch: 'mallory/spam',
797
803
  state: opts.prState ?? 'open',
804
+ // Authorship as the server reads it: the stored hash, never the caller's
805
+ // claim. Defaults to someone OTHER than makeUser(), so a test that says
806
+ // nothing about authorship is testing the admin grant alone.
807
+ // `null` means a request with no stored author (opened outside this
808
+ // backend); undefined means "someone, just not the caller".
809
+ authorId:
810
+ opts.authorEmail === null ? undefined : hashEmail(opts.authorEmail ?? STRANGER_AUTHOR),
798
811
  });
799
812
  const access = makeAccessControl();
800
813
  (access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.isAdmin ?? false);
801
814
  const chain: Record<string, unknown> = {};
815
+ // Two different selects run under one chain mock: the branch-retirement
816
+ // lookup asks for `{ sourceBranch }`, and `openChangeRequestOn` asks for
817
+ // `{ number, sourceBranch }`. Answer by projection rather than call order,
818
+ // so the retirement path is exercised (a branch to retire, and no other
819
+ // open request holding it) without the test depending on call sequence.
820
+ let projection: Record<string, unknown> = {};
802
821
  Object.assign(chain, {
803
- select: vi.fn(() => chain),
822
+ select: vi.fn((proj?: Record<string, unknown>) => {
823
+ projection = proj ?? {};
824
+ return chain;
825
+ }),
804
826
  from: vi.fn(() => chain),
805
827
  where: vi.fn(() => chain),
806
- limit: vi.fn(async () => []),
828
+ limit: vi.fn(async () =>
829
+ 'number' in projection ? [] : [{ sourceBranch: 'mallory/spam' }],
830
+ ),
807
831
  update: vi.fn(() => chain),
808
832
  set: vi.fn(() => chain),
809
833
  returning: vi.fn(async () => [{ id: 1 }]),
810
834
  });
811
835
  const workspaces = makeWorkspaceService();
812
836
  const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), workspaces, access, makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
813
- return { svc, prs, db: chain, access, workspaces };
837
+ return { svc, prs, db: chain, access, workspaces, git };
814
838
  }
815
839
 
816
- it('refuses a non-admin with 403 and touches nothing', async () => {
817
- const { svc, db } = makeDeleteHarness({ isAdmin: false });
840
+ it('refuses a non-admin who is not the author, with 403 and the author-or-admin message', async () => {
841
+ const { svc, db, git } = makeDeleteHarness({ isAdmin: false });
842
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
843
+ status: 403,
844
+ message: "Only the request's author or an admin can delete it.",
845
+ });
846
+ // "Changes nothing" is the whole promise of the refusal: no row flipped,
847
+ // no branch removed.
848
+ expect(db.update).not.toHaveBeenCalled();
849
+ expect(git.deleteBranch).not.toHaveBeenCalled();
850
+ });
851
+
852
+ it('lets the AUTHOR delete their own request without admin rights: closes it and retires the branch', async () => {
853
+ const { svc, prs, db, git } = makeDeleteHarness({
854
+ isAdmin: false,
855
+ authorEmail: AUTHOR_EMAIL,
856
+ });
857
+ await svc.deleteChangeRequest(9, makeUser());
858
+ expect(db.update).toHaveBeenCalled();
859
+ expect(prs.invalidateDetailCache).toHaveBeenCalledWith(9);
860
+ expect(git.deleteBranch).toHaveBeenCalledWith(
861
+ expect.any(String),
862
+ 'mallory/spam',
863
+ expect.objectContaining({ email: AUTHOR_EMAIL }),
864
+ expect.objectContaining({ systemCleanup: true }),
865
+ );
866
+ });
867
+
868
+ it('matches the author case-insensitively, as hashEmail does', async () => {
869
+ // The stored hash is of the normalized address; a caller signed in as
870
+ // `Alice@Example.com` is the same person and must not be refused.
871
+ const { svc, db } = makeDeleteHarness({ isAdmin: false, authorEmail: AUTHOR_EMAIL });
872
+ await svc.deleteChangeRequest(9, makeUser({ email: ' Alice@Example.com ' }));
873
+ expect(db.update).toHaveBeenCalled();
874
+ });
875
+
876
+ it('refuses a request with no stored author to a non-admin', async () => {
877
+ // Opened outside this backend: nobody can claim authorship of it, so the
878
+ // admin grant is the only one left. An absent hash must never read as a
879
+ // match.
880
+ const { svc, db } = makeDeleteHarness({ isAdmin: false, authorEmail: null });
818
881
  await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
819
882
  expect(db.update).not.toHaveBeenCalled();
820
883
  });
@@ -832,6 +895,94 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
832
895
  expect(db.update).not.toHaveBeenCalled();
833
896
  });
834
897
 
898
+ it('refuses the AUTHOR on an applied request with 422 and the applied message', async () => {
899
+ // Applied between opening the dialog and confirming. The author's new
900
+ // grant does not reach applied history either.
901
+ const { svc, db, git } = makeDeleteHarness({
902
+ isAdmin: false,
903
+ prState: 'merged',
904
+ authorEmail: AUTHOR_EMAIL,
905
+ });
906
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
907
+ status: 422,
908
+ message: 'This change request has already been applied.',
909
+ });
910
+ expect(db.update).not.toHaveBeenCalled();
911
+ expect(git.deleteBranch).not.toHaveBeenCalled();
912
+ });
913
+
914
+ it('refuses the AUTHOR with 409 when the base cannot be verified, and changes nothing', async () => {
915
+ // The strict base fetch is NOT skipped for an author, even though their
916
+ // grant needs no roles.yaml read: an unreachable origin means the branch
917
+ // removal would fail silently, and the delete must refuse whole rather
918
+ // than close the request and leave the branch.
919
+ const { svc, db, git, workspaces } = makeDeleteHarness({
920
+ isAdmin: false,
921
+ authorEmail: AUTHOR_EMAIL,
922
+ });
923
+ (workspaces.ensureRemotesFetched as ReturnType<typeof vi.fn>).mockRejectedValue(
924
+ new Error('origin unreachable'),
925
+ );
926
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
927
+ status: 409,
928
+ message: expect.stringContaining('Retry in a moment.'),
929
+ });
930
+ expect(db.update).not.toHaveBeenCalled();
931
+ expect(git.deleteBranch).not.toHaveBeenCalled();
932
+ });
933
+
934
+ it('refuses the AUTHOR with 409 when the base ref does not resolve', async () => {
935
+ const { svc, db, access } = makeDeleteHarness({
936
+ isAdmin: false,
937
+ authorEmail: AUTHOR_EMAIL,
938
+ });
939
+ (access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(null);
940
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 409 });
941
+ expect(db.update).not.toHaveBeenCalled();
942
+ });
943
+
944
+ it('a request already closed elsewhere still has its branch retired', async () => {
945
+ // Withdrawn in another tab: the guarded update flips no row, the request
946
+ // is not merged, so the delete finishes the job the withdraw left undone.
947
+ const { svc, db, git } = makeDeleteHarness({
948
+ isAdmin: false,
949
+ prState: 'closed',
950
+ authorEmail: AUTHOR_EMAIL,
951
+ });
952
+ // The update is guarded on `state = 'open'`, so it flips nothing here.
953
+ (db.returning as ReturnType<typeof vi.fn>).mockResolvedValue([]);
954
+ await expect(svc.deleteChangeRequest(9, makeUser())).resolves.toBeUndefined();
955
+ expect(git.deleteBranch).toHaveBeenCalledWith(
956
+ expect.any(String),
957
+ 'mallory/spam',
958
+ expect.anything(),
959
+ expect.objectContaining({ systemCleanup: true }),
960
+ );
961
+ });
962
+
963
+ it('keeps a branch another open request still uses, and closes the request silently', async () => {
964
+ const { svc, db, git } = makeDeleteHarness({
965
+ isAdmin: false,
966
+ authorEmail: AUTHOR_EMAIL,
967
+ });
968
+ // `openChangeRequestOn` finds a live request on the branch (the
969
+ // `{ number, sourceBranch }` projection), so retirement backs off.
970
+ let projection: Record<string, unknown> = {};
971
+ (db.select as ReturnType<typeof vi.fn>).mockImplementation((proj?: Record<string, unknown>) => {
972
+ projection = proj ?? {};
973
+ return db;
974
+ });
975
+ (db.limit as ReturnType<typeof vi.fn>).mockImplementation(async () =>
976
+ 'number' in projection
977
+ ? [{ number: 11, sourceBranch: 'mallory/spam' }]
978
+ : [{ sourceBranch: 'mallory/spam' }],
979
+ );
980
+ // No throw, no message: the request closes and the branch survives.
981
+ await expect(svc.deleteChangeRequest(9, makeUser())).resolves.toBeUndefined();
982
+ expect(db.update).toHaveBeenCalled();
983
+ expect(git.deleteBranch).not.toHaveBeenCalled();
984
+ });
985
+
835
986
  it('kicks the deleted-branch sweep when the base workspace cannot be resolved', async () => {
836
987
  const { svc, db, workspaces } = makeDeleteHarness({ isAdmin: true });
837
988
  (workspaces.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
@@ -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
+ });