@bevel-software/platform-core-backend 0.14.0 → 0.15.1

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 (385) hide show
  1. package/THIRD-PARTY-NOTICES.md +6 -6
  2. package/dist/core/catalog-cache-invalidation.d.ts +29 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -0
  4. package/dist/core/catalog-cache-invalidation.js +71 -0
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -0
  6. package/dist/core/create-core-server.d.ts.map +1 -1
  7. package/dist/core/create-core-server.js +99 -8
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +18 -1
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +96 -43
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/modules/access/access-control.interface.d.ts +18 -6
  14. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts +26 -1
  16. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.js +213 -71
  18. package/dist/modules/access/access-control.service.js.map +1 -1
  19. package/dist/modules/access/access.routes.d.ts.map +1 -1
  20. package/dist/modules/access/access.routes.js +59 -7
  21. package/dist/modules/access/access.routes.js.map +1 -1
  22. package/dist/modules/access-model/access-grammar.d.ts +51 -1
  23. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  24. package/dist/modules/access-model/access-grammar.js +95 -1
  25. package/dist/modules/access-model/access-grammar.js.map +1 -1
  26. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  27. package/dist/modules/access-model/group-files.js +4 -1
  28. package/dist/modules/access-model/group-files.js.map +1 -1
  29. package/dist/modules/access-model/plugin-principals.d.ts +49 -0
  30. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  31. package/dist/modules/access-model/plugin-principals.js +125 -0
  32. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  33. package/dist/modules/database/core-schema.d.ts +326 -0
  34. package/dist/modules/database/core-schema.d.ts.map +1 -1
  35. package/dist/modules/database/core-schema.js +52 -0
  36. package/dist/modules/database/core-schema.js.map +1 -1
  37. package/dist/modules/kb-sync/index.d.ts +6 -0
  38. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  39. package/dist/modules/kb-sync/index.js +5 -0
  40. package/dist/modules/kb-sync/index.js.map +1 -0
  41. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  42. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  43. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  44. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  45. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  46. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  47. package/dist/modules/kb-sync/kb-sync.routes.js +135 -0
  48. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  49. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  50. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  51. package/dist/modules/kb-sync/kb-sync.service.js +183 -0
  52. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  53. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  54. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  55. package/dist/modules/kb-sync/sync-auth.js +62 -0
  56. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  57. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  58. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  59. package/dist/modules/kb-sync/sync-payload.js +105 -0
  60. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  61. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  62. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  63. package/dist/modules/marketplace/git-http.routes.js +190 -0
  64. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  65. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +23 -0
  66. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  67. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +59 -0
  68. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  69. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  70. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  71. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  72. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  73. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +99 -0
  74. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  75. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +147 -0
  76. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  77. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  78. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  79. package/dist/modules/marketplace/github-facade/github-facade.routes.js +228 -0
  80. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  81. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +109 -0
  82. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  83. package/dist/modules/marketplace/github-facade/github-facade.service.js +172 -0
  84. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  85. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  86. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  87. package/dist/modules/marketplace/github-facade/index.js +6 -0
  88. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  89. package/dist/modules/marketplace/index.d.ts +4 -0
  90. package/dist/modules/marketplace/index.d.ts.map +1 -0
  91. package/dist/modules/marketplace/index.js +4 -0
  92. package/dist/modules/marketplace/index.js.map +1 -0
  93. package/dist/modules/marketplace/marketplace-repo.service.d.ts +122 -0
  94. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  95. package/dist/modules/marketplace/marketplace-repo.service.js +300 -0
  96. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  97. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  98. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +4 -0
  99. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  100. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  101. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  102. package/dist/modules/mcp/oauth/oauth-consent.routes.js +13 -1
  103. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  104. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  105. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  106. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  107. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  108. package/dist/modules/plugins/compile/compile-marketplace.d.ts +97 -0
  109. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  110. package/dist/modules/plugins/compile/compile-marketplace.js +240 -0
  111. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  112. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +66 -0
  113. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  114. package/dist/modules/plugins/compile/marketplace-compiler.service.js +132 -0
  115. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  116. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  117. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  118. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +135 -0
  119. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  120. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +45 -0
  121. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  122. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +147 -0
  123. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  124. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +37 -0
  125. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  126. package/dist/modules/plugins/discovery/kb-plugin-source.js +107 -0
  127. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  128. package/dist/modules/plugins/discovery/native.source.d.ts +15 -0
  129. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  130. package/dist/modules/plugins/discovery/native.source.js +102 -0
  131. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  132. package/dist/modules/plugins/discovery/plugin-source.d.ts +96 -0
  133. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  134. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  135. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  136. package/dist/modules/plugins/index.d.ts +10 -1
  137. package/dist/modules/plugins/index.d.ts.map +1 -1
  138. package/dist/modules/plugins/index.js +8 -0
  139. package/dist/modules/plugins/index.js.map +1 -1
  140. package/dist/modules/plugins/plugin-links.d.ts +66 -0
  141. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  142. package/dist/modules/plugins/plugin-links.js +160 -0
  143. package/dist/modules/plugins/plugin-links.js.map +1 -0
  144. package/dist/modules/plugins/plugin-links.service.d.ts +114 -0
  145. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  146. package/dist/modules/plugins/plugin-links.service.js +304 -0
  147. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  148. package/dist/modules/plugins/plugin-provision.service.d.ts +39 -3
  149. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  150. package/dist/modules/plugins/plugin-provision.service.js +152 -52
  151. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  152. package/dist/modules/plugins/plugin-rename.service.d.ts +69 -0
  153. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  154. package/dist/modules/plugins/plugin-rename.service.js +245 -0
  155. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  156. package/dist/modules/plugins/plugins.contract.d.ts +27 -1
  157. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  158. package/dist/modules/plugins/plugins.routes.d.ts +7 -1
  159. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  160. package/dist/modules/plugins/plugins.routes.js +120 -12
  161. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  162. package/dist/modules/plugins/plugins.service.d.ts +50 -3
  163. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  164. package/dist/modules/plugins/plugins.service.js +108 -47
  165. package/dist/modules/plugins/plugins.service.js.map +1 -1
  166. package/dist/modules/settings/deployment-settings.service.d.ts +9 -0
  167. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  168. package/dist/modules/settings/deployment-settings.service.js +82 -2
  169. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  170. package/dist/modules/settings/setup.routes.d.ts +26 -0
  171. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  172. package/dist/modules/settings/setup.routes.js +9 -1
  173. package/dist/modules/settings/setup.routes.js.map +1 -1
  174. package/dist/modules/skills/index.d.ts +1 -0
  175. package/dist/modules/skills/index.d.ts.map +1 -1
  176. package/dist/modules/skills/index.js +1 -0
  177. package/dist/modules/skills/index.js.map +1 -1
  178. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  179. package/dist/modules/skills/pending-skills.service.js +9 -4
  180. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  181. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  182. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  183. package/dist/modules/skills/skill-access-requests.routes.js +168 -0
  184. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  185. package/dist/modules/skills/skills.contract.d.ts +31 -0
  186. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  187. package/dist/modules/skills/skills.routes.d.ts +28 -3
  188. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  189. package/dist/modules/skills/skills.routes.js +42 -3
  190. package/dist/modules/skills/skills.routes.js.map +1 -1
  191. package/dist/modules/skills/skills.service.d.ts +4 -0
  192. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  193. package/dist/modules/skills/skills.service.js +83 -22
  194. package/dist/modules/skills/skills.service.js.map +1 -1
  195. package/dist/modules/tool-auth/external-api-key.interface.d.ts +18 -1
  196. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  197. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  198. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  199. package/dist/modules/tool-auth/external-api-key.service.d.ts +13 -4
  200. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  201. package/dist/modules/tool-auth/external-api-key.service.js +26 -9
  202. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  203. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  204. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  205. package/dist/modules/tool-manuals/mcp-json-discovery.js +130 -92
  206. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  207. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +6 -1
  208. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  209. package/dist/modules/tool-manuals/tool-manuals.service.js +18 -25
  210. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  211. package/dist/modules/workflow/git/git.service.d.ts +40 -2
  212. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  213. package/dist/modules/workflow/git/git.service.js +227 -54
  214. package/dist/modules/workflow/git/git.service.js.map +1 -1
  215. package/dist/modules/workflow/git/pull-request.service.d.ts +6 -0
  216. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  217. package/dist/modules/workflow/git/pull-request.service.js +8 -0
  218. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  219. package/dist/modules/workflow/workflow.service.d.ts +52 -1
  220. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  221. package/dist/modules/workflow/workflow.service.js +242 -9
  222. package/dist/modules/workflow/workflow.service.js.map +1 -1
  223. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +35 -0
  224. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  225. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +121 -0
  226. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  227. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  228. package/dist/modules/workspace/startup/steps/seed-tree.js +46 -2
  229. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/steps/template-files.step.js +112 -25
  232. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +9 -9
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +33 -0
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +110 -16
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/shared/domain-errors.d.ts +20 -0
  241. package/dist/shared/domain-errors.d.ts.map +1 -1
  242. package/dist/shared/domain-errors.js +29 -0
  243. package/dist/shared/domain-errors.js.map +1 -1
  244. package/dist/shared/fs-errors.d.ts +8 -0
  245. package/dist/shared/fs-errors.d.ts.map +1 -0
  246. package/dist/shared/fs-errors.js +11 -0
  247. package/dist/shared/fs-errors.js.map +1 -0
  248. package/dist/shared/fs-walk.d.ts +20 -6
  249. package/dist/shared/fs-walk.d.ts.map +1 -1
  250. package/dist/shared/fs-walk.js +35 -27
  251. package/dist/shared/fs-walk.js.map +1 -1
  252. package/dist/shared/kb-walk.d.ts +52 -0
  253. package/dist/shared/kb-walk.d.ts.map +1 -0
  254. package/dist/shared/kb-walk.js +76 -0
  255. package/dist/shared/kb-walk.js.map +1 -0
  256. package/dist/shared/path-order.d.ts +18 -0
  257. package/dist/shared/path-order.d.ts.map +1 -0
  258. package/dist/shared/path-order.js +35 -0
  259. package/dist/shared/path-order.js.map +1 -0
  260. package/dist/shared/ttl-cache.d.ts +28 -1
  261. package/dist/shared/ttl-cache.d.ts.map +1 -1
  262. package/dist/shared/ttl-cache.js +33 -1
  263. package/dist/shared/ttl-cache.js.map +1 -1
  264. package/kb-template/.bevelignore +1 -1
  265. package/kb-template/AGENTS.md +95 -30
  266. package/kb-template/Skills/.gitkeep +0 -0
  267. package/migrations/0005_github_facade.sql +25 -0
  268. package/migrations/meta/0005_snapshot.json +1717 -0
  269. package/migrations/meta/_journal.json +7 -0
  270. package/package.json +6 -6
  271. package/src/__tests__/kb-layout-config.test.ts +110 -0
  272. package/src/core/__tests__/catalog-cache-invalidation.test.ts +146 -0
  273. package/src/core/catalog-cache-invalidation.ts +92 -0
  274. package/src/core/create-core-server.ts +123 -7
  275. package/src/core/create-core-services.ts +162 -41
  276. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +86 -0
  277. package/src/modules/access/__tests__/access-control.service.test.ts +19 -3
  278. package/src/modules/access/__tests__/access-plugin-principals.test.ts +368 -0
  279. package/src/modules/access/__tests__/groups-admin.service.test.ts +8 -0
  280. package/src/modules/access/access-control.interface.ts +20 -9
  281. package/src/modules/access/access-control.service.ts +240 -78
  282. package/src/modules/access/access.routes.ts +96 -14
  283. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  284. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  285. package/src/modules/access-model/access-grammar.ts +114 -2
  286. package/src/modules/access-model/group-files.ts +4 -0
  287. package/src/modules/access-model/plugin-principals.ts +137 -0
  288. package/src/modules/database/core-schema.ts +54 -0
  289. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  290. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  291. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  292. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  293. package/src/modules/kb-sync/index.ts +19 -0
  294. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  295. package/src/modules/kb-sync/kb-sync.routes.ts +197 -0
  296. package/src/modules/kb-sync/kb-sync.service.ts +201 -0
  297. package/src/modules/kb-sync/sync-auth.ts +118 -0
  298. package/src/modules/kb-sync/sync-payload.ts +143 -0
  299. package/src/modules/marketplace/__tests__/github-facade.test.ts +443 -0
  300. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +179 -0
  301. package/src/modules/marketplace/git-http.routes.ts +193 -0
  302. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +78 -0
  303. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  304. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +208 -0
  305. package/src/modules/marketplace/github-facade/github-facade.routes.ts +268 -0
  306. package/src/modules/marketplace/github-facade/github-facade.service.ts +221 -0
  307. package/src/modules/marketplace/github-facade/index.ts +25 -0
  308. package/src/modules/marketplace/index.ts +3 -0
  309. package/src/modules/marketplace/marketplace-repo.service.ts +337 -0
  310. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -0
  311. package/src/modules/mcp/oauth/oauth-consent.routes.ts +24 -2
  312. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  313. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +163 -0
  314. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +294 -0
  315. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +56 -12
  316. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +287 -0
  317. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +205 -5
  318. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +394 -0
  319. package/src/modules/plugins/__tests__/plugins.routes.test.ts +80 -33
  320. package/src/modules/plugins/compile/compile-marketplace.ts +403 -0
  321. package/src/modules/plugins/compile/marketplace-compiler.service.ts +150 -0
  322. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +329 -0
  323. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +140 -0
  324. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +175 -0
  325. package/src/modules/plugins/discovery/kb-plugin-source.ts +114 -0
  326. package/src/modules/plugins/discovery/native.source.ts +122 -0
  327. package/src/modules/plugins/discovery/plugin-source.ts +99 -0
  328. package/src/modules/plugins/index.ts +10 -0
  329. package/src/modules/plugins/plugin-links.service.ts +363 -0
  330. package/src/modules/plugins/plugin-links.ts +184 -0
  331. package/src/modules/plugins/plugin-provision.service.ts +160 -54
  332. package/src/modules/plugins/plugin-rename.service.ts +298 -0
  333. package/src/modules/plugins/plugins.contract.ts +28 -1
  334. package/src/modules/plugins/plugins.routes.ts +128 -11
  335. package/src/modules/plugins/plugins.service.ts +106 -53
  336. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +57 -0
  337. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  338. package/src/modules/settings/deployment-settings.service.ts +95 -2
  339. package/src/modules/settings/setup.routes.ts +24 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  341. package/src/modules/skills/__tests__/skills.service.test.ts +85 -0
  342. package/src/modules/skills/index.ts +1 -0
  343. package/src/modules/skills/pending-skills.service.ts +7 -5
  344. package/src/modules/skills/skill-access-requests.routes.ts +199 -0
  345. package/src/modules/skills/skills.contract.ts +32 -0
  346. package/src/modules/skills/skills.routes.ts +59 -3
  347. package/src/modules/skills/skills.service.ts +84 -22
  348. package/src/modules/tool-auth/external-api-key.interface.ts +20 -1
  349. package/src/modules/tool-auth/external-api-key.service.ts +31 -13
  350. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +24 -1
  351. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +2 -0
  352. package/src/modules/tool-manuals/mcp-json-discovery.ts +154 -88
  353. package/src/modules/tool-manuals/tool-manuals.service.ts +14 -23
  354. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  355. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -0
  356. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +2 -2
  357. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +119 -2
  358. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +437 -0
  359. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  360. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  361. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  362. package/src/modules/workflow/git/git.service.ts +239 -56
  363. package/src/modules/workflow/git/pull-request.service.ts +9 -0
  364. package/src/modules/workflow/workflow.service.ts +260 -9
  365. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +6 -7
  366. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +93 -0
  367. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  368. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +42 -12
  369. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +219 -4
  370. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +129 -0
  371. package/src/modules/workspace/startup/steps/seed-tree.ts +44 -2
  372. package/src/modules/workspace/startup/steps/template-files.step.ts +131 -27
  373. package/src/modules/workspace/workspace.routes.ts +9 -17
  374. package/src/modules/workspace/workspace.service.ts +107 -16
  375. package/src/shared/__tests__/filename.test.ts +0 -0
  376. package/src/shared/__tests__/fs-walk.test.ts +22 -1
  377. package/src/shared/__tests__/kb-walk.test.ts +107 -0
  378. package/src/shared/__tests__/path-order.test.ts +24 -0
  379. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  380. package/src/shared/domain-errors.ts +29 -0
  381. package/src/shared/fs-errors.ts +10 -0
  382. package/src/shared/fs-walk.ts +38 -23
  383. package/src/shared/kb-walk.ts +95 -0
  384. package/src/shared/path-order.ts +33 -0
  385. package/src/shared/ttl-cache.ts +33 -1
