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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (434) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,218 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
5
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
6
+ import type { ToolContext } from '../../tool-helpers/tool.contract.js';
7
+ import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
8
+ import { BranchNotFoundError } from '../../../shared/domain-errors.js';
9
+ import { registerSkillsTools } from '../skills.tools.js';
10
+ import { unmergedSkillNotice } from '../skills.service.js';
11
+ import type { GetSkillOptions, ISkillService, ListSkillsOptions, Skill } from '../skills.contract.js';
12
+ import { AllowedToolsChecker } from '../allowed-tools-check.js';
13
+ import type { ToolManualDetail, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
14
+ import { testKbContext } from '../../../__tests__/kb-context.js';
15
+
16
+ /**
17
+ * The two skill tools reading a draft branch: the input reaches the service,
18
+ * the branch's own answer comes back with its mark and its first line, the
19
+ * `allowed-tools` warnings are computed from the BRANCH's version of the
20
+ * skill, a branch nobody pushed is the file tools' 404, and `branch` with
21
+ * `version` is refused rather than silently ranked.
22
+ */
23
+
24
+ const DRAFT = 'juan/skill-deck';
25
+ const KB_DIR = 'knowledge-base';
26
+
27
+ const hubspot: ToolManualSummary = { slug: 'hubspot', name: 'hubspot', path: 'Plugins/Sales/hubspot.tool', type: 'inline' };
28
+ const checker = new AllowedToolsChecker(
29
+ { listExternal: async () => [] },
30
+ {
31
+ listAccessible: async () => [hubspot],
32
+ getDetail: async (): Promise<ToolManualDetail> => ({
33
+ ...hubspot,
34
+ description: null,
35
+ capabilities: [{ name: 'search', description: null }],
36
+ }),
37
+ },
38
+ testKbContext({ kbDirName: KB_DIR }),
39
+ );
40
+
41
+ /** The released skill and the draft's changed copy of it — only the draft names a tool nobody serves. */
42
+ const released: Skill = {
43
+ name: 'rfi',
44
+ description: 'RFI.',
45
+ path: 'Plugins/Sales/rfi',
46
+ body: '# RFI',
47
+ files: [],
48
+ allowedTools: ['hubspot.search'],
49
+ };
50
+ const onDraft: Skill = {
51
+ ...released,
52
+ body: `${unmergedSkillNotice(DRAFT)}\n\n# RFI, rewritten`,
53
+ allowedTools: ['hubspot.search', 'hubspot.serch'],
54
+ unmerged: true,
55
+ branch: DRAFT,
56
+ };
57
+
58
+ /** What the tools handed the service, so a test can see the branch arrive (or not). */
59
+ let listArgs: [string | undefined, ListSkillsOptions | undefined][] = [];
60
+ let getArgs: [string, string, string | undefined, GetSkillOptions | undefined][] = [];
61
+
62
+ const skillService: ISkillService = {
63
+ listSkills: async (userEmail?: string, options?: ListSkillsOptions) => {
64
+ listArgs.push([userEmail, options]);
65
+ if (options?.branch === 'juan/typo') throw new BranchNotFoundError('juan/typo');
66
+ if (options?.branch === DRAFT) {
67
+ return [
68
+ { name: 'rfi', description: 'RFI.', path: released.path, unmerged: true, branch: DRAFT },
69
+ { name: 'make-deck', description: 'Decks.', path: 'Plugins/make-deck', unmerged: true, branch: DRAFT },
70
+ ];
71
+ }
72
+ return [{ name: 'rfi', description: 'RFI.', path: released.path }];
73
+ },
74
+ getSkill: async (userEmail: string, name: string, file?: string, options?: GetSkillOptions) => {
75
+ getArgs.push([userEmail, name, file, options]);
76
+ if (options?.branch === 'juan/typo') throw new BranchNotFoundError('juan/typo');
77
+ return { ok: true, kind: 'skill', skill: options?.branch === DRAFT ? onDraft : released };
78
+ },
79
+ invalidate: () => {},
80
+ };
81
+
82
+ let httpServer: HttpServer | undefined;
83
+ let registry: ToolRegistry;
84
+
85
+ async function start(): Promise<string> {
86
+ listArgs = [];
87
+ getArgs = [];
88
+ registry = new ToolRegistry();
89
+ const resolve = async (auth: ToolAuth, signal: AbortSignal): Promise<ToolContext> =>
90
+ ({
91
+ user: { id: 'u', email: 'e@x', name: 'N' },
92
+ scope: auth.scope,
93
+ source: auth.source,
94
+ abortSignal: signal,
95
+ workspaceService: {} as never,
96
+ workflowService: {} as never,
97
+ events: {} as never,
98
+ getFilesystem: async () => ({}) as never,
99
+ }) as unknown as ToolContext;
100
+ const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
101
+ req.toolAuth = { source: 'external', userId: 'u', scope: 'read' };
102
+ next();
103
+ };
104
+ const app = express();
105
+ app.use(express.json());
106
+ const router = express.Router();
107
+ registerSkillsTools(registry, router, fakeAuth, createToolHandlerFactory(resolve), skillService, checker);
108
+ app.use('/api', router);
109
+ httpServer = await new Promise<HttpServer>((r) => {
110
+ const s = app.listen(0, () => r(s));
111
+ });
112
+ return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
113
+ }
114
+
115
+ const post = (url: string, body: unknown) =>
116
+ fetch(url, {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
119
+ body: JSON.stringify(body),
120
+ });
121
+
122
+ afterEach(async () => {
123
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
124
+ httpServer = undefined;
125
+ });
126
+
127
+ describe('the skill tools read a branch when asked', () => {
128
+ it('declares `branch` as an OPTIONAL input on both tools', async () => {
129
+ await start();
130
+ const defs = await registry.listExternal({ userEmail: 'e@x' });
131
+ for (const name of ['list_skills', 'get_skill']) {
132
+ const def = defs.find((t) => t.name === name)!;
133
+ // `toolDef` wraps a tool's flat inputs under `body`.
134
+ const inputs = (def.inputs as { properties: { body: { properties: Record<string, unknown>; required?: string[] } } })
135
+ .properties.body;
136
+ expect(inputs.properties.branch).toBeDefined();
137
+ expect(inputs.required ?? []).not.toContain('branch');
138
+ }
139
+ });
140
+
141
+ it('list_skills passes the branch on and returns what that branch has, marked', async () => {
142
+ const base = await start();
143
+ const body = (await (await post(`${base}/api/agent/tools/list_skills`, { branch: DRAFT })).json()) as {
144
+ skills: { name: string; unmerged?: boolean; branch?: string }[];
145
+ };
146
+ expect(listArgs).toEqual([['e@x', { branch: DRAFT }]]);
147
+ expect(body.skills.map((s) => s.name)).toEqual(['rfi', 'make-deck']);
148
+ expect(body.skills.every((s) => s.unmerged === true && s.branch === DRAFT)).toBe(true);
149
+ });
150
+
151
+ it('list_skills without a branch asks for the released catalog', async () => {
152
+ const base = await start();
153
+ const body = (await (await post(`${base}/api/agent/tools/list_skills`, {})).json()) as {
154
+ skills: { name: string; unmerged?: boolean }[];
155
+ };
156
+ expect(listArgs).toEqual([['e@x', { branch: undefined }]]);
157
+ expect(body.skills).toEqual([{ name: 'rfi', description: 'RFI.', path: released.path }]);
158
+ });
159
+
160
+ it('a blank branch is no branch at all: the released catalog answers', async () => {
161
+ const base = await start();
162
+ await post(`${base}/api/agent/tools/list_skills`, { branch: ' ' });
163
+ await post(`${base}/api/agent/tools/get_skill`, { name: 'rfi', branch: '' });
164
+ expect(listArgs).toEqual([['e@x', { branch: undefined }]]);
165
+ expect(getArgs[0][3]).toEqual({ version: undefined, branch: undefined });
166
+ });
167
+
168
+ it('get_skill passes the branch on, and the body keeps its unmerged first line', async () => {
169
+ const base = await start();
170
+ const body = (await (await post(`${base}/api/agent/tools/get_skill`, { name: 'rfi', branch: DRAFT })).json()) as {
171
+ skill: { body: string; unmerged?: boolean; branch?: string };
172
+ };
173
+ expect(getArgs).toEqual([['e@x', 'rfi', undefined, { version: undefined, branch: DRAFT }]]);
174
+ expect(body.skill.body.split('\n')[0]).toBe(unmergedSkillNotice(DRAFT));
175
+ expect(body.skill.unmerged).toBe(true);
176
+ expect(body.skill.branch).toBe(DRAFT);
177
+ });
178
+
179
+ it('the allowed-tools check runs on the BRANCH version of the skill', async () => {
180
+ const base = await start();
181
+ const onBranch = (await (
182
+ await post(`${base}/api/agent/tools/get_skill`, { name: 'rfi', branch: DRAFT })
183
+ ).json()) as { warnings: { entry: string; suggestion?: string }[] };
184
+ // `hubspot.serch` is only in the DRAFT's `allowed-tools`: the warning can
185
+ // only come from the copy the branch serves.
186
+ expect(onBranch.warnings.map((w) => w.entry)).toEqual(['hubspot.serch']);
187
+ expect(onBranch.warnings[0].suggestion).toBe('hubspot.search');
188
+ const releasedRes = (await (await post(`${base}/api/agent/tools/get_skill`, { name: 'rfi' })).json()) as {
189
+ warnings: unknown[];
190
+ };
191
+ expect(releasedRes.warnings).toEqual([]);
192
+ });
193
+
194
+ it('refuses `branch` together with `version`, naming both, without asking the service', async () => {
195
+ const base = await start();
196
+ const res = await post(`${base}/api/agent/tools/get_skill`, { name: 'rfi', branch: DRAFT, version: '1.4.0' });
197
+ expect(res.status).toBe(400);
198
+ const body = (await res.json()) as { error: string };
199
+ expect(body.error).toContain('`branch` or `version`, not both');
200
+ expect(getArgs).toEqual([]);
201
+ });
202
+
203
+ it('answers 404 naming a branch that does not exist, on both tools', async () => {
204
+ const base = await start();
205
+ for (const [tool, args] of [
206
+ ['list_skills', { branch: 'juan/typo' }],
207
+ ['get_skill', { name: 'rfi', branch: 'juan/typo' }],
208
+ ] as const) {
209
+ const res = await post(`${base}/api/agent/tools/${tool}`, args);
210
+ expect(res.status).toBe(404);
211
+ expect(await res.json()).toMatchObject({
212
+ kind: 'branch-not-found',
213
+ branch: 'juan/typo',
214
+ error: 'There is no branch named juan/typo.',
215
+ });
216
+ }
217
+ });
218
+ });
@@ -27,15 +27,19 @@ const FOLDER = 'Skills/Eng/deploy';
27
27
 
