@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
@@ -43,6 +43,7 @@ import type {
43
43
  ChangeRequestApplyFailureKind,
44
44
  ChangeRequestComment,
45
45
  ChangeRequestDetail,
46
+ ChangeRequestUpdateResult,
46
47
  ChangeRequestState,
47
48
  ChangedFile,
48
49
  FileApproval,
@@ -62,7 +63,7 @@ import {
62
63
  isPlatformRestoreShape,
63
64
  } from '@bevel-software/platform-shared';
64
65
  import type { KbContext } from '../../shared/kb-context.js';
65
- import { and, eq, inArray, isNotNull, isNull, lt, or } from 'drizzle-orm';
66
+ import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
66
67
  import type { Database } from '../database/connection.js';
67
68
  import { changeRequests } from '../database/schema.js';
68
69
  import type { GitService } from './git/git.service.js';
@@ -323,9 +324,9 @@ export class WorkflowService implements IWorkflowService {
323
324
  * the composition root registers module-owned behavior against it
324
325
  * (`core.workflowService.hooks.onCommitValidation(…)` /
325
326
  * `.onPreWrite(…)`) after `createCoreServices` returns. The composition
326
- * root passes the SAME instance `GitService` and the session-ontology
327
- * gate consult; the default keeps test constructions (which don't
328
- * exercise hooks) unchanged.
327
+ * root passes the SAME instance `GitService` and the agent-access gate
328
+ * consult; the default keeps test constructions (which don't exercise
329
+ * hooks) unchanged.
329
330
  */
330
331
  public readonly hooks: WorkflowHooks = new WorkflowHooks(),
331
332
  ) {
@@ -576,8 +577,11 @@ export class WorkflowService implements IWorkflowService {
576
577
  * and nothing else. The pull itself reports the change, since it is the
577
578
  * only code that can observe it under the workspace mutex.
578
579
  */
579
- private async pullWorkspace(workspaceId: string): Promise<boolean> {
580
- const { treeChanged } = await this.git.pull(workspaceId);
580
+ private async pullWorkspace(
581
+ workspaceId: string,
582
+ opts: { preserveMerges?: boolean } = {},
583
+ ): Promise<boolean> {
584
+ const { treeChanged } = await this.git.pull(workspaceId, opts);
581
585
  const defaultBranch = this.kb.defaultBranch;
582
586
  if (treeChanged && branchForWorkspaceId(workspaceId) === defaultBranch) {
583
587
  this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: defaultBranch });
@@ -906,6 +910,22 @@ export class WorkflowService implements IWorkflowService {
906
910
  return this.git.fileContentsAtCommit(workspaceId, path, sha);
907
911
  }
908
912
 
913
+ /**
914
+ * The BYTES of one file at a change — what the file page's byte-reading
915
+ * viewers (pdf, image, docx, pptx, xlsx, email) need to show a past save,
916
+ * and what "Download this version" hands over. `fileAtChange` above cannot
917
+ * serve them: it decodes to UTF-8, which ruins every format that is not
918
+ * text.
919
+ */
920
+ fileBytesAtChange(
921
+ workspaceId: string,
922
+ path: string,
923
+ sha: string,
924
+ side: 'after' | 'before',
925
+ ): Promise<{ bytes: Buffer; blobId: string } | null> {
926
+ return this.git.fileBytesAtCommit(workspaceId, path, sha, side);
927
+ }
928
+
909
929
  /**
910
930
  * Resolve write permission for `targetPath` against the access tree at
911
931
  * `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
@@ -1729,6 +1749,38 @@ export class WorkflowService implements IWorkflowService {
1729
1749
  return this.prs.getPr(number);
1730
1750
  }
1731
1751
 
1752
+ /**
1753
+ * The newest non-open change request from `sourceBranch` by `authorEmail`.
1754
+ *
1755
+ * Read straight off `change_requests` rather than through the PR listing,
1756
+ * which serves OPEN requests only: the whole point here is the closed ones.
1757
+ * `author_email` is stored lowercased at insert, so the needle is too.
1758
+ * Ordered by `closed_at` with `created_at` as the tiebreak, because a row
1759
+ * closed before the column existed (or by a path that forgot to stamp it)
1760
+ * still has a creation time and must not sort as the oldest.
1761
+ */
1762
+ async latestClosedChangeRequest(
1763
+ authorEmail: string,
1764
+ sourceBranch: string,
1765
+ ): Promise<{ number: number; state: ChangeRequestState } | null> {
1766
+ const email = authorEmail.trim().toLowerCase();
1767
+ if (!email || !sourceBranch) return null;
1768
+ const [row] = await this.db
1769
+ .select({ number: changeRequests.number, state: changeRequests.state })
1770
+ .from(changeRequests)
1771
+ .where(
1772
+ and(
1773
+ eq(changeRequests.authorEmail, email),
1774
+ eq(changeRequests.sourceBranch, sourceBranch),
1775
+ ne(changeRequests.state, 'open'),
1776
+ ),
1777
+ )
1778
+ .orderBy(desc(changeRequests.closedAt), desc(changeRequests.createdAt))
1779
+ .limit(1);
1780
+ if (!row) return null;
1781
+ return { number: row.number, state: row.state as ChangeRequestState };
1782
+ }
1783
+
1732
1784
  getChangeRequestDetail(
1733
1785
  number: number,
1734
1786
  opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
@@ -1953,13 +2005,29 @@ export class WorkflowService implements IWorkflowService {
1953
2005
  * the enforcement read one predicate. A conflicting merge is aborted
1954
2006
  * inside `mergeFromOrigin`: nothing is committed or pushed, and the branch
1955
2007
  * is exactly what it was.
2008
+ *
2009
+ * ONE detail read after the merge. The read before it exists only to
2010
+ * decide authority and to name the two branches, so it asks for no patches
2011
+ * — that is one git subprocess per changed file it does not spend. The
2012
+ * approvals carry-forward runs BEFORE the final read rather than after it,
2013
+ * against a head resolved with `resolvePrShas` (two rev-parses) instead of
2014
+ * a whole second detail, so the detail that goes back already describes the
2015
+ * approvals it carried. What used to be three detail reads is now one.
1956
2016
  */
1957
2017
  async updateFromTarget(
1958
2018
  workspaceId: string,
1959
2019
  user: AuthUser,
1960
2020
  number: number,
1961
- ): Promise<ChangeRequestDetail> {
1962
- const detail = await this.prs.getPrDetail(number, { fresh: true, workspaceId, viewerEmail: user.email });
2021
+ ): Promise<ChangeRequestUpdateResult> {
2022
+ // No patches: all this read decides is authority, state and the two
2023
+ // branch names. Asking for patch text here would diff every changed file
2024
+ // in a request that is about to be merged and read in full anyway.
2025
+ const detail = await this.prs.getPrDetail(number, {
2026
+ fresh: true,
2027
+ workspaceId,
2028
+ viewerEmail: user.email,
2029
+ patches: false,
2030
+ });
1963
2031
  if (!detail) {
1964
2032
  throw new WorkflowValidationError(
1965
2033
  `Change request #${number} not found.`,
@@ -1987,8 +2055,15 @@ export class WorkflowService implements IWorkflowService {
1987
2055
  // merge commit that then cannot push, stranding it in the workspace. A
1988
2056
  // rebase conflict hands the stranded saves to recovery, as every other
1989
2057
  // pull does, before the refusal reaches the caller.
2058
+ //
2059
+ // `preserveMerges`: this is the one pull whose unpushed commit may be a
2060
+ // MERGE — the update that ran before this one and could not push. A plain
2061
+ // rebase replays it as cherry-picks of the target's commits, which drops
2062
+ // the target as a parent, so the branch never contains the target's head
2063
+ // however many times the update runs. Replayed with `--rebase-merges` it
2064
+ // stays a merge and the next push ends the loop.
1990
2065
  try {
1991
- await this.pullWorkspace(workspaceId);
2066
+ await this.pullWorkspace(workspaceId, { preserveMerges: true });
1992
2067
  } catch (err) {
1993
2068
  if (err instanceof PullRebaseConflictError) {
1994
2069
  await this.queuePullConflictRecovery(workspaceId, err, user);
@@ -2026,24 +2101,53 @@ export class WorkflowService implements IWorkflowService {
2026
2101
  if (outcome.kind === 'conflicts') {
2027
2102
  throw new ChangeRequestConflictsError(detail.branch, detail.base, outcome.paths);
2028
2103
  }
2029
- // If a new merge commit landed, push so the CR picks it up. When
2030
- // already up to date there's nothing to share — short-circuit the push.
2031
- if (!outcome.alreadyUpToDate) {
2104
+ // Push whenever the CLONE holds a commit origin has not seen — not when
2105
+ // THIS merge authored one. The two differ in exactly the case that keeps
2106
+ // a request behind for good: an earlier update merged, its push failed,
2107
+ // and the merge is sitting in the workspace. That merge already contains
2108
+ // the target, so this call's `mergeFromOrigin` reports
2109
+ // `alreadyUpToDate` and the old gate skipped the push — leaving the
2110
+ // request behind on the remote, and the next open with the same work to
2111
+ // do. Asking git what is unpushed retries it instead.
2112
+ //
2113
+ // A failed probe falls back to the old gate rather than to "push
2114
+ // anyway": `trackedPush` on a clone with nothing to push is a wasted
2115
+ // round trip on every single update.
2116
+ const hasUnpushed = await this.git
2117
+ .hasUnpushedCommits(workspaceId)
2118
+ .catch((err: unknown) => {
2119
+ log.warn(
2120
+ `could not tell whether change request #${number} has unpushed commits; falling back to whether this merge authored one`,
2121
+ { err },
2122
+ );
2123
+ return !outcome.alreadyUpToDate;
2124
+ });
2125
+ if (hasUnpushed) {
2032
2126
  await this.trackedPush(workspaceId, user);
2033
- // The source head moved: the refusal described a revision that is gone.
2034
- await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
2035
2127
  }
2036
- this.prs.invalidateDetailCache(number);
2037
- const refreshed = await this.prs.getPrDetail(number, {
2038
- fresh: true,
2039
- workspaceId,
2040
- viewerEmail: user.email,
2041
- });
2042
- if (!refreshed) {
2043
- throw new WorkflowValidationError(
2044
- `Refreshed change request #${number} but could not re-fetch its detail.`,
2128
+ // Where the branch ended up, as PUBLISHED — two rev-parses, not a second
2129
+ // detail read. Everything below needs only the sha, and the detail that
2130
+ // goes back to the caller is read once, at the end, after the approvals
2131
+ // it should describe have been carried.
2132
+ let headAfterMerge = headBeforeMerge;
2133
+ try {
2134
+ const at = await this.git.resolvePrShas(workspaceId, detail.base, detail.branch);
2135
+ headAfterMerge = at.headSha;
2136
+ } catch (err) {
2137
+ log.warn(
2138
+ `could not resolve the published head of change request #${number} after updating it from target`,
2139
+ { err },
2045
2140
  );
2046
2141
  }
2142
+ const headMoved = Boolean(headBeforeMerge) && headAfterMerge !== headBeforeMerge;
2143
+ // The source head moved: the refusal described a revision that is gone.
2144
+ // `hasUnpushed` counts as well as the sha comparison, and deliberately —
2145
+ // a push that just landed moved the published head by definition, and
2146
+ // reading the sha to confirm it is a call that can fail. A refusal left
2147
+ // standing over a revision nobody can apply any more is the worse error.
2148
+ if (headMoved || hasUnpushed) {
2149
+ await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
2150
+ }
2047
2151
  // The merge is a commit the APPROVERS did not make. Re-pin their
2048
2152
  // approvals onto the new head for every file whose bytes it left alone —
2049
2153
  // git's own two-dot diff between the two heads is the verdict on which
@@ -2051,45 +2155,61 @@ export class WorkflowService implements IWorkflowService {
2051
2155
  // than the bookkeeping, so a failure here is logged and the fresh detail
2052
2156
  // still goes back.
2053
2157
  //
2054
- // `alreadyUpToDate` is the real gate: no merge commit, no head to compare,
2055
- // nothing moved under anyone. The other two conditions are belt-and-braces
2056
- // over a sha that is not a sha — a resolve that failed onto a detail with
2057
- // no head, or two heads that somehow read equal — both of which
2058
- // `pathsChangedBetween` and `carryApprovalsForward` would refuse anyway.
2059
- // A merge that authored a commit cannot leave the head where it was.
2060
- if (!outcome.alreadyUpToDate && headBeforeMerge && refreshed.headSha !== headBeforeMerge) {
2158
+ // The head moving is the real gate: nothing moved, nothing moved under
2159
+ // anyone. It also covers the sha that is not a sha — a resolve that
2160
+ // failed onto a detail with no head — which `pathsChangedBetween` and
2161
+ // `carryApprovalsForward` would refuse anyway.
2162
+ //
2163
+ // This runs BEFORE the detail read, not after it. The rows it writes are
2164
+ // what the caller's `approvals` must describe, so doing it first means
2165
+ // the one read at the end is already correct — where carrying afterwards
2166
+ // needed a THIRD read to correct a detail assembled before the rows
2167
+ // existed.
2168
+ //
2169
+ // The same list answers the caller's `updatedPaths`: "which files did
2170
+ // this merge change" is one question, and the two answers must not be
2171
+ // allowed to differ — a file whose approval was carried (so: untouched)
2172
+ // that the dialog then re-read would be the dialog discarding content the
2173
+ // server just called unchanged.
2174
+ let updatedPaths: string[] = [];
2175
+ if (headMoved) {
2061
2176
  try {
2062
- const changedPaths = await this.git.pathsChangedBetween(
2177
+ updatedPaths = await this.git.pathsChangedBetween(
2063
2178
  workspaceId,
2064
2179
  headBeforeMerge,
2065
- refreshed.headSha,
2180
+ headAfterMerge,
2066
2181
  );
2067
- const carried = await this.reviewWorkflow.carryApprovalsForward(
2182
+ await this.reviewWorkflow.carryApprovalsForward(
2068
2183
  number,
2069
2184
  headBeforeMerge,
2070
- refreshed.headSha,
2071
- changedPaths,
2185
+ headAfterMerge,
2186
+ updatedPaths,
2072
2187
  );
2073
- // The detail above was assembled before those rows existed, so its
2074
- // `approvals` (and the merge gate derived from them) still describe
2075
- // the pre-merge head. Only re-read when something actually carried.
2076
- if (carried > 0) {
2077
- this.prs.invalidateDetailCache(number);
2078
- const withApprovals = await this.prs.getPrDetail(number, {
2079
- fresh: true,
2080
- workspaceId,
2081
- viewerEmail: user.email,
2082
- });
2083
- if (withApprovals) return withApprovals;
2084
- }
2085
2188
  } catch (err) {
2086
2189
  log.warn(
2087
2190
  `could not carry approvals forward on change request #${number} after updating from target`,
2088
2191
  { err },
2089
2192
  );
2193
+ // The diff is what failed, so which files moved is unknown. Say so as
2194
+ // "every file this request had", which is every file a caller can
2195
+ // already be showing: slow beats a dialog that goes on presenting
2196
+ // pre-merge text as current. Anything the merge ADDED is not on the
2197
+ // list and does not need to be — no caller has read it yet.
2198
+ updatedPaths = (detail.files ?? []).map((f) => f.path);
2090
2199
  }
2091
2200
  }
2092
- return refreshed;
2201
+ this.prs.invalidateDetailCache(number);
2202
+ const refreshed = await this.prs.getPrDetail(number, {
2203
+ fresh: true,
2204
+ workspaceId,
2205
+ viewerEmail: user.email,
2206
+ });
2207
+ if (!refreshed) {
2208
+ throw new WorkflowValidationError(
2209
+ `Refreshed change request #${number} but could not re-fetch its detail.`,
2210
+ );
2211
+ }
2212
+ return { ...refreshed, updatedPaths };
2093
2213
  }
2094
2214
 
2095
2215
  listComments(number: number): Promise<ChangeRequestComment[]> {
@@ -2651,11 +2771,16 @@ export class WorkflowService implements IWorkflowService {
2651
2771
  /**
2652
2772
  * DELETE a change request outright: close it (whatever its diff says) and
2653
2773
  * retire its source branch — the request, its proposal, and the branch that
2654
- * carried it are gone in one verb. Admin-only, resolved the same way every
2655
- * other admin check here is (write on `roles.yaml` at `origin/<base>`,
2656
- * never a local ref): this is the moderation verb for a shared deployment,
2657
- * stronger than reject (which the author or the files' owners can do, and
2658
- * which leaves the branch for a second round).
2774
+ * carried it are gone in one verb. The request's AUTHOR or an admin, and
2775
+ * nobody else: it is the author's own proposal and their own branch to throw
2776
+ * away, and it is the moderation verb for a shared deployment. Admin rights
2777
+ * resolve the same way every other admin check here does (write on
2778
+ * `roles.yaml` at `origin/<base>`, never a local ref); authorship is the
2779
+ * STORED `authorId` hash, never anything the caller sends.
2780
+ *
2781
+ * Stronger than reject, which the changed files' owners may also do and which
2782
+ * leaves the branch for a second round. They keep reject and do not get this:
2783
+ * an owner must not be able to destroy someone else's text and branch.
2659
2784
  */
2660
2785
  async deleteChangeRequest(number: number, user: AuthUser): Promise<void> {
2661
2786
  const summary = await this.prs.getPr(number);
@@ -2714,8 +2839,17 @@ export class WorkflowService implements IWorkflowService {
2714
2839
  409,
2715
2840
  );
2716
2841
  }
2717
- if (isAdmin !== true) {
2718
- throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
2842
+ // Authorship is decided from the STORED author hash, never from anything
2843
+ // the caller sends. Note what is deliberately NOT short-circuited for an
2844
+ // author: the strict fetch and the `null` check above still run. An
2845
+ // author's grant needs no `roles.yaml` read, so skipping them would look
2846
+ // free — but it would let a delete close the request while the branch
2847
+ // removal silently failed against an unreachable origin, and "the shared
2848
+ // repository was down, so nothing changed" is the one refusal that has to
2849
+ // stay honest.
2850
+ const callerIsAuthor = !!(summary.authorId && summary.authorId === hashEmail(user.email));
2851
+ if (!callerIsAuthor && isAdmin !== true) {
2852
+ throw new WorkflowDomainError("Only the request's author or an admin can delete it.", 403);
2719
2853
  }
2720
2854
 
2721
2855
  // Close first (idempotent: an already-closed request just skips to the
@@ -2906,6 +3040,107 @@ export class WorkflowService implements IWorkflowService {
2906
3040
  return closed;
2907
3041
  }
2908
3042
 
3043
+ /**
3044
+ * How many change requests are open right now.
3045
+ *
3046
+ * Read by the setup screen's repository-change confirmation, which has to
3047
+ * say how many requests the admin is deciding about BEFORE the address is
3048
+ * saved. A plain count — nothing here reaches the repository, so it answers
3049
+ * just as well for a repository that is already gone.
3050
+ */
3051
+ async countOpenChangeRequests(): Promise<number> {
3052
+ const [row] = await this.db
3053
+ .select({ open: sql<number>`count(*)::int` })
3054
+ .from(changeRequests)
3055
+ .where(eq(changeRequests.state, 'open'));
3056
+ return row?.open ?? 0;
3057
+ }
3058
+
3059
+ /**
3060
+ * Close every open change request because the knowledge-base REPOSITORY was
3061
+ * replaced, and answer how many were closed.
3062
+ *
3063
+ * The admin's own choice on the setup screen: a repository that merely
3064
+ * MOVED keeps its requests (the branches came along), while a repository
3065
+ * that was REPLACED has none of those branches, so every request points at
3066
+ * something this deployment can no longer fetch. Nothing is deleted — the
3067
+ * rows stay, closed, carrying `closed_reason = 'repository-replaced'` so
3068
+ * the reason survives the person who chose it.
3069
+ *
3070
+ * Locks held on those branches go with them. Their holder cannot publish
3071
+ * the bytes (the branch is not in the new repository) and cannot release
3072
+ * the lock by finishing (the request is closed), so the row would sit there
3073
+ * refusing the path until its TTL ran out — and a still-connected client's
3074
+ * heartbeat keeps pushing that out.
3075
+ *
3076
+ * The close is ONE statement per pass — `update … where state = 'open'
3077
+ * returning` — not a count followed by a row-at-a-time loop. Read first and
3078
+ * then written, every request opened while the loop ran would be missed and
3079
+ * left open against a branch the new repository does not have. One
3080
+ * set-shaped statement cannot miss a row that exists when it runs, and the
3081
+ * guard is the statement's own `where`, so a merge or a withdrawal racing it
3082
+ * wins and its request is simply not in the returned set.
3083
+ *
3084
+ * Repeated while a pass still finds something, because a request can be
3085
+ * opened BETWEEN passes: the caller has not saved the new address yet, so
3086
+ * the deployment is still live and `POST /change-requests` is still being
3087
+ * served. Bounded, so a client opening requests in a loop cannot hold the
3088
+ * save open forever — what survives the last pass is a request opened after
3089
+ * the repository was replaced, which the deleted-branch sweep closes on its
3090
+ * own terms.
3091
+ */
3092
+ async closeOpenChangeRequestsAsRepositoryReplaced(): Promise<number> {
3093
+ let closed = 0;
3094
+ for (let pass = 0; pass < 5; pass++) {
3095
+ const now = new Date();
3096
+ const justClosed = await this.db
3097
+ .update(changeRequests)
3098
+ .set({ state: 'closed', closedAt: now, updatedAt: now, closedReason: 'repository-replaced' })
3099
+ .where(eq(changeRequests.state, 'open'))
3100
+ .returning({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch });
3101
+ if (justClosed.length === 0) break;
3102
+ for (const cr of justClosed) {
3103
+ // Best effort, per branch: a lock table that will not answer must not
3104
+ // leave the requests half closed — the close is the decision the admin
3105
+ // made, and the locks expire on their own within the minute.
3106
+ try {
3107
+ const released = await this.fileLocks.releaseAllOnBranch(cr.sourceBranch);
3108
+ if (released > 0) {
3109
+ crLog.info(`released ${released} file lock(s) held on "${cr.sourceBranch}"`);
3110
+ }
3111
+ } catch (err) {
3112
+ crLog.warn(`could not release the file locks on "${cr.sourceBranch}":`, { err });
3113
+ }
3114
+ // The bytes a release already enqueued belong to the repository that
3115
+ // is gone — see `markNeedsAttentionOnBranch`. Taken out of the
3116
+ // worker's reach in the same breath as the locks, because dropping a
3117
+ // lock is precisely what leaves a row behind.
3118
+ try {
3119
+ const quarantined = await this.pendingCommits.markNeedsAttentionOnBranch(
3120
+ cr.sourceBranch,
3121
+ 'The knowledge-base repository was replaced while this commit was still queued, so it was never ' +
3122
+ 'written. The bytes are kept here: the branch it was meant for belongs to the previous repository.',
3123
+ );
3124
+ if (quarantined > 0) {
3125
+ crLog.warn(
3126
+ `${quarantined} queued commit(s) on "${cr.sourceBranch}" need attention: the repository was replaced ` +
3127
+ 'before they landed, so they were not written to the new one.',
3128
+ );
3129
+ }
3130
+ } catch (err) {
3131
+ crLog.warn(`could not set aside the queued commits on "${cr.sourceBranch}":`, { err });
3132
+ }
3133
+ this.prs.invalidateDetailCache(cr.number);
3134
+ this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
3135
+ closed++;
3136
+ }
3137
+ }
3138
+ if (closed > 0) {
3139
+ crLog.info(`closed ${closed} change request(s): the knowledge-base repository was replaced`);
3140
+ }
3141
+ return closed;
3142
+ }
3143
+
2909
3144
  /**
2910
3145
  * The sweep above, on demand. Boot runs it once (`create-core-server`),
2911
3146
  * which clears requests stranded BEFORE the process started; a branch that
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
+ import {
3
+ notifyAgentRead,
4
+ assertAgentWriteAllowed,
5
+ SESSION_ID_DESCRIPTION,
6
+ SESSION_ID_INPUT,
7
+ ToolDescriptionNotes,
8
+ type AgentAccessGate,
9
+ } from '../agent-access.gate.js';
10
+ import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
11
+ import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
12
+
13
+ const RECOVERY_EMAIL = 'recovery-bot@bevel.local';
14
+ const P_FILE = 'knowledge-base/KnowledgeBase/Product/Knowledge/Roadmap.md';
15
+
16
+ function ctx(over: Partial<ToolContext> = {}): ToolContext {
17
+ return {
18
+ user: { id: 'u1', email: 'a@b.c', name: 'A' },
19
+ scope: 'write',
20
+ source: 'external',
21
+ sessionId: 's1',
22
+ abortSignal: new AbortController().signal,
23
+ workspaceService: {} as never,
24
+ workflowService: {} as never,
25
+ events: {} as never,
26
+ getFilesystem: async () => ({}) as never,
27
+ ...over,
28
+ } as ToolContext;
29
+ }
30
+
31
+ /** A gate with a fresh hook registry, plus the two lists of what the hooks saw. */
32
+ function makeGate(): { gate: AgentAccessGate; reads: AgentOperationContext[]; writes: AgentOperationContext[] } {
33
+ const hooks = new WorkflowHooks();
34
+ const reads: AgentOperationContext[] = [];
35
+ const writes: AgentOperationContext[] = [];
36
+ hooks.onAgentRead(async (op) => {
37
+ reads.push(op);
38
+ });
39
+ hooks.onPreWrite(async (op) => {
40
+ writes.push(op);
41
+ });
42
+ return { gate: { recoveryBotEmail: RECOVERY_EMAIL, hooks, notes: new ToolDescriptionNotes() }, reads, writes };
43
+ }
44
+
45
+ describe('notifyAgentRead', () => {
46
+ it('calls the read hook once with the session, path, branch, user and source', async () => {
47
+ const { gate, reads, writes } = makeGate();
48
+ await notifyAgentRead(gate, ctx(), 'draft-1', P_FILE);
49
+ expect(reads).toEqual([
50
+ {
51
+ sessionId: 's1',
52
+ wsPath: P_FILE,
53
+ branch: 'draft-1',
54
+ user: { id: 'u1', email: 'a@b.c', name: 'A' },
55
+ source: 'external',
56
+ },
57
+ ]);
58
+ expect(writes).toEqual([]);
59
+ });
60
+
61
+ it('carries the in-app agent as source `internal`', async () => {
62
+ const { gate, reads } = makeGate();
63
+ await notifyAgentRead(gate, ctx({ source: 'internal' }), 'main', P_FILE);
64
+ expect(reads[0].source).toBe('internal');
65
+ });
66
+
67
+ it('calls the hook with no session id when the call carried none — the hook decides', async () => {
68
+ const { gate, reads } = makeGate();
69
+ await notifyAgentRead(gate, ctx({ sessionId: undefined }), 'main', P_FILE);
70
+ expect(reads).toHaveLength(1);
71
+ expect(reads[0].sessionId).toBeUndefined();
72
+ });
73
+
74
+ it('never reaches the hooks for a caller that is not an agent', async () => {
75
+ const { gate, reads } = makeGate();
76
+ await notifyAgentRead(gate, ctx({ source: 'session' }), 'main', P_FILE);
77
+ expect(reads).toEqual([]);
78
+ });
79
+
80
+ it('never reaches the hooks for the recovery bot, whatever the case of its address', async () => {
81
+ const { gate, reads } = makeGate();
82
+ await notifyAgentRead(gate, ctx({ user: { id: 'bot', email: 'Recovery-Bot@Bevel.local', name: 'bot' } }), 'main', P_FILE);
83
+ expect(reads).toEqual([]);
84
+ });
85
+
86
+ it('refuses the read with the hook\'s own message and status', async () => {
87
+ const hooks = new WorkflowHooks();
88
+ hooks.onAgentRead(async () => {
89
+ throw new ToolError('Not in this conversation.', 403);
90
+ });
91
+ const gate: AgentAccessGate = { recoveryBotEmail: RECOVERY_EMAIL, hooks, notes: new ToolDescriptionNotes() };
92
+ await expect(notifyAgentRead(gate, ctx(), 'main', P_FILE)).rejects.toMatchObject({
93
+ message: 'Not in this conversation.',
94
+ status: 403,
95
+ });
96
+ });
97
+
98
+ it('is a no-op with no hook registered', async () => {
99
+ const gate: AgentAccessGate = {
100
+ recoveryBotEmail: RECOVERY_EMAIL,
101
+ hooks: new WorkflowHooks(),
102
+ notes: new ToolDescriptionNotes(),
103
+ };
104
+ await expect(notifyAgentRead(gate, ctx({ sessionId: undefined }), 'main', P_FILE)).resolves.toBeUndefined();
105
+ });
106
+ });
107
+
108
+ describe('assertAgentWriteAllowed', () => {
109
+ it('calls the write hook with the path, and never the read hook', async () => {
110
+ const { gate, reads, writes } = makeGate();
111
+ await assertAgentWriteAllowed(gate, ctx(), 'draft-1', P_FILE);
112
+ expect(reads).toEqual([]);
113
+ expect(writes).toHaveLength(1);
114
+ expect(writes[0]).toMatchObject({ sessionId: 's1', wsPath: P_FILE, branch: 'draft-1', source: 'external' });
115
+ });
116
+
117
+ it('calls the write hook with NO path for a write-capable call that has none', async () => {
118
+ const { gate, writes } = makeGate();
119
+ await assertAgentWriteAllowed(gate, ctx(), 'main');
120
+ expect(writes).toHaveLength(1);
121
+ expect(writes[0].wsPath).toBeUndefined();
122
+ expect(writes[0].branch).toBe('main');
123
+ });
124
+
125
+ it('refuses the write with the hook\'s own message and status', async () => {
126
+ const hooks = new WorkflowHooks();
127
+ hooks.onPreWrite(async () => {
128
+ throw new ToolError('Not in this conversation.', 403);
129
+ });
130
+ const gate: AgentAccessGate = { recoveryBotEmail: RECOVERY_EMAIL, hooks, notes: new ToolDescriptionNotes() };
131
+ await expect(assertAgentWriteAllowed(gate, ctx(), 'main', P_FILE)).rejects.toMatchObject({
132
+ message: 'Not in this conversation.',
133
+ status: 403,
134
+ });
135
+ });
136
+
137
+ it('runs every registered write hook, in registration order, until one throws', async () => {
138
+ const hooks = new WorkflowHooks();
139
+ const seen: string[] = [];
140
+ hooks.onPreWrite(async () => {
141
+ seen.push('first');
142
+ });
143
+ hooks.onPreWrite(async () => {
144
+ seen.push('second');
145
+ throw new ToolError('no', 403);
146
+ });
147
+ hooks.onPreWrite(async () => {
148
+ seen.push('third');
149
+ });
150
+ const gate: AgentAccessGate = { recoveryBotEmail: RECOVERY_EMAIL, hooks, notes: new ToolDescriptionNotes() };
151
+ await expect(assertAgentWriteAllowed(gate, ctx(), 'main', P_FILE)).rejects.toThrow('no');
152
+ expect(seen).toEqual(['first', 'second']);
153
+ });
154
+
155
+ it('never reaches the hooks for a person in the app or for the recovery bot', async () => {
156
+ const { gate, writes } = makeGate();
157
+ await assertAgentWriteAllowed(gate, ctx({ source: 'session' }), 'main', P_FILE);
158
+ await assertAgentWriteAllowed(gate, ctx({ user: { id: 'bot', email: RECOVERY_EMAIL, name: 'bot' } }), 'main', P_FILE);
159
+ expect(writes).toEqual([]);
160
+ });
161
+
162
+ it('is a no-op with no hook registered, with or without a session id', async () => {
163
+ const gate: AgentAccessGate = {
164
+ recoveryBotEmail: RECOVERY_EMAIL,
165
+ hooks: new WorkflowHooks(),
166
+ notes: new ToolDescriptionNotes(),
167
+ };
168
+ await expect(assertAgentWriteAllowed(gate, ctx(), 'main', P_FILE)).resolves.toBeUndefined();
169
+ await expect(assertAgentWriteAllowed(gate, ctx({ sessionId: undefined }), 'main', P_FILE)).resolves.toBeUndefined();
170
+ });
171
+ });
172
+
173
+ describe('ToolDescriptionNotes', () => {
174
+ let notes: ToolDescriptionNotes;
175
+ beforeEach(() => {
176
+ notes = new ToolDescriptionNotes();
177
+ });
178
+
179
+ it('has no note registered by default, which is every Hexis-only deployment', () => {
180
+ expect(notes.gatedToolNote()).toBe('');
181
+ expect(notes.sessionIdDescription()).toBe(SESSION_ID_DESCRIPTION);
182
+ });
183
+
184
+ it('appends a registered gated-tool note and a registered sessionId note', () => {
185
+ notes.registerGatedToolNote(' Stay put.');
186
+ notes.registerSessionIdNote(' It also pins the boundary.');
187
+ expect(notes.gatedToolNote()).toBe(' Stay put.');
188
+ expect(notes.sessionIdDescription()).toBe(`${SESSION_ID_DESCRIPTION} It also pins the boundary.`);
189
+ });
190
+
191
+ it('tells its subscribers on every registration, so mounted tools re-describe', () => {
192
+ let calls = 0;
193
+ notes.onChange(() => {
194
+ calls++;
195
+ });
196
+ notes.registerGatedToolNote(' a');
197
+ notes.registerSessionIdNote(' b');
198
+ expect(calls).toBe(2);
199
+ });
200
+
201
+ it('the default sessionId input says what the id is, and mentions no ontology', () => {
202
+ expect(SESSION_ID_INPUT.description).toBe(SESSION_ID_DESCRIPTION);
203
+ expect(SESSION_ID_DESCRIPTION).toMatch(/conversation/i);
204
+ expect(SESSION_ID_DESCRIPTION).toMatch(/start_session/);
205
+ expect(SESSION_ID_DESCRIPTION).toMatch(/ask/);
206
+ expect(SESSION_ID_DESCRIPTION.toLowerCase()).not.toContain('ontolog');
207
+ });
208
+ });