@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
@@ -9,11 +9,76 @@ import { createToolContextResolver } from '../../../tool-helpers/tool-context.js
9
9
  import { createToolHandlerFactory } from '../../../tool-helpers/tool-handler.js';
10
10
  import { createManualRoutes } from '../../../tool-registry/manual.routes.js';
11
11
  import { registerWorkflowTools } from '../workflow.tools.js';
12
- import { OpenChangeRequestBlocksMergeError } from '../../../../shared/domain-errors.js';
12
+ import { ChangeRequestConflictsError, OpenChangeRequestBlocksMergeError } from '../../../../shared/domain-errors.js';
13
13
 
14
14
  const WS = 'target-company-state';
15
15
 
16
+ /**
17
+ * One file of a change-request detail as the service returns it: a 15 KB
18
+ * addition, patch included. The reported failure was 27 of these — the detail
19
+ * answered ~445,000 characters and overflowed the agent's tool-result limit.
20
+ */
21
+ function addedFile(i: number): Record<string, unknown> {
22
+ const path = `KnowledgeBase/Engineering/Knowledge/Hexis/Generated-Node-${i}.md`;
23
+ return {
24
+ path,
25
+ status: 'added',
26
+ additions: 400,
27
+ deletions: 0,
28
+ // 400 × 37 characters ≈ 15 KB, as reported.
29
+ patch: `@@ -0,0 +1,400 @@\n${'+a line of generated knowledge text\n'.repeat(400)}`,
30
+ isBinary: false,
31
+ sha: `blob-${i}`,
32
+ rawUrl: `https://raw.example.com/blob-${i}`,
33
+ };
34
+ }
35
+
36
+ /** The per-file approval state the detail carries, as the app's dialog reads it. */
37
+ function approvalOf(path: string): Record<string, unknown> {
38
+ return {
39
+ path,
40
+ eligibleApprovers: {
41
+ roles: ['Admin', 'Engineering'],
42
+ users: [{ name: 'Razvan Radulescu', email: 'razvan.radulescu@bevel.software' }],
43
+ },
44
+ approvedBy: [],
45
+ eligibilityResolved: true,
46
+ isApproved: false,
47
+ inMergeGate: true,
48
+ viewerCanApprove: true,
49
+ };
50
+ }
51
+
52
+ /** A full change-request detail over the given files — what `openChangeRequest` answers. */
53
+ function detailOf(files: Record<string, unknown>[]): Record<string, unknown> {
54
+ return {
55
+ number: 7,
56
+ url: 'https://bevel.example.com/change-requests/7',
57
+ title: 'My change',
58
+ state: 'open',
59
+ branch: 'me/draft',
60
+ base: 'target-company-state',
61
+ body: 'Body shown verbatim to reviewers.',
62
+ author: { login: 'bevel-bot' },
63
+ headSha: 'head-1',
64
+ baseSha: 'base-1',
65
+ files,
66
+ comments: [],
67
+ approvals: files.map((f) => approvalOf(f.path as string)),
68
+ mergeableInBevel: false,
69
+ mergeBlockedReasons: files.map((f) => `${f.path as string} is not approved`),
70
+ mergeWarnings: files.map((f) => `${f.path as string} is not approved`),
71
+ };
72
+ }
73
+
74
+ /** The reported case: 27 new files of 15 KB each. */
75
+ const BIG = () => detailOf(Array.from({ length: 27 }, (_, i) => addedFile(i)));
76
+
16
77
  let calls: unknown[][] = [];