28
28
  async function makeHarness(listBranches: IWorkflowService['listBranches']) {
29
29
  const workflow = {
30
- listChangeRequestsAuthoredBy: vi.fn(async () => []),
30
+ listChangeRequests: vi.fn(async () => []),
31
31
  listBranches: vi.fn(listBranches),
32
32
  createBranch: vi.fn(async () => ({ name: 'x', isDefault: false, isProtected: false })),
33
+ deleteBranch: vi.fn(async () => undefined),
33
34
  commitChanges: vi.fn(async () => null),
34
35
  openChangeRequest: vi.fn(async () => ({ number: 42 })),
36
+ getChangeRequestDetail: vi.fn(async () => ({ body: '' })),
35
37
  } as unknown as IWorkflowService;
36
38
  const workspaceService = {
37
39
  getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: workspaceIdForBranch(branch) })),
38
40
  readFile: vi.fn(async () => '---\n---\nwrite:\n - Eve <eve@x.io>\n'),
41
+ readFileAtRef: vi.fn(async () => null),
42
+ ensureRemotesFetched: vi.fn(async () => undefined),
39
43
  writeFile: vi.fn(async () => undefined),
40
44
  } as unknown as WorkspaceService;
41
45
  const accessControl = { canWrite: vi.fn(async () => false) } as unknown as IAccessControl;
