@bevel-software/platform-core-backend 0.13.6 → 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 (456) 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 +101 -9
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +20 -1
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +105 -43
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +12 -5
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +18 -6
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +26 -1
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +213 -71
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access.routes.d.ts.map +1 -1
  23. package/dist/modules/access/access.routes.js +59 -7
  24. package/dist/modules/access/access.routes.js.map +1 -1
  25. package/dist/modules/access-model/access-grammar.d.ts +51 -1
  26. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  27. package/dist/modules/access-model/access-grammar.js +95 -1
  28. package/dist/modules/access-model/access-grammar.js.map +1 -1
  29. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  30. package/dist/modules/access-model/group-files.js +4 -1
  31. package/dist/modules/access-model/group-files.js.map +1 -1
  32. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  33. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  34. package/dist/modules/access-model/kb-read-filter.js +15 -0
  35. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  36. package/dist/modules/access-model/plugin-principals.d.ts +49 -0
  37. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  38. package/dist/modules/access-model/plugin-principals.js +125 -0
  39. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  40. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  41. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  42. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  43. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  44. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  45. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  46. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  47. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  48. package/dist/modules/connection-probe/index.d.ts +3 -0
  49. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  50. package/dist/modules/connection-probe/index.js +3 -0
  51. package/dist/modules/connection-probe/index.js.map +1 -0
  52. package/dist/modules/database/core-schema.d.ts +326 -0
  53. package/dist/modules/database/core-schema.d.ts.map +1 -1
  54. package/dist/modules/database/core-schema.js +52 -0
  55. package/dist/modules/database/core-schema.js.map +1 -1
  56. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  57. package/dist/modules/diff/diff.routes.js +3 -5
  58. package/dist/modules/diff/diff.routes.js.map +1 -1
  59. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  60. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/mutex.js +48 -5
  62. package/dist/modules/kb-fs/mutex.js.map +1 -1
  63. package/dist/modules/kb-sync/index.d.ts +6 -0
  64. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  65. package/dist/modules/kb-sync/index.js +5 -0
  66. package/dist/modules/kb-sync/index.js.map +1 -0
  67. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  68. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  69. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  70. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  71. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  72. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  73. package/dist/modules/kb-sync/kb-sync.routes.js +135 -0
  74. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  75. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  76. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  77. package/dist/modules/kb-sync/kb-sync.service.js +183 -0
  78. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  79. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  80. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  81. package/dist/modules/kb-sync/sync-auth.js +62 -0
  82. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  83. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  84. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  85. package/dist/modules/kb-sync/sync-payload.js +105 -0
  86. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  87. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  88. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  89. package/dist/modules/marketplace/git-http.routes.js +190 -0
  90. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  91. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +23 -0
  92. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  93. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +59 -0
  94. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  95. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  96. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  97. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  98. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  99. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +99 -0
  100. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  101. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +147 -0
  102. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  103. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  104. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  105. package/dist/modules/marketplace/github-facade/github-facade.routes.js +228 -0
  106. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  107. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +109 -0
  108. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  109. package/dist/modules/marketplace/github-facade/github-facade.service.js +172 -0
  110. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  111. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  112. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  113. package/dist/modules/marketplace/github-facade/index.js +6 -0
  114. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  115. package/dist/modules/marketplace/index.d.ts +4 -0
  116. package/dist/modules/marketplace/index.d.ts.map +1 -0
  117. package/dist/modules/marketplace/index.js +4 -0
  118. package/dist/modules/marketplace/index.js.map +1 -0
  119. package/dist/modules/marketplace/marketplace-repo.service.d.ts +122 -0
  120. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  121. package/dist/modules/marketplace/marketplace-repo.service.js +300 -0
  122. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  123. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  124. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +4 -0
  125. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  126. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  127. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  128. package/dist/modules/mcp/oauth/oauth-consent.routes.js +13 -1
  129. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  130. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  131. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  132. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  133. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  134. package/dist/modules/plugins/compile/compile-marketplace.d.ts +97 -0
  135. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  136. package/dist/modules/plugins/compile/compile-marketplace.js +240 -0
  137. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  138. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +66 -0
  139. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  140. package/dist/modules/plugins/compile/marketplace-compiler.service.js +132 -0
  141. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  142. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  143. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  144. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +135 -0
  145. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  146. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +45 -0
  147. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  148. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +147 -0
  149. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  150. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +37 -0
  151. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  152. package/dist/modules/plugins/discovery/kb-plugin-source.js +107 -0
  153. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  154. package/dist/modules/plugins/discovery/native.source.d.ts +15 -0
  155. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  156. package/dist/modules/plugins/discovery/native.source.js +102 -0
  157. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  158. package/dist/modules/plugins/discovery/plugin-source.d.ts +96 -0
  159. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  160. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  161. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  162. package/dist/modules/plugins/index.d.ts +10 -1
  163. package/dist/modules/plugins/index.d.ts.map +1 -1
  164. package/dist/modules/plugins/index.js +8 -0
  165. package/dist/modules/plugins/index.js.map +1 -1
  166. package/dist/modules/plugins/plugin-links.d.ts +66 -0
  167. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  168. package/dist/modules/plugins/plugin-links.js +160 -0
  169. package/dist/modules/plugins/plugin-links.js.map +1 -0
  170. package/dist/modules/plugins/plugin-links.service.d.ts +114 -0
  171. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  172. package/dist/modules/plugins/plugin-links.service.js +304 -0
  173. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  174. package/dist/modules/plugins/plugin-provision.service.d.ts +39 -3
  175. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  176. package/dist/modules/plugins/plugin-provision.service.js +152 -52
  177. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  178. package/dist/modules/plugins/plugin-rename.service.d.ts +69 -0
  179. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  180. package/dist/modules/plugins/plugin-rename.service.js +245 -0
  181. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  182. package/dist/modules/plugins/plugins.contract.d.ts +27 -1
  183. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  184. package/dist/modules/plugins/plugins.routes.d.ts +7 -1
  185. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  186. package/dist/modules/plugins/plugins.routes.js +120 -12
  187. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  188. package/dist/modules/plugins/plugins.service.d.ts +50 -3
  189. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  190. package/dist/modules/plugins/plugins.service.js +108 -47
  191. package/dist/modules/plugins/plugins.service.js.map +1 -1
  192. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  193. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  194. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  195. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  196. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  197. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  198. package/dist/modules/settings/deployment-settings.service.d.ts +9 -0
  199. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  200. package/dist/modules/settings/deployment-settings.service.js +82 -2
  201. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  202. package/dist/modules/settings/setup.routes.d.ts +26 -0
  203. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  204. package/dist/modules/settings/setup.routes.js +9 -1
  205. package/dist/modules/settings/setup.routes.js.map +1 -1
  206. package/dist/modules/skills/index.d.ts +1 -0
  207. package/dist/modules/skills/index.d.ts.map +1 -1
  208. package/dist/modules/skills/index.js +1 -0
  209. package/dist/modules/skills/index.js.map +1 -1
  210. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  211. package/dist/modules/skills/pending-skills.service.js +9 -4
  212. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  213. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  214. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  215. package/dist/modules/skills/skill-access-requests.routes.js +168 -0
  216. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  217. package/dist/modules/skills/skills.contract.d.ts +31 -0
  218. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  219. package/dist/modules/skills/skills.routes.d.ts +28 -3
  220. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  221. package/dist/modules/skills/skills.routes.js +42 -3
  222. package/dist/modules/skills/skills.routes.js.map +1 -1
  223. package/dist/modules/skills/skills.service.d.ts +4 -0
  224. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  225. package/dist/modules/skills/skills.service.js +83 -22
  226. package/dist/modules/skills/skills.service.js.map +1 -1
  227. package/dist/modules/tool-auth/external-api-key.interface.d.ts +18 -1
  228. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  229. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  230. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  231. package/dist/modules/tool-auth/external-api-key.service.d.ts +13 -4
  232. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  233. package/dist/modules/tool-auth/external-api-key.service.js +26 -9
  234. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  235. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  236. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  237. package/dist/modules/tool-manuals/mcp-json-discovery.js +130 -83
  238. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  239. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  240. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  241. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  242. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  243. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  244. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +15 -2
  245. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  246. package/dist/modules/tool-manuals/tool-manuals.service.js +199 -38
  247. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  248. package/dist/modules/workflow/git/git.service.d.ts +58 -3
  249. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  250. package/dist/modules/workflow/git/git.service.js +325 -60
  251. package/dist/modules/workflow/git/git.service.js.map +1 -1
  252. package/dist/modules/workflow/git/pull-request.service.d.ts +6 -0
  253. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  254. package/dist/modules/workflow/git/pull-request.service.js +8 -0
  255. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  256. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  257. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  258. package/dist/modules/workflow/workflow.routes.js +98 -9
  259. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  260. package/dist/modules/workflow/workflow.service.d.ts +133 -9
  261. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  262. package/dist/modules/workflow/workflow.service.js +462 -52
  263. package/dist/modules/workflow/workflow.service.js.map +1 -1
  264. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +35 -0
  265. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  266. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +121 -0
  267. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  268. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  269. package/dist/modules/workspace/startup/steps/seed-tree.js +46 -2
  270. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  271. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  272. package/dist/modules/workspace/startup/steps/template-files.step.js +112 -25
  273. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  274. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  275. package/dist/modules/workspace/workspace.routes.js +11 -14
  276. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  277. package/dist/modules/workspace/workspace.service.d.ts +38 -1
  278. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  279. package/dist/modules/workspace/workspace.service.js +131 -20
  280. package/dist/modules/workspace/workspace.service.js.map +1 -1
  281. package/dist/shared/domain-errors.d.ts +20 -0
  282. package/dist/shared/domain-errors.d.ts.map +1 -1
  283. package/dist/shared/domain-errors.js +29 -0
  284. package/dist/shared/domain-errors.js.map +1 -1
  285. package/dist/shared/fs-errors.d.ts +8 -0
  286. package/dist/shared/fs-errors.d.ts.map +1 -0
  287. package/dist/shared/fs-errors.js +11 -0
  288. package/dist/shared/fs-errors.js.map +1 -0
  289. package/dist/shared/fs-walk.d.ts +20 -6
  290. package/dist/shared/fs-walk.d.ts.map +1 -1
  291. package/dist/shared/fs-walk.js +35 -27
  292. package/dist/shared/fs-walk.js.map +1 -1
  293. package/dist/shared/kb-walk.d.ts +52 -0
  294. package/dist/shared/kb-walk.d.ts.map +1 -0
  295. package/dist/shared/kb-walk.js +76 -0
  296. package/dist/shared/kb-walk.js.map +1 -0
  297. package/dist/shared/path-order.d.ts +18 -0
  298. package/dist/shared/path-order.d.ts.map +1 -0
  299. package/dist/shared/path-order.js +35 -0
  300. package/dist/shared/path-order.js.map +1 -0
  301. package/dist/shared/token-crypto.d.ts +17 -2
  302. package/dist/shared/token-crypto.d.ts.map +1 -1
  303. package/dist/shared/token-crypto.js +25 -8
  304. package/dist/shared/token-crypto.js.map +1 -1
  305. package/dist/shared/ttl-cache.d.ts +28 -1
  306. package/dist/shared/ttl-cache.d.ts.map +1 -1
  307. package/dist/shared/ttl-cache.js +33 -1
  308. package/dist/shared/ttl-cache.js.map +1 -1
  309. package/kb-template/.bevelignore +1 -1
  310. package/kb-template/AGENTS.md +95 -30
  311. package/kb-template/Skills/.gitkeep +0 -0
  312. package/migrations/0005_github_facade.sql +25 -0
  313. package/migrations/meta/0005_snapshot.json +1717 -0
  314. package/migrations/meta/_journal.json +7 -0
  315. package/package.json +6 -6
  316. package/src/__tests__/core-config.admin.test.ts +21 -0
  317. package/src/__tests__/kb-layout-config.test.ts +110 -0
  318. package/src/core/__tests__/catalog-cache-invalidation.test.ts +146 -0
  319. package/src/core/catalog-cache-invalidation.ts +92 -0
  320. package/src/core/create-core-server.ts +126 -7
  321. package/src/core/create-core-services.ts +173 -41
  322. package/src/core-config.ts +14 -7
  323. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +86 -0
  324. package/src/modules/access/__tests__/access-control.service.test.ts +19 -3
  325. package/src/modules/access/__tests__/access-plugin-principals.test.ts +368 -0
  326. package/src/modules/access/__tests__/groups-admin.service.test.ts +8 -0
  327. package/src/modules/access/access-control.interface.ts +20 -9
  328. package/src/modules/access/access-control.service.ts +240 -78
  329. package/src/modules/access/access.routes.ts +96 -14
  330. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  331. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  332. package/src/modules/access-model/access-grammar.ts +114 -2
  333. package/src/modules/access-model/group-files.ts +4 -0
  334. package/src/modules/access-model/kb-read-filter.ts +22 -0
  335. package/src/modules/access-model/plugin-principals.ts +137 -0
  336. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  337. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  338. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  339. package/src/modules/connection-probe/index.ts +2 -0
  340. package/src/modules/database/core-schema.ts +54 -0
  341. package/src/modules/diff/diff.routes.ts +9 -5
  342. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  343. package/src/modules/kb-fs/mutex.ts +50 -5
  344. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  345. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  346. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  347. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  348. package/src/modules/kb-sync/index.ts +19 -0
  349. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  350. package/src/modules/kb-sync/kb-sync.routes.ts +197 -0
  351. package/src/modules/kb-sync/kb-sync.service.ts +201 -0
  352. package/src/modules/kb-sync/sync-auth.ts +118 -0
  353. package/src/modules/kb-sync/sync-payload.ts +143 -0
  354. package/src/modules/marketplace/__tests__/github-facade.test.ts +443 -0
  355. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +179 -0
  356. package/src/modules/marketplace/git-http.routes.ts +193 -0
  357. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +78 -0
  358. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  359. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +208 -0
  360. package/src/modules/marketplace/github-facade/github-facade.routes.ts +268 -0
  361. package/src/modules/marketplace/github-facade/github-facade.service.ts +221 -0
  362. package/src/modules/marketplace/github-facade/index.ts +25 -0
  363. package/src/modules/marketplace/index.ts +3 -0
  364. package/src/modules/marketplace/marketplace-repo.service.ts +337 -0
  365. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -0
  366. package/src/modules/mcp/oauth/oauth-consent.routes.ts +24 -2
  367. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  368. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +163 -0
  369. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +294 -0
  370. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +56 -12
  371. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +287 -0
  372. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +205 -5
  373. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +394 -0
  374. package/src/modules/plugins/__tests__/plugins.routes.test.ts +80 -33
  375. package/src/modules/plugins/compile/compile-marketplace.ts +403 -0
  376. package/src/modules/plugins/compile/marketplace-compiler.service.ts +150 -0
  377. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +329 -0
  378. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +140 -0
  379. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +175 -0
  380. package/src/modules/plugins/discovery/kb-plugin-source.ts +114 -0
  381. package/src/modules/plugins/discovery/native.source.ts +122 -0
  382. package/src/modules/plugins/discovery/plugin-source.ts +99 -0
  383. package/src/modules/plugins/index.ts +10 -0
  384. package/src/modules/plugins/plugin-links.service.ts +363 -0
  385. package/src/modules/plugins/plugin-links.ts +184 -0
  386. package/src/modules/plugins/plugin-provision.service.ts +160 -54
  387. package/src/modules/plugins/plugin-rename.service.ts +298 -0
  388. package/src/modules/plugins/plugins.contract.ts +28 -1
  389. package/src/modules/plugins/plugins.routes.ts +128 -11
  390. package/src/modules/plugins/plugins.service.ts +106 -53
  391. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  392. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  393. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  394. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  395. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  396. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +57 -0
  397. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  398. package/src/modules/settings/deployment-settings.service.ts +95 -2
  399. package/src/modules/settings/setup.routes.ts +24 -0
  400. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  401. package/src/modules/skills/__tests__/skills.service.test.ts +85 -0
  402. package/src/modules/skills/index.ts +1 -0
  403. package/src/modules/skills/pending-skills.service.ts +7 -5
  404. package/src/modules/skills/skill-access-requests.routes.ts +199 -0
  405. package/src/modules/skills/skills.contract.ts +32 -0
  406. package/src/modules/skills/skills.routes.ts +59 -3
  407. package/src/modules/skills/skills.service.ts +84 -22
  408. package/src/modules/tool-auth/external-api-key.interface.ts +20 -1
  409. package/src/modules/tool-auth/external-api-key.service.ts +31 -13
  410. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +35 -1
  411. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +2 -0
  412. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  413. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  414. package/src/modules/tool-manuals/mcp-json-discovery.ts +154 -76
  415. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  416. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  417. package/src/modules/tool-manuals/tool-manuals.service.ts +210 -37
  418. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  419. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  420. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  421. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -0
  422. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  423. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +97 -10
  424. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +119 -2
  425. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +437 -0
  426. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  427. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  428. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  429. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  430. package/src/modules/workflow/git/git.service.ts +354 -63
  431. package/src/modules/workflow/git/pull-request.service.ts +9 -0
  432. package/src/modules/workflow/workflow.routes.ts +104 -4
  433. package/src/modules/workflow/workflow.service.ts +505 -64
  434. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +6 -7
  435. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +93 -0
  436. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  437. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +42 -12
  438. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +219 -4
  439. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +129 -0
  440. package/src/modules/workspace/startup/steps/seed-tree.ts +44 -2
  441. package/src/modules/workspace/startup/steps/template-files.step.ts +131 -27
  442. package/src/modules/workspace/workspace.routes.ts +17 -21
  443. package/src/modules/workspace/workspace.service.ts +128 -19
  444. package/src/shared/__tests__/filename.test.ts +0 -0
  445. package/src/shared/__tests__/fs-walk.test.ts +22 -1
  446. package/src/shared/__tests__/kb-walk.test.ts +107 -0
  447. package/src/shared/__tests__/path-order.test.ts +24 -0
  448. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  449. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  450. package/src/shared/domain-errors.ts +29 -0
  451. package/src/shared/fs-errors.ts +10 -0
  452. package/src/shared/fs-walk.ts +38 -23
  453. package/src/shared/kb-walk.ts +95 -0
  454. package/src/shared/path-order.ts +33 -0
  455. package/src/shared/token-crypto.ts +28 -10
  456. 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,9 +41,10 @@ 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