78
+ /** What the service answers `openChangeRequest` with; a test replaces it. */
79
+ let opened: Record<string, unknown> = BIG();
80
+ /** Set to make `openChangeRequest` throw instead. */
81
+ let openFails: Error | undefined;
17
82
  const externalApiKeyService = {
18
83
  looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
19
84
  verifyAndLoadToken: async (t: string) =>
@@ -38,7 +103,8 @@ const workflowService = {
38
103
  },
39
104
  openChangeRequest: async (ws: string, user: { id: string }, body: unknown) => {
40
105
  calls.push(['openChangeRequest', ws, user.id, body]);
41
- return { number: 7, url: 'https://bevel.example.com/change-requests/7' };
106
+ if (openFails) throw openFails;
107
+ return opened;
42
108
  },
43
109
  createBranch: async (ws: string, name: string) => {
44
110
  calls.push(['createBranch', ws, name]);
@@ -114,6 +180,8 @@ const post = (url: string, bearer: string, body: unknown = {}) =>
114
180
 
115
181
  beforeEach(() => {
116
182
  calls = [];
183
+ opened = BIG();
184
+ openFails = undefined;
117
185
  });
118
186
  afterEach(async () => {
119
187
  if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
@@ -229,7 +297,7 @@ describe('registerWorkflowTools', () => {
229
297
  expect(calls).toContainEqual(['listBranches', 'existing-ws']);
230
298
  });
231
299
 
232
- it('open_change_request returns the CR detail', async () => {
300
+ it('open_change_request acts on the SOURCE branch\'s workspace', async () => {
233
301
  const base = await start();
234
302
  const res = await post(`${base}/api/agent/tools/open_change_request`, writeTok(), {
235
303
  sourceBranch: 'me/draft',
@@ -237,9 +305,6 @@ describe('registerWorkflowTools', () => {
237
305
  title: 'My change',
238
306
  });
239
307
  expect(res.status).toBe(200);
240
- expect(await res.json()).toMatchObject({
241
- changeRequest: { number: 7, url: 'https://bevel.example.com/change-requests/7' },
242
- });
243
308
  // The workspace must be derived from the SOURCE branch (not a separate
244
309
  // `branch` arg) — encodeURIComponent('me/draft'). Regression guard for the
245
310
  // `-b undefined` clone bug when the model omitted `branch`.
@@ -251,6 +316,199 @@ describe('registerWorkflowTools', () => {
251
316
  ]);
252
317
  });
253
318
 
319
+ /**
320
+ * One test per Scenario of the Specification
321
+ * (`hx-change-request-answer-is-small-spec`). The reported failure: opening a
322
+ * request for 27 files answered ~445,000 characters, because the answer was
323
+ * the detail the app's dialog reads, every patch included. It overflowed the
324
+ * tool-result limit, so the agent could not even read the link.
325
+ */
326
+ describe('what open_change_request answers', () => {
327
+ const open = async (base: string, include?: string[]) => {
328
+ const res = await post(`${base}/api/agent/tools/open_change_request`, writeTok(), {
329
+ sourceBranch: 'me/draft',
330
+ targetBranch: 'target-company-state',
331
+ title: 'My change',
332
+ ...(include ? { include } : {}),
333
+ });
334
+ expect(res.status).toBe(200);
335
+ return (await res.json()) as Record<string, unknown>;
336
+ };
337
+
338
+ it('answers a summary for 27 new files: the link first, 25 paths of 27, approvers, blockers, no patch', async () => {
339
+ const base = await start();
340
+ const body = await open(base);
341
+
342
+ // The link is the FIRST field: the one thing the agent must hand the
343
+ // user, so it survives a truncation of anything after it.
344
+ expect(Object.keys(body)[0]).toBe('url');
345
+ expect(body).toMatchObject({
346
+ url: 'https://bevel.example.com/change-requests/7',
347
+ number: 7,
348
+ title: 'My change',
349
+ state: 'open',
350
+ sourceBranch: 'me/draft',
351
+ targetBranch: 'target-company-state',
352
+ totalFiles: 27,
353
+ });
354
+
355
+ const files = body.files as { path: string; change: string }[];
356
+ expect(files).toHaveLength(25);
357
+ expect(new Set(files.map((f) => f.change))).toEqual(new Set(['added']));
358
+ expect(files[0].path).toBe('KnowledgeBase/Engineering/Knowledge/Hexis/Generated-Node-0.md');
359
+
360
+ // Who must approve, per path — what Juan found useful and must stay.
361
+ expect(body.approvals).toHaveLength(25);
362
+ expect((body.approvals as unknown[])[0]).toEqual({
363
+ path: 'KnowledgeBase/Engineering/Knowledge/Hexis/Generated-Node-0.md',
364
+ roles: ['Admin', 'Engineering'],
365
+ users: ['Razvan Radulescu'],
366
+ approved: false,
367
+ inMergeGate: true,
368
+ });
369
+ // What blocks the merge: every reason, for cut paths too.
370
+ expect(body.mergeBlockedReasons).toHaveLength(27);
371
+
372
+ // No patch, and no file content under any other name.
373
+ const json = JSON.stringify(body);
374
+ expect(json).not.toContain('patch');
375
+ expect(json).not.toContain('a line of generated knowledge text');
376
+ expect(files.every((f) => !('patch' in f))).toBe(true);
377
+ });
378
+
379
+ it('keeps the 27-file answer under 10,000 characters', async () => {
380
+ const base = await start();
381
+ const body = await open(base);
382
+ // The detail this was shaped from is the ~445,000-character one.
383
+ expect(JSON.stringify(opened).length).toBeGreaterThan(400_000);
384
+ // 8,918 characters as this fixture stands. The three per-path lists
385
+ // (`files`, `approvals`, `mergeBlockedReasons`) are what fills it, so a
386
+ // field added to any of them eats the margin ~25 times over.
387
+ expect(JSON.stringify(body).length).toBeLessThan(10_000);
388
+ });
389
+
390
+ it('`include: ["all-paths"]` lists all 27 paths, still without patches', async () => {
391
+ const base = await start();
392
+ const body = await open(base, ['all-paths']);
393
+ expect(body.files).toHaveLength(27);
394
+ expect(body.totalFiles).toBe(27);
395
+ expect(body.approvals).toHaveLength(27);
396
+ expect(JSON.stringify(body)).not.toContain('a line of generated knowledge text');
397
+ });
398
+
399
+ it('`include: ["patches"]` gives each listed file its patch', async () => {
400
+ const base = await start();
401
+ const body = await open(base, ['patches']);
402
+ const files = body.files as { path: string; patch?: string }[];
403
+ expect(files).toHaveLength(25);
404
+ expect(files.every((f) => f.patch?.startsWith('@@ -0,0 +1,400 @@'))).toBe(true);
405
+ // Cut to 25 still: `patches` adds the diffs, it does not lift the cut.
406
+ expect(body.totalFiles).toBe(27);
407
+ });
408
+
409
+ it('both together: every path, each with its patch', async () => {
410
+ const base = await start();
411
+ const body = await open(base, ['patches', 'all-paths']);
412
+ const files = body.files as { patch?: string }[];
413
+ expect(files).toHaveLength(27);
414
+ expect(files.every((f) => typeof f.patch === 'string')).toBe(true);
415
+ });
416
+
417
+ it('reads a modification `changed` and a rename `moved`, with its old path', async () => {
418
+ const base = await start();
419
+ opened = detailOf([
420
+ { path: 'KnowledgeBase/Kept.md', status: 'modified', additions: 2, deletions: 1, patch: '@@ x', isBinary: false, sha: 'b1', rawUrl: 'r1' },
421
+ { path: 'KnowledgeBase/New-Home.md', previousPath: 'KnowledgeBase/Old-Home.md', status: 'renamed', additions: 0, deletions: 0, isBinary: false, sha: 'b2', rawUrl: 'r2' },
422
+ ]);
423
+ const body = await open(base);
424
+ expect(body.files).toEqual([
425
+ { path: 'KnowledgeBase/Kept.md', change: 'changed' },
426
+ { path: 'KnowledgeBase/New-Home.md', change: 'moved', previousPath: 'KnowledgeBase/Old-Home.md' },
427
+ ]);
428
+ expect(body.totalFiles).toBe(2);
429
+ });
430
+
431
+ it('a deletion reads `deleted`', async () => {
432
+ const base = await start();
433
+ opened = detailOf([
434
+ { path: 'KnowledgeBase/Gone.md', status: 'removed', additions: 0, deletions: 9, isBinary: false, sha: 'b3', rawUrl: 'r3' },
435
+ ]);
436
+ expect((await open(base)).files).toEqual([{ path: 'KnowledgeBase/Gone.md', change: 'deleted' }]);
437
+ });
438
+
439
+ it('says the approvers are unknown rather than implying nobody must approve', async () => {
440
+ const base = await start();
441
+ opened = detailOf([
442
+ { path: 'KnowledgeBase/Opaque.md', status: 'added', additions: 1, deletions: 0, isBinary: false, sha: 'b4', rawUrl: 'r4' },
443
+ ]);
444
+ // No usable access config on the base: the empty approver set means "not
445
+ // known", and nothing may be granted on the strength of that emptiness.
446
+ opened.approvals = [
447
+ { path: 'KnowledgeBase/Opaque.md', eligibleApprovers: { roles: [], users: [] }, approvedBy: [], eligibilityResolved: false, isApproved: false, inMergeGate: false },
448
+ ];
449
+ expect((await open(base)).approvals).toEqual([
450
+ { path: 'KnowledgeBase/Opaque.md', roles: [], users: [], approved: false, inMergeGate: false, approversUnknown: true },
451
+ ]);
452
+
453
+ // A detail that carries no verdict at all reads the same way: absent is
454
+ // not resolved, so the emptiness still means "not known".
455
+ const legacy = { ...(opened.approvals as Record<string, unknown>[])[0] };
456
+ delete legacy.eligibilityResolved;
457
+ opened.approvals = [legacy];
458
+ expect((await open(base)).approvals).toEqual([
459
+ { path: 'KnowledgeBase/Opaque.md', roles: [], users: [], approved: false, inMergeGate: false, approversUnknown: true },
460
+ ]);
461
+ });
462
+
463
+ it('surfaces the `change-request-conflicts` error as before', async () => {
464
+ const base = await start();
465
+ openFails = new ChangeRequestConflictsError('me/draft', 'target-company-state', ['KnowledgeBase/Clash.md']);
466
+ const res = await post(`${base}/api/agent/tools/open_change_request`, writeTok(), {
467
+ sourceBranch: 'me/draft',
468
+ targetBranch: 'target-company-state',
469
+ title: 'My change',
470
+ });
471
+ expect(res.status).toBe(409);
472
+ expect(await res.json()).toMatchObject({
473
+ kind: 'change-request-conflicts',
474
+ sourceBranch: 'me/draft',
475
+ targetBranch: 'target-company-state',
476
+ conflictedPaths: ['KnowledgeBase/Clash.md'],
477
+ });
478
+ });
479
+
480
+ /**
481
+ * The app's dialog reads the same detail from its own route. Shaping the
482
+ * agent's answer must not touch it — so the detail the service handed back
483
+ * still carries all 27 files and all their patches after the tool answered.
484
+ */
485
+ it('leaves the detail the app\'s dialog reads exactly as the service returned it', async () => {
486
+ const base = await start();
487
+ const before = JSON.stringify(opened);
488
+ await open(base);
489
+ expect(JSON.stringify(opened)).toBe(before);
490
+ expect((opened.files as unknown[])).toHaveLength(27);
491
+ expect((opened.files as { patch?: string }[]).every((f) => typeof f.patch === 'string')).toBe(true);
492
+ });
493
+
494
+ it('says in its description what the default answer holds and how to ask for more', async () => {
495
+ await start();
496
+ for (const tools of [await registryRef!.listInternal(), await registryRef!.listExternal()]) {
497
+ const def = tools.find((t) => t.name === 'open_change_request')!;
498
+ expect(def.description).toMatch(/SUMMARY/);
499
+ expect(def.description).toMatch(/cut to 25/);
500
+ expect(def.description).toMatch(/No patches and no file content/);
501
+ expect(def.description).toMatch(/include: \["patches"\]/);
502
+ expect(def.description).toMatch(/include: \["all-paths"\]/);
503
+ // `toolDef` wraps a tool's flat inputs under `body`.
504
+ const body = (def.inputs as { properties: { body: { properties: Record<string, { items?: { enum?: string[] }; description?: string }> } } })
505
+ .properties.body;
506
+ expect(body.properties.include.items?.enum).toEqual(['patches', 'all-paths']);
507
+ expect(body.properties.include.description).toMatch(/every changed path instead of the first 25/);
508
+ }
509
+ });
510
+ });
511
+
254
512
  it('create_branch forks in the BASE branch\'s workspace, never the new draft\'s', async () => {
255
513
  const base = await start();
256
514
  const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), {
@@ -278,9 +536,149 @@ describe('registerWorkflowTools', () => {
278
536
  expect(calls.filter((c) => c[0] === 'createBranch')).toHaveLength(0);
279
537
  });
280
538
 
539
+ /**
540
+ * These tools address their workspace by ID — `workspaceIdForBranch(branch)`
541
+ * straight from the argument — so they never pass the `getFilesystem` choke
542
+ * point that guards the file tools. A branch-less call used to commit to a
543
+ * workspace id that was literally the string "undefined" — and `create_branch`
544
+ * used to fork from one, cloning a branch of that name and answering "There is
545
+ * no branch named undefined." The guard sits on the mount and is keyed on
546
+ * whether the tool's declared inputs REQUIRE `branch`, so it covers the
547
+ * injected input and a tool's own alike, without any of them remembering it.
548
+ */
549
+ describe('a branch-less call to a tool that declares `branch`', () => {
550
+ /**
551
+ * Every tool whose DECLARED inputs require `branch`, read off the mounted
552
+ * registry — not a list written by hand. The hand-written list is how the
553
+ * gap that failed Local Testing stayed invisible: it named the three tools
554
+ * that take the injected input and missed `create_branch`, whose `branch`
555
+ * is its own (the fork base) and is just as required. Anything mounted
556
+ * later is swept in without this file being touched.
557
+ */
558
+ const branchTakingTools = async (): Promise<string[]> =>
559
+ (await registryRef!.listInternal())
560
+ .filter((t) => {
561
+ const body = (t.inputs as { properties?: { body?: { required?: string[] } } }).properties?.body;
562
+ return (body?.required ?? []).includes('branch');
563
+ })
564
+ .map((t) => t.name);
565
+
566
+ // The inputs each tool needs BESIDES `branch`, so the only thing wrong with
567
+ // every call below is the missing branch.
568
+ const bodyFor = (tool: string): Record<string, unknown> =>
569
+ ({
570
+ commit_change: { summary: 's' },
571
+ save_file: { path: 'KnowledgeBase/x.md', content: 'c' },
572
+ create_branch: { name: 'alice/new-draft' },
573
+ write_file: { path: 'KnowledgeBase/x.md', content: 'c' },
574
+ })[tool] ?? {};
575
+
576
+ it('400s branch-required on every mounted tool that declares `branch`, and acts on nothing', async () => {
577
+ const base = await start();
578
+ const tools = await branchTakingTools();
579
+ // Both kinds are in here: the injected input and a tool's own required
580
+ // `branch`. `create_branch` is the one the reported bug survived on.
581
+ expect(tools).toContain('commit_change');
582
+ expect(tools).toContain('create_branch');
583
+ expect(tools).toContain('switch_branch');
584
+
585
+ for (const tool of tools) {
586
+ const res = await post(`${base}/api/agent/tools/${tool}`, writeTok(), bodyFor(tool));
587
+
588
+ expect(res.status, `${tool} must 400 on a branch-less call`).toBe(400);
589
+ expect(await res.json(), `${tool} must answer the shared refusal`).toEqual({
590
+ kind: 'branch-required',
591
+ error: '`branch` is required: pass the branch (draft) you are working on.',
592
+ });
593
+ }
594
+ // No workflow call was made at all — least of all one naming a workspace
595
+ // id built out of the missing value, or a branch forked from it.
596
+ expect(calls).toEqual([]);
597
+ });
598
+
599
+ /**
600
+ * The exact shapes Local Testing drove over MCP against `create_branch`:
601
+ * a missing branch answered 404 `There is no branch named undefined.`
602
+ * (a clone of that "branch" was attempted), an empty one 500 'Invalid
603
+ * workspace ID', and `["main"]` — which `encodeURIComponent` stringifies
604
+ * straight back to `main` — answered 200 and really created and pushed a
605
+ * branch off the default. All four are one refusal now.
606
+ */
607
+ it('refuses every absent shape on create_branch, and forks from nothing', async () => {
608
+ const base = await start();
609
+ const absent: Array<[string, Record<string, unknown>]> = [
610
+ ['missing', { name: 'alice/new-draft' }],
611
+ ['empty', { name: 'alice/new-draft', branch: '' }],
612
+ ['null', { name: 'alice/new-draft', branch: null }],
613
+ ['a number', { name: 'alice/new-draft', branch: 42 }],
614
+ ['an array (stringifies to a real branch name)', { name: 'alice/new-draft', branch: ['main'] }],
615
+ ['the literal "undefined"', { name: 'alice/new-draft', branch: 'undefined' }],
616
+ ['the literal "null"', { name: 'alice/new-draft', branch: 'null' }],
617
+ ];
618
+
619
+ for (const [label, body] of absent) {
620
+ const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), body);
621
+ expect(res.status, `branch ${label} must 400`).toBe(400);
622
+ const answered = await res.json();
623
+ expect(answered.kind, `branch ${label} must answer branch-required`).toBe('branch-required');
624
+ expect(JSON.stringify(answered), `branch ${label} must not echo an absent value`).not.toContain('undefined');
625
+ }
626
+ // Nothing was forked, from any workspace id.
627
+ expect(calls).toEqual([]);
628
+ });
629
+
630
+ /**
631
+ * `create_branch` compares `branch` (the fork base) to `name` and refuses
632
+ * the two being equal, QUOTING the name. A call that sent neither compared
633
+ * undefined to undefined, matched, and answered "not 'undefined', the draft
634
+ * being created" — an absent value presented as a draft name. `name` is
635
+ * refused by name instead, so no such sentence can be produced.
636
+ */
637
+ it('refuses a missing `name` without quoting it back', async () => {
638
+ const base = await start();
639
+
640
+ const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), { branch: 'main' });
641
+
642
+ expect(res.status).toBe(400);
643
+ const answered = await res.json();
644
+ expect(answered).toEqual({ kind: 'name-required', error: '`name` is required: pass the name of the draft to create.' });
645
+ expect(JSON.stringify(answered)).not.toContain('undefined');
646
+ expect(calls).toEqual([]);
647
+ });
648
+
649
+ it('still forks from a named base once both inputs are given', async () => {
650
+ const base = await start();
651
+ // The guards refuse absent inputs, not the tool.
652
+ const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), { name: 'alice/new-draft', branch: 'main' });
653
+
654
+ expect(res.status).toBe(200);
655
+ expect(calls).toContainEqual(['createBranch', 'main', 'alice/new-draft']);
656
+ });
657
+
658
+ it('refuses the stringified absent values the same way', async () => {
659
+ const base = await start();
660
+ for (const literal of ['undefined', 'null']) {
661
+ const res = await post(`${base}/api/agent/tools/commit_change`, writeTok(), { summary: 's', branch: literal });
662
+ expect(res.status, `branch "${literal}" must 400`).toBe(400);
663
+ expect((await res.json()).kind).toBe('branch-required');
664
+ }
665
+ expect(calls).toEqual([]);
666
+ });
667
+
668
+ it('leaves a tool that declares no `branch` alone', async () => {
669
+ const base = await start();
670
+ // `list_branches` is repo-global and deliberately takes no branch: the
671
+ // guard must not start demanding one from the tools that opted out.
672
+ expect((await post(`${base}/api/agent/tools/list_branches`, writeTok(), {})).status).toBe(200);
673
+ });
674
+ });
675
+
281
676
  it('works under a connection key too (both surface)', async () => {
282
677
  const base = await start();
283
- expect((await post(`${base}/api/agent/tools/commit_change`, 'bevel_key', { summary: 's' })).status).toBe(200);
678
+ // `branch` is named because every caller must name it — this test is about
679
+ // the connection-key SURFACE, and an external key is precisely the caller
680
+ // that carries no focused branch to fall back on.
681
+ expect((await post(`${base}/api/agent/tools/commit_change`, 'bevel_key', { summary: 's', branch: WS })).status).toBe(200);
284
682
  });
285
683
 
286
684
  it('switch_branch validates + pre-warms + emits a user-scoped event (internal-only)', async () => {
@@ -0,0 +1,182 @@
1
+ import type {
2
+ ChangeRequestDetail,
3
+ ChangeRequestState,
4
+ ChangedFile,
5
+ FileApproval,
6
+ PrFileStatus,
7
+ } from '@bevel-software/platform-shared';
8
+
9
+ /**
10
+ * What `open_change_request` answers, and why it is not the detail the app's
11
+ * dialog reads.
12
+ *
13
+ * The dialog renders every file's diff, so its detail carries every patch. An
14
+ * agent that opens a change request needs none of that: it needs the link to
15
+ * hand the user, and enough of the request to confirm it carries the files it
16
+ * meant to propose and to say what the request is now waiting on. Handing it
17
+ * the dialog's detail cost ~445,000 characters for 27 files of 15 KB — past
18
+ * every tool-result limit, so the one field the agent actually needed (the
19
+ * link) could not be read at all.
20
+ *
21
+ * So this is a purpose-made answer: the link first, the request's identity,
22
+ * who must approve each path, what blocks the merge verbatim from the detail,
23
+ * and the changed paths with their kind of change — no patch, no file content.
24
+ * An agent that wants more asks for it by name through `include`.
25
+ */
26
+
27
+ /** What `include` accepts. */
28
+ export type ChangeRequestInclude = 'patches' | 'all-paths';
29
+
30
+ /** The two `include` values, for the tool's input schema and its validation. */
31
+ export const CHANGE_REQUEST_INCLUDES: readonly ChangeRequestInclude[] = ['patches', 'all-paths'];
32
+
33
+ /** How many paths the answer lists before it cuts; `all-paths` lifts the cut. */
34
+ export const CHANGE_REQUEST_PATH_LIMIT = 25;
35
+
36
+ /**
37
+ * The kind of change to one path, in the four words the answer speaks. Git's
38
+ * own statuses are finer (`modified` vs `changed`, `renamed` vs `copied`) in
39
+ * ways nothing downstream acts on.
40
+ */
41
+ export type ChangeKind = 'added' | 'changed' | 'deleted' | 'moved';
42
+
43
+ /** One changed path. `previousPath` is where a `moved` one came from. */
44
+ export interface ChangeRequestSummaryFile {
45
+ path: string;
46
+ change: ChangeKind;
47
+ previousPath?: string;
48
+ /** Unified diff. Only with `include: ["patches"]`, and absent for a binary or oversized file. */
49
+ patch?: string;
50
+ }
51
+
52
+ /**
53
+ * Who must approve one path, at the same per-path granularity the detail
54
+ * carries (the access tree resolves a role per file or per folder; this says
55
+ * what it resolved to for this file).
56
+ *
57
+ * The detail's own entry is the dialog's: it also carries each approver's
58
+ * email, the viewer's own eligibility, and every approval's timestamp and
59
+ * staleness. Repeated over 25 files that is ~4,500 characters an agent cannot
60
+ * act on — it may not approve anything — and it is what would push the
61
+ * 27-file answer back over the limit this ticket exists to get under. So the
62
+ * answer keeps what "who must approve" means and nothing else.
63
+ */
64
+ export interface ChangeRequestApprovers {
65
+ path: string;
66
+ /** Roles that confer approval here. `everyone` means any signed-in approver. */
67
+ roles: string[];
68
+ /** Approvers named directly, by display name. */
69
+ users: string[];
70
+ /** True once an eligible approver has approved at the request's current head. */
71
+ approved: boolean;
72
+ /** False when the merge gate does not bind this path — then nobody has to approve it. */
73
+ inMergeGate: boolean;
74
+ /**
75
+ * Present when the access tree could not be resolved for this path — which
76
+ * includes a detail that carries no verdict at all. Then empty
77
+ * `roles`/`users` means "not known", NOT "nobody has to approve".
78
+ */
79
+ approversUnknown?: true;
80
+ }
81
+
82
+ export interface ChangeRequestSummary {
83
+ /** First field on purpose: it is the one an agent must hand the user. */
84
+ url: string;
85
+ urlNote?: string;
86
+ number: number;
87
+ title: string;
88
+ state: ChangeRequestState;
89
+ sourceBranch: string;
90
+ targetBranch: string;
91
+ /** Who must approve, one entry per listed path. */
92
+ approvals: ChangeRequestApprovers[];
93
+ /** What blocks the merge — verbatim from the detail. Empty when nothing does. */
94
+ mergeBlockedReasons: string[];
95
+ /** The changed paths, cut to {@link CHANGE_REQUEST_PATH_LIMIT} unless `all-paths` was asked for. */
96
+ files: ChangeRequestSummaryFile[];
97
+ /** How many files the request changes in total, cut or not. */
98
+ totalFiles: number;
99
+ }
100
+
101
+ /**
102
+ * A copy and a rename both carry `previousPath`, and the one thing a reader
103
+ * does with either is look at where the text came from — so both read `moved`.
104
+ * `unchanged` is a file the request lists without changing (git reports it on
105
+ * a mode-only change); `changed` is the honest word for it among these four.
106
+ */
107
+ function changeKindOf(status: PrFileStatus | undefined): ChangeKind {
108
+ switch (status) {
109
+ case 'added':
110
+ return 'added';
111
+ case 'removed':
112
+ return 'deleted';
113
+ case 'renamed':
114
+ case 'copied':
115
+ return 'moved';
116
+ default:
117
+ return 'changed';
118
+ }
119
+ }
120
+
121
+ function approversOf(approval: FileApproval): ChangeRequestApprovers {
122
+ const eligible = approval.eligibleApprovers ?? { roles: [], users: [] };
123
+ return {
124
+ path: approval.path,
125
+ roles: eligible.roles ?? [],
126
+ users: (eligible.users ?? []).map((u) => u.name),
127
+ approved: approval.isApproved,
128
+ inMergeGate: approval.inMergeGate,
129
+ // Anything but an explicit `true` is unresolved — a detail built before
130
+ // the flag existed carries no flag at all, and reading that as "resolved"
131
+ // would turn an empty approver set into "nobody must approve". Same
132
+ // fail-closed reading as `computeViewerCanUpdate`.
133
+ ...(approval.eligibilityResolved !== true ? { approversUnknown: true as const } : {}),
134
+ };
135
+ }
136
+
137
+ function summaryFileOf(file: ChangedFile, withPatch: boolean): ChangeRequestSummaryFile {
138
+ return {
139
+ path: file.path,
140
+ change: changeKindOf(file.status),
141
+ ...(file.previousPath ? { previousPath: file.previousPath } : {}),
142
+ ...(withPatch && file.patch ? { patch: file.patch } : {}),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Shape the tool's answer out of the detail the service already returns. Pure:
148
+ * the service and the app's routes are untouched, and the detail they serve is
149
+ * exactly what it was.
150
+ *
151
+ * `approvals` is narrowed to the paths the answer lists, because the detail's
152
+ * contract is one entry per listed file. Nothing about the merge gate is lost
153
+ * by that: `mergeBlockedReasons` names every missing approval, cut or not.
154
+ */
155
+ export function summarizeChangeRequest(
156
+ detail: ChangeRequestDetail,
157
+ include: readonly ChangeRequestInclude[] = [],
158
+ ): ChangeRequestSummary {
159
+ const all = detail.files ?? [];
160
+ const withPatch = include.includes('patches');
161
+ const listed = include.includes('all-paths') ? all : all.slice(0, CHANGE_REQUEST_PATH_LIMIT);
162
+ const listedPaths = new Set(listed.map((f) => f.path));
163
+ return {
164
+ url: detail.url,
165
+ ...(detail.urlNote ? { urlNote: detail.urlNote } : {}),
166
+ number: detail.number,
167
+ title: detail.title,
168
+ state: detail.state,
169
+ sourceBranch: detail.branch,
170
+ targetBranch: detail.base,
171
+ approvals: (detail.approvals ?? []).filter((a) => listedPaths.has(a.path)).map(approversOf),
172
+ mergeBlockedReasons: detail.mergeBlockedReasons ?? [],
173
+ files: listed.map((f) => summaryFileOf(f, withPatch)),
174
+ totalFiles: all.length,
175
+ };
176
+ }
177
+
178
+ /** The `include` values off a tool call's arguments; anything else is ignored. */
179
+ export function parseInclude(raw: unknown): ChangeRequestInclude[] {
180
+ if (!Array.isArray(raw)) return [];
181
+ return CHANGE_REQUEST_INCLUDES.filter((v) => raw.includes(v));
182
+ }