@@ -1,10 +1,11 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { NodeFs } from '../../kb-fs/node-fs.js';
3
- import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, rm, mkdir, writeFile, cp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
7
- import { SkillService } from '../skills.service.js';
7
+ import { SkillService, unmergedSkillNotice } from '../skills.service.js';
8
+ import { BranchNotFoundError } from '../../../shared/domain-errors.js';
8
9
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
10
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
10
11
  import type { IAccessControl } from '../../access/access-control.interface.js';
@@ -38,11 +39,20 @@ You answer RFIs.
38
39
 
39
40
  describe('SkillService', () => {
40
41
  let root: string;
42
+ /**
43
+ * The branches that have a clone under `root`. A name that is not in here
44
+ * answers the way the real service does for a branch nobody ever pushed: the
45
+ * 404 the file tools answer, not an empty workspace.
46
+ */
47
+ let clones: Set<string>;
41
48
 
42
- // WorkspaceService stub: default-branch workspace lives at <root>/<wsId>, the
43
- // KB clone at <root>/<wsId>/<KB_DIR>.
49
+ // WorkspaceService stub: a branch's workspace lives at <root>/<branch's id>,
50
+ // its KB clone at <root>/<id>/<KB_DIR>.
44
51
  const workspaceService = {
45
- getOrCreateForBranch: async () => ({ id: wsId }),
52
+ getOrCreateForBranch: async (branch: string) => {
53
+ if (!clones.has(branch)) throw new BranchNotFoundError(branch);
54
+ return { id: workspaceIdForBranch(branch) };
55
+ },
46
56
  getWorkspacePath: async (id: string) => join(root, id),
47
57
  readFile: async (id: string, rel: string) => fsReadFile(join(root, id, rel), 'utf-8'),
48
58
  } as unknown as WorkspaceService;
@@ -58,6 +68,7 @@ describe('SkillService', () => {
58
68
 
59
69
  beforeEach(async () => {
60
70
  root = await mkdtemp(join(tmpdir(), 'skills-'));
71
+ clones = new Set([DEFAULT_BRANCH]);
61
72
  const skills = join(root, wsId, KB_DIR, 'Plugins');
62
73
  await mkdir(join(skills, 'rfi', 'scripts'), { recursive: true });
63
74
  await writeFile(join(skills, 'rfi', 'SKILL.md'), RFI_SKILL);
@@ -480,4 +491,217 @@ describe('SkillService', () => {
480
491
  });
481
492
  });
482
493
  });
494
+
495
+ /**
496
+ * Reading a DRAFT branch: what an agent needs to try the skill it has just
497
+ * written, with the answer saying plainly that nobody approved it.
498
+ *
499
+ * The draft starts as a copy of the released tree — which is what a clone of
500
+ * a branch with no skill changes on it is — and each test changes it.
501
+ */
502
+ describe('reading a branch', () => {
503
+ const DRAFT = 'juan/skill-deck';
504
+ const draftWsId = workspaceIdForBranch(DRAFT);
505
+ const user = 'user@x.eu';
506
+ const draftKb = () => join(root, draftWsId, KB_DIR);
507
+ const named = async (service = svc()) =>
508
+ Object.fromEntries((await service.listSkills(user, { branch: DRAFT })).map((s) => [s.name, s]));
509
+
510
+ const writeDeck = (body = '# Deck\n') =>
511
+ mkdir(join(draftKb(), 'Plugins', 'make-deck'), { recursive: true }).then(() =>
512
+ writeFile(join(draftKb(), 'Plugins', 'make-deck', 'SKILL.md'), `---\ndescription: Decks.\n---\n\n${body}`),
513
+ );
514
+
515
+ beforeEach(async () => {
516
+ await cp(join(root, wsId), join(root, draftWsId), { recursive: true });
517
+ clones.add(DRAFT);
518
+ });
519
+
520
+ test('a skill that exists only on the branch is listed as unmerged; the released ones are not marked', async () => {
521
+ await writeDeck();
522
+ const list = await named();
523
+ expect(Object.keys(list).sort()).toEqual(['coding-guidelines', 'make-deck', 'rfi']);
524
+ expect(list['make-deck']).toMatchObject({ unmerged: true, branch: DRAFT, description: 'Decks.' });
525
+ // The skills the branch shares with the default branch are the released
526
+ // ones, read through a draft's clone: nothing about them is unapproved.
527
+ expect(list['rfi'].unmerged).toBeUndefined();
528
+ expect(list['rfi'].branch).toBeUndefined();
529
+ expect(list['coding-guidelines'].unmerged).toBeUndefined();
530
+ });
531
+
532
+ test('without a branch the draft-only skill is absent, and nothing carries a mark', async () => {
533
+ await writeDeck();
534
+ const list = await svc().listSkills(user);
535
+ expect(list.map((s) => s.name)).toEqual(['coding-guidelines', 'rfi']);
536
+ expect(list.some((s) => s.unmerged !== undefined || s.branch !== undefined)).toBe(false);
537
+ });
538
+
539
+ test('get_skill loads the draft-only skill, body starting with the unmerged line', async () => {
540
+ await writeDeck();
541
+ const res = await svc().getSkill(user, 'make-deck', undefined, { branch: DRAFT });
542
+ expect(res.ok && res.kind === 'skill').toBe(true);
543
+ if (!res.ok || res.kind !== 'skill') return;
544
+ expect(res.skill.body.split('\n')[0]).toBe(
545
+ `This skill is read from the unmerged branch "${DRAFT}" and is not approved.`,
546
+ );
547
+ expect(res.skill.body.split('\n')[0]).toBe(unmergedSkillNotice(DRAFT));
548
+ expect(res.skill.body).toContain('# Deck');
549
+ expect(res.skill).toMatchObject({ unmerged: true, branch: DRAFT, path: 'Plugins/make-deck' });
550
+ // Without the branch there is no such skill at all.
551
+ expect(await svc().getSkill(user, 'make-deck')).toEqual({ ok: false, error: 'not_found' });
552
+ });
553
+
554
+ test('a skill changed on the branch loads changed there and released without the branch', async () => {
555
+ await writeFile(
556
+ join(draftKb(), 'Plugins', 'rfi', 'SKILL.md'),
557
+ RFI_SKILL.replace('You answer RFIs.', 'You answer RFIs, in three paragraphs.'),
558
+ );
559
+ const service = svc();
560
+ const draft = await service.getSkill(user, 'rfi', undefined, { branch: DRAFT });
561
+ expect(draft.ok && draft.kind === 'skill' && draft.skill.body).toContain('in three paragraphs');
562
+ expect(draft.ok && draft.kind === 'skill' && draft.skill.body.split('\n')[0]).toBe(unmergedSkillNotice(DRAFT));
563
+ expect(draft.ok && draft.kind === 'skill' && draft.skill.unmerged).toBe(true);
564
+ expect((await named(service))['rfi']).toMatchObject({ unmerged: true, branch: DRAFT });
565
+
566
+ const released = await service.getSkill(user, 'rfi');
567
+ expect(released.ok && released.kind === 'skill' && released.skill.body).not.toContain('three paragraphs');
568
+ expect(released.ok && released.kind === 'skill' && released.skill.body).not.toContain('unmerged');
569
+ expect(released.ok && released.kind === 'skill' && released.skill.unmerged).toBeUndefined();
570
+ });
571
+
572
+ test('a skill unchanged on the branch carries no mark and no line — it is the released answer', async () => {
573
+ const service = svc();
574
+ expect(await service.getSkill(user, 'rfi', undefined, { branch: DRAFT })).toEqual(
575
+ await service.getSkill(user, 'rfi'),
576
+ );
577
+ expect(await service.getSkill(user, 'rfi', 'scripts/build_xlsx.py', { branch: DRAFT })).toEqual(
578
+ await service.getSkill(user, 'rfi', 'scripts/build_xlsx.py'),
579
+ );
580
+ });
581
+
582
+ test('a bundled file changed on the branch makes the skill unmerged, and rides beside its content', async () => {
583
+ // The SKILL.md is byte-identical: the difference is in the folder.
584
+ await writeFile(join(draftKb(), 'Plugins', 'rfi', 'scripts', 'build_xlsx.py'), 'print("xlsx v2")\n');
585
+ expect((await named())['rfi']).toMatchObject({ unmerged: true, branch: DRAFT });
586
+ const file = await svc().getSkill(user, 'rfi', 'scripts/build_xlsx.py', { branch: DRAFT });
587
+ expect(file).toEqual({
588
+ ok: true,
589
+ kind: 'file',
590
+ file: {
591
+ name: 'rfi',
592
+ file: 'scripts/build_xlsx.py',
593
+ path: 'Plugins/rfi/scripts/build_xlsx.py',
594
+ // The notice never goes INSIDE a script.
595
+ content: 'print("xlsx v2")\n',
596
+ unmerged: true,
597
+ branch: DRAFT,
598
+ },
599
+ });
600
+ });
601
+
602
+ test('an asset added on the branch is a difference too', async () => {
603
+ await writeFile(join(draftKb(), 'Plugins', 'rfi', 'scripts', 'extra.py'), 'print("new")\n');
604
+ expect((await named())['rfi']).toMatchObject({ unmerged: true, branch: DRAFT });
605
+ });
606
+
607
+ test('a skill deleted on the branch is absent there and still released without it', async () => {
608
+ await rm(join(draftKb(), 'Plugins', 'rfi'), { recursive: true });
609
+ const service = svc();
610
+ expect(Object.keys(await named(service))).toEqual(['coding-guidelines']);
611
+ expect(await service.getSkill(user, 'rfi', undefined, { branch: DRAFT })).toEqual({
612
+ ok: false,
613
+ error: 'not_found',
614
+ });
615
+ expect((await service.listSkills(user)).map((s) => s.name)).toContain('rfi');
616
+ });
617
+
618
+ test("access is judged on the branch, with that branch's rules", async () => {
619
+ // The same caller, the same skill: readable on the default branch,
620
+ // denied in the draft's own workspace.
621
+ const perBranch: IAccessControl = {
622
+ canRead: async () => true,
623
+ canReadBatch: async (w: string, _e: string, paths: string[]) =>
624
+ new Map(paths.map((p) => [p, !(w === draftWsId && p.includes('/rfi/'))])),
625
+ } as unknown as IAccessControl;
626
+ const service = svc(perBranch);
627
+ expect(Object.keys(await named(service))).toEqual(['coding-guidelines']);
628
+ // Not `forbidden`: a draft skill the caller may not read there is one
629
+ // they must not learn exists.
630
+ expect(await service.getSkill(user, 'rfi', undefined, { branch: DRAFT })).toEqual({
631
+ ok: false,
632
+ error: 'not_found',
633
+ });
634
+ expect(await service.getSkill(user, 'rfi', 'scripts/build_xlsx.py', { branch: DRAFT })).toEqual({
635
+ ok: false,
636
+ error: 'not_found',
637
+ });
638
+ // The released answer is untouched by the draft's rules.
639
+ expect((await service.listSkills(user)).map((s) => s.name)).toContain('rfi');
640
+ expect((await service.getSkill(user, 'rfi')).ok).toBe(true);
641
+ });
642
+
643
+ test('a branch that does not exist is a 404 naming it, as on the file tools', async () => {
644
+ const service = svc();
645
+ await expect(service.listSkills(user, { branch: 'juan/typo' })).rejects.toMatchObject({
646
+ status: 404,
647
+ kind: 'branch-not-found',
648
+ branch: 'juan/typo',
649
+ message: 'There is no branch named juan/typo.',
650
+ });
651
+ await expect(service.getSkill(user, 'rfi', undefined, { branch: 'juan/typo' })).rejects.toMatchObject({
652
+ status: 404,
653
+ kind: 'branch-not-found',
654
+ });
655
+ });
656
+
657
+ test('naming the default branch answers exactly as naming nothing', async () => {
658
+ await writeDeck();
659
+ const service = svc();
660
+ expect(await service.listSkills(user, { branch: DEFAULT_BRANCH })).toEqual(await service.listSkills(user));
661
+ expect(await service.getSkill(user, 'rfi', undefined, { branch: DEFAULT_BRANCH })).toEqual(
662
+ await service.getSkill(user, 'rfi'),
663
+ );
664
+ });
665
+
666
+ test('a branch read is never cached: the next call sees what the agent just wrote', async () => {
667
+ const service = svc();
668
+ expect(Object.keys(await named(service))).not.toContain('make-deck');
669
+ await writeDeck();
670
+ // Same instance, no `invalidate()`: the released catalog is cached, a
671
+ // draft is not.
672
+ expect(Object.keys(await named(service))).toContain('make-deck');
673
+ });
674
+
675
+ // The `unmerged` verdict compares two folder digests, and the released
676
+ // side of that comparison is memoized per cached catalog (it used to be
677
+ // re-hashed once per skill per call). These two pin what the memo may and
678
+ // may not do.
679
+ test('the memo never makes a draft stale: an edit on the branch is seen on the next call', async () => {
680
+ const service = svc();
681
+ expect((await named(service))['rfi'].unmerged).toBeUndefined();
682
+ await writeFile(join(draftKb(), 'Plugins', 'rfi', 'scripts', 'build_xlsx.py'), 'print("on the branch")\n');
683
+ // Same instance, no `invalidate()`: the draft's digests are built per
684
+ // call along with its catalog, so the agent's own last write shows.
685
+ expect((await named(service))['rfi']).toMatchObject({ unmerged: true, branch: DRAFT });
686
+ });
687
+
688
+ test('the released digests live and die with the released catalog', async () => {
689
+ const service = svc();
690
+ expect((await named(service))['rfi'].unmerged).toBeUndefined();
691
+ // An asset of the RELEASED copy changes. The catalog caches the file
692
+ // list, not the contents, so this is visible to a digest the moment one
693
+ // is taken — and the memo holds the pre-change one for the same TTL the
694
+ // rest of the released answer is held for.
695
+ await writeFile(join(root, wsId, KB_DIR, 'Plugins', 'rfi', 'scripts', 'build_xlsx.py'), 'print("released")\n');
696
+ expect((await named(service))['rfi'].unmerged).toBeUndefined();
697
+ service.invalidate();
698
+ expect((await named(service))['rfi']).toMatchObject({ unmerged: true, branch: DRAFT });
699
+ });
700
+
701
+ test('the global set (no caller) can be read from a branch too', async () => {
702
+ await writeDeck();
703
+ const list = await svc().listSkills(undefined, { branch: DRAFT });
704
+ expect(list.find((s) => s.name === 'make-deck')).toMatchObject({ unmerged: true, branch: DRAFT });
705
+ });
706
+ });
483
707
  });