- import { and, eq } from 'drizzle-orm';
47
+ import { and, eq, or } from 'drizzle-orm';
46
48
  import type { Database } from '../database/connection.js';
47
49
  import { changeRequests } from '../database/schema.js';
48
50
  import type { GitService } from './git/git.service.js';
@@ -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
  /**
@@ -282,6 +322,85 @@ export class WorkflowService implements IWorkflowService {
282
322
  */
283
323
  private readonly branchLifecycle = new WorkspaceMutex();
284
324
 
325
+ /**
326
+ * Hold the lifecycle lock of BOTH branches a change request names, so a
327
+ * check-then-insert spanning them excludes either one being deleted.
328
+ *
329
+ * ATOMICALLY, via `runAll` — not `run(a, () => run(b, …))`. Taking the keys
330
+ * one at a time leaves a gap between them in which a delete of the second
331
+ * branch can start, pass its "is any open request using this?" check
332
+ * (the row does not exist yet) and finish, so the request lands pointing at
333
+ * a branch that was removed while it was being opened. That is the same
334
+ * failure this PR is about, reintroduced one level down — and it would have
335
+ * been a REGRESSION on the source branch, which the previous single-key
336
+ * lock held for the whole sequence.
337
+ *
338
+ * Reserving the set in one step also removes the deadlock question that
339
+ * sequential acquisition raises: two requests may legally be open in
340
+ * opposite directions (`A -> B` and `B -> A`), and nothing is ever held
341
+ * while waiting for the other key.
342
+ *
343
+ * A PROTECTED target's key is NOT reserved. No path in the system can
344
+ * delete a protected branch (`git.deleteBranch` throws
345
+ * `ProtectedBranchError` before any lock, `retireMergedSourceBranch`
346
+ * returns early on one), so the key would exclude nothing. It would only
347
+ * cost: nearly every request targets the protected default branch, so one
348
+ * shared key there would serialize every open on the hottest path for the
349
+ * duration of a fetch + merge + push + diff. The source needs no such
350
+ * carve-out; `openChangeRequest` refuses protected sources before locking.
351
+ */
352
+ private runOnBranchPair<T>(
353
+ source: string,
354
+ target: string,
355
+ fn: () => Promise<T>,
356
+ ): Promise<T> {
357
+ const keys = [`branch:${source}`];
358
+ if (!isProtectedBranch(target)) keys.push(`branch:${target}`);
359
+ return this.branchLifecycle.runAll(keys, fn);
360
+ }
361
+
362
+ /**
363
+ * The open change requests that deleting `branch` would strand — every one
364
+ * that NAMES it, at EITHER end.
365
+ *
366
+ * Both ends, not just the source. A change request is a proposal to move
367
+ * commits FROM one branch INTO another and it needs both to exist: the
368
+ * detail read resolves the target BEFORE the source, so a request whose
369
+ * target has been deleted cannot be read, declined, or deleted either. It
370
+ * is also the worse half to lose — a stranded source is closed by the boot
371
+ * sweep, while a stranded target used to survive every restart, because
372
+ * every guard here asked only "is this branch anybody's SOURCE?".
373
+ *
374
+ * That question is what let a merge retire a branch a second, still-open
375
+ * request was pointing INTO: the pair `X -> B` (merged, B retired) and
376
+ * `Y -> X` (open, now unusable forever).
377
+ *
378
+ * Returns the first such request's number and which end of it names the
379
+ * branch, or null when the branch is free. The end matters to the caller's
380
+ * error message: "withdraw your request" is only actionable when the asker
381
+ * owns the request, and a request proposing INTO the branch is someone
382
+ * else's.
383
+ */
384
+ private async openChangeRequestOn(
385
+ branch: string,
386
+ ): Promise<{ number: number; end: 'source' | 'target' } | null> {
387
+ const [row] = await this.db
388
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
389
+ .from(changeRequests)
390
+ .where(
391
+ and(
392
+ or(
393
+ eq(changeRequests.sourceBranch, branch),
394
+ eq(changeRequests.targetBranch, branch),
395
+ ),
396
+ eq(changeRequests.state, 'open'),
397
+ ),
398
+ )
399
+ .limit(1);
400
+ if (!row) return null;
401
+ return { number: row.number, end: row.sourceBranch === branch ? 'source' : 'target' };
402
+ }
403
+
285
404
  async deleteBranch(
286
405
  workspaceId: string,
287
406
  name: string,
@@ -296,16 +415,19 @@ export class WorkflowService implements IWorkflowService {
296
415
  // its own open-request check under this same lock, and the legacy
297
416
  // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
298
417
  if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
299
- const open = await this.db
300
- .select({ number: changeRequests.number })
301
- .from(changeRequests)
302
- .where(
303
- and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')),
304
- )
305
- .limit(1);
306
- if (open.length > 0) {
418
+ const open = await this.openChangeRequestOn(name);
419
+ if (open !== null) {
420
+ // Two different situations, two different actors. A request FROM
421
+ // this branch is the deleter's own to withdraw; a request INTO it
422
+ // belongs to someone else, and telling the branch owner to
423
+ // "withdraw" it points them at a verb they cannot use.
307
424
  throw new WorkflowValidationError(
308
- `Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`,
425
+ open.end === 'source'
426
+ ? `Branch "${name}" has an open change request (#${open.number}). ` +
427
+ `Withdraw or decline it before deleting the branch.`
428
+ : `Open change request #${open.number} proposes changes into "${name}". ` +
429
+ `Its author can withdraw it, or an admin can decline it; ` +
430
+ `after that the branch can be deleted.`,
309
431
  );
310
432
  }
311
433
  }
@@ -363,9 +485,41 @@ export class WorkflowService implements IWorkflowService {
363
485
  return this.git.fetch(workspaceId);
364
486
  }
365
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
+
366
520
  async updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void> {
367
521
  try {
368
- await this.git.pull(workspaceId);
522
+ await this.pullWorkspace(workspaceId);
369
523
  } catch (err) {
370
524
  if (err instanceof PullRebaseConflictError) {
371
525
  await this.queuePullConflictRecovery(workspaceId, err, user);
@@ -374,6 +528,157 @@ export class WorkflowService implements IWorkflowService {
374
528
  }
375
529
  }
376
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
+
377
682
  /**
378
683
  * A pull hit a rebase conflict — the workspace carries local commits that
379
684
  * conflict with origin, a state that never resolves on its own (every
@@ -700,7 +1005,7 @@ export class WorkflowService implements IWorkflowService {
700
1005
  let recoveryError: unknown = null;
701
1006
  if (looksLikeNonFastForward) {
702
1007
  try {
703
- await this.git.pull(workspaceId);
1008
+ await this.pullWorkspace(workspaceId);
704
1009
  await this.git.push(workspaceId, user);
705
1010
  recovered = true;
706
1011
  this.noteGitSyncOk(workspaceId, branch);
@@ -951,7 +1256,34 @@ export class WorkflowService implements IWorkflowService {
951
1256
  * machinery is unaffected — all this adds is that someone finds out, rather
952
1257
  * than the deployment looking healthy while nothing reaches the remote.
953
1258
  */
954
- 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);
955
1287
  // Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
956
1288
  const id = branchForWorkspaceId(workspaceId);
957
1289
  this.gitSyncFailing.add(id);
@@ -961,7 +1293,8 @@ export class WorkflowService implements IWorkflowService {
961
1293
  branch,
962
1294
  // Git stderr can quote a credentialed URL; the banner is user-facing and
963
1295
  // the string also lands in client logs, so sanitize before it leaves.
964
- reason: sanitizeError(err),
1296
+ reason: conflict ? conflict.message : sanitizeError(err),
1297
+ ...(conflict ? { conflictedPaths: conflict.paths } : {}),
965
1298
  });
966
1299
  }
967
1300
 
@@ -1020,7 +1353,7 @@ export class WorkflowService implements IWorkflowService {
1020
1353
  let recoveryError: unknown = null;
1021
1354
  if (looksLikeNonFastForward) {
1022
1355
  try {
1023
- await this.git.pull(workspaceId);
1356
+ await this.pullWorkspace(workspaceId);
1024
1357
  await this.git.push(workspaceId, user, opts);
1025
1358
  recovered = true;
1026
1359
  this.noteGitSyncOk(workspaceId, branch);
@@ -1284,12 +1617,17 @@ export class WorkflowService implements IWorkflowService {
1284
1617
  }
1285
1618
 
1286
1619
  // The whole open sequence — uniqueness check through row insert — runs
1287
- // under the source branch's lifecycle lock. `retireMergedSourceBranch`
1288
- // takes the same lock around its own check-then-delete, so a merge
1289
- // cleanup can never race into the multi-second gap between this
1290
- // method's "no open request" answer and its row landing, and delete the
1291
- // branch out from under a request being opened.
1292
- return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
1620
+ // under the lifecycle lock of BOTH branches. `retireMergedSourceBranch`
1621
+ // and `deleteBranch` take the lock of the branch they are removing around
1622
+ // their own check-then-delete, so a cleanup can never race into the
1623
+ // multi-second gap between this method's "no open request" answer and its
1624
+ // row landing, and delete a branch out from under a request being opened.
1625
+ //
1626
+ // BOTH, because that gap is symmetric: a request is unusable if EITHER of
1627
+ // its branches is retired inside the window, and holding only the source
1628
+ // left a `Y -> X` request open to X being deleted under it — the very
1629
+ // failure the widened guards exist to prevent.
1630
+ return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
1293
1631
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
1294
1632
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
1295
1633
  // force-fresh to catch a CR that opened just now.
@@ -1623,7 +1961,7 @@ export class WorkflowService implements IWorkflowService {
1623
1961
  // refs, but the restore commits from the working tree — a stale tree
1624
1962
  // would push non-fast-forward and fail loudly anyway; this just makes
1625
1963
  // that rare.
1626
- await this.git.pull(ws.id).catch(() => undefined);
1964
+ await this.pullWorkspace(ws.id).catch(() => undefined);
1627
1965
 
1628
1966
  // The verb acts on the request as it is NOW — never a cached file list.
1629
1967
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
@@ -1701,14 +2039,56 @@ export class WorkflowService implements IWorkflowService {
1701
2039
  throw new WorkflowDomainError('This change request has already been applied.', 422);
1702
2040
  }
1703
2041
 
1704
- const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1705
- await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
1706
- const isAdmin = await this.accessControl.canWriteAtRef(
1707
- ws.id,
1708
- `origin/${summary.base}`,
1709
- user.email,
1710
- 'roles.yaml',
1711
- );
2042
+ // Which `roles.yaml` authorizes this? The base branch's, exactly as every
2043
+ // other admin check here. When the base branch itself is gone, this
2044
+ // bootstrap (or the at-ref check below) fails; that failure is NOT proven
2045
+ // absence, so it never re-keys authorization to some other branch. It
2046
+ // kicks the deleted-branch sweep instead: the one mechanism that proves a
2047
+ // branch is missing (fresh fetch, then a set lookup) and closes every
2048
+ // request stranded by it, this one included. This call still fails; by
2049
+ // the next attempt the sweep has either closed the row (nothing left to
2050
+ // delete) or shown the base alive, meaning the failure was transient and
2051
+ // a retry can succeed.
2052
+ let ws: { id: string };
2053
+ let isAdmin: boolean | null;
2054
+ try {
2055
+ ws = await this.workspaceService.getOrCreateForBranch(summary.base);
2056
+ // STRICT: the admin check below reads roles.yaml at `origin/<base>`,
2057
+ // and on a swallowed fetch failure that ref is whatever the clone last
2058
+ // saw — a user whose rights were since revoked would still pass
2059
+ // against the stale tree. Authorization never runs against refs of
2060
+ // unknown age.
2061
+ try {
2062
+ await this.workspaceService.ensureRemotesFetched(ws.id, { strict: true });
2063
+ } catch {
2064
+ throw new WorkflowDomainError(
2065
+ `Could not verify the base branch "${summary.base}" — origin was unreachable. Retry in a moment.`,
2066
+ 409,
2067
+ );
2068
+ }
2069
+ isAdmin = await this.accessControl.canWriteAtRef(
2070
+ ws.id,
2071
+ `origin/${summary.base}`,
2072
+ user.email,
2073
+ 'roles.yaml',
2074
+ );
2075
+ } catch (err) {
2076
+ this.kickDeletedBranchSweep();
2077
+ throw err;
2078
+ }
2079
+ // null is "the ref did not resolve", not "denied" — and saying "only an
2080
+ // admin can do this" to an admin whose base branch is merely unfetchable
2081
+ // sent people chasing their role instead of the branch. Kick the sweep
2082
+ // (it proves absence itself, with a strict fetch, and closes the
2083
+ // stranded rows) and fail with the honest condition; by the next
2084
+ // attempt the sweep has either closed the row or shown the base alive.
2085
+ if (isAdmin === null) {
2086
+ this.kickDeletedBranchSweep();
2087
+ throw new WorkflowDomainError(
2088
+ `The base branch "${summary.base}" could not be resolved — it may have been deleted, or origin was unreachable. A background check is running; retry in a moment.`,
2089
+ 409,
2090
+ );
2091
+ }
1712
2092
  if (isAdmin !== true) {
1713
2093
  throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
1714
2094
  }
@@ -1780,17 +2160,23 @@ export class WorkflowService implements IWorkflowService {
1780
2160
  }
1781
2161
 
1782
2162
  /**
1783
- * Close every open change request whose source branch no longer exists.
2163
+ * Close every open change request either of whose branches no longer exists.
1784
2164
  *
1785
- * A change request is a proposal to merge a branch. When that branch is
1786
- * gone — deleted after a manual merge, pruned as abandoned, or left behind
1787
- * by a feature that has itself been removed — the request cannot be read,
1788
- * reviewed, applied or declined. It is not a pending decision, it is a
1789
- * tombstone, and it costs more than tidiness: `listOpenPrs` resolves each
1790
- * open request's changed paths, so every one of these throws
2165
+ * A change request is a proposal to merge one branch into another, so it
2166
+ * needs BOTH. When either is gone — deleted after a manual merge, pruned as
2167
+ * abandoned, or left behind by a feature that has itself been removed — the
2168
+ * request cannot be read, reviewed, applied or declined. It is not a pending
2169
+ * decision, it is a tombstone, and it costs more than tidiness: `listOpenPrs`
2170
+ * resolves each open request's changed paths, so every one of these throws
1791
2171
  * `unknown branch` on every poll, filling the log and slowing the list it
1792
2172
  * appears in.
1793
2173
  *
2174
+ * The TARGET half is the one that used to be missed, and it was the worse
2175
+ * half: a missing source could only ever arrive by a path that also closed
2176
+ * the request, while a request whose target had been retired under it was
2177
+ * unreadable, undeclinable, undeletable — and invisible here, so it survived
2178
+ * every restart.
2179
+ *
1794
2180
  * CLOSED, NOT DELETED. The row is evidence — who proposed what, when, and
1795
2181
  * that it was never applied — and it is the only remaining trace once the
1796
2182
  * branch is gone. Closing takes it out of every open list (which is the
@@ -1799,7 +2185,7 @@ export class WorkflowService implements IWorkflowService {
1799
2185
  * operation in the workflow that destroys history without a human asking.
1800
2186
  *
1801
2187
  * ONE fetch, then a set lookup. The freshness matters more than the cost:
1802
- * `changedPathsForPr` fails when the branch is missing from THAT
2188
+ * `changedPathsForPr` fails when a branch is missing from THAT
1803
2189
  * workspace's refs, which is not the same thing as missing from origin —
1804
2190
  * a clone that never fetched a branch someone else created reports exactly
1805
2191
  * the same error. Closing on that signal would eat live requests. So the
@@ -1815,7 +2201,11 @@ export class WorkflowService implements IWorkflowService {
1815
2201
  */
1816
2202
  async closeChangeRequestsWithDeletedBranches(): Promise<number> {
1817
2203
  const open = await this.db
1818
- .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
2204
+ .select({
2205
+ number: changeRequests.number,
2206
+ sourceBranch: changeRequests.sourceBranch,
2207
+ targetBranch: changeRequests.targetBranch,
2208
+ })
1819
2209
  .from(changeRequests)
1820
2210
  .where(eq(changeRequests.state, 'open'));
1821
2211
  if (open.length === 0) return 0;
@@ -1824,6 +2214,11 @@ export class WorkflowService implements IWorkflowService {
1824
2214
  try {
1825
2215
  const branches = await this.git.listBranches(workspaceIdForBranch(DEFAULT_BRANCH), {
1826
2216
  freshFetch: true,
2217
+ // The list is used to PROVE absence, and the listing's normal
2218
+ // degrade-to-stale behaviour would report every branch created since
2219
+ // the last good fetch as missing — closing live requests. Strict:
2220
+ // a failed fetch throws into the catch below, which closes nothing.
2221
+ strictFetch: true,
1827
2222
  });
1828
2223
  live = new Set(branches.map((b) => b.name));
1829
2224
  } catch (err) {
@@ -1839,7 +2234,15 @@ export class WorkflowService implements IWorkflowService {
1839
2234
 
1840
2235
  let closed = 0;
1841
2236
  for (const cr of open) {
1842
- if (live.has(cr.sourceBranch)) continue;
2237
+ // Either end being gone makes the request unusable — the detail read
2238
+ // resolves the target first, so a missing target fails it just as hard
2239
+ // as a missing source, and no other path ever closes those.
2240
+ const missing = !live.has(cr.sourceBranch)
2241
+ ? cr.sourceBranch
2242
+ : !live.has(cr.targetBranch)
2243
+ ? cr.targetBranch
2244
+ : null;
2245
+ if (!missing) continue;
1843
2246
  const now = new Date();
1844
2247
  // Guarded on `state = 'open'`, so a merge or withdraw racing this call
1845
2248
  // wins and this becomes a no-op.
@@ -1852,13 +2255,45 @@ export class WorkflowService implements IWorkflowService {
1852
2255
  this.prs.invalidateDetailCache(cr.number);
1853
2256
  this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
1854
2257
  console.log(
1855
- `[cr] closed change request #${cr.number}: source branch "${cr.sourceBranch}" no longer exists`,
2258
+ `[cr] closed change request #${cr.number}: branch "${missing}" no longer exists`,
1856
2259
  );
1857
2260
  closed++;
1858
2261
  }
1859
2262
  return closed;
1860
2263
  }
1861
2264
 
2265
+ /**
2266
+ * The sweep above, on demand. Boot runs it once (`create-core-server`),
2267
+ * which clears requests stranded BEFORE the process started; a branch that
2268
+ * goes missing mid-uptime would otherwise keep its requests broken until
2269
+ * the next restart. Verbs that hit a failure which MIGHT mean a missing
2270
+ * branch kick it here.
2271
+ *
2272
+ * Detached, because the kicking verb is already surfacing its own error
2273
+ * and must not hold its response on a fetch. Coalesced, because the kick
2274
+ * sites are user actions that cluster when something is stranded (retry,
2275
+ * decline, delete) and one sweep answers all of them. The sweep proves
2276
+ * absence itself and fails safe by closing nothing, so a spurious kick
2277
+ * costs one fetch and never closes a live request.
2278
+ */
2279
+ private sweepKick: Promise<void> | null = null;
2280
+ private kickDeletedBranchSweep(): void {
2281
+ if (this.sweepKick) return;
2282
+ this.sweepKick = this.closeChangeRequestsWithDeletedBranches()
2283
+ .then((n) => {
2284
+ if (n > 0) {
2285
+ console.log(
2286
+ `[cr] on-demand sweep closed ${n} stranded change request${n === 1 ? '' : 's'}`,
2287
+ );
2288
+ }
2289
+ })
2290
+ .catch((err) => console.warn('[cr] on-demand deleted-branch sweep failed:', err))
2291
+ .finally(() => {
2292
+ this.sweepKick = null;
2293
+ });
2294
+ }
2295
+
2296
+
1862
2297
  /**
1863
2298
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
1864
2299
  * authorized set is:
@@ -1892,15 +2327,26 @@ export class WorkflowService implements IWorkflowService {
1892
2327
  // The path list comes from the PR detail — fetch fresh so the rule
1893
2328
  // applies against the current head (a stale list would let a user
1894
2329
  // reject a CR whose files they no longer all own).
1895
- const detail = await this.prs.getPrDetail(number, {
1896
- fresh: true,
1897
- workspaceId,
1898
- viewerEmail: user.email,
1899
- // Only `files[].path` is read below; a patch per file would be one
1900
- // git subprocess each, generated for nothing, on every reject by a
1901
- // non-author.
1902
- patches: false,
1903
- });
2330
+ let detail;
2331
+ try {
2332
+ detail = await this.prs.getPrDetail(number, {
2333
+ fresh: true,
2334
+ workspaceId,
2335
+ viewerEmail: user.email,
2336
+ // Only `files[].path` is read below; a patch per file would be one
2337
+ // git subprocess each, generated for nothing, on every reject by a
2338
+ // non-author.
2339
+ patches: false,
2340
+ });
2341
+ } catch (err) {
2342
+ // The detail read resolves both branch tips (`resolvePrShas`), so a
2343
+ // request one of whose branches is gone fails HERE, on the verb that
2344
+ // exists to get rid of it. Kick the sweep (which proves the absence
2345
+ // and closes the stranded rows) and surface the failure; see
2346
+ // `kickDeletedBranchSweep`.
2347
+ this.kickDeletedBranchSweep();
2348
+ throw err;
2349
+ }
1904
2350
  const paths = detail?.files.map((f) => f.path) ?? [];
1905
2351
  if (paths.length > 0) {
1906
2352
  const writeMap = await this.accessControl.canWriteBatchAtRef(
@@ -2026,7 +2472,7 @@ export class WorkflowService implements IWorkflowService {
2026
2472
  try {
2027
2473
  const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
2028
2474
  targetWorkspaceId = targetWorkspace.id;
2029
- await this.git.pull(targetWorkspace.id);
2475
+ await this.pullWorkspace(targetWorkspace.id);
2030
2476
  } catch (err) {
2031
2477
  // Still best-effort for the merge response (the merge already landed
2032
2478
  // on origin) — but a rebase CONFLICT here means the target workspace
@@ -2086,17 +2532,12 @@ export class WorkflowService implements IWorkflowService {
2086
2532
  // until retirement finishes (and fails loudly on the missing branch,
2087
2533
  // retryable, instead of losing the branch mid-open).
2088
2534
  await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
2089
- const stillOpen = await this.db
2090
- .select({ number: changeRequests.number })
2091
- .from(changeRequests)
2092
- .where(
2093
- and(
2094
- eq(changeRequests.sourceBranch, sourceBranch),
2095
- eq(changeRequests.state, 'open'),
2096
- ),
2097
- )
2098
- .limit(1);
2099
- if (stillOpen.length > 0) return;
2535
+ // EITHER end (see `openChangeRequestOn`). The request being merged is
2536
+ // already `merged` by now, so it never matches itself; anything left
2537
+ // is a live request that still needs this branch — including one
2538
+ // proposing INTO it, which the source-only question used to miss.
2539
+ const stillOpen = await this.openChangeRequestOn(sourceBranch);
2540
+ if (stillOpen !== null) return;
2100
2541
 
2101
2542
  const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
2102
2543
  // The remote-tracking ref must be current, or deleteBranch's