@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,164 @@
1
+ /**
2
+ * Agent access gate — the per-call seam every gated agent tool runs through.
3
+ *
4
+ * It decides nothing of its own. It calls the hooks a DEPLOYMENT registered
5
+ * (`WorkflowHooks`): one before an agent read of a knowledge-base path, one
6
+ * before an agent write. A hook that throws refuses the operation and the
7
+ * caller sees that hook's own message and status. With no hook registered —
8
+ * every core-only deployment — nothing is refused and nothing is recorded, and
9
+ * a call that carries no `sessionId` is an ordinary call.
10
+ *
11
+ * The gate owns exactly two skips, and they are the ones core can decide on
12
+ * its own identity model: the caller is not an agent (a person in the app, a
13
+ * browser JWT), or the caller is the recovery/merge bot. Everything else —
14
+ * which paths matter, whether a session id is required, what the refusal says
15
+ * — belongs to the hook.
16
+ *
17
+ * Lives beside `assertCanRead` in the workspace tools; it does NOT touch
18
+ * `acquireLock` (that gate is protected-branch-only, and this one applies on
19
+ * all branches and to reads).
20
+ */
21
+
22
+ import type { JsonSchema } from '../tool-registry/tool.contract.js';
23
+ import type { ToolContext } from '../tool-helpers/tool.contract.js';
24
+ import type { WorkflowHooks } from '../workflow/workflow-hooks.js';
25
+
26
+ /**
27
+ * What the `sessionId` input says on its own, before any deployment note. The
28
+ * id is the CONVERSATION's id: the in-process agent supplies its thread id
29
+ * automatically (via its per-run internal token), and an external agent mints
30
+ * one with `start_session` and passes it explicitly, because the MCP proxy is
31
+ * a pure passthrough and never injects args. The same id is the one `ask`
32
+ * takes, so a run's reads and its questions share one conversation.
33
+ */
34
+ export const SESSION_ID_DESCRIPTION =
35
+ "This conversation's id, so the server can tell one run's calls apart. External agents (direct MCP calls and `call_tool_chain` alike): call the `start_session` tool ONCE at the very start of your work to obtain it, then pass that id here — the same id `ask` takes. The in-process agent runtime supplies this automatically (do not set it yourself).";
36
+
37
+ /**
38
+ * The `sessionId` input every gated tool declares. The description a MOUNTED
39
+ * tool ends up advertising is {@link ToolDescriptionNotes.sessionIdDescription},
40
+ * which appends whatever note the deployment registered; this constant is the
41
+ * unadorned default the notes are applied on top of.
42
+ */
43
+ export const SESSION_ID_INPUT: JsonSchema = {
44
+ type: 'string',
45
+ description: SESSION_ID_DESCRIPTION,
46
+ };
47
+
48
+ /**
49
+ * The registration point for the wording a DEPLOYMENT wants agents to read
50
+ * about the gated tools — the counterpart of the hooks, which carry its rule.
51
+ * A deployment that refuses some calls can say so here; core registers
52
+ * nothing, so a core-only deployment advertises the plain descriptions.
53
+ *
54
+ * Registration may land AFTER the tools are mounted (an overlay registers from
55
+ * the tool-surface hook), so mounted tools subscribe with {@link onChange} and
56
+ * rewrite their own description and `sessionId` input when a note arrives.
57
+ */
58
+ export class ToolDescriptionNotes {
59
+ private gatedTool = '';
60
+ private sessionId = '';
61
+ private readonly listeners = new Set<() => void>();
62
+
63
+ /** Append `note` to the description of every gated tool. */
64
+ registerGatedToolNote(note: string): void {
65
+ this.gatedTool = note;
66
+ this.announce();
67
+ }
68
+
69
+ /** Append `note` to the description of the `sessionId` input. */
70
+ registerSessionIdNote(note: string): void {
71
+ this.sessionId = note;
72
+ this.announce();
73
+ }
74
+
75
+ /** The registered gated-tool note, `''` when none is registered. */
76
+ gatedToolNote(): string {
77
+ return this.gatedTool;
78
+ }
79
+
80
+ /** The `sessionId` input's description, with any registered note appended. */
81
+ sessionIdDescription(): string {
82
+ return SESSION_ID_DESCRIPTION + this.sessionId;
83
+ }
84
+
85
+ /** Re-run `listener` whenever a note is registered (mounted tools re-describe). */
86
+ onChange(listener: () => void): void {
87
+ this.listeners.add(listener);
88
+ }
89
+
90
+ private announce(): void {
91
+ for (const listener of this.listeners) listener();
92
+ }
93
+ }
94
+
95
+ /** The per-deployment configuration the gate needs, built once in the composition root. */
96
+ export interface AgentAccessGate {
97
+ /** Lowercased email of the recovery/merge bot, which never reaches the hooks. */
98
+ recoveryBotEmail: string;
99
+ /**
100
+ * The workflow lifecycle-hook registry holding the BLOCKING `agentRead` and
101
+ * `preWrite` hooks. Core registers none; a deployment registers against
102
+ * `workflowService.hooks` — pass that same instance here.
103
+ */
104
+ hooks: WorkflowHooks;
105
+ /** The notes a deployment adds to what agents read about these tools. */
106
+ notes: ToolDescriptionNotes;
107
+ }
108
+
109
+ /** Only agent callers are gated; `session` (browser JWT) and humans are not. */
110
+ function agentSourceOf(ctx: ToolContext): 'internal' | 'external' | null {
111
+ return ctx.source === 'internal' || ctx.source === 'external' ? ctx.source : null;
112
+ }
113
+
114
+ /**
115
+ * The hook context for this call, or `null` when the call never reaches a hook:
116
+ * the caller is a person rather than an agent, or is the recovery/merge bot,
117
+ * which legitimately spans everything.
118
+ */
119
+ function operationContext(
120
+ gate: AgentAccessGate,
121
+ ctx: ToolContext,
122
+ branch: string,
123
+ wsPath?: string,
124
+ ): { sessionId?: string; wsPath?: string; branch: string; user: ToolContext['user']; source: 'internal' | 'external' } | null {
125
+ const source = agentSourceOf(ctx);
126
+ if (source === null) return null;
127
+ if (ctx.user.email.toLowerCase() === gate.recoveryBotEmail) return null;
128
+ return { sessionId: ctx.sessionId, wsPath, branch, user: ctx.user, source };
129
+ }
130
+
131
+ /**
132
+ * Tell the registered read hooks about an agent read of `wsPath`. A hook that
133
+ * throws refuses the read with its own message and status; with none
134
+ * registered this is a no-op.
135
+ */
136
+ export async function notifyAgentRead(
137
+ gate: AgentAccessGate,
138
+ ctx: ToolContext,
139
+ branch: string,
140
+ wsPath: string,
141
+ ): Promise<void> {
142
+ const op = operationContext(gate, ctx, branch, wsPath);
143
+ if (op === null) return;
144
+ await gate.hooks.runAgentRead(op);
145
+ }
146
+
147
+ /**
148
+ * Ask the registered write hooks whether an agent write may proceed. A hook
149
+ * that throws refuses it with its own message and status; with none registered
150
+ * this is a no-op.
151
+ *
152
+ * `wsPath` is omitted by the one write-capable tool with no resolvable target,
153
+ * `execute_command` — the hook then decides at the coarser session level.
154
+ */
155
+ export async function assertAgentWriteAllowed(
156
+ gate: AgentAccessGate,
157
+ ctx: ToolContext,
158
+ branch: string,
159
+ wsPath?: string,
160
+ ): Promise<void> {
161
+ const op = operationContext(gate, ctx, branch, wsPath);
162
+ if (op === null) return;
163
+ await gate.hooks.runPreWrite(op);
164
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Per-run write restriction for background/routine agents.
3
3
  *
4
- * The ontology-session gate ({@link ../workflow/session-ontology.service}) bounds
5
- * WHICH ontology a run may write; this narrower gate bounds WHAT KIND of file a
6
- * run may write, by extension. It exists for the `watchlist_check` routine, which is
4
+ * The agent-access gate ({@link ./agent-access.gate}) lets a deployment decide
5
+ * WHETHER a run may write a path at all; this narrower gate bounds WHAT KIND of
6
+ * file a run may write, by extension. It exists for the `watchlist_check` routine, which is
7
7
  * allowed to refresh dashboard views (`.html`) but must never touch the
8
8
  * knowledge-graph nodes (`.md`) or any other file — a hard, code-level guarantee
9
9
  * rather than a prompt request (a prompt-only rule has already proven insufficient).
@@ -11,9 +11,8 @@
11
11
  * Keyed by the agent run's `sessionId` (the background agent passes its run id as
12
12
  * the session id, which the internal token then carries onto every loopback tool
13
13
  * call as `ctx.sessionId`). A restriction lives only for the duration of one