@@ -3,16 +3,12 @@ import { logger } from '../../shared/logging.js';
3
3
 
4
4
  const log = logger('skills');
5
5
  import '../auth/auth.middleware.js'; // Express Request.userId / userEmail augmentation
6
- import {
7
- joinBranchFor,
8
- type AuthUser,
9
- type IWorkflowService,
10
- } from '@bevel-software/platform-shared';
6
+ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
11
7
  import type { IAccessControl } from '../access/access-control.interface.js';
12
- import { spliceGrant } from '../access-model/access-splice.js';
8
+ import { folderTarget } from '../access/access-requests.contract.js';
9
+ import { AccessRequestsService } from '../access/access-requests.service.js';
13
10
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
14
11
  import { domainErrorBody } from '../../shared/http-errors.js';
15
- import { isAbsence } from '../../shared/fs.contract.js';
16
12
  import type { KbContext } from '../../shared/kb-context.js';
17
13
  import type { WorkspaceService } from '../workspace/workspace.service.js';
18
14
  import type { JoinRequestsService } from '../plugins/join-requests.service.js';
@@ -28,13 +24,11 @@ import type { ISkillService } from './skills.contract.js';
28
24
  *
29
25
  * Why it exists: linking a skill into a plugin needs write on the skill's
30
26
  * rules (the link grants the plugin's principal there), and a plugin manager
31
- * often has no say over a skill some other scope owns. The request is a plain
32
- * change request whose branch splices `write: <requester>` into the skill
33
- * folder's `access.md`; the skill's editors see its proposals and accept
34
- * through the ordinary grant path, and it retires itself once the default
35
- * branch carries the grant (`JoinRequestsService`, keyed on the skill FOLDER
36
- * path rather than a plugin name — the branch-name convention takes any
37
- * exact string).
27
+ * often has no say over a skill some other scope owns. The request is opened
28
+ * by the shared `AccessRequestsService` — the same one the Manage access
29
+ * dialog's Request access uses — keyed on the skill FOLDER path, so this
30
+ * button and a Can edit request on that same folder are ONE request, shown in
31
+ * the skill page's banner and in the folder's dialog alike.
38
32
  *
39
33
  * Fail-closed like the skill catalog: a skill the caller cannot read answers
40
34
  * as unknown, and the editors' listing answers `[]` to anyone else.
@@ -49,10 +43,18 @@ export function createSkillAccessRequestRoutes(deps: {
49
43
  resolveUser: (req: express.Request) => Promise<AuthUser | null>;
50
44
  }): express.Router {
51
45
  const { skillService, accessControl, workflow, workspaceService, joinRequests, kb, resolveUser } = deps;
52
- const { kbDirName } = kb;
53
46
  const router = express.Router();
54
47
  const wsId = () => kb.defaultWorkspaceId();
55
48
  const rulesOf = (folder: string) => `${folder}/access.md`;
49
+ // The same opener the Manage access dialog uses, pointed at a skill's
50
+ // folder. Keyed on that folder's PATH, which is what makes this button and
51
+ // a Can edit request on the same folder one request rather than two.
52
+ const requests = new AccessRequestsService({
53
+ workflow,
54
+ workspaceService,
55
+ lifecycle: joinRequests,
56
+ kb,
57
+ });
56
58
 
57
59
  /** The skill the caller may read, by name — or null after answering 404. */
58
60
  async function readableSkill(
@@ -82,65 +84,13 @@ export function createSkillAccessRequestRoutes(deps: {
82
84
  res.status(409).json({ error: 'You can already edit this skill', kind: 'already-writable' });
83
85
  return;
84
86
  }
85
- const branch = joinBranchFor(user.email, folder);
86
- // FRESH: a repeated click must find the request the previous one opened,
87
- // and the cached listing can trail it — a miss here would send the
88
- // retry into a duplicate refusal.
89
- const existing = (await workflow.listChangeRequestsAuthoredBy(user.email, { fresh: true })).find(
90
- (cr) => cr.state === 'open' && cr.branch === branch,
91
- );
92
- if (existing) {
93
- res.json({ ok: true, number: existing.number });
94
- return;
95
- }
96
- // The branch may already exist (a request listed as closed, a retry
97
- // after a failed open). Existence is PROBED, before and — if creation
98
- // fails — after: the race shows up as "already exists" locally or as a
99
- // rejected push when origin got there first, and a message is not a
100
- // contract. A branch that is there is proceeded against; anything else
101
- // is a real failure, and a proposal on a branch that was not made would
102
- // be worse than the error. STRICT: the list proves absence, and a
103
- // listing that could not fetch proves nothing — it throws instead of
104
- // serving refs from before the branch was made (or deleted).
105
- const branchExists = async () =>
106
- (await workflow.listBranches(wsId(), { freshFetch: true, strictFetch: true })).some(
107
- (b) => b.name === branch,
108
- );
109
- if (!(await branchExists())) {
110
- try {
111
- await workflow.createBranch(wsId(), branch, kb.defaultBranch);
112
- } catch (err) {
113
- if (!(await branchExists())) throw err;
114
- }
115
- }
116
- const ws = await workspaceService.getOrCreateForBranch(branch);
117
- const accessPath = `${kbDirName}/${rulesOf(folder)}`;
118
- // Only a PROVEN absence reads as "no rules yet": any other failure must
119
- // not be turned into an empty file that a splice then overwrites.
120
- const current = await workspaceService.readFile(ws.id, accessPath).catch((err: unknown) => {
121
- if (!isAbsence(err)) throw err;
122
- return '';
123
- });
124
- const spliced = spliceGrant(
125
- current,
126
- 'write',
127
- { kind: 'user', email: user.email, displayName: user.name },
128
- { target: 'folder' },
129
- );
130
- if (spliced.changed) {
131
- await workspaceService.writeFile(ws.id, accessPath, spliced.text);
132
- await workflow.commitChanges(ws.id, user, `Request write access to ${name}`);
133
- }
134
- const detail = await workflow.openChangeRequest(ws.id, user, {
135
- sourceBranch: branch,
136
- targetBranch: kb.defaultBranch,
137
- title: `Access request: ${name}`,
138
- description:
139
- `${user.name} asked to edit the skill ${name}. An editor of the skill accepts by ` +
140
- `granting the access this branch proposes; the request closes itself once ` +
141
- `every proposal has landed.`,
87
+ const { number } = await requests.open({
88
+ user,
89
+ target: folderTarget(folder),
90
+ itemName: name,
91
+ level: 'write',
142
92
  });
143
- res.json({ ok: true, number: detail.number });
93
+ res.json({ ok: true, number });
144
94
  } catch (err) {
145
95
  if (err instanceof WorkflowDomainError) {
146
96
  res.status(err.status).json(domainErrorBody(err));
@@ -171,7 +121,7 @@ export function createSkillAccessRequestRoutes(deps: {
171
121
  const ctx = await requireEditor(req, res, () => res.json({ requests: [] }));
172
122
  if (!ctx) return;
173
123
  const crs = await workflow.listChangeRequests();
174
- res.json({ requests: await joinRequests.list(ctx.folder, ctx.folder, crs, ctx.user) });
124
+ res.json({ requests: await joinRequests.list(ctx.folder, folderTarget(ctx.folder), crs, ctx.user) });
175
125
  } catch (err) {
176
126
  log.error('failed to list access requests:', { err });
177
127
  res.status(500).json({ error: 'Failed to list access requests' });
@@ -192,7 +142,7 @@ export function createSkillAccessRequestRoutes(deps: {
192
142
  res.status(404).json({ error: 'Not found' });
193
143
  return;
194
144
  }
195
- res.json({ closed: await joinRequests.reconcile(ctx.folder, ctx.folder, cr, ctx.user) });
145
+ res.json({ closed: await joinRequests.reconcile(ctx.folder, folderTarget(ctx.folder), cr, ctx.user) });
196
146
  } catch (err) {
197
147
  log.error('failed to reconcile an access request:', { err });
198
148
  res.status(500).json({ error: 'Failed to reconcile the request' });