@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,253 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { GitService } from '../git.service.js';
6
+ import { RemoteBranchGoneError } from '../../../../shared/domain-errors.js';
7
+ import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
8
+
9
+ /**
10
+ * `syncFromRemote` against real repositories: what it reports about the clone
11
+ * before and after, the paths it names, an upstream that has no commits yet,
12
+ * and a branch the host has deleted.
13
+ */
14
+ const BRANCH = 'target-company-state';
15
+
16
+ async function bareUpstream(root: string, withCommit: boolean): Promise<string> {
17
+ const upstream = path.join(root, 'upstream.git');
18
+ await runGit(root, ['init', '--bare', '-b', BRANCH, upstream]);
19
+ if (withCommit) {
20
+ const seed = path.join(root, '.seed');
21
+ await fs.mkdir(seed);
22
+ await runGit(seed, ['init', '-b', BRANCH]);
23
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
24
+ await fs.writeFile(path.join(seed, 'a.md'), 'a\n');
25
+ await fs.writeFile(path.join(seed, 'old name.md'), 'same content that survives a rename\n');
26
+ await runGit(seed, ['add', '.']);
27
+ await runGit(seed, ['commit', '-m', 'init']);
28
+ await runGit(seed, ['push', 'origin', BRANCH]);
29
+ }
30
+ await runGit(upstream, ['symbolic-ref', 'HEAD', `refs/heads/${BRANCH}`]);
31
+ return upstream;
32
+ }
33
+
34
+ async function cloneWorkspace(root: string, upstream: string, workspaceId: string, branch = BRANCH) {
35
+ const workspaceDir = path.join(root, workspaceId);
36
+ const repo = path.join(workspaceDir, 'knowledge-base');
37
+ await fs.mkdir(workspaceDir, { recursive: true });
38
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', '-b', branch, upstream, repo]);
39
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
40
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
41
+ await runGit(repo, ['config', 'core.autocrlf', 'false']);
42
+ return { workspaceDir, repo };
43
+ }
44
+
45
+ /** Land one commit on origin from a second clone; returns the paths it touched. */
46
+ async function pushFromElsewhere(root: string, upstream: string, edit: (dir: string) => Promise<void>, branch = BRANCH) {
47
+ const pusher = path.join(root, `.pusher-${Math.random().toString(36).slice(2)}`);
48
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', '-b', branch, upstream, pusher]);
49
+ await runGit(pusher, ['config', 'user.email', 'other@bevel.test']);
50
+ await runGit(pusher, ['config', 'user.name', 'Other']);
51
+ await edit(pusher);
52
+ await runGit(pusher, ['add', '-A']);
53
+ await runGit(pusher, ['commit', '-m', 'from origin']);
54
+ await runGit(pusher, ['push', 'origin', branch]);
55
+ }
56
+
57
+ describe('GitService.syncFromRemote', () => {
58
+ let root: string;
59
+
60
+ beforeEach(async () => {
61
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-sync-'));
62
+ });
63
+
64
+ afterEach(async () => {
65
+ for (let i = 0; i < 5; i++) {
66
+ try {
67
+ await fs.rm(root, { recursive: true, force: true });
68
+ return;
69
+ } catch {
70
+ await new Promise((r) => setTimeout(r, 100));
71
+ }
72
+ }
73
+ });
74
+
75
+ it('reports where HEAD was and is, that the tree changed, and every path including both ends of a rename', async () => {
76
+ const upstream = await bareUpstream(root, true);
77
+ const { workspaceDir, repo } = await cloneWorkspace(root, upstream, 'ws');
78
+ const before = (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
79
+ await pushFromElsewhere(root, upstream, async (dir) => {
80
+ await fs.writeFile(path.join(dir, 'a.md'), 'a changed\n');
81
+ await fs.writeFile(path.join(dir, 'b.md'), 'b\n');
82
+ await fs.rename(path.join(dir, 'old name.md'), path.join(dir, 'new name.md'));
83
+ });
84
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
85
+
86
+ const r = await git.syncFromRemote('ws');
87
+ expect(r.before).toBe(before);
88
+ expect(r.after).toBe((await gitOut(repo, ['rev-parse', 'HEAD'])).trim());
89
+ expect(r.after).not.toBe(before);
90
+ expect(r.treeChanged).toBe(true);
91
+ expect([...r.changedPaths].sort()).toEqual(['a.md', 'b.md', 'new name.md', 'old name.md'].sort());
92
+
93
+ // Nothing new: same shas, no change, no paths.
94
+ const again = await git.syncFromRemote('ws');
95
+ expect(again).toEqual({ before: r.after, after: r.after, treeChanged: false, changedPaths: [] });
96
+ });
97
+
98
+ it('an unborn clone of an empty upstream is tolerated, and receives its first content as a change from null', async () => {
99
+ const upstream = await bareUpstream(root, false);
100
+ const workspaceDir = path.join(root, 'ws');
101
+ const repo = path.join(workspaceDir, 'knowledge-base');
102
+ await fs.mkdir(workspaceDir, { recursive: true });
103
+ // Cloning an empty repository warns but succeeds; HEAD is unborn.
104
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', upstream, repo]);
105
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
106
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
107
+ await runGit(repo, ['checkout', '-b', BRANCH]).catch(() => undefined);
108
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
109
+
110
+ // Still empty on both sides: nothing to sync, and NOT 'remote gone' — a
111
+ // fresh deployment nobody has pushed to must never have its clone retired.
112
+ expect(await git.syncFromRemote('ws')).toEqual({ before: null, after: null, treeChanged: false, changedPaths: [] });
113
+
114
+ // Origin gains its first commit.
115
+ const seed = path.join(root, '.first');
116
+ await fs.mkdir(seed);
117
+ await runGit(seed, ['init', '-b', BRANCH]);
118
+ await runGit(seed, ['config', 'user.email', 'other@bevel.test']);
119
+ await runGit(seed, ['config', 'user.name', 'Other']);
120
+ await fs.writeFile(path.join(seed, 'README.md'), 'hello\n');
121
+ await runGit(seed, ['add', '.']);
122
+ await runGit(seed, ['commit', '-m', 'first']);
123
+ await runGit(seed, ['push', upstream, BRANCH]);
124
+
125
+ const r = await git.syncFromRemote('ws');
126
+ expect(r.before).toBeNull();
127
+ expect(r.after).toMatch(/^[0-9a-f]{40}$/);
128
+ expect(r.treeChanged).toBe(true);
129
+ expect(r.changedPaths).toEqual(['README.md']);
130
+ });
131
+
132
+ it('a branch deleted on the host is a typed "remote branch gone", not an unreachable remote', async () => {
133
+ const upstream = await bareUpstream(root, true);
134
+ await runGit(upstream, ['branch', 'ali/x', BRANCH]);
135
+ const { workspaceDir } = await cloneWorkspace(root, upstream, 'ali%2Fx', 'ali/x');
136
+ await runGit(upstream, ['update-ref', '-d', 'refs/heads/ali/x']);
137
+ const git = new GitService(stubWorkspaceService({ 'ali%2Fx': workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
138
+ await expect(git.syncFromRemote('ali%2Fx')).rejects.toBeInstanceOf(RemoteBranchGoneError);
139
+ });
140
+ });
141
+
142
+ describe('GitService.syncFromRemote — the cases git exits 0 on but should not pass', () => {
143
+ let root: string;
144
+ beforeEach(async () => {
145
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-sync2-'));
146
+ });
147
+ afterEach(async () => {
148
+ for (let i = 0; i < 5; i++) {
149
+ try {
150
+ await fs.rm(root, { recursive: true, force: true });
151
+ return;
152
+ } catch {
153
+ await new Promise((r) => setTimeout(r, 100));
154
+ }
155
+ }
156
+ });
157
+
158
+ it('an autostashed edit that conflicts on reapply is a typed conflict, the tree is left clean, the edit is kept in stash', async () => {
159
+ const upstream = await bareUpstream(root, true);
160
+ const { workspaceDir, repo } = await cloneWorkspace(root, upstream, 'ws');
161
+ // A tracked edit nobody committed — what a lock-holder's in-progress save looks like.
162
+ await fs.writeFile(path.join(repo, 'a.md'), 'edited locally\n');
163
+ await pushFromElsewhere(root, upstream, async (dir) => {
164
+ await fs.writeFile(path.join(dir, 'a.md'), 'edited on the host\n');
165
+ });
166
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
167
+
168
+ await expect(git.syncFromRemote('ws')).rejects.toMatchObject({
169
+ name: 'PullRebaseConflictError',
170
+ conflictedPaths: ['a.md'],
171
+ });
172
+ // Clean tree at the remote commit; the local edit survives in the stash.
173
+ expect(await gitOut(repo, ['status', '--porcelain'])).toBe('');
174
+ expect(await gitOut(repo, ['rev-parse', 'HEAD'])).toBe(await gitOut(repo, ['rev-parse', 'refs/remotes/origin/' + BRANCH]));
175
+ expect((await gitOut(repo, ['stash', 'list'])).split('\n').filter(Boolean)).toHaveLength(1);
176
+ });
177
+
178
+ it('an unborn clone whose branch was created and deleted on an origin that has other branches is remote gone, not fresh', async () => {
179
+ const upstream = await bareUpstream(root, true); // origin has BRANCH
180
+ const workspaceDir = path.join(root, 'ws');
181
+ const repo = path.join(workspaceDir, 'knowledge-base');
182
+ await fs.mkdir(workspaceDir, { recursive: true });
183
+ // An unborn clone checked out on a branch origin does not have.
184
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', upstream, repo]);
185
+ await runGit(repo, ['checkout', '--orphan', 'ali/x']);
186
+ await runGit(repo, ['rm', '-rf', '--cached', '.']).catch(() => undefined);
187
+ await fs.rm(path.join(repo, 'a.md'), { force: true });
188
+ await fs.rm(path.join(repo, 'old name.md'), { force: true });
189
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
190
+ await expect(git.syncFromRemote('ws')).rejects.toBeInstanceOf(RemoteBranchGoneError);
191
+ });
192
+
193
+ it('remoteBranchExists answers from origin, not from the clone', async () => {
194
+ const upstream = await bareUpstream(root, true);
195
+ await runGit(upstream, ['branch', 'ali/x', BRANCH]);
196
+ const { workspaceDir } = await cloneWorkspace(root, upstream, 'ws');
197
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
198
+ expect(await git.remoteBranchExists('ws', 'ali/x')).toBe(true);
199
+ await runGit(upstream, ['update-ref', '-d', 'refs/heads/ali/x']);
200
+ expect(await git.remoteBranchExists('ws', 'ali/x')).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe('GitService.syncFromRemote — an unborn clone that already holds staged files', () => {
205
+ let root: string;
206
+ beforeEach(async () => {
207
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-sync3-'));
208
+ });
209
+ afterEach(async () => {
210
+ for (let i = 0; i < 5; i++) {
211
+ try {
212
+ await fs.rm(root, { recursive: true, force: true });
213
+ return;
214
+ } catch {
215
+ await new Promise((r) => setTimeout(r, 100));
216
+ }
217
+ }
218
+ });
219
+
220
+ it('is refused as a typed conflict naming the staged paths; nothing on disk is touched', async () => {
221
+ const upstream = await bareUpstream(root, false);
222
+ const workspaceDir = path.join(root, 'ws');
223
+ const repo = path.join(workspaceDir, 'knowledge-base');
224
+ await fs.mkdir(workspaceDir, { recursive: true });
225
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', upstream, repo]);
226
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
227
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
228
+ await runGit(repo, ['checkout', '-b', BRANCH]).catch(() => undefined);
229
+ // Someone wrote and staged a file before the first commit ever existed —
230
+ // with a name git's line output would quote ("Entw\\303\\274rfe.md"), so
231
+ // the outcome has to carry the real spelling.
232
+ await fs.writeFile(path.join(repo, 'Entwürfe.md'), 'not yet committed\n');
233
+ await runGit(repo, ['add', 'Entwürfe.md']);
234
+ // Origin gains its first commit meanwhile.
235
+ const seed = path.join(root, '.first');
236
+ await fs.mkdir(seed);
237
+ await runGit(seed, ['init', '-b', BRANCH]);
238
+ await runGit(seed, ['config', 'user.email', 'other@bevel.test']);
239
+ await runGit(seed, ['config', 'user.name', 'Other']);
240
+ await fs.writeFile(path.join(seed, 'README.md'), 'hello\n');
241
+ await runGit(seed, ['add', '.']);
242
+ await runGit(seed, ['commit', '-m', 'first']);
243
+ await runGit(seed, ['push', upstream, BRANCH]);
244
+ const git = new GitService(stubWorkspaceService({ ws: workspaceDir }), stubWorkflowHooks(), 'knowledge-base');
245
+
246
+ await expect(git.syncFromRemote('ws')).rejects.toMatchObject({
247
+ name: 'PullRebaseConflictError',
248
+ conflictedPaths: ['Entwürfe.md'],
249
+ });
250
+ expect(await fs.readFile(path.join(repo, 'Entwürfe.md'), 'utf8')).toBe('not yet committed\n');
251
+ expect((await gitOut(repo, ['ls-files', '--cached', '-z'])).replace(/\0$/, '')).toBe('Entwürfe.md');
252
+ });
253
+ });
@@ -7,6 +7,7 @@ import type {
7
7
  BranchInfo,
8
8
  CommitAttribution,
9
9
  IGitService,
10
+ RemoteSyncPullResult,
10
11
  PrFileStatus,
11
12
  PullRequestFile,
12
13
  ShareChangesRequest,
@@ -32,6 +33,8 @@ import {
32
33
  WorkflowValidationError,
33
34
  ProtectedBranchError,
34
35
  PullRebaseConflictError,
36
+ RemoteBranchGoneError,
37
+ isMissingRemoteBranchFailure,
35
38
  } from '../../../shared/domain-errors.js';
36
39
 
37
40
  const execFileAsync = promisify(execFile);
@@ -1546,70 +1549,250 @@ export class GitService implements IGitService {
1546
1549
  return `refs/remotes/origin/${branch}`;
1547
1550
  }
1548
1551
 
1549
- async pull(workspaceId: string): Promise<void> {
1552
+ /** A commit-free tree, for diffing against a HEAD that did not exist yet. */
1553
+ private static readonly EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
1554
+
1555
+ /**
1556
+ * The checked-out branch by its symbolic ref, which an UNBORN HEAD (a clone
1557
+ * of an empty upstream) still has — `rev-parse --abbrev-ref HEAD` does not
1558
+ * resolve there, so the tolerant tree reads below would never be reached.
1559
+ */
1560
+ private async checkedOutBranch(cwd: string): Promise<string> {
1561
+ const { stdout } = await this.git(cwd, ['symbolic-ref', '--short', 'HEAD']);
1562
+ return stdout.trim();
1563
+ }
1564
+
1565
+ /**
1566
+ * Whether origin still has `branch`. `ls-remote --exit-code` answers with
1567
+ * exit status 2 for "no such ref" — the one failure that is a fact about the
1568
+ * branch rather than about reaching the host, which propagates.
1569
+ */
1570
+ async remoteBranchExists(workspaceId: string, branch: string): Promise<boolean> {
1571
+ assertValidBranchName(branch);
1572
+ const cwd = await this.repoDir(workspaceId);
1573
+ try {
1574
+ await this.git(cwd, ['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${branch}`]);
1575
+ return true;
1576
+ } catch (err) {
1577
+ if ((err as { exitCode?: number }).exitCode === 2) return false;
1578
+ throw err;
1579
+ }
1580
+ }
1581
+
1582
+ /** Whether origin has ANY branch — an empty repository has none. */
1583
+ private async remoteHasAnyBranch(cwd: string): Promise<boolean> {
1584
+ const { stdout } = await this.git(cwd, ['ls-remote', '--heads', 'origin']);
1585
+ return stdout.trim().length > 0;
1586
+ }
1587
+
1588
+ /** `git rev-parse --verify` that answers null for a rev that does not resolve (an unborn HEAD). */
1589
+ private async revParseOrNull(cwd: string, rev: string): Promise<string | null> {
1590
+ try {
1591
+ const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', rev]);
1592
+ return stdout.trim() || null;
1593
+ } catch {
1594
+ return null;
1595
+ }
1596
+ }
1597
+
1598
+ /**
1599
+ * Refresh `refs/remotes/origin/<branch>` for a sync, naming the one failure
1600
+ * that is not "origin unreachable": the branch no longer exists there.
1601
+ */
1602
+ private async refreshRemoteBranchRefForSync(cwd: string, branch: string): Promise<string> {
1603
+ try {
1604
+ return await this.refreshRemoteBranchRef(cwd, branch);
1605
+ } catch (err) {
1606
+ const msg = err instanceof Error ? err.message : String(err);
1607
+ if (isMissingRemoteBranchFailure(msg)) throw new RemoteBranchGoneError(branch);
1608
+ throw err;
1609
+ }
1610
+ }
1611
+
1612
+ /**
1613
+ * Rebase the checked-out branch onto `remoteRef`, turning a content
1614
+ * conflict into the typed error the workflow layer queues recovery for.
1615
+ * Shared by `pull` and `syncFromRemote` so the two cannot drift.
1616
+ */
1617
+ private async rebaseOntoRemote(cwd: string, branch: string, remoteRef: string): Promise<void> {
1618
+ // An UNBORN HEAD (a clone of an upstream that was empty when cloned) has
1619
+ // no commit to replay and `git rebase` refuses to start from it, so the
1620
+ // branch is moved to the remote commit instead. That is only safe while
1621
+ // the index is EMPTY: files staged in an unborn clone are tracked, and a
1622
+ // hard reset would destroy them where the normal path autostashes — and
1623
+ // `git stash` cannot run without an initial commit. So a non-empty index
1624
+ // is refused as a conflict, naming the staged paths, and takes the same
1625
+ // recovery path a rebase conflict does; nothing on disk is touched.
1626
+ if ((await this.revParseOrNull(cwd, 'HEAD')) === null) {
1627
+ // `-z`: NUL-delimited and unquoted. Line output quotes a path with a
1628
+ // non-ASCII or control character ("Entw\303\274rfe.md"), and that
1629
+ // spelling would name a file that does not exist to whoever recovers.
1630
+ const { stdout: stagedOut } = await this.git(cwd, ['ls-files', '--cached', '-z']);
1631
+ const staged = stagedOut.split('\0').filter(Boolean);
1632
+ if (staged.length > 0) {
1633
+ throw new PullRebaseConflictError(
1634
+ branch,
1635
+ staged,
1636
+ 'The clone has files staged before its first commit; they cannot be replayed onto the remote branch automatically.',
1637
+ );
1638
+ }
1639
+ await this.git(cwd, ['reset', '--hard', remoteRef]);
1640
+ return;
1641
+ }
1642
+ try {
1643
+ // `--autostash` is load-bearing for the cooperative push recovery.
1644
+ // Under save=share the working tree is *supposed* to stay clean, but
1645
+ // in practice it can carry modified tracked artifacts (e.g. the
1646
+ // validator regenerates a dashboard HTML on every commit) that the
1647
+ // single-file commit didn't pick up. A rebase without it aborts
1648
+ // outright on those — "cannot rebase: You have unstaged
1649
+ // changes" — which is exactly the failure that strands a diverged
1650
+ // branch in an unrecoverable loop (the retry push stays non-fast-
1651
+ // forward forever). Autostash stashes those modifications, rebases
1652
+ // the local commits onto origin, then reapplies them, so the retry
1653
+ // push can fast-forward. Untracked files don't block rebase and are
1654
+ // left untouched.
1655
+ await this.git(cwd, ['rebase', '--autostash', remoteRef]);
1656
+ } catch (err) {
1657
+ // Capture the contested paths BEFORE the abort wipes the rebase
1658
+ // state — `--diff-filter=U` lists exactly the files whose replay
1659
+ // conflicted. Best-effort: an empty list just means this wasn't a
1660
+ // content conflict (or the probe itself failed) and the raw error
1661
+ // is surfaced unchanged.
1662
+ let conflictedPaths: string[] = [];
1663
+ try {
1664
+ const { stdout } = await this.git(cwd, ['diff', '--name-only', '--diff-filter=U']);
1665
+ conflictedPaths = stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1666
+ } catch {
1667
+ // fall through with an empty list
1668
+ }
1669
+ // A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
1670
+ // which makes every subsequent git command behave unexpectedly. Return the
1671
+ // working tree to a clean state before surfacing the original error.
1672
+ let abortSucceeded = true;
1673
+ await this.git(cwd, ['rebase', '--abort']).catch(() => {
1674
+ abortSucceeded = false;
1675
+ });
1676
+ if (abortSucceeded && conflictedPaths.length > 0) {
1677
+ // Typed so the workflow layer can queue background recovery — this
1678
+ // divergence never resolves on its own (every retry pull hits the
1679
+ // same conflict) and the unpushed local commits are someone's saved
1680
+ // content stuck violating save=share.
1681
+ //
1682
+ // Gated on the abort succeeding: a failed abort means either no
1683
+ // rebase was ever active (the unmerged paths pre-date this pull —
1684
+ // some earlier operation left the index broken) or the clone is in
1685
+ // a state git itself can't unwind. Queueing recovery there would
1686
+ // point the worker's commitFile at a repo mid-conflict, risking a
1687
+ // commit full of conflict markers — surface the raw error instead
1688
+ // and leave diagnosis to a human.
1689
+ throw new PullRebaseConflictError(
1690
+ branch,
1691
+ conflictedPaths,
1692
+ err instanceof Error ? err.message : String(err),
1693
+ );
1694
+ }
1695
+ throw err;
1696
+ }
1697
+ // `--autostash` reapplies the stashed edits AFTER the rebase, and when that
1698
+ // reapply conflicts git exits 0: the clone is left with an unmerged index,
1699
+ // the edits kept in `stash@{0}`, and nothing above has thrown. Read the
1700
+ // index rather than the exit status. The rebase itself landed, so the
1701
+ // branch is at the remote commit; resetting to it drops only the
1702
+ // half-applied stash (still in the stash list) and lets the conflict
1703
+ // travel the same typed path a rebase conflict does.
1704
+ const { stdout: unmergedOut } = await this.git(cwd, ['diff', '--name-only', '--diff-filter=U']);
1705
+ const unmerged = unmergedOut.split('\n').map((l) => l.trim()).filter(Boolean);
1706
+ if (unmerged.length > 0) {
1707
+ await this.git(cwd, ['reset', '--hard', 'HEAD']);
1708
+ throw new PullRebaseConflictError(
1709
+ branch,
1710
+ unmerged,
1711
+ 'Reapplying the autostashed working-tree edits conflicted; the edits are kept in git stash.',
1712
+ );
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Repo-relative paths whose content differs between two commits. Rename-
1718
+ * aware: a renamed file reports both its old and new path, so a viewer
1719
+ * holding the OLD path learns it is gone. Caller holds the mutex.
1720
+ */
1721
+ private async changedPathsBetween(cwd: string, fromSha: string, toSha: string): Promise<string[]> {
1722
+ const { stdout } = await this.git(cwd, [
1723
+ 'diff', '--name-status', '-M', '--no-color', '-z', fromSha, toSha,
1724
+ ]);
1725
+ const fields = stdout.split('\0');
1726
+ const paths = new Set<string>();
1727
+ for (let i = 0; i < fields.length; ) {
1728
+ const status = fields[i];
1729
+ if (!status) break;
1730
+ if (status.startsWith('R') || status.startsWith('C')) {
1731
+ if (fields[i + 1]) paths.add(fields[i + 1]);
1732
+ if (fields[i + 2]) paths.add(fields[i + 2]);
1733
+ i += 3;
1734
+ } else {
1735
+ if (fields[i + 1]) paths.add(fields[i + 1]);
1736
+ i += 2;
1737
+ }
1738
+ }
1739
+ return [...paths];
1740
+ }
1741
+
1742
+ async syncFromRemote(workspaceId: string): Promise<RemoteSyncPullResult> {
1743
+ // ONE mutex section for everything the sync reports about this clone.
1744
+ // Bracketing `pull` with separate HEAD reads and a diff took the hold
1745
+ // four times, and a save landing between any two of them was announced
1746
+ // back to its author as a "Git sync" change with the wrong sha.
1550
1747
  return this.mutex.run(workspaceId, async () => {
1551
1748
  const cwd = await this.repoDir(workspaceId);
1552
- const branch = await this.currentBranch(cwd);
1749
+ const branch = await this.checkedOutBranch(cwd);
1750
+ const before = await this.revParseOrNull(cwd, 'HEAD');
1751
+ const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1752
+ let remoteRef: string;
1553
1753
  try {
1554
- // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
1555
- // `refreshRemoteBranchRef` for why both halves are load-bearing.
1556
- //
1557
- // `--autostash` is load-bearing for the cooperative push recovery.
1558
- // Under save=share the working tree is *supposed* to stay clean, but
1559
- // in practice it can carry modified tracked artifacts (e.g. the
1560
- // validator regenerates a dashboard HTML on every commit) that the
1561
- // single-file commit didn't pick up. A rebase without it aborts
1562
- // outright on those — "cannot rebase: You have unstaged
1563
- // changes" — which is exactly the failure that strands a diverged
1564
- // branch in an unrecoverable loop (the retry push stays non-fast-
1565
- // forward forever). Autostash stashes those modifications, rebases
1566
- // the local commits onto origin, then reapplies them, so the retry
1567
- // push can fast-forward. Untracked files don't block rebase and are
1568
- // left untouched.
1569
- const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1570
- await this.git(cwd, ['rebase', '--autostash', remoteRef]);
1754
+ remoteRef = await this.refreshRemoteBranchRefForSync(cwd, branch);
1571
1755
  } catch (err) {
1572
- // Capture the contested paths BEFORE the abort wipes the rebase
1573
- // state — `--diff-filter=U` lists exactly the files whose replay
1574
- // conflicted. Best-effort: an empty list just means this wasn't a
1575
- // content conflict (or the probe itself failed) and the raw error
1576
- // is surfaced unchanged.
1577
- let conflictedPaths: string[] = [];
1578
- try {
1579
- const { stdout } = await this.git(cwd, ['diff', '--name-only', '--diff-filter=U']);
1580
- conflictedPaths = stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1581
- } catch {
1582
- // fall through with an empty list
1583
- }
1584
- // A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
1585
- // which makes every subsequent git command behave unexpectedly. Return the
1586
- // working tree to a clean state before surfacing the original error.
1587
- let abortSucceeded = true;
1588
- await this.git(cwd, ['rebase', '--abort']).catch(() => {
1589
- abortSucceeded = false;
1590
- });
1591
- if (abortSucceeded && conflictedPaths.length > 0) {
1592
- // Typed so the workflow layer can queue background recovery — this
1593
- // divergence never resolves on its own (every retry pull hits the
1594
- // same conflict) and the unpushed local commits are someone's saved
1595
- // content stuck violating save=share.
1596
- //
1597
- // Gated on the abort succeeding: a failed abort means either no
1598
- // rebase was ever active (the unmerged paths pre-date this pull —
1599
- // some earlier operation left the index broken) or the clone is in
1600
- // a state git itself can't unwind. Queueing recovery there would
1601
- // point the worker's commitFile at a repo mid-conflict, risking a
1602
- // commit full of conflict markers — surface the raw error instead
1603
- // and leave diagnosis to a human.
1604
- throw new PullRebaseConflictError(
1605
- branch,
1606
- conflictedPaths,
1607
- err instanceof Error ? err.message : String(err),
1608
- );
1756
+ // An unborn clone whose branch origin does not have either: on a FRESH
1757
+ // deployment (origin has no branch at all) nobody has pushed yet —
1758
+ // nothing to sync, nothing to retire. But an origin that does have
1759
+ // branches and lacks this one has had this branch created and deleted
1760
+ // since the clone was made, and the clone is as stale as any other.
1761
+ if (err instanceof RemoteBranchGoneError && before === null && !(await this.remoteHasAnyBranch(cwd))) {
1762
+ return { before: null, after: null, treeChanged: false, changedPaths: [] };
1609
1763
  }
1610
1764
  throw err;
1611
1765
  }
1766
+ await this.rebaseOntoRemote(cwd, branch, remoteRef);
1767
+ this.accessControl?.invalidate(workspaceId);
1768
+ const after = await this.revParseOrNull(cwd, 'HEAD');
1769
+ const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1770
+ const treeChanged = treeAfter !== treeBefore;
1771
+ const changedPaths =
1772
+ treeChanged && after
1773
+ ? await this.changedPathsBetween(cwd, before ?? GitService.EMPTY_TREE_SHA, after)
1774
+ : [];
1775
+ return { before, after, treeChanged, changedPaths };
1776
+ });
1777
+ }
1778
+
1779
+ async pull(workspaceId: string): Promise<{ treeChanged: boolean }> {
1780
+ return this.mutex.run(workspaceId, async () => {
1781
+ const cwd = await this.repoDir(workspaceId);
1782
+ const branch = await this.checkedOutBranch(cwd);
1783
+ // For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
1784
+ // a pull that lands only content-identical commits (an empty commit)
1785
+ // must not read as a content change. Tolerant of an unborn HEAD (a
1786
+ // clone of an empty upstream): "" before + a tree after correctly
1787
+ // reads as changed, "" both sides as not.
1788
+ const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1789
+ // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
1790
+ // `refreshRemoteBranchRef` for why both halves are load-bearing.
1791
+ const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1792
+ await this.rebaseOntoRemote(cwd, branch, remoteRef);
1612
1793
  this.accessControl?.invalidate(workspaceId);
1794
+ const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1795
+ return { treeChanged: treeAfter !== treeBefore };
1613
1796
  });
1614
1797
  }
1615
1798
 
@@ -438,6 +438,15 @@ export class PullRequestService implements IPullRequestService {
438
438
  this.cachedList.clear();
439
439
  }
440
440
 
441
+ /**
442
+ * Evict the CR-list caches alone. Each list entry carries `touchedNodePaths`
443
+ * resolved against a workspace's tree at the time; a remote sync that moved
444
+ * that tree makes them stale without touching any one request.
445
+ */
446
+ invalidateListCache(): void {
447
+ this.cachedList.clear();
448
+ }
449
+
441
450
  private async findRow(prNumber: number): Promise<ChangeRequestRow | null> {
442
451
  const [row] = await this.db
443
452
  .select()