14
- * in-process run, so the store is a plain in-memory map — no durability needed,
15
- * unlike the ontology touched-set which must survive restarts and span the MCP
16
- * proxy. The executor sets the restriction before streaming and clears it after.
14
+ * in-process run, so the store is a plain in-memory map — no durability needed.
15
+ * The executor sets the restriction before streaming and clears it after.
17
16
  */
18
17
 
19
18
  import { ToolError } from '../tool-helpers/tool.contract.js';
@@ -1,12 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  /**
4
- * Port behind `start_session` (workspace.tools.ts): mints the session id an
5
- * external agent scopes its ontology boundary under. The workspace module owns
6
- * the port; WHAT backs the id is the composition root's choice:
4
+ * Port behind `start_session` (workspace.tools.ts): mints the id an external
5
+ * agent names its conversation with. The workspace module owns the port; WHAT
6
+ * backs the id is the composition root's choice:
7
7
  *
8
- * - Core default ({@link UuidSessionSink}): a bare random id — sufficient for
9
- * the session-ontology gate, with no app-side session record.
8
+ * - Core default ({@link UuidSessionSink}): a bare random id — all the
9
+ * agent-access hooks need, with no app-side session record.
10
10
  * - The enterprise app substitutes a sink that mints a REAL chat thread, so the
11
11
  * same id also works end to end with `ask` (its sessionId IS a chat thread).
12
12
  * See the `start_session` comment in workspace.tools.ts for why that
@@ -0,0 +1,96 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Moving a working copy of a repository that is no longer the configured one
6
+ * OUT OF THE WAY, rather than deleting it.
7
+ *
8
+ * Two places find such a clone: the KB startup phase, sweeping the whole
9
+ * workspaces root at boot and on the save that changes the address, and the
10
+ * workspace service, refusing to adopt one it finds on disk. Both must do the
11
+ * same thing with it, because what is in it is the same thing: commits that
12
+ * may exist nowhere else. A clone fetches and pushes through the address in
13
+ * its own `remote.origin.url`, and two repositories need not share a commit,
14
+ * so such a clone cannot be re-pointed — but that is a reason not to USE it,
15
+ * never a reason to destroy it.
16
+ */
17
+
18
+ /**
19
+ * Where working copies are set aside. NOT under the workspaces root: the
20
+ * workspace service's orphan sweep removes every folder there that is not a
21
+ * known branch's, and what is set aside has to outlive that sweep.
22
+ *
23
+ * PASS THE OVERRIDE, and pass BOTH CALLERS THE SAME ONE. The fallback is a
24
+ * sibling of the workspaces root, which is a lasting place only when that
25
+ * parent is itself a mounted volume — in the shipped compose file it is the
26
+ * container's own filesystem, so a copy left there is gone at the next
27
+ * recreate, having been kept on the promise that it would not be. The
28
+ * composition root therefore roots both the startup phase and the workspace
29
+ * service at one directory under the backups volume; the fallback is for a
30
+ * test, or a caller with no deployment layout to speak of.
31
+ */
32
+ export function setAsideRootFor(workspacesRoot: string, override?: string): string {
33
+ return override ?? path.resolve(workspacesRoot, '..', 'replaced-working-copies');
34
+ }
35
+
36
+ /**
37
+ * One folder per occasion, so what was set aside together stays together and
38
+ * two replacements never land in each other's folder.
39
+ */
40
+ export function setAsideStamp(at: Date = new Date()): string {
41
+ return at.toISOString().replace(/[:.]/g, '-');
42
+ }
43
+
44
+ /**
45
+ * Move the clone at `repoDir` to `dest`. A rename when it can be one; a copy
46
+ * when it cannot (another volume, a handle held open on it), and only then is
47
+ * the original removed — so a copy that fails leaves the clone exactly where
48
+ * it was rather than half of it in each place.
49
+ *
50
+ * Throws with what to do about it. The caller decides how much that costs:
51
+ * the startup phase stops the boot (leaving the clone would fail a step later
52
+ * with git's words about a repository nobody configured), while a single
53
+ * branch open refuses that branch.
54
+ */
55
+ export async function setAsideClone(repoDir: string, dest: string): Promise<void> {
56
+ // A destination that is already there is somebody's set-aside work, and
57
+ // never this call's to touch: the copy below cleans up after itself by
58
+ // removing the destination, which must then be one this call made.
59
+ const taken = (): Error =>
60
+ new Error(
61
+ `Could not set aside the working copy at ${repoDir}: ${dest} already holds one. Nothing was deleted or ` +
62
+ 'moved. Try again; the folder is named for the moment it is made.',
63
+ );
64
+ if (await fs.access(dest).then(() => true, () => false)) throw taken();
65
+ await fs.mkdir(path.dirname(dest), { recursive: true });
66
+ try {
67
+ await fs.rename(repoDir, dest);
68
+ return;
69
+ } catch {
70
+ // Another volume, or a handle held open on it: copy instead.
71
+ }
72
+ // The destination is MADE here, by the one call that will fill it. Two
73
+ // calls that both found it absent above cannot both make it: the second
74
+ // is refused by the filesystem, before the cleanup below could ever be
75
+ // about a folder it did not create.
76
+ try {
77
+ await fs.mkdir(dest);
78
+ } catch (err) {
79
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') throw taken();
80
+ throw err;
81
+ }
82
+ try {
83
+ // Into the empty folder made above, which is why an existing destination
84
+ // is not an error here; nothing is overwritten (`force: false`).
85
+ await fs.cp(repoDir, dest, { recursive: true, errorOnExist: false, force: false, verbatimSymlinks: true });
86
+ } catch (err) {
87
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
88
+ throw new Error(
89
+ `Could not set aside the working copy at ${repoDir}, a clone of a repository that is no longer the ` +
90
+ `configured one, into ${dest}: ${err instanceof Error ? err.message : String(err)}. Nothing was deleted. ` +
91
+ 'Make room there, or move that folder away by hand, and start again.',
92
+ { cause: err },
93
+ );
94
+ }
95
+ await fs.rm(repoDir, { recursive: true, force: true });
96
+ }
@@ -79,6 +79,7 @@ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> =
79
79
  gitRunner: new NodeGitRunner(undefined, credentials),