@@ -0,0 +1,297 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import type { Server } from 'node:http';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createKbSyncRoutes, isSyncRawBodyPath } from '../kb-sync.routes.js';
6
+ import type { IKbSyncService, SyncResult } from '../kb-sync.interface.js';
7
+
8
+ const SECRET = 'a-very-long-and-random-sync-secret';
9
+
10
+ const SYNCED: SyncResult = {
11
+ status: 'synced',
12
+ results: [{ branch: 'main', outcome: 'updated', from: 'aaa', to: 'bbb' }],
13
+ changeRequests: { closedDeletedBranch: 0 },
14
+ };
15
+
16
+ let server: Server | undefined;
17
+ afterEach(() => {
18
+ server?.close();
19
+ server = undefined;
20
+ });
21
+
22
+ async function mount(opts: {
23
+ secret?: string;
24
+ result?: SyncResult;
25
+ admin?: boolean;
26
+ /** Install the production-shaped global JSON parser in front of the router. */
27
+ globalJson?: boolean;
28
+ }): Promise<{ base: string; sync: ReturnType<typeof vi.fn> }> {
29
+ // A suite may mount twice in one test; the earlier server must not outlive it.
30
+ server?.close();
31
+ const sync = vi.fn(async () => opts.result ?? SYNCED);
32
+ const kbSync: IKbSyncService = { sync };
33
+ const app = express();
34
+ // Mirrors production: the global JSON parser skips `/api/sync`, so the
35
+ // router's own raw parser sees the bytes. Installing `express.json()` here
36
+ // would hide exactly the wiring bug the signature path depends on.
37
+ if (opts.globalJson) {
38
+ const json = express.json();
39
+ app.use((req, res, next) => (isSyncRawBodyPath(req.path) ? next() : json(req, res, next)));
40
+ }
41
+ app.use(
42
+ '/api',
43
+ createKbSyncRoutes({
44
+ kbSync,
45
+ syncSecret: () => opts.secret ?? SECRET,
46
+ authService: {
47
+ verifyToken: (token: string) => {
48
+ if (token !== 'jwt-ok') throw new Error('bad token');
49
+ return { userId: 'u1', email: 'person@example.com' };
50
+ },
51
+ },
52
+ adminAccess: { isAdmin: async () => opts.admin === true },
53
+ }),
54
+ );
55
+ await new Promise<void>((resolve) => {
56
+ server = app.listen(0, resolve);
57
+ });
58
+ const address = server!.address();
59
+ if (typeof address === 'string' || !address) throw new Error('no port');
60
+ return { base: `http://127.0.0.1:${address.port}/api/sync`, sync };
61
+ }
62
+
63
+ function post(url: string, init: { headers?: Record<string, string>; body?: string } = {}) {
64
+ return fetch(url, { method: 'POST', headers: init.headers ?? {}, body: init.body });
65
+ }
66
+
67
+ describe('POST /api/sync — credentials', () => {
68
+ it('401 with no credential, and advertises the bearer challenge', async () => {
69
+ const { base, sync } = await mount({});
70
+ const res = await post(base);
71
+ expect(res.status).toBe(401);
72
+ expect(res.headers.get('www-authenticate')).toContain('Bearer');
73
+ expect(sync).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('401 with the wrong secret', async () => {
77
+ const { base, sync } = await mount({});
78
+ const res = await post(base, { headers: { authorization: 'Bearer wrong' } });
79
+ expect(res.status).toBe(401);
80
+ expect(sync).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('503 when no secret is configured and a hook presents one', async () => {
84
+ const { base } = await mount({ secret: '' });
85
+ const res = await post(base, { headers: { authorization: `Bearer ${SECRET}` } });
86
+ expect(res.status).toBe(503);
87
+ expect(((await res.json()) as { error: string }).error).toContain('KB_SYNC_SECRET');
88
+ });
89
+
90
+ it("an admin's session works; a non-admin's does not", async () => {
91
+ const admin = await mount({ admin: true });
92
+ expect((await post(admin.base, { headers: { authorization: 'Bearer jwt-ok' } })).status).toBe(200);
93
+ const person = await mount({ admin: false });
94
+ expect((await post(person.base, { headers: { authorization: 'Bearer jwt-ok' } })).status).toBe(401);
95
+ });
96
+
97
+ it('accepts the GitLab token header', async () => {
98
+ const { base } = await mount({});
99
+ const res = await post(base, { headers: { 'x-gitlab-token': SECRET } });
100
+ expect(res.status).toBe(200);
101
+ });
102
+
103
+ it('accepts a GitHub signature computed over the exact bytes received', async () => {
104
+ const { base, sync } = await mount({});
105
+ const body = '{"ref":"refs/heads/main","after":"b"}';
106
+ const sig = 'sha256=' + createHmac('sha256', SECRET).update(body).digest('hex');
107
+ const res = await post(base, {
108
+ headers: { 'x-hub-signature-256': sig, 'content-type': 'application/json' },
109
+ body,
110
+ });
111
+ expect(res.status).toBe(200);
112
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['main'] }));
113
+ });
114
+ });
115
+
116
+ describe('POST /api/sync — body and result', () => {
117
+ it('an empty body syncs everything', async () => {
118
+ const { base, sync } = await mount({});
119
+ const res = await post(base, { headers: { authorization: `Bearer ${SECRET}` } });
120
+ expect(res.status).toBe(200);
121
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: 'all' }));
122
+ expect(await res.json()).toEqual(SYNCED);
123
+ });
124
+
125
+ it('an explicit body names the branches', async () => {
126
+ const { base, sync } = await mount({});
127
+ await post(base, {
128
+ headers: { authorization: `Bearer ${SECRET}`, 'content-type': 'application/json' },
129
+ body: JSON.stringify({ branches: ['main', 'ali/x'] }),
130
+ });
131
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['main', 'ali/x'] }));
132
+ });
133
+
134
+ it('an Azure DevOps push payload names the pushed branch', async () => {
135
+ const { base, sync } = await mount({});
136
+ await post(base, {
137
+ headers: { authorization: `Bearer ${SECRET}`, 'content-type': 'application/json' },
138
+ body: JSON.stringify({
139
+ eventType: 'git.push',
140
+ resource: { refUpdates: [{ name: 'refs/heads/ali/x' }] },
141
+ }),
142
+ });
143
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['ali/x'] }));
144
+ });
145
+
146
+ it('400 for a branch name git would refuse, and for a body that is not JSON', async () => {
147
+ const { base, sync } = await mount({});
148
+ const bad = await post(base, {
149
+ headers: { authorization: `Bearer ${SECRET}`, 'content-type': 'application/json' },
150
+ body: JSON.stringify({ branches: ['--upload-pack=x'] }),
151
+ });
152
+ expect(bad.status).toBe(400);
153
+ const notJson = await post(base, {
154
+ headers: { authorization: `Bearer ${SECRET}`, 'content-type': 'application/json' },
155
+ body: '{not json',
156
+ });
157
+ expect(notJson.status).toBe(400);
158
+ expect(sync).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('409 when any branch is in conflict — the message rides in the body', async () => {
162
+ const { base } = await mount({
163
+ result: {
164
+ status: 'partial',
165
+ results: [
166
+ { branch: 'ali/x', outcome: 'updated', from: 'a', to: 'b' },
167
+ {
168
+ branch: 'main',
169
+ outcome: 'conflict',
170
+ conflictedPaths: ['Plugins/x/SKILL.md'],
171
+ error: 'main is not in sync yet: Plugins/x/SKILL.md changed both in Hexis and on the git host. Recovery is queued; if this stays, open the files on main in Hexis, keep what you want, and save.',
172
+ },
173
+ ],
174
+ changeRequests: { closedDeletedBranch: 0 },
175
+ },
176
+ });
177
+ const res = await post(base, { headers: { authorization: `Bearer ${SECRET}` } });
178
+ expect(res.status).toBe(409);
179
+ const body = (await res.json()) as SyncResult;
180
+ expect(body.results[1]).toMatchObject({ outcome: 'conflict' });
181
+ expect((body.results[1] as { error: string }).error).toContain('Recovery is queued');
182
+ });
183
+
184
+ it('503 when a branch could not be pulled and none is in conflict', async () => {
185
+ const { base } = await mount({
186
+ result: {
187
+ status: 'partial',
188
+ results: [{ branch: 'main', outcome: 'error', error: 'could not read from remote' }],
189
+ changeRequests: { closedDeletedBranch: 0 },
190
+ },
191
+ });
192
+ expect((await post(base, { headers: { authorization: `Bearer ${SECRET}` } })).status).toBe(503);
193
+ });
194
+ });
195
+
196
+ describe('POST /api/sync/<branch> — the branch in the URL', () => {
197
+ it('syncs exactly that branch, spelled as segments or percent-encoded', async () => {
198
+ for (const spelling of ['main', 'ali/new-skill', 'ali%2Fnew-skill']) {
199
+ const { base, sync } = await mount({});
200
+ const res = await post(`${base}/${spelling}`, { headers: { authorization: `Bearer ${SECRET}` } });
201
+ expect(res.status).toBe(200);
202
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: [decodeURIComponent(spelling)] }));
203
+ server?.close();
204
+ }
205
+ });
206
+
207
+ it('ignores the body for branch selection — a GitHub Action can post anything', async () => {
208
+ const { base, sync } = await mount({});
209
+ await post(`${base}/main`, {
210
+ headers: { authorization: `Bearer ${SECRET}`, 'content-type': 'application/json' },
211
+ body: JSON.stringify({ branches: ['other'] }),
212
+ });
213
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['main'] }));
214
+ });
215
+
216
+ it('still checks the credential first', async () => {
217
+ const { base, sync } = await mount({});
218
+ expect((await post(`${base}/main`)).status).toBe(401);
219
+ expect(sync).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('400 for a branch name git would refuse', async () => {
223
+ const { base, sync } = await mount({});
224
+ const res = await post(`${base}/--upload-pack=x`, { headers: { authorization: `Bearer ${SECRET}` } });
225
+ expect(res.status).toBe(400);
226
+ expect(sync).not.toHaveBeenCalled();
227
+ });
228
+ });
229
+
230
+ describe('POST /api/sync — behind the production JSON parser', () => {
231
+ it('the branch route still sees the raw bytes, so a GitHub signature verifies', async () => {
232
+ const { base, sync } = await mount({ globalJson: true });
233
+ const body = '{"ref":"refs/heads/main"}';
234
+ const sig = 'sha256=' + createHmac('sha256', SECRET).update(body).digest('hex');
235
+ const res = await post(`${base}/main`, {
236
+ headers: { 'x-hub-signature-256': sig, 'content-type': 'application/json' },
237
+ body,
238
+ });
239
+ expect(res.status).toBe(200);
240
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['main'] }));
241
+ });
242
+
243
+ it('and so does the bare route', async () => {
244
+ const { base } = await mount({ globalJson: true });
245
+ const body = '{"ref":"refs/heads/main"}';
246
+ const sig = 'sha256=' + createHmac('sha256', SECRET).update(body).digest('hex');
247
+ const res = await post(base, {
248
+ headers: { 'x-hub-signature-256': sig, 'content-type': 'application/json' },
249
+ body,
250
+ });
251
+ expect(res.status).toBe(200);
252
+ });
253
+
254
+ it('isSyncRawBodyPath covers both shapes and nothing else', () => {
255
+ expect(isSyncRawBodyPath('/api/sync')).toBe(true);
256
+ expect(isSyncRawBodyPath('/api/sync/main')).toBe(true);
257
+ expect(isSyncRawBodyPath('/api/sync/ali/new-skill')).toBe(true);
258
+ expect(isSyncRawBodyPath('/api/synchronise')).toBe(false);
259
+ expect(isSyncRawBodyPath('/api/setup/status')).toBe(false);
260
+ });
261
+ });
262
+
263
+ describe('POST /api/sync — the response marker', () => {
264
+ it('every answer carries the header a proxy never sets, including the 503 and the 401', async () => {
265
+ const ok = await mount({});
266
+ expect((await post(ok.base, { headers: { authorization: `Bearer ${SECRET}` } })).headers.get('x-hexis-sync')).toBe('result');
267
+ server?.close();
268
+ const down = await mount({
269
+ result: {
270
+ status: 'partial',
271
+ results: [{ branch: 'main', outcome: 'error', error: 'could not read from remote' }],
272
+ changeRequests: { closedDeletedBranch: 0 },
273
+ },
274
+ });
275
+ const res = await post(down.base, { headers: { authorization: `Bearer ${SECRET}` } });
276
+ expect(res.status).toBe(503);
277
+ expect(res.headers.get('x-hexis-sync')).toBe('result');
278
+ expect((await post(down.base)).headers.get('x-hexis-sync')).toBe('result');
279
+ });
280
+ });
281
+
282
+ describe('POST /api/Sync — an odd-cased URL', () => {
283
+ it('is matched by Express and by the raw-body carve-out alike, so it works exactly like the lower-case one', async () => {
284
+ const { base, sync } = await mount({ globalJson: true });
285
+ const oddCase = base.replace('/api/sync', '/api/Sync');
286
+ const body = '{"ref":"refs/heads/main"}';
287
+ const sig = 'sha256=' + createHmac('sha256', SECRET).update(body).digest('hex');
288
+ const res = await post(`${oddCase}/main`, {
289
+ headers: { 'x-hub-signature-256': sig, 'content-type': 'application/json' },
290
+ body,
291
+ });
292
+ expect(res.status).toBe(200);
293
+ expect(sync).toHaveBeenCalledWith(expect.objectContaining({ branches: ['main'] }));
294
+ expect(isSyncRawBodyPath('/api/Sync')).toBe(true);
295
+ expect(isSyncRawBodyPath('/API/SYNC/main')).toBe(true);
296
+ });
297
+ });
@@ -0,0 +1,295 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { BranchSyncOutcome } from '@bevel-software/platform-shared';
3
+ import { KbSyncService } from '../kb-sync.service.js';
4
+ import type { SyncWorkflowPort, SyncWorkspacePort } from '../kb-sync.interface.js';
5
+
6
+ function deferred<T>() {
7
+ let resolve!: (v: T) => void;
8
+ const promise = new Promise<T>((res) => {
9
+ resolve = res;
10
+ });
11
+ return { promise, resolve };
12
+ }
13
+
14
+ function workspaces(branches: string[]): SyncWorkspacePort {
15
+ return {
16
+ listClonedWorkspaces: async () => branches.map((branch) => ({ id: encodeURIComponent(branch), branch })),
17
+ };
18
+ }
19
+
20
+ function updated(branch: string): BranchSyncOutcome {
21
+ return { branch, outcome: 'updated', from: 'aaa', to: 'bbb' };
22
+ }
23
+
24
+ describe('KbSyncService', () => {
25
+ it('syncs every known clone for an "all" request and reports unknown branches as not cloned', async () => {
26
+ const workflow: SyncWorkflowPort = {
27
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => updated(decodeURIComponent(id))),
28
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 1),
29
+ retireRemoteGoneClone: vi.fn(async () => true),
30
+ };
31
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x']));
32
+
33
+ const all = await svc.sync({ branches: 'all' });
34
+ expect(all.status).toBe('synced');
35
+ expect(all.results.map((r) => r.branch)).toEqual(['main', 'ali/x']);
36
+ expect(all.changeRequests.closedDeletedBranch).toBe(1);
37
+ expect(workflow.syncWorkspaceFromRemote).toHaveBeenCalledWith('ali%2Fx');
38
+
39
+ const some = await svc.sync({ branches: ['ali/x', 'juan/other'] });
40
+ expect(some.results).toEqual([updated('ali/x'), { branch: 'juan/other', outcome: 'not-cloned' }]);
41
+ });
42
+
43
+ it('a conflict or an error makes the result partial; the other branches still sync', async () => {
44
+ const workflow: SyncWorkflowPort = {
45
+ syncWorkspaceFromRemote: vi.fn(async (id: string): Promise<BranchSyncOutcome> =>
46
+ id === 'main'
47
+ ? { branch: 'main', outcome: 'conflict', conflictedPaths: ['a.md'], error: 'msg' }
48
+ : updated(decodeURIComponent(id)),
49
+ ),
50
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
51
+ retireRemoteGoneClone: vi.fn(async () => true),
52
+ };
53
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x']));
54
+ const r = await svc.sync({ branches: 'all' });
55
+ expect(r.status).toBe('partial');
56
+ expect(r.results[1]).toEqual(updated('ali/x'));
57
+ });
58
+
59
+ it('a failing sweep does not fail the sync', async () => {
60
+ const workflow: SyncWorkflowPort = {
61
+ syncWorkspaceFromRemote: vi.fn(async () => updated('main')),
62
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => {
63
+ throw new Error('origin down');
64
+ }),
65
+ retireRemoteGoneClone: vi.fn(async () => true),
66
+ };
67
+ const svc = new KbSyncService(workflow, workspaces(['main']));
68
+ const r = await svc.sync({ branches: 'all' });
69
+ expect(r.status).toBe('synced');
70
+ expect(r.changeRequests.closedDeletedBranch).toBe(0);
71
+ });
72
+
73
+ it('coalesces everything that arrives mid-run into ONE follow-up carrying the union', async () => {
74
+ const gate = deferred<void>();
75
+ // Each run's branches, in order. The first run parks on `gate` so the
76
+ // others arrive mid-run; a new run is recognised by its first branch
77
+ // landing after the gate opened.
78
+ const calls: string[][] = [['']];
79
+ let gateOpened = false;
80
+ const workflow: SyncWorkflowPort = {
81
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => {
82
+ const branch = decodeURIComponent(id);
83
+ if (!gateOpened && branch === 'main') {
84
+ await gate.promise;
85
+ gateOpened = true;
86
+ calls[0] = [];
87
+ } else if (gateOpened && calls.length === 1) {
88
+ calls.push([]);
89
+ }
90
+ calls[calls.length - 1].push(branch);
91
+ return updated(branch);
92
+ }),
93
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
94
+ retireRemoteGoneClone: vi.fn(async () => true),
95
+ };
96
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x', 'juan/y']));
97
+
98
+ const first = svc.sync({ branches: ['main'] });
99
+ // Three requests land while the first is parked on `gate`.
100
+ const second = svc.sync({ branches: ['ali/x'] });
101
+ const third = svc.sync({ branches: ['juan/y'] });
102
+ const fourth = svc.sync({ branches: ['ali/x'] });
103
+ gate.resolve();
104
+
105
+ const [r1, r2, r3, r4] = await Promise.all([first, second, third, fourth]);
106
+ expect(r1.results.map((r) => r.branch)).toEqual(['main']);
107
+ // One follow-up, the union, in arrival order — and every mid-run caller
108
+ // answered for the branches IT asked about, never for the others'.
109
+ expect(calls).toEqual([['main'], ['ali/x', 'juan/y']]);
110
+ expect(r2.results.map((r) => r.branch)).toEqual(['ali/x']);
111
+ expect(r3.results.map((r) => r.branch)).toEqual(['juan/y']);
112
+ expect(r4.results.map((r) => r.branch)).toEqual(['ali/x']);
113
+ expect(workflow.closeChangeRequestsWithDeletedBranches).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it('an "all" arriving mid-run absorbs the queued branches', async () => {
117
+ const gate = deferred<void>();
118
+ const workflow: SyncWorkflowPort = {
119
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => {
120
+ if (id === 'main') await gate.promise;
121
+ return updated(decodeURIComponent(id));
122
+ }),
123
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
124
+ retireRemoteGoneClone: vi.fn(async () => true),
125
+ };
126
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x', 'juan/y']));
127
+ const first = svc.sync({ branches: ['main'] });
128
+ const second = svc.sync({ branches: ['ali/x'] });
129
+ const third = svc.sync({ branches: 'all' });
130
+ gate.resolve();
131
+ await first;
132
+ // The follow-up pulled everything; the "all" caller hears all of it, the
133
+ // one-branch caller only its branch.
134
+ expect((await third).results.map((x) => x.branch)).toEqual(['main', 'ali/x', 'juan/y']);
135
+ expect((await second).results.map((x) => x.branch)).toEqual(['ali/x']);
136
+ });
137
+ });
138
+
139
+ describe('KbSyncService.lastSync', () => {
140
+ it('is null before the first sync, then records when, who and what', async () => {
141
+ const workflow: SyncWorkflowPort = {
142
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => updated(decodeURIComponent(id))),
143
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
144
+ retireRemoteGoneClone: vi.fn(async () => true),
145
+ };
146
+ let clock = 1_000;
147
+ const svc = new KbSyncService(workflow, workspaces(['main']), () => clock);
148
+ expect(svc.lastSync()).toBeNull();
149
+
150
+ await svc.sync({ branches: 'all', by: 'bearer' });
151
+ expect(svc.lastSync()).toEqual({
152
+ at: 1_000,
153
+ by: 'bearer',
154
+ status: 'synced',
155
+ results: [updated('main')],
156
+ });
157
+
158
+ clock = 2_000;
159
+ await svc.sync({ branches: ['main'], by: 'admin@example.com' });
160
+ expect(svc.lastSync()).toMatchObject({ at: 2_000, by: 'admin@example.com' });
161
+ });
162
+
163
+ it('a coalesced follow-up names everyone who asked for it', async () => {
164
+ const gate = deferred<void>();
165
+ const workflow: SyncWorkflowPort = {
166
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => {
167
+ if (id === 'main') await gate.promise;
168
+ return updated(decodeURIComponent(id));
169
+ }),
170
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
171
+ retireRemoteGoneClone: vi.fn(async () => true),
172
+ };
173
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x']));
174
+ const first = svc.sync({ branches: ['main'], by: 'bearer' });
175
+ const second = svc.sync({ branches: ['ali/x'], by: 'github-signature' });
176
+ const third = svc.sync({ branches: ['ali/x'], by: 'admin@example.com' });
177
+ gate.resolve();
178
+ await Promise.all([first, second, third]);
179
+ expect(svc.lastSync()?.by).toBe('github-signature, admin@example.com');
180
+ });
181
+ });
182
+
183
+ describe('KbSyncService — a throwing branch step', () => {
184
+ it('becomes an error outcome; the other branches, the sweep and the record all still happen', async () => {
185
+ const workflow: SyncWorkflowPort = {
186
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => {
187
+ if (id === 'main') throw new Error('fatal: unable to access https://x:ghp_secret@host/r');
188
+ return updated(decodeURIComponent(id));
189
+ }),
190
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 1),
191
+ retireRemoteGoneClone: vi.fn(async () => true),
192
+ };
193
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x']));
194
+ const r = await svc.sync({ branches: 'all', by: 'bearer' });
195
+ expect(r.status).toBe('partial');
196
+ expect(r.results[0]).toMatchObject({ branch: 'main', outcome: 'error' });
197
+ expect((r.results[0] as { error: string }).error).not.toContain('ghp_secret');
198
+ expect(r.results[1]).toEqual(updated('ali/x'));
199
+ expect(r.changeRequests.closedDeletedBranch).toBe(1);
200
+ expect(svc.lastSync()?.status).toBe('partial');
201
+ });
202
+ });
203
+
204
+ describe('KbSyncService — clones the host no longer has, and clones that cannot be read', () => {
205
+ it('a remote-gone branch is retired through the workflow, counts as clean, and does not stop the others', async () => {
206
+ const workflow: SyncWorkflowPort = {
207
+ syncWorkspaceFromRemote: vi.fn(async (id: string): Promise<BranchSyncOutcome> =>
208
+ id === 'ali%2Fx' ? { branch: 'ali/x', outcome: 'remote-gone' } : updated(decodeURIComponent(id)),
209
+ ),
210
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 1),
211
+ retireRemoteGoneClone: vi.fn(async () => true),
212
+ };
213
+ const svc = new KbSyncService(workflow, workspaces(['ali/x', 'main']));
214
+ const r = await svc.sync({ branches: 'all' });
215
+ expect(r.status).toBe('synced');
216
+ expect(r.results).toEqual([{ branch: 'ali/x', outcome: 'remote-gone' }, updated('main')]);
217
+ expect(workflow.retireRemoteGoneClone).toHaveBeenCalledTimes(1);
218
+ expect(workflow.retireRemoteGoneClone).toHaveBeenCalledWith('ali%2Fx');
219
+ });
220
+
221
+ it('a clone that could not be read is that branch’s error, not everyone’s', async () => {
222
+ const workflow: SyncWorkflowPort = {
223
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => updated(decodeURIComponent(id))),
224
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
225
+ retireRemoteGoneClone: vi.fn(async () => true),
226
+ };
227
+ const port: SyncWorkspacePort = {
228
+ listClonedWorkspaces: async () => [
229
+ { id: 'main', branch: 'main' },
230
+ { id: 'juan%2Fy', branch: 'juan/y', unreadable: "Could not read this branch's clone: EPERM" },
231
+ ],
232
+ };
233
+ const svc = new KbSyncService(workflow, port);
234
+ const r = await svc.sync({ branches: 'all' });
235
+ expect(r.status).toBe('partial');
236
+ expect(r.results).toEqual([
237
+ updated('main'),
238
+ { branch: 'juan/y', outcome: 'error', error: "Could not read this branch's clone: EPERM" },
239
+ ]);
240
+ expect(workflow.syncWorkspaceFromRemote).toHaveBeenCalledTimes(1);
241
+ });
242
+ });
243
+
244
+ describe('KbSyncService — a coalesced caller is judged by its own branches', () => {
245
+ it("branch A's hook is not failed by branch B's conflict in the same follow-up", async () => {
246
+ const gate = deferred<void>();
247
+ const workflow: SyncWorkflowPort = {
248
+ syncWorkspaceFromRemote: vi.fn(async (id: string): Promise<BranchSyncOutcome> => {
249
+ if (id === 'main') await gate.promise;
250
+ if (id === 'juan%2Fy') return { branch: 'juan/y', outcome: 'conflict', conflictedPaths: ['a.md'], error: 'msg' };
251
+ return updated(decodeURIComponent(id));
252
+ }),
253
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
254
+ retireRemoteGoneClone: vi.fn(async () => true),
255
+ };
256
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x', 'juan/y']));
257
+ const first = svc.sync({ branches: ['main'] });
258
+ const hookA = svc.sync({ branches: ['ali/x'] });
259
+ const hookB = svc.sync({ branches: ['juan/y'] });
260
+ gate.resolve();
261
+ await first;
262
+ const a = await hookA;
263
+ const b = await hookB;
264
+ expect(a).toMatchObject({ status: 'synced', results: [updated('ali/x')] });
265
+ expect(b.status).toBe('partial');
266
+ expect(b.results.map((r) => r.outcome)).toEqual(['conflict']);
267
+ // The record keeps the whole follow-up.
268
+ expect(svc.lastSync()?.results.map((r) => r.branch)).toEqual(['ali/x', 'juan/y']);
269
+ });
270
+ });
271
+
272
+ describe('KbSyncService — a waiter keeps the branches it asked for', () => {
273
+ it('a caller that edits its array after queueing changes nothing about its sync or its answer', async () => {
274
+ const gate = deferred<void>();
275
+ const workflow: SyncWorkflowPort = {
276
+ syncWorkspaceFromRemote: vi.fn(async (id: string) => {
277
+ if (id === 'main') await gate.promise;
278
+ return updated(decodeURIComponent(id));
279
+ }),
280
+ closeChangeRequestsWithDeletedBranches: vi.fn(async () => 0),
281
+ retireRemoteGoneClone: vi.fn(async () => true),
282
+ };
283
+ const svc = new KbSyncService(workflow, workspaces(['main', 'ali/x', 'juan/y']));
284
+ const first = svc.sync({ branches: ['main'] });
285
+ const asked = ['ali/x'];
286
+ const waiting = svc.sync({ branches: asked });
287
+ asked.push('juan/y');
288
+ asked[0] = 'main';
289
+ gate.resolve();
290
+ await first;
291
+ const r = await waiting;
292
+ expect(r.results.map((x) => x.branch)).toEqual(['ali/x']);
293
+ expect(workflow.syncWorkspaceFromRemote).not.toHaveBeenCalledWith('juan%2Fy');
294
+ });
295
+ });