@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
@@ -25,6 +25,7 @@ import type {
25
25
  AcquireLockResult,
26
26
  AuthUser,
27
27
  Branch,
28
+ BranchSyncOutcome,
28
29
  BranchWorkspaceStatus,
29
30
  CancelChangeRequestResult,
30
31
  Change,
@@ -40,6 +41,7 @@ import type {
40
41
  MergeChangeRequestOutcome,
41
42
  OpenChangeRequestInput,
42
43
  PostChangeRequestCommentInput,
44
+ RemoteSyncPullResult,
43
45
  } from '@bevel-software/platform-shared';
44
46
  import { isProtectedBranch, DEFAULT_BRANCH } from '@bevel-software/platform-shared';
45
47
  import { and, eq, or } from 'drizzle-orm';
@@ -65,6 +67,7 @@ import {
65
67
  RolesYamlPreservationError,
66
68
  PullRebaseConflictError,
67
69
  PushNeedsAgentResolutionError,
70
+ RemoteBranchGoneError,
68
71
  WorkflowDomainError,
69
72
  WorkflowValidationError,
70
73
  } from '../../shared/domain-errors.js';
@@ -100,6 +103,33 @@ function isOpenPairViolation(err: unknown): boolean {
100
103
  return typeof message === 'string' && message.includes(OPEN_PAIR_CONSTRAINT);
101
104
  }
102
105
 
106
+ /**
107
+ * The one sentence a remote-sync conflict is reported with — in the sync
108
+ * response, on the branch's banner and in the log — so whoever sees any of
109
+ * the three reads the same thing: which branch, which files, that automatic
110
+ * recovery is under way, and what to do if it does not clear.
111
+ */
112
+ export function syncConflictMessage(branch: string, conflictedPaths: string[]): string {
113
+ // Bounded: the same sentence goes to the response, the banner and the log,
114
+ // and it must be one sentence in all three whatever the conflict's size.
115
+ // The full path list rides beside it as `conflictedPaths`.
116
+ const shown = conflictedPaths.slice(0, SYNC_CONFLICT_FILES_NAMED);
117
+ const more = conflictedPaths.length - shown.length;
118
+ const files =
119
+ conflictedPaths.length === 0
120
+ ? 'some files'
121
+ : more > 0
122
+ ? `${shown.join(', ')} and ${more} more file${more === 1 ? '' : 's'}`
123
+ : shown.join(', ');
124
+ return (
125
+ `${branch} is not in sync yet: ${files} changed both in Hexis and on the git host. ` +
126
+ `Recovery is queued; if this stays, open the files on ${branch} in Hexis, keep what you want, and save.`
127
+ );
128
+ }
129
+
130
+ /** How many conflicted files the sync-conflict sentence names before "and N more". */
131
+ const SYNC_CONFLICT_FILES_NAMED = 3;
132
+
103
133
  /** Redact the shared GitHub token from any error string before surfacing it. */
104
134
  function redactTokens(msg: string): string {
105
135
  const tokens = [process.env.GITHUB_TOKEN, process.env.GH_TOKEN].filter(
@@ -246,7 +276,10 @@ export class WorkflowService implements IWorkflowService {
246
276
  */
247
277
  private orphanSweepStarted = false;
248
278
 
249
- async listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]> {
279
+ async listBranches(
280
+ workspaceId: string,
281
+ opts?: { freshFetch?: boolean; strictFetch?: boolean },
282
+ ): Promise<Branch[]> {
250
283
  const branches = await this.git.listBranches(workspaceId, opts);
251
284
  if (!this.orphanSweepStarted) {
252
285
  this.orphanSweepStarted = true;
@@ -268,7 +301,14 @@ export class WorkflowService implements IWorkflowService {
268
301
  }
269
302
 
270
303
  createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch> {
271
- return this.git.createBranch(workspaceId, name, fromBase);
304
+ // Under the branch's lifecycle lock, like `deleteBranch` and the
305
+ // retirement of a clone the host deleted: creating a branch is the one
306
+ // operation that can bring a "gone" branch back, and the retirement's
307
+ // "still gone on origin?" check is only a guarantee if nothing can
308
+ // recreate the branch between that check and the delete.
309
+ return this.branchLifecycle.run(`branch:${name}`, () =>
310
+ this.git.createBranch(workspaceId, name, fromBase),
311
+ );
272
312
  }
273
313
 
274
314
  /**
@@ -445,9 +485,41 @@ export class WorkflowService implements IWorkflowService {
445
485
  return this.git.fetch(workspaceId);
446
486
  }
447
487
 
488
+ /**
489
+ * `git pull` on a workspace, plus the one announcement a pull owes the rest
490
+ * of the process.
491
+ *
492
+ * A pull rewrites a working tree without going through the write routes or
493
+ * the commit pipeline, so it emits neither of the signals those produce —
494
+ * and the catalogs that scan the DEFAULT branch's tree (skills, tool
495
+ * manuals, the plugin index) are keyed to exactly that tree. Every path that
496
+ * pulls the default workspace therefore has to say so, or those catalogs
497
+ * keep serving the pre-pull scan for the rest of their TTL: a merge landing
498
+ * an approved skill, the recovery ladder after a non-fast-forward push, and
499
+ * a user's update-from-remote alike. Routing every pull through here makes
500
+ * that ONE mechanism — "the default branch's tree changed" — instead of a
501
+ * separate special case per event. (The remote sync, `syncWorkspaceFromRemote`,
502
+ * pulls directly: it owes the same announcement to every branch and per
503
+ * file, and makes it from the same `treeChanged` verdict.)
504
+ *
505
+ * Announced only after the pull RESOLVES, and only when it CHANGED the
506
+ * tree's content: a pull that threw left the tree as it was, and so did an
507
+ * "already up to date" pull or one that landed only content-identical
508
+ * commits — dropping every catalog and making every attached browser
509
+ * refetch its file tree for a change that did not happen buys a re-scan
510
+ * and nothing else. The pull itself reports the change, since it is the
511
+ * only code that can observe it under the workspace mutex.
512
+ */
513
+ private async pullWorkspace(workspaceId: string): Promise<void> {
514
+ const { treeChanged } = await this.git.pull(workspaceId);
515
+ if (treeChanged && branchForWorkspaceId(workspaceId) === DEFAULT_BRANCH) {
516
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: DEFAULT_BRANCH });
517
+ }
518
+ }
519
+
448
520
  async updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void> {
449
521
  try {
450
- await this.git.pull(workspaceId);
522
+ await this.pullWorkspace(workspaceId);
451
523
  } catch (err) {
452
524
  if (err instanceof PullRebaseConflictError) {
453
525
  await this.queuePullConflictRecovery(workspaceId, err, user);
@@ -456,6 +528,157 @@ export class WorkflowService implements IWorkflowService {
456
528
  }
457
529
  }
458
530
 
531
+ async retireRemoteGoneClone(workspaceId: string): Promise<boolean> {
532
+ const branch = branchForWorkspaceId(workspaceId);
533
+ const id = workspaceIdForBranch(branch);
534
+ // The lock `createBranch` and `deleteBranch` take: no Hexis operation can
535
+ // bring this branch back into being while the clone is examined and
536
+ // removed. The sync's own hold on the clone ended when its outcome was
537
+ // returned, so the branch is asked about AGAIN here rather than trusted
538
+ // from then — a recreate that landed in between keeps its clone. A clone
539
+ // being bootstrapped right now is left alone too: it can only be cloning
540
+ // a branch that exists, so the retirement is already moot. What remains
541
+ // is a recreate pushed from OUTSIDE Hexis in the milliseconds between the
542
+ // origin check and the delete, with a bootstrap starting in that same
543
+ // window; the next sync of that branch clones it fresh.
544
+ return this.branchLifecycle.run(`branch:${branch}`, async () => {
545
+ if (!(await this.workspaceService.hasBootstrappedWorkspace(id))) return false;
546
+ if (this.workspaceService.isBootstrapInFlight(branch)) return false;
547
+ if (await this.git.remoteBranchExists(branch, branch)) return false;
548
+ await this.workspaceService.deleteWorkspace(id);
549
+ // Whatever a sync still owed that clone is owed to nothing now.
550
+ this.owedAnnouncements.delete(id);
551
+ return true;
552
+ });
553
+ }
554
+
555
+ /**
556
+ * Cap on per-path `file-changed` events one sync announces for a branch.
557
+ * Past it the tree event alone carries the news — a bulk import that
558
+ * rewrote hundreds of files would otherwise fan hundreds of events out to
559
+ * every open browser to say what one refresh says.
560
+ */
561
+ private static readonly SYNC_FILE_EVENT_CAP = 200;
562
+
563
+ /**
564
+ * Announcements a sync still OWES, per workspace: a pull landed but the
565
+ * events for it could not be sent. The pull is not redone on retry — HEAD
566
+ * has not moved since — so the announcement cannot be re-derived from sha
567
+ * movement; it is remembered here and delivered by the next sync of that
568
+ * workspace, merged with whatever that sync finds.
569
+ */
570
+ private readonly owedAnnouncements = new Map<string, { after: string; changedPaths: string[] }>();
571
+
572
+ async syncWorkspaceFromRemote(workspaceId: string): Promise<BranchSyncOutcome> {
573
+ const branch = branchForWorkspaceId(workspaceId);
574
+ const id = workspaceIdForBranch(branch);
575
+ // Two spellings of one workspace id are in circulation — the encoded
576
+ // directory name (`ali%2Fx`, what the clone listing yields) and the
577
+ // Express-decoded form (`ali/x`, what every HTTP route hands the git
578
+ // layer) — and the git layer's mutex keys on the raw string. Speaking the
579
+ // routes' spelling to git is what puts a webhook's rebase in the same
580
+ // queue as the author's concurrent save on a slashed branch; the event
581
+ // bus gets the encoded form, which every SSE consumer canonicalises.
582
+ const gitId = branch;
583
+ // Every failure below is an OUTCOME, never a throw: the caller runs this
584
+ // over many clones, and one broken clone must not abort the others.
585
+ let pulled: RemoteSyncPullResult;
586
+ try {
587
+ pulled = await this.git.syncFromRemote(gitId);
588
+ } catch (err) {
589
+ if (err instanceof RemoteBranchGoneError) {
590
+ // Not a failure: the host deleted the branch, so there is nothing to
591
+ // sync and the caller retires the stale clone. No banner — nobody is
592
+ // editing a branch that no longer exists. An announcement still owed
593
+ // for that clone dies with it: a branch recreated under the same name
594
+ // is a different tree, and replaying the old paths against it would
595
+ // announce changes that never happened there.
596
+ this.owedAnnouncements.delete(id);
597
+ console.log(`[sync] branch "${branch}" no longer exists on the host`);
598
+ return { branch, outcome: 'remote-gone' };
599
+ }
600
+ if (err instanceof PullRebaseConflictError) {
601
+ // Same path as `updateFromRemote`: the rebase was aborted inside
602
+ // the pull (the clone is exactly what it was) and the divergence goes
603
+ // to the retry → recovery-agent → escalate ladder, attributed to the
604
+ // recovery bot since no person triggered this. It is still reported
605
+ // as a conflict because it is not resolved at request time — the
606
+ // caller's retry, or the ladder, settles it; the banner clears on the
607
+ // next clean pull or push.
608
+ await this.queuePullConflictRecovery(gitId, err);
609
+ const message = syncConflictMessage(branch, err.conflictedPaths);
610
+ console.warn(`[sync] ${message}`);
611
+ this.noteGitSyncFailed(gitId, branch, err, { paths: err.conflictedPaths, message });
612
+ return { branch, outcome: 'conflict', conflictedPaths: err.conflictedPaths, error: message };
613
+ }
614
+ const message = sanitizeError(err);
615
+ console.warn(`[sync] pull failed for branch "${branch}": ${message}`);
616
+ this.noteGitSyncFailed(gitId, branch, err);
617
+ return { branch, outcome: 'error', error: message };
618
+ }
619
+ // A clean pull is proof origin is reachable and this clone rebases onto
620
+ // it — the condition a `git-sync-failed` banner on this branch was
621
+ // waiting for, whether a sync or a push raised it.
622
+ this.noteGitSyncOk(gitId, branch);
623
+ const { before, after, treeChanged, changedPaths } = pulled;
624
+ // What this branch reports about ITS clone is decided by the pull alone:
625
+ // `updated` when HEAD moved, `up-to-date` otherwise.
626
+ const outcome: BranchSyncOutcome =
627
+ after === null || after === before
628
+ ? { branch, outcome: 'up-to-date', to: after ?? '' }
629
+ : { branch, outcome: 'updated', from: before, to: after };
630
+ // What it ANNOUNCES is what has changed since the last announcement that
631
+ // was actually delivered: this pull's content change, if any, plus one an
632
+ // earlier sync landed but could not send. The announcement keys on the
633
+ // pull's verdict about CONTENT — commits that left the tree identical are
634
+ // nothing for a browser to refetch or a catalog to drop.
635
+ const owed = this.owedAnnouncements.get(id);
636
+ if (!treeChanged && !owed) return outcome;
637
+ const announce = {
638
+ after: after ?? owed?.after ?? '',
639
+ changedPaths: [...new Set([...(owed?.changedPaths ?? []), ...changedPaths])],
640
+ };
641
+ try {
642
+ // Announce the new tree. The tree event alone already refreshes every
643
+ // open file explorer and, on the default branch, drops the skill / tool /
644
+ // plugin catalogues (`catalog-cache-invalidation.ts`); the CR list
645
+ // caches the touched paths it resolved against the old tree, so drop
646
+ // that too.
647
+ this.prs.invalidateListCache();
648
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: id, branch });
649
+ // Per-file events so an open tab on a file the host rewrote refetches
650
+ // instead of showing stale bytes until its next tree refresh. The paths
651
+ // were observed under the same hold as the pull, so they are exactly
652
+ // this sync's — never a save that landed alongside. Capped: a bulk
653
+ // import would otherwise fan hundreds of events out to say what one
654
+ // refresh says.
655
+ if (announce.changedPaths.length <= WorkflowService.SYNC_FILE_EVENT_CAP) {
656
+ for (const p of announce.changedPaths) {
657
+ this.events?.emit({
658
+ kind: 'file-changed',
659
+ workspaceId: id,
660
+ branch,
661
+ path: `${this.kbDirName}/${p}`,
662
+ newSha: announce.after,
663
+ byUserId: 'system',
664
+ byUserName: 'Git sync',
665
+ });
666
+ }
667
+ }
668
+ } catch (err) {
669
+ // The pull landed, so origin is fine and no banner is raised — but the
670
+ // caller must hear that this branch's announcement did not happen, and
671
+ // the announcement stays owed: the retry the error invites will not
672
+ // move HEAD again, so it must find the debt here rather than in the shas.
673
+ this.owedAnnouncements.set(id, announce);
674
+ const message = sanitizeError(err);
675
+ console.warn(`[sync] pulled "${branch}" but could not announce it: ${message}`);
676
+ return { branch, outcome: 'error', error: message };
677
+ }
678
+ this.owedAnnouncements.delete(id);
679
+ return outcome;
680
+ }
681
+
459
682
  /**
460
683
  * A pull hit a rebase conflict — the workspace carries local commits that
461
684
  * conflict with origin, a state that never resolves on its own (every
@@ -782,7 +1005,7 @@ export class WorkflowService implements IWorkflowService {
782
1005
  let recoveryError: unknown = null;
783
1006
  if (looksLikeNonFastForward) {
784
1007
  try {
785
- await this.git.pull(workspaceId);
1008
+ await this.pullWorkspace(workspaceId);
786
1009
  await this.git.push(workspaceId, user);
787
1010
  recovered = true;
788
1011
  this.noteGitSyncOk(workspaceId, branch);
@@ -1033,7 +1256,34 @@ export class WorkflowService implements IWorkflowService {
1033
1256
  * machinery is unaffected — all this adds is that someone finds out, rather
1034
1257
  * than the deployment looking healthy while nothing reaches the remote.
1035
1258
  */
1036
- private noteGitSyncFailed(workspaceId: string, branch: string, err: unknown): void {
1259
+ private noteGitSyncFailed(
1260
+ workspaceId: string,
1261
+ branch: string,
1262
+ err: unknown,
1263
+ /**
1264
+ * The files a person must reconcile, and the sentence we composed about
1265
+ * them. That sentence is ours (branch name + repo paths, no git stderr),
1266
+ * so it goes out verbatim — the same string the sync response and the
1267
+ * log carry — rather than through the sanitiser, whose 200-character
1268
+ * clip would make the banner disagree with them. Callers that already
1269
+ * hold the sentence pass it; otherwise it is derived below.
1270
+ */
1271
+ explicitConflict?: { paths: string[]; message: string },
1272
+ ): void {
1273
+ // A rebase conflict is a conflict whichever path ran into it. The remote
1274
+ // sync names its files on purpose; the push-recovery paths (autosave, the
1275
+ // lock release, the queued retries) raise the same banner with the raw
1276
+ // error — and the banner keeps the LATEST failure per branch, so a retry
1277
+ // landing after the sync would replace the conflict variant, with the
1278
+ // files as links, by the generic "check the server logs" one. Seen on
1279
+ // staging: the sync's 409 named the file, the worker's retry a moment
1280
+ // later took the links away. Derive the paths from the error itself so
1281
+ // every path agrees.
1282
+ const conflict =
1283
+ explicitConflict ??
1284
+ (err instanceof PullRebaseConflictError
1285
+ ? { paths: err.conflictedPaths, message: syncConflictMessage(branch, err.conflictedPaths) }
1286
+ : undefined);
1037
1287
  // Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
1038
1288
  const id = branchForWorkspaceId(workspaceId);
1039
1289
  this.gitSyncFailing.add(id);
@@ -1043,7 +1293,8 @@ export class WorkflowService implements IWorkflowService {
1043
1293
  branch,
1044
1294
  // Git stderr can quote a credentialed URL; the banner is user-facing and
1045
1295
  // the string also lands in client logs, so sanitize before it leaves.
1046
- reason: sanitizeError(err),
1296
+ reason: conflict ? conflict.message : sanitizeError(err),
1297
+ ...(conflict ? { conflictedPaths: conflict.paths } : {}),
1047
1298
  });
1048
1299
  }
1049
1300
 
@@ -1102,7 +1353,7 @@ export class WorkflowService implements IWorkflowService {
1102
1353
  let recoveryError: unknown = null;
1103
1354
  if (looksLikeNonFastForward) {
1104
1355
  try {
1105
- await this.git.pull(workspaceId);
1356
+ await this.pullWorkspace(workspaceId);
1106
1357
  await this.git.push(workspaceId, user, opts);
1107
1358
  recovered = true;
1108
1359
  this.noteGitSyncOk(workspaceId, branch);
@@ -1710,7 +1961,7 @@ export class WorkflowService implements IWorkflowService {
1710
1961
  // refs, but the restore commits from the working tree — a stale tree
1711
1962
  // would push non-fast-forward and fail loudly anyway; this just makes
1712
1963
  // that rare.
1713
- await this.git.pull(ws.id).catch(() => undefined);
1964
+ await this.pullWorkspace(ws.id).catch(() => undefined);
1714
1965
 
1715
1966
  // The verb acts on the request as it is NOW — never a cached file list.
1716
1967
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
@@ -2221,7 +2472,7 @@ export class WorkflowService implements IWorkflowService {
2221
2472
  try {
2222
2473
  const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
2223
2474
  targetWorkspaceId = targetWorkspace.id;
2224
- await this.git.pull(targetWorkspace.id);
2475
+ await this.pullWorkspace(targetWorkspace.id);
2225
2476
  } catch (err) {
2226
2477
  // Still best-effort for the merge response (the merge already landed
2227
2478
  // on origin) — but a rebase CONFLICT here means the target workspace
@@ -182,11 +182,10 @@ describe('read-permission gates on human read routes', () => {
182
182
 
183
183
  it('keeps the structural root folders visible even when nothing is readable', async () => {
184
184
  // A user with no read grants: the access batch denies everything. The
185
- // structural top-level folders (KnowledgeBase, Data, Agents, Pipelines,
186
- // Plugins) must still resolve readable (so the explorer renders its
187
- // section view), while their contents stay gated. The retired Skills/
188
- // and Tools/ roots get NO structural treatment — they are ordinary
189
- // (denied) folders now.
185
+ // structural top-level folders (KnowledgeBase, Skills, Data, Agents,
186
+ // Pipelines, Plugins) must still resolve readable (so the explorer renders
187
+ // its section view), while their contents stay gated. The retired Tools/
188
+ // root gets NO structural treatment — it is an ordinary (denied) folder.
190
189
  h = await makeHarness({ allowFn: () => false });
191
190
  expect((await get('/files')).status).toBe(200);
192
191
  const filter = h.listFilesFilter();
@@ -208,8 +207,8 @@ describe('read-permission gates on human read routes', () => {
208
207
  expect(verdict.get(`${KB}/Agents`)).toBe(true);
209
208
  expect(verdict.get(`${KB}/Pipelines`)).toBe(true);
210
209
  expect(verdict.get(`${KB}/Plugins`)).toBe(true);
211
- // Retired roots: no override, the deny-all batch verdict stands.
212
- expect(verdict.get(`${KB}/Skills`)).toBe(false);
210
+ expect(verdict.get(`${KB}/Skills`)).toBe(true);
211
+ // Retired root: no override, the deny-all batch verdict stands.
213
212
  expect(verdict.get(`${KB}/Tools`)).toBe(false);
214
213
  // Their contents: still gated (the override matches only the exact folder).
215
214
  expect(verdict.get(`${KB}/KnowledgeBase/Open/a.md`)).toBe(false);
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { WorkspaceService } from '../workspace.service.js';
6
+
7
+ /**
8
+ * `listClonedWorkspaces` is what the remote sync enumerates. It must read the
9
+ * DISK, not the in-memory bootstrap cache: clones survive a restart, and a
10
+ * hook that fires before anyone has opened a branch must still find them.
11
+ */
12
+ describe('WorkspaceService.listClonedWorkspaces', () => {
13
+ let root: string;
14
+
15
+ beforeEach(async () => {
16
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-ws-list-'));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.rm(root, { recursive: true, force: true });
21
+ });
22
+
23
+ async function clone(dirName: string, withGit = true) {
24
+ const repo = path.join(root, dirName, 'knowledge-base');
25
+ await fs.mkdir(withGit ? path.join(repo, '.git') : repo, { recursive: true });
26
+ }
27
+
28
+ it('finds every finished clone on disk without any branch having been opened this process', async () => {
29
+ await clone('main');
30
+ await clone('ali%2Fnew-skill');
31
+ // Half-built: the directory exists but the clone never finished.
32
+ await clone('juan%2Fabandoned', false);
33
+ // Not one of ours: the name is not the encoding of any branch.
34
+ await clone('weird%zz');
35
+ // A stray file at the root is ignored.
36
+ await fs.writeFile(path.join(root, 'notes.txt'), 'x');
37
+
38
+ const svc = new WorkspaceService(root, 'https://example.test/kb.git', 'knowledge-base');
39
+ const found = await svc.listClonedWorkspaces();
40
+ expect(found.sort((a, b) => a.branch.localeCompare(b.branch))).toEqual([
41
+ { id: 'ali%2Fnew-skill', branch: 'ali/new-skill' },
42
+ { id: 'main', branch: 'main' },
43
+ ]);
44
+ });
45
+
46
+ it('is empty when the workspaces root does not exist yet', async () => {
47
+ const svc = new WorkspaceService(path.join(root, 'missing'), 'https://example.test/kb.git', 'knowledge-base');
48
+ expect(await svc.listClonedWorkspaces()).toEqual([]);
49
+ });
50
+ });
51
+
52
+ describe('WorkspaceService.listClonedWorkspaces — what is not a clone', () => {
53
+ let root: string;
54
+ beforeEach(async () => {
55
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-ws-list-'));
56
+ });
57
+ afterEach(async () => {
58
+ await fs.rm(root, { recursive: true, force: true });
59
+ });
60
+
61
+ it('a directory whose name round-trips but is not a branch git accepts', async () => {
62
+ // `foo!`, `-leading-dash` and `a..b` survive encodeURIComponent unchanged, so
63
+ // only the validator can exclude them; `has space` does not round-trip and
64
+ // is excluded one step earlier. Both guards have to hold.
65
+ for (const name of ['main', 'foo!', '-leading-dash', 'a..b', 'has space']) {
66
+ await fs.mkdir(path.join(root, name, 'knowledge-base', '.git'), { recursive: true });
67
+ }
68
+ const svc = new WorkspaceService(root, 'https://example.test/kb.git', 'knowledge-base');
69
+ expect(await svc.listClonedWorkspaces()).toEqual([{ id: 'main', branch: 'main' }]);
70
+ });
71
+
72
+ it('a branch whose bootstrap is in flight right now', async () => {
73
+ await fs.mkdir(path.join(root, 'main', 'knowledge-base', '.git'), { recursive: true });
74
+ await fs.mkdir(path.join(root, 'ali%2Fx', 'knowledge-base', '.git'), { recursive: true });
75
+ const svc = new WorkspaceService(root, 'https://example.test/kb.git', 'knowledge-base');
76
+ // While a clone runs, `.git` exists but the tree is not checked out yet.
77
+ // The listing asks the public predicate, which is stubbed here rather than
78
+ // the tracker behind it — no public seam can hold a real clone open.
79
+ vi.spyOn(svc, 'isBootstrapInFlight').mockImplementation((branch) => branch === 'ali/x');
80
+ expect(await svc.listClonedWorkspaces()).toEqual([{ id: 'main', branch: 'main' }]);
81
+ });
82
+
83
+ it('a root that is a file counts as no root, not as a clone list', async () => {
84
+ // A FILE where the root should be: readdir fails with ENOTDIR on POSIX and
85
+ // ENOENT on Windows — both "no root", so an empty list. A permission
86
+ // failure is the case that must propagate, and there is no portable way
87
+ // to stage one in a temp dir; the code path is the same `throw err`.
88
+ const file = path.join(root, 'not-a-dir');
89
+ await fs.writeFile(file, 'x');
90
+ const svc = new WorkspaceService(file, 'https://example.test/kb.git', 'knowledge-base');
91
+ expect(await svc.listClonedWorkspaces()).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isMissingRemoteBranchFailure } from '../../../shared/domain-errors.js';
3
+
4
+ /**
5
+ * The one line that decides whether a failed bootstrap is "this branch does
6
+ * not exist" (410, the browser says so) or "something is wrong" (500). Git's
7
+ * two wordings for a missing ref are matched; an unreachable host or a
8
+ * refused credential must NOT be, or a deployment outage would read as
9
+ * "your branch is gone".
10
+ */
11
+ describe('isMissingRemoteBranchFailure', () => {
12
+ it('recognises both of git’s wordings for a ref origin does not have', () => {
13
+ expect(
14
+ isMissingRemoteBranchFailure(
15
+ "Command failed: git clone -b sync-test/conflict-2 https://[REDACTED]@github.com/x/y.git /w\nfatal: Remote branch sync-test/conflict-2 not found in upstream origin",
16
+ ),
17
+ ).toBe(true);
18
+ expect(isMissingRemoteBranchFailure("fatal: couldn't find remote ref refs/heads/ali/x")).toBe(true);
19
+ });
20
+
21
+ it('does not mistake an unreachable host or a refused credential for a missing branch', () => {
22
+ expect(
23
+ isMissingRemoteBranchFailure("fatal: unable to access 'https://github.com/x/y.git/': Could not resolve host: github.com"),
24
+ ).toBe(false);
25
+ expect(isMissingRemoteBranchFailure('remote: Invalid username or password.\nfatal: Authentication failed')).toBe(false);
26
+ expect(isMissingRemoteBranchFailure('fatal: repository not found')).toBe(false);
27
+ });
28
+ });
@@ -38,8 +38,14 @@ describe('WorkspaceService.listFiles — read filter', () => {
38
38
  await mkFile(workspaceDir, 'Open/a.md');
39
39
  await mkFile(workspaceDir, 'Open/b.md');
40
40
  await mkFile(workspaceDir, 'Secret/s1.md');
41
+ await mkFile(workspaceDir, 'Secret/Vault/v1.md');
41
42
  await mkFile(workspaceDir, 'Mixed/hidden.md');
42
43
  await mkFile(workspaceDir, 'top.md');
44
+ // A denied folder with a grant below it: Scopes/ itself is closed, but
45
+ // Scopes/Deploy/ is shared (a linked skill's folder opened to a plugin's
46
+ // readers, say), and its files are readable.
47
+ await mkFile(workspaceDir, 'Scopes/Deploy/SKILL.md');
48
+ await mkFile(workspaceDir, 'Scopes/other.md');
43
49
  svc = new WorkspaceService(root, 'https://example.invalid/repo.git', KB);
44
50
  await svc.getWorkspacePath(workspaceId);
45
51
  });
@@ -64,27 +70,51 @@ describe('WorkspaceService.listFiles — read filter', () => {
64
70
  expect(filtered).toEqual(unfiltered);
65
71
  });
66
72
 
67
- it('drops denied files and denied directories (subtree skipped)', async () => {
68
- const asked: string[] = [];
69
- const filter: ReadTreeFilter = async (ps) => {
70
- asked.push(...ps);
71
- return new Map(ps.map((p) => [p, !(p.includes('Secret') || p.endsWith('hidden.md'))]));
72
- };
73
- const tree = await svc.listFiles(workspaceId, filter);
74
- const all = paths(tree);
73
+ it('drops denied files, and a denied directory with nothing readable beneath it', async () => {
74
+ const filter: ReadTreeFilter = async (ps) =>
75
+ new Map(ps.map((p) => [p, !(p.includes('Secret') || p.includes('Scopes') || p.endsWith('hidden.md'))]));
76
+ const all = paths(await svc.listFiles(workspaceId, filter));
75
77
 
76
78
  // Readable files survive.
77
79
  expect(all).toContain('Open/a.md');
78
80
  expect(all).toContain('Open/b.md');
79
81
  expect(all).toContain('top.md');
80
- // Denied directory and its whole subtree are gone.
82
+ // A denied directory whose whole subtree is denied is gone, with the
83
+ // subtree — including a nested folder the walk did look into.
81
84
  expect(all).not.toContain('Secret');
82
85
  expect(all).not.toContain('Secret/s1.md');
86
+ expect(all).not.toContain('Secret/Vault');
87
+ expect(all).not.toContain('Scopes');
83
88
  // Denied file is gone.
84
89
  expect(all).not.toContain('Mixed/hidden.md');
85
- // The denied directory's subtree is never walked — the filter is never
86
- // asked about a path inside Secret/.
87
- expect(asked.some((p) => p.startsWith('Secret/'))).toBe(false);
90
+ });
91
+
92
+ it('keeps a denied directory as the way to what is readable beneath it', async () => {
93
+ // Scopes/ is closed; Scopes/Deploy/ and its file are shared. The closed
94
+ // folder shows as a container — the reader can reach the skill through
95
+ // the tree — with its own contents still filtered (other.md is gone).
96
+ const filter: ReadTreeFilter = async (ps) =>
97
+ new Map(ps.map((p) => [p, p.startsWith('Scopes/Deploy') || (!p.includes('Secret') && !p.includes('Scopes') && !p.endsWith('hidden.md'))]));
98
+ const all = paths(await svc.listFiles(workspaceId, filter));
99
+ expect(all).toContain('Scopes');
100
+ expect(all).toContain('Scopes/Deploy');
101
+ expect(all).toContain('Scopes/Deploy/SKILL.md');
102
+ expect(all).not.toContain('Scopes/other.md');
103
+ // Unchanged for a subtree with nothing readable in it.
104
+ expect(all).not.toContain('Secret');
105
+ });
106
+
107
+ it('keeps a closed folder whose only readable content is an EMPTY sub-folder', async () => {
108
+ // A readable folder is kept on its own verdict, children or not — so the
109
+ // closed folder above it has a child, and stays as the way there.
110
+ await fs.mkdir(path.join(workspaceDir, 'Scopes', 'Empty'), { recursive: true });
111
+ const filter: ReadTreeFilter = async (ps) =>
112
+ new Map(ps.map((p) => [p, p === 'Scopes/Empty' || (!p.includes('Secret') && !p.includes('Scopes') && !p.endsWith('hidden.md'))]));
113
+ const all = paths(await svc.listFiles(workspaceId, filter));
114
+ expect(all).toContain('Scopes');
115
+ expect(all).toContain('Scopes/Empty');
116
+ expect(all).not.toContain('Scopes/Deploy');
117
+ expect(all).not.toContain('Scopes/other.md');
88
118
  });
89
119
 
90
120
  it('keeps a readable directory left empty after filtering (D4)', async () => {