80
80
  kbRepoUrl: () => upstream,
81
81
  workspacesRoot,
82
+ setAsideRoot: path.join(root, 'set-aside'),
82
83
  kbDirName: 'knowledge-base',
83
84
  templateDir: path.join(root, 'template'),
84
85
  defaultBranch: () => DEFAULT_BRANCH,
@@ -103,6 +104,302 @@ async function checkout(branch: string): Promise<string> {
103
104
  return dir;
104
105
  }
105
106
 
107
+ /**
108
+ * The configured repository was replaced while working copies of the old one
109
+ * were still on disk. A clone fetches through its OWN stored address, so
110
+ * every one of them kept asking the repository that was gone: the boot
111
+ * stopped on "repository not found" and took the setup screen with it.
112
+ */
113
+ describe('KbStartupRunner — the repository was replaced', () => {
114
+ const touchDefault = step('touch', async (ctx) => {
115
+ await (await ctx.defaultBranch()).repoDir();
116
+ return { outcome: 'ok' };
117
+ });
118
+ const cloneDir = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
119
+
120
+ /** A second, unrelated repository with the same branch names. */
121
+ async function replacementUpstream(): Promise<string> {
122
+ const other = path.join(root, 'replacement.git');
123
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, other]);
124
+ const seed = path.join(root, '.seed-replacement');
125
+ await fs.mkdir(seed);
126
+ await git(seed, ['init', '-b', DEFAULT_BRANCH]);
127
+ await fs.writeFile(path.join(seed, 'marker.txt'), 'replacement', 'utf8');
128
+ await git(seed, ['add', '-A']);
129
+ await git(seed, ['commit', '-m', 'init']);
130
+ await git(seed, ['branch', PROTECTED[1]!]);
131
+ await git(seed, ['remote', 'add', 'origin', other]);
132
+ await git(seed, ['push', 'origin', ...PROTECTED]);
133
+ return other;
134
+ }
135
+
136
+ /** Boot once against the first repository, and leave a draft's clone beside it. */
137
+ async function bootedOnTheOldRepository(): Promise<void> {
138
+ await populatedUpstream();
139
+ await makeRunner([touchDefault]).runAll();
140
+ await fs.mkdir(path.dirname(cloneDir('someone/draft')), { recursive: true });
141
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, cloneDir('someone/draft')]);
142
+ }
143
+
144
+ /** Where one run set its working copies aside: the single dated folder under the root. */
145
+ async function setAside(): Promise<string> {
146
+ const runs = await fs.readdir(path.join(root, 'set-aside'));
147
+ expect(runs).toHaveLength(1);
148
+ return path.join(root, 'set-aside', runs[0]!);
149
+ }
150
+
151
+ it('clones the new repository in place of the old one\'s clones, even with the old one gone', async () => {
152
+ await bootedOnTheOldRepository();
153
+ const replacement = await replacementUpstream();
154
+ // Gone, as it was in production: any fetch through the old address fails.
155
+ await fs.rm(upstream, { recursive: true, force: true });
156
+
157
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
158
+
159
+ const repo = cloneDir(DEFAULT_BRANCH);
160
+ expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(replacement);
161
+ expect(await fs.readFile(path.join(repo, 'marker.txt'), 'utf8')).toBe('replacement');
162
+ // A clone this boot had no reason to touch leaves the workspaces root
163
+ // too: the workspace service would otherwise adopt it and fetch from the
164
+ // old address.
165
+ await expect(fs.access(cloneDir('someone/draft'))).rejects.toThrow();
166
+ });
167
+
168
+ /**
169
+ * The old repository is GONE, so its clones are the last copy of what it
170
+ * held, and the commit nobody pushed exists nowhere else at all. They
171
+ * leave the workspaces root and nothing else happens to them.
172
+ */
173
+ it('sets the old repository\'s clones aside whole, unpushed work included, and deletes nothing', async () => {
174
+ await bootedOnTheOldRepository();
175
+ const old = cloneDir(DEFAULT_BRANCH);
176
+ await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
177
+ await git(old, ['add', '-A']);
178
+ await git(old, ['commit', '-m', 'never pushed']);
179
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
180
+ const replacement = await replacementUpstream();
181
+ await fs.rm(upstream, { recursive: true, force: true });
182
+
183
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
184
+
185
+ const kept = await setAside();
186
+ const keptDefault = path.join(kept, encodeURIComponent(DEFAULT_BRANCH));
187
+ expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
188
+ expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
189
+ expect(await fs.readFile(path.join(keptDefault, 'marker.txt'), 'utf8')).toBe('seeded');
190
+ // The draft's clone too, under its own workspace's name.
191
+ await expect(fs.access(path.join(kept, encodeURIComponent('someone/draft'), '.git'))).resolves.toBeUndefined();
192
+ // And the probe that compared the histories left no refs behind in it.
193
+ expect((await git(keptDefault, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
194
+ });
195
+
196
+ /**
197
+ * The shipped compose files mount the backups root as a volume of its own,
198
+ * where a rename across to it is refused. That is the path production
199
+ * takes: a copy, and the original removed only once the copy is whole.
200
+ */
201
+ it('sets a clone aside across volumes, by copying it whole', async () => {
202
+ await bootedOnTheOldRepository();
203
+ const old = cloneDir(DEFAULT_BRANCH);
204
+ await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
205
+ await git(old, ['add', '-A']);
206
+ await git(old, ['commit', '-m', 'never pushed']);
207
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
208
+ const replacement = await replacementUpstream();
209
+ await fs.rm(upstream, { recursive: true, force: true });
210
+ const crossDevice = Object.assign(new Error('EXDEV: cross-device link not permitted'), { code: 'EXDEV' });
211
+ const rename = vi.spyOn(fs, 'rename').mockRejectedValue(crossDevice);
212
+ try {
213
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
214
+ } finally {
215
+ rename.mockRestore();
216
+ }
217
+
218
+ const keptDefault = path.join(await setAside(), encodeURIComponent(DEFAULT_BRANCH));
219
+ expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
220
+ expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
221
+ expect(await fs.readFile(path.join(cloneDir(DEFAULT_BRANCH), 'marker.txt'), 'utf8')).toBe('replacement');
222
+ });
223
+
224
+ /**
225
+ * A clone that cannot be set aside stays exactly where it is, and the boot
226
+ * stops saying why: deleting it to get past would be the one outcome this
227
+ * whole path exists to rule out.
228
+ */
229
+ it('deletes nothing when a clone cannot be set aside, and says why', async () => {
230
+ await bootedOnTheOldRepository();
231
+ const old = cloneDir(DEFAULT_BRANCH);
232
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
233
+ const replacement = await replacementUpstream();
234
+ await fs.rm(upstream, { recursive: true, force: true });
235
+ const rename = vi.spyOn(fs, 'rename').mockRejectedValue(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
236
+ const cp = vi.spyOn(fs, 'cp').mockRejectedValue(Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }));
237
+ try {
238
+ await expect(makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll()).rejects.toThrow(
239
+ /Could not set aside the working copy.*no space left.*Nothing was deleted/s,
240
+ );
241
+ } finally {
242
+ rename.mockRestore();
243
+ cp.mockRestore();
244
+ }
245
+ expect((await git(old, ['rev-parse', 'HEAD'])).trim()).toBe(head);
246
+ });
247
+
248
+ /**
249
+ * The one change the setup screen tells an admin to make: the same
250
+ * repository, moved. The clone is pointed at the new address and kept, and
251
+ * the commit that was never pushed goes on to be pushed there.
252
+ */
253
+ it('keeps a clone of the same repository at a new address, and points it there', async () => {
254
+ await bootedOnTheOldRepository();
255
+ const repo = cloneDir(DEFAULT_BRANCH);
256
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
257
+ await git(repo, ['add', '-A']);
258
+ await git(repo, ['commit', '-m', 'local work']);
259
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
260
+ // Moved: everything the old address held is at the new one, and the old
261
+ // one answers nothing any more.
262
+ const moved = path.join(root, 'moved.git');
263
+ await git(root, ['clone', '--bare', upstream, moved]);
264
+ await fs.rm(upstream, { recursive: true, force: true });
265
+
266
+ await makeRunner([touchDefault], { kbRepoUrl: () => moved }).runAll();
267
+
268
+ expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
269
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
270
+ expect((await git(cloneDir('someone/draft'), ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
271
+ expect((await git(repo, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
272
+ // Nothing was another repository's, so nothing was set aside.
273
+ await expect(fs.access(path.join(root, 'set-aside'))).rejects.toThrow();
274
+ });
275
+
276
+ it('leaves a clone of the configured repository alone, unpushed work included', async () => {
277
+ await bootedOnTheOldRepository();
278
+ const repo = cloneDir(DEFAULT_BRANCH);
279
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
280
+ await git(repo, ['add', '-A']);
281
+ await git(repo, ['commit', '-m', 'local work']);
282
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
283
+
284
+ await makeRunner([touchDefault]).runAll();
285
+
286
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
287
+ await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
288
+ });
289
+
290
+ it('does not take a different spelling of the same address for another repository', async () => {
291
+ await bootedOnTheOldRepository();
292
+ const repo = cloneDir(DEFAULT_BRANCH);
293
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
294
+ await git(repo, ['add', '-A']);
295
+ await git(repo, ['commit', '-m', 'local work']);
296
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
297
+
298
+ // `upstream` ends in `.git`; the same repository without it, and with a
299
+ // trailing slash.
300
+ await makeRunner([touchDefault], { kbRepoUrl: () => `${upstream.replace(/\.git$/, '')}/` }).runAll().catch(() => {});
301
+
302
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
303
+ });
304
+
305
+ it('tells the workspace layer about every working copy it deletes', async () => {
306
+ // On a SAVE the process is already running, and the workspace service
307
+ // caches branch→directory. A clone that was set aside, left in that cache,
308
+ // is a path to nothing, and nothing would ever re-clone it.
309
+ await bootedOnTheOldRepository();
310
+ const replacement = await replacementUpstream();
311
+ const discarded: string[] = [];
312
+
313
+ await makeRunner([touchDefault], {
314
+ kbRepoUrl: () => replacement,
315
+ onCloneDiscarded: (id: string) => discarded.push(id),
316
+ }).runAll();
317
+
318
+ expect(discarded.sort()).toEqual(
319
+ [encodeURIComponent(DEFAULT_BRANCH), encodeURIComponent('someone/draft')].sort(),
320
+ );
321
+ });
322
+
323
+ /**
324
+ * Holding back the queue of a working copy is a precondition of setting it
325
+ * aside. A phase that carried on past a failure there would clone the
326
+ * replacement over an untouched queue, and the worker would commit those
327
+ * bytes into a repository they were never meant for.
328
+ */
329
+ it('stops, with the working copy still in place, when its queue cannot be held back first', async () => {
330
+ await bootedOnTheOldRepository();
331
+ const replacement = await replacementUpstream();
332
+ const before = (await git(cloneDir(DEFAULT_BRANCH), ['config', '--get', 'remote.origin.url'])).trim();
333
+ const discarded: string[] = [];
334
+
335
+ await expect(
336
+ makeRunner([touchDefault], {
337
+ kbRepoUrl: () => replacement,
338
+ beforeCloneSetAside: async () => {
339
+ throw new Error('pending_commits is down');
340
+ },
341
+ onCloneDiscarded: (id: string) => discarded.push(id),
342
+ }).runAll(),
343
+ ).rejects.toThrow(/pending_commits is down/);
344
+
345
+ // Nothing moved: the next run finds the same copy and asks again.
346
+ expect((await git(cloneDir(DEFAULT_BRANCH), ['config', '--get', 'remote.origin.url'])).trim()).toBe(before);
347
+ expect(discarded).toEqual([]);
348
+ });
349
+
350
+ it('holds back the queue of each working copy before it sets that copy aside', async () => {
351
+ await bootedOnTheOldRepository();
352
+ const replacement = await replacementUpstream();
353
+ const order: string[] = [];
354
+
355
+ await makeRunner([touchDefault], {
356
+ kbRepoUrl: () => replacement,
357
+ beforeCloneSetAside: async (id: string) => void order.push(`queue ${id}`),
358
+ onCloneDiscarded: (id: string) => void order.push(`aside ${id}`),
359
+ }).runAll();
360
+
361
+ const main = encodeURIComponent(DEFAULT_BRANCH);
362
+ expect(order.indexOf(`queue ${main}`)).toBeGreaterThanOrEqual(0);
363
+ expect(order.indexOf(`queue ${main}`)).toBeLessThan(order.indexOf(`aside ${main}`));
364
+ });
365
+
366
+ it('tells the git layer that each working copy it cloned is freshly fetched', async () => {
367
+ // A clone holds every ref, so it IS a successful fetch. Unsaid, the git
368
+ // layer keeps the per-workspace record that drove the replacement — a
369
+ // FAILED fetch of the repository that is gone — and a strict branch
370
+ // listing inside its TTL refuses the new clone's refs as unproven.
371
+ await bootedOnTheOldRepository();
372
+ const replacement = await replacementUpstream();
373
+ const announced: string[] = [];
374
+
375
+ await makeRunner([touchDefault], {
376
+ kbRepoUrl: () => replacement,
377
+ onCloneCreated: (id: string) => announced.push(id),
378
+ }).runAll();
379
+
380
+ // Every working copy the phase made in place of one it set aside.
381
+ expect(announced).toContain(encodeURIComponent(DEFAULT_BRANCH));
382
+ });
383
+
384
+ it('says nothing about a working copy it keeps', async () => {
385
+ await bootedOnTheOldRepository();
386
+ const discarded: string[] = [];
387
+ await makeRunner([touchDefault], { onCloneDiscarded: (id: string) => discarded.push(id) }).runAll();
388
+ expect(discarded).toEqual([]);
389
+ });
390
+
391
+ it('keeps every clone when the configured repository cannot be reached', async () => {
392
+ // A typo in the address must not cost the working copies: the sweep runs
393
+ // only once the configured remote has answered.
394
+ await bootedOnTheOldRepository();
395
+ await expect(
396
+ makeRunner([touchDefault], { kbRepoUrl: () => path.join(root, 'no-such-repository.git') }).runAll(),
397
+ ).rejects.toThrow();
398
+ await expect(fs.access(path.join(cloneDir(DEFAULT_BRANCH), '.git'))).resolves.toBeUndefined();
399
+ await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
400
+ });
401
+ });
402
+
106
403
  describe('KbStartupRunner', () => {
107
404
  it('seeds an empty remote with every protected branch before any step runs', async () => {
108
405
  const seen: string[] = [];