@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
@@ -21,14 +21,14 @@ import { promises as fs } from 'node:fs';
21
21
  import { execFile } from 'node:child_process';
22
22
  import { promisify } from 'node:util';
23
23
  import { isProtectedBranch, DEFAULT_BRANCH } from '@bevel-software/platform-shared';
24
- import { and, eq } from 'drizzle-orm';
24
+ import { and, eq, or } from 'drizzle-orm';
25
25
  import { changeRequests } from '../database/schema.js';
26
26
  import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
27
27
  import { sanitizeError } from './sanitize-error.js';
28
28
  import { WorkflowHooks } from './workflow-hooks.js';
29
29
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
30
30
  import { hashEmail } from '../../shared/hash-email.js';
31
- import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, WorkflowDomainError, WorkflowValidationError, } from '../../shared/domain-errors.js';
31
+ import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, RemoteBranchGoneError, WorkflowDomainError, WorkflowValidationError, } from '../../shared/domain-errors.js';
32
32
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
33
33
  import { AccessDeniedError } from '../access-model/access-errors.js';
34
34
  const execFileAsync = promisify(execFile);
@@ -58,6 +58,28 @@ function isOpenPairViolation(err) {
58
58
  const message = fieldOf(err, 'message') ?? fieldOf(cause, 'message');
59
59
  return typeof message === 'string' && message.includes(OPEN_PAIR_CONSTRAINT);
60
60
  }
61
+ /**
62
+ * The one sentence a remote-sync conflict is reported with — in the sync
63
+ * response, on the branch's banner and in the log — so whoever sees any of
64
+ * the three reads the same thing: which branch, which files, that automatic
65
+ * recovery is under way, and what to do if it does not clear.
66
+ */
67
+ export function syncConflictMessage(branch, conflictedPaths) {
68
+ // Bounded: the same sentence goes to the response, the banner and the log,
69
+ // and it must be one sentence in all three whatever the conflict's size.
70
+ // The full path list rides beside it as `conflictedPaths`.
71
+ const shown = conflictedPaths.slice(0, SYNC_CONFLICT_FILES_NAMED);
72
+ const more = conflictedPaths.length - shown.length;
73
+ const files = conflictedPaths.length === 0
74
+ ? 'some files'
75
+ : more > 0
76
+ ? `${shown.join(', ')} and ${more} more file${more === 1 ? '' : 's'}`
77
+ : shown.join(', ');
78
+ return (`${branch} is not in sync yet: ${files} changed both in Hexis and on the git host. ` +
79
+ `Recovery is queued; if this stays, open the files on ${branch} in Hexis, keep what you want, and save.`);
80
+ }
81
+ /** How many conflicted files the sync-conflict sentence names before "and N more". */
82
+ const SYNC_CONFLICT_FILES_NAMED = 3;
61
83
  /** Redact the shared GitHub token from any error string before surfacing it. */
62
84
  function redactTokens(msg) {
63
85
  const tokens = [process.env.GITHUB_TOKEN, process.env.GH_TOKEN].filter((t) => !!t && t.length > 0);
@@ -222,7 +244,12 @@ export class WorkflowService {
222
244
  return branches;
223
245
  }
224
246
  createBranch(workspaceId, name, fromBase) {
225
- return this.git.createBranch(workspaceId, name, fromBase);
247
+ // Under the branch's lifecycle lock, like `deleteBranch` and the
248
+ // retirement of a clone the host deleted: creating a branch is the one
249
+ // operation that can bring a "gone" branch back, and the retirement's
250
+ // "still gone on origin?" check is only a guarantee if nothing can
251
+ // recreate the branch between that check and the delete.
252
+ return this.branchLifecycle.run(`branch:${name}`, () => this.git.createBranch(workspaceId, name, fromBase));
226
253
  }
227
254
  /**
228
255
  * Serialises everything that decides a branch's fate — opening a change
@@ -234,6 +261,71 @@ export class WorkflowService {
234
261
  * one server process owns the workspaces directory.
235
262
  */
236
263
  branchLifecycle = new WorkspaceMutex();
264
+ /**
265
+ * Hold the lifecycle lock of BOTH branches a change request names, so a
266
+ * check-then-insert spanning them excludes either one being deleted.
267
+ *
268
+ * ATOMICALLY, via `runAll` — not `run(a, () => run(b, …))`. Taking the keys
269
+ * one at a time leaves a gap between them in which a delete of the second
270
+ * branch can start, pass its "is any open request using this?" check
271
+ * (the row does not exist yet) and finish, so the request lands pointing at
272
+ * a branch that was removed while it was being opened. That is the same
273
+ * failure this PR is about, reintroduced one level down — and it would have
274
+ * been a REGRESSION on the source branch, which the previous single-key
275
+ * lock held for the whole sequence.
276
+ *
277
+ * Reserving the set in one step also removes the deadlock question that
278
+ * sequential acquisition raises: two requests may legally be open in
279
+ * opposite directions (`A -> B` and `B -> A`), and nothing is ever held
280
+ * while waiting for the other key.
281
+ *
282
+ * A PROTECTED target's key is NOT reserved. No path in the system can
283
+ * delete a protected branch (`git.deleteBranch` throws
284
+ * `ProtectedBranchError` before any lock, `retireMergedSourceBranch`
285
+ * returns early on one), so the key would exclude nothing. It would only
286
+ * cost: nearly every request targets the protected default branch, so one
287
+ * shared key there would serialize every open on the hottest path for the
288
+ * duration of a fetch + merge + push + diff. The source needs no such
289
+ * carve-out; `openChangeRequest` refuses protected sources before locking.
290
+ */
291
+ runOnBranchPair(source, target, fn) {
292
+ const keys = [`branch:${source}`];
293
+ if (!isProtectedBranch(target))
294
+ keys.push(`branch:${target}`);
295
+ return this.branchLifecycle.runAll(keys, fn);
296
+ }
297
+ /**
298
+ * The open change requests that deleting `branch` would strand — every one
299
+ * that NAMES it, at EITHER end.
300
+ *
301
+ * Both ends, not just the source. A change request is a proposal to move
302
+ * commits FROM one branch INTO another and it needs both to exist: the
303
+ * detail read resolves the target BEFORE the source, so a request whose
304
+ * target has been deleted cannot be read, declined, or deleted either. It
305
+ * is also the worse half to lose — a stranded source is closed by the boot
306
+ * sweep, while a stranded target used to survive every restart, because
307
+ * every guard here asked only "is this branch anybody's SOURCE?".
308
+ *
309
+ * That question is what let a merge retire a branch a second, still-open
310
+ * request was pointing INTO: the pair `X -> B` (merged, B retired) and
311
+ * `Y -> X` (open, now unusable forever).
312
+ *
313
+ * Returns the first such request's number and which end of it names the
314
+ * branch, or null when the branch is free. The end matters to the caller's
315
+ * error message: "withdraw your request" is only actionable when the asker
316
+ * owns the request, and a request proposing INTO the branch is someone
317
+ * else's.
318
+ */
319
+ async openChangeRequestOn(branch) {
320
+ const [row] = await this.db
321
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
322
+ .from(changeRequests)
323
+ .where(and(or(eq(changeRequests.sourceBranch, branch), eq(changeRequests.targetBranch, branch)), eq(changeRequests.state, 'open')))
324
+ .limit(1);
325
+ if (!row)
326
+ return null;
327
+ return { number: row.number, end: row.sourceBranch === branch ? 'source' : 'target' };
328
+ }
237
329
  async deleteBranch(workspaceId, name, user, opts) {
238
330
  return this.branchLifecycle.run(`branch:${name}`, async () => {
239
331
  // A branch backing an OPEN change request is load-bearing: deleting it
@@ -243,13 +335,18 @@ export class WorkflowService {
243
335
  // its own open-request check under this same lock, and the legacy
244
336
  // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
245
337
  if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
246
- const open = await this.db
247
- .select({ number: changeRequests.number })
248
- .from(changeRequests)
249
- .where(and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')))
250
- .limit(1);
251
- if (open.length > 0) {
252
- throw new WorkflowValidationError(`Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`);
338
+ const open = await this.openChangeRequestOn(name);
339
+ if (open !== null) {
340
+ // Two different situations, two different actors. A request FROM
341
+ // this branch is the deleter's own to withdraw; a request INTO it
342
+ // belongs to someone else, and telling the branch owner to
343
+ // "withdraw" it points them at a verb they cannot use.
344
+ throw new WorkflowValidationError(open.end === 'source'
345
+ ? `Branch "${name}" has an open change request (#${open.number}). ` +
346
+ `Withdraw or decline it before deleting the branch.`
347
+ : `Open change request #${open.number} proposes changes into "${name}". ` +
348
+ `Its author can withdraw it, or an admin can decline it; ` +
349
+ `after that the branch can be deleted.`);
253
350
  }
254
351
  }
255
352
  await this.deleteBranchUnlocked(workspaceId, name, user, opts);
@@ -291,9 +388,40 @@ export class WorkflowService {
291
388
  refreshRemotes(workspaceId) {
292
389
  return this.git.fetch(workspaceId);
293
390
  }
391
+ /**
392
+ * `git pull` on a workspace, plus the one announcement a pull owes the rest
393
+ * of the process.
394
+ *
395
+ * A pull rewrites a working tree without going through the write routes or
396
+ * the commit pipeline, so it emits neither of the signals those produce —
397
+ * and the catalogs that scan the DEFAULT branch's tree (skills, tool
398
+ * manuals, the plugin index) are keyed to exactly that tree. Every path that
399
+ * pulls the default workspace therefore has to say so, or those catalogs
400
+ * keep serving the pre-pull scan for the rest of their TTL: a merge landing
401
+ * an approved skill, the recovery ladder after a non-fast-forward push, and
402
+ * a user's update-from-remote alike. Routing every pull through here makes
403
+ * that ONE mechanism — "the default branch's tree changed" — instead of a
404
+ * separate special case per event. (The remote sync, `syncWorkspaceFromRemote`,
405
+ * pulls directly: it owes the same announcement to every branch and per
406
+ * file, and makes it from the same `treeChanged` verdict.)
407
+ *
408
+ * Announced only after the pull RESOLVES, and only when it CHANGED the
409
+ * tree's content: a pull that threw left the tree as it was, and so did an
410
+ * "already up to date" pull or one that landed only content-identical
411
+ * commits — dropping every catalog and making every attached browser
412
+ * refetch its file tree for a change that did not happen buys a re-scan
413
+ * and nothing else. The pull itself reports the change, since it is the
414
+ * only code that can observe it under the workspace mutex.
415
+ */
416
+ async pullWorkspace(workspaceId) {
417
+ const { treeChanged } = await this.git.pull(workspaceId);
418
+ if (treeChanged && branchForWorkspaceId(workspaceId) === DEFAULT_BRANCH) {
419
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: DEFAULT_BRANCH });
420
+ }
421
+ }
294
422
  async updateFromRemote(workspaceId, user) {
295
423
  try {
296
- await this.git.pull(workspaceId);
424
+ await this.pullWorkspace(workspaceId);
297
425
  }
298
426
  catch (err) {
299
427
  if (err instanceof PullRebaseConflictError) {
@@ -302,6 +430,158 @@ export class WorkflowService {
302
430
  throw err;
303
431
  }
304
432
  }
433
+ async retireRemoteGoneClone(workspaceId) {
434
+ const branch = branchForWorkspaceId(workspaceId);
435
+ const id = workspaceIdForBranch(branch);
436
+ // The lock `createBranch` and `deleteBranch` take: no Hexis operation can
437
+ // bring this branch back into being while the clone is examined and
438
+ // removed. The sync's own hold on the clone ended when its outcome was
439
+ // returned, so the branch is asked about AGAIN here rather than trusted
440
+ // from then — a recreate that landed in between keeps its clone. A clone
441
+ // being bootstrapped right now is left alone too: it can only be cloning
442
+ // a branch that exists, so the retirement is already moot. What remains
443
+ // is a recreate pushed from OUTSIDE Hexis in the milliseconds between the
444
+ // origin check and the delete, with a bootstrap starting in that same
445
+ // window; the next sync of that branch clones it fresh.
446
+ return this.branchLifecycle.run(`branch:${branch}`, async () => {
447
+ if (!(await this.workspaceService.hasBootstrappedWorkspace(id)))
448
+ return false;
449
+ if (this.workspaceService.isBootstrapInFlight(branch))
450
+ return false;
451
+ if (await this.git.remoteBranchExists(branch, branch))
452
+ return false;
453
+ await this.workspaceService.deleteWorkspace(id);
454
+ // Whatever a sync still owed that clone is owed to nothing now.
455
+ this.owedAnnouncements.delete(id);
456
+ return true;
457
+ });
458
+ }
459
+ /**
460
+ * Cap on per-path `file-changed` events one sync announces for a branch.
461
+ * Past it the tree event alone carries the news — a bulk import that
462
+ * rewrote hundreds of files would otherwise fan hundreds of events out to
463
+ * every open browser to say what one refresh says.
464
+ */
465
+ static SYNC_FILE_EVENT_CAP = 200;
466
+ /**
467
+ * Announcements a sync still OWES, per workspace: a pull landed but the
468
+ * events for it could not be sent. The pull is not redone on retry — HEAD
469
+ * has not moved since — so the announcement cannot be re-derived from sha
470
+ * movement; it is remembered here and delivered by the next sync of that
471
+ * workspace, merged with whatever that sync finds.
472
+ */
473
+ owedAnnouncements = new Map();
474
+ async syncWorkspaceFromRemote(workspaceId) {
475
+ const branch = branchForWorkspaceId(workspaceId);
476
+ const id = workspaceIdForBranch(branch);
477
+ // Two spellings of one workspace id are in circulation — the encoded
478
+ // directory name (`ali%2Fx`, what the clone listing yields) and the
479
+ // Express-decoded form (`ali/x`, what every HTTP route hands the git
480
+ // layer) — and the git layer's mutex keys on the raw string. Speaking the
481
+ // routes' spelling to git is what puts a webhook's rebase in the same
482
+ // queue as the author's concurrent save on a slashed branch; the event
483
+ // bus gets the encoded form, which every SSE consumer canonicalises.
484
+ const gitId = branch;
485
+ // Every failure below is an OUTCOME, never a throw: the caller runs this
486
+ // over many clones, and one broken clone must not abort the others.
487
+ let pulled;
488
+ try {
489
+ pulled = await this.git.syncFromRemote(gitId);
490
+ }
491
+ catch (err) {
492
+ if (err instanceof RemoteBranchGoneError) {
493
+ // Not a failure: the host deleted the branch, so there is nothing to
494
+ // sync and the caller retires the stale clone. No banner — nobody is
495
+ // editing a branch that no longer exists. An announcement still owed
496
+ // for that clone dies with it: a branch recreated under the same name
497
+ // is a different tree, and replaying the old paths against it would
498
+ // announce changes that never happened there.
499
+ this.owedAnnouncements.delete(id);
500
+ console.log(`[sync] branch "${branch}" no longer exists on the host`);
501
+ return { branch, outcome: 'remote-gone' };
502
+ }
503
+ if (err instanceof PullRebaseConflictError) {
504
+ // Same path as `updateFromRemote`: the rebase was aborted inside
505
+ // the pull (the clone is exactly what it was) and the divergence goes
506
+ // to the retry → recovery-agent → escalate ladder, attributed to the
507
+ // recovery bot since no person triggered this. It is still reported
508
+ // as a conflict because it is not resolved at request time — the
509
+ // caller's retry, or the ladder, settles it; the banner clears on the
510
+ // next clean pull or push.
511
+ await this.queuePullConflictRecovery(gitId, err);
512
+ const message = syncConflictMessage(branch, err.conflictedPaths);
513
+ console.warn(`[sync] ${message}`);
514
+ this.noteGitSyncFailed(gitId, branch, err, { paths: err.conflictedPaths, message });
515
+ return { branch, outcome: 'conflict', conflictedPaths: err.conflictedPaths, error: message };
516
+ }
517
+ const message = sanitizeError(err);
518
+ console.warn(`[sync] pull failed for branch "${branch}": ${message}`);
519
+ this.noteGitSyncFailed(gitId, branch, err);
520
+ return { branch, outcome: 'error', error: message };
521
+ }
522
+ // A clean pull is proof origin is reachable and this clone rebases onto
523
+ // it — the condition a `git-sync-failed` banner on this branch was
524
+ // waiting for, whether a sync or a push raised it.
525
+ this.noteGitSyncOk(gitId, branch);
526
+ const { before, after, treeChanged, changedPaths } = pulled;
527
+ // What this branch reports about ITS clone is decided by the pull alone:
528
+ // `updated` when HEAD moved, `up-to-date` otherwise.
529
+ const outcome = after === null || after === before
530
+ ? { branch, outcome: 'up-to-date', to: after ?? '' }
531
+ : { branch, outcome: 'updated', from: before, to: after };
532
+ // What it ANNOUNCES is what has changed since the last announcement that
533
+ // was actually delivered: this pull's content change, if any, plus one an
534
+ // earlier sync landed but could not send. The announcement keys on the
535
+ // pull's verdict about CONTENT — commits that left the tree identical are
536
+ // nothing for a browser to refetch or a catalog to drop.
537
+ const owed = this.owedAnnouncements.get(id);
538
+ if (!treeChanged && !owed)
539
+ return outcome;
540
+ const announce = {
541
+ after: after ?? owed?.after ?? '',
542
+ changedPaths: [...new Set([...(owed?.changedPaths ?? []), ...changedPaths])],
543
+ };
544
+ try {
545
+ // Announce the new tree. The tree event alone already refreshes every
546
+ // open file explorer and, on the default branch, drops the skill / tool /
547
+ // plugin catalogues (`catalog-cache-invalidation.ts`); the CR list
548
+ // caches the touched paths it resolved against the old tree, so drop
549
+ // that too.
550
+ this.prs.invalidateListCache();
551
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: id, branch });
552
+ // Per-file events so an open tab on a file the host rewrote refetches
553
+ // instead of showing stale bytes until its next tree refresh. The paths
554
+ // were observed under the same hold as the pull, so they are exactly
555
+ // this sync's — never a save that landed alongside. Capped: a bulk
556
+ // import would otherwise fan hundreds of events out to say what one
557
+ // refresh says.
558
+ if (announce.changedPaths.length <= WorkflowService.SYNC_FILE_EVENT_CAP) {
559
+ for (const p of announce.changedPaths) {
560
+ this.events?.emit({
561
+ kind: 'file-changed',
562
+ workspaceId: id,
563
+ branch,
564
+ path: `${this.kbDirName}/${p}`,
565
+ newSha: announce.after,
566
+ byUserId: 'system',
567
+ byUserName: 'Git sync',
568
+ });
569
+ }
570
+ }
571
+ }
572
+ catch (err) {
573
+ // The pull landed, so origin is fine and no banner is raised — but the
574
+ // caller must hear that this branch's announcement did not happen, and
575
+ // the announcement stays owed: the retry the error invites will not
576
+ // move HEAD again, so it must find the debt here rather than in the shas.
577
+ this.owedAnnouncements.set(id, announce);
578
+ const message = sanitizeError(err);
579
+ console.warn(`[sync] pulled "${branch}" but could not announce it: ${message}`);
580
+ return { branch, outcome: 'error', error: message };
581
+ }
582
+ this.owedAnnouncements.delete(id);
583
+ return outcome;
584
+ }
305
585
  /**
306
586
  * A pull hit a rebase conflict — the workspace carries local commits that
307
587
  * conflict with origin, a state that never resolves on its own (every
@@ -549,7 +829,7 @@ export class WorkflowService {
549
829
  let recoveryError = null;
550
830
  if (looksLikeNonFastForward) {
551
831
  try {
552
- await this.git.pull(workspaceId);
832
+ await this.pullWorkspace(workspaceId);
553
833
  await this.git.push(workspaceId, user);
554
834
  recovered = true;
555
835
  this.noteGitSyncOk(workspaceId, branch);
@@ -760,7 +1040,29 @@ export class WorkflowService {
760
1040
  * machinery is unaffected — all this adds is that someone finds out, rather
761
1041
  * than the deployment looking healthy while nothing reaches the remote.
762
1042
  */
763
- noteGitSyncFailed(workspaceId, branch, err) {
1043
+ noteGitSyncFailed(workspaceId, branch, err,
1044
+ /**
1045
+ * The files a person must reconcile, and the sentence we composed about
1046
+ * them. That sentence is ours (branch name + repo paths, no git stderr),
1047
+ * so it goes out verbatim — the same string the sync response and the
1048
+ * log carry — rather than through the sanitiser, whose 200-character
1049
+ * clip would make the banner disagree with them. Callers that already
1050
+ * hold the sentence pass it; otherwise it is derived below.
1051
+ */
1052
+ explicitConflict) {
1053
+ // A rebase conflict is a conflict whichever path ran into it. The remote
1054
+ // sync names its files on purpose; the push-recovery paths (autosave, the
1055
+ // lock release, the queued retries) raise the same banner with the raw
1056
+ // error — and the banner keeps the LATEST failure per branch, so a retry
1057
+ // landing after the sync would replace the conflict variant, with the
1058
+ // files as links, by the generic "check the server logs" one. Seen on
1059
+ // staging: the sync's 409 named the file, the worker's retry a moment
1060
+ // later took the links away. Derive the paths from the error itself so
1061
+ // every path agrees.
1062
+ const conflict = explicitConflict ??
1063
+ (err instanceof PullRebaseConflictError
1064
+ ? { paths: err.conflictedPaths, message: syncConflictMessage(branch, err.conflictedPaths) }
1065
+ : undefined);
764
1066
  // Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
765
1067
  const id = branchForWorkspaceId(workspaceId);
766
1068
  this.gitSyncFailing.add(id);
@@ -770,7 +1072,8 @@ export class WorkflowService {
770
1072
  branch,
771
1073
  // Git stderr can quote a credentialed URL; the banner is user-facing and
772
1074
  // the string also lands in client logs, so sanitize before it leaves.
773
- reason: sanitizeError(err),
1075
+ reason: conflict ? conflict.message : sanitizeError(err),
1076
+ ...(conflict ? { conflictedPaths: conflict.paths } : {}),
774
1077
  });
775
1078
  }
776
1079
  /**
@@ -819,7 +1122,7 @@ export class WorkflowService {
819
1122
  let recoveryError = null;
820
1123
  if (looksLikeNonFastForward) {
821
1124
  try {
822
- await this.git.pull(workspaceId);
1125
+ await this.pullWorkspace(workspaceId);
823
1126
  await this.git.push(workspaceId, user, opts);
824
1127
  recovered = true;
825
1128
  this.noteGitSyncOk(workspaceId, branch);
@@ -1019,12 +1322,17 @@ export class WorkflowService {
1019
1322
  throw new WorkflowValidationError(`Cannot open a change request *from* a protected branch ("${input.sourceBranch}").`, { kind: 'source-is-protected', sourceBranch: input.sourceBranch });
1020
1323
  }
1021
1324
  // The whole open sequence — uniqueness check through row insert — runs
1022
- // under the source branch's lifecycle lock. `retireMergedSourceBranch`
1023
- // takes the same lock around its own check-then-delete, so a merge
1024
- // cleanup can never race into the multi-second gap between this
1025
- // method's "no open request" answer and its row landing, and delete the
1026
- // branch out from under a request being opened.
1027
- return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
1325
+ // under the lifecycle lock of BOTH branches. `retireMergedSourceBranch`
1326
+ // and `deleteBranch` take the lock of the branch they are removing around
1327
+ // their own check-then-delete, so a cleanup can never race into the
1328
+ // multi-second gap between this method's "no open request" answer and its
1329
+ // row landing, and delete a branch out from under a request being opened.
1330
+ //
1331
+ // BOTH, because that gap is symmetric: a request is unusable if EITHER of
1332
+ // its branches is retired inside the window, and holding only the source
1333
+ // left a `Y -> X` request open to X being deleted under it — the very
1334
+ // failure the widened guards exist to prevent.
1335
+ return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
1028
1336
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
1029
1337
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
1030
1338
  // force-fresh to catch a CR that opened just now.
@@ -1236,7 +1544,7 @@ export class WorkflowService {
1236
1544
  // refs, but the restore commits from the working tree — a stale tree
1237
1545
  // would push non-fast-forward and fail loudly anyway; this just makes
1238
1546
  // that rare.
1239
- await this.git.pull(ws.id).catch(() => undefined);
1547
+ await this.pullWorkspace(ws.id).catch(() => undefined);
1240
1548
  // The verb acts on the request as it is NOW — never a cached file list.
1241
1549
  const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1242
1550
  if (!paths.includes(repoRelPath)) {
@@ -1292,9 +1600,47 @@ export class WorkflowService {
1292
1600
  if (summary.state === 'merged') {
1293
1601
  throw new WorkflowDomainError('This change request has already been applied.', 422);
1294
1602
  }
1295
- const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1296
- await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
1297
- const isAdmin = await this.accessControl.canWriteAtRef(ws.id, `origin/${summary.base}`, user.email, 'roles.yaml');
1603
+ // Which `roles.yaml` authorizes this? The base branch's, exactly as every
1604
+ // other admin check here. When the base branch itself is gone, this
1605
+ // bootstrap (or the at-ref check below) fails; that failure is NOT proven
1606
+ // absence, so it never re-keys authorization to some other branch. It
1607
+ // kicks the deleted-branch sweep instead: the one mechanism that proves a
1608
+ // branch is missing (fresh fetch, then a set lookup) and closes every
1609
+ // request stranded by it, this one included. This call still fails; by
1610
+ // the next attempt the sweep has either closed the row (nothing left to
1611
+ // delete) or shown the base alive, meaning the failure was transient and
1612
+ // a retry can succeed.
1613
+ let ws;
1614
+ let isAdmin;
1615
+ try {
1616
+ ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1617
+ // STRICT: the admin check below reads roles.yaml at `origin/<base>`,
1618
+ // and on a swallowed fetch failure that ref is whatever the clone last
1619
+ // saw — a user whose rights were since revoked would still pass
1620
+ // against the stale tree. Authorization never runs against refs of
1621
+ // unknown age.
1622
+ try {
1623
+ await this.workspaceService.ensureRemotesFetched(ws.id, { strict: true });
1624
+ }
1625
+ catch {
1626
+ throw new WorkflowDomainError(`Could not verify the base branch "${summary.base}" — origin was unreachable. Retry in a moment.`, 409);
1627
+ }
1628
+ isAdmin = await this.accessControl.canWriteAtRef(ws.id, `origin/${summary.base}`, user.email, 'roles.yaml');
1629
+ }
1630
+ catch (err) {
1631
+ this.kickDeletedBranchSweep();
1632
+ throw err;
1633
+ }
1634
+ // null is "the ref did not resolve", not "denied" — and saying "only an
1635
+ // admin can do this" to an admin whose base branch is merely unfetchable
1636
+ // sent people chasing their role instead of the branch. Kick the sweep
1637
+ // (it proves absence itself, with a strict fetch, and closes the
1638
+ // stranded rows) and fail with the honest condition; by the next
1639
+ // attempt the sweep has either closed the row or shown the base alive.
1640
+ if (isAdmin === null) {
1641
+ this.kickDeletedBranchSweep();
1642
+ throw new WorkflowDomainError(`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.`, 409);
1643
+ }
1298
1644
  if (isAdmin !== true) {
1299
1645
  throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
1300
1646
  }
@@ -1364,17 +1710,23 @@ export class WorkflowService {
1364
1710
  return true;
1365
1711
  }
1366
1712
  /**
1367
- * Close every open change request whose source branch no longer exists.
1713
+ * Close every open change request either of whose branches no longer exists.
1368
1714
  *
1369
- * A change request is a proposal to merge a branch. When that branch is
1370
- * gone — deleted after a manual merge, pruned as abandoned, or left behind
1371
- * by a feature that has itself been removed — the request cannot be read,
1372
- * reviewed, applied or declined. It is not a pending decision, it is a
1373
- * tombstone, and it costs more than tidiness: `listOpenPrs` resolves each
1374
- * open request's changed paths, so every one of these throws
1715
+ * A change request is a proposal to merge one branch into another, so it
1716
+ * needs BOTH. When either is gone — deleted after a manual merge, pruned as
1717
+ * abandoned, or left behind by a feature that has itself been removed — the
1718
+ * request cannot be read, reviewed, applied or declined. It is not a pending
1719
+ * decision, it is a tombstone, and it costs more than tidiness: `listOpenPrs`
1720
+ * resolves each open request's changed paths, so every one of these throws
1375
1721
  * `unknown branch` on every poll, filling the log and slowing the list it
1376
1722
  * appears in.
1377
1723
  *
1724
+ * The TARGET half is the one that used to be missed, and it was the worse
1725
+ * half: a missing source could only ever arrive by a path that also closed
1726
+ * the request, while a request whose target had been retired under it was
1727
+ * unreadable, undeclinable, undeletable — and invisible here, so it survived
1728
+ * every restart.
1729
+ *
1378
1730
  * CLOSED, NOT DELETED. The row is evidence — who proposed what, when, and
1379
1731
  * that it was never applied — and it is the only remaining trace once the
1380
1732
  * branch is gone. Closing takes it out of every open list (which is the
@@ -1383,7 +1735,7 @@ export class WorkflowService {
1383
1735
  * operation in the workflow that destroys history without a human asking.
1384
1736
  *
1385
1737
  * ONE fetch, then a set lookup. The freshness matters more than the cost:
1386
- * `changedPathsForPr` fails when the branch is missing from THAT
1738
+ * `changedPathsForPr` fails when a branch is missing from THAT
1387
1739
  * workspace's refs, which is not the same thing as missing from origin —
1388
1740
  * a clone that never fetched a branch someone else created reports exactly
1389
1741
  * the same error. Closing on that signal would eat live requests. So the
@@ -1399,7 +1751,11 @@ export class WorkflowService {
1399
1751
  */
1400
1752
  async closeChangeRequestsWithDeletedBranches() {
1401
1753
  const open = await this.db
1402
- .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
1754
+ .select({
1755
+ number: changeRequests.number,
1756
+ sourceBranch: changeRequests.sourceBranch,
1757
+ targetBranch: changeRequests.targetBranch,
1758
+ })
1403
1759
  .from(changeRequests)
1404
1760
  .where(eq(changeRequests.state, 'open'));
1405
1761
  if (open.length === 0)
@@ -1408,6 +1764,11 @@ export class WorkflowService {
1408
1764
  try {
1409
1765
  const branches = await this.git.listBranches(workspaceIdForBranch(DEFAULT_BRANCH), {
1410
1766
  freshFetch: true,
1767
+ // The list is used to PROVE absence, and the listing's normal
1768
+ // degrade-to-stale behaviour would report every branch created since
1769
+ // the last good fetch as missing — closing live requests. Strict:
1770
+ // a failed fetch throws into the catch below, which closes nothing.
1771
+ strictFetch: true,
1411
1772
  });
1412
1773
  live = new Set(branches.map((b) => b.name));
1413
1774
  }
@@ -1423,7 +1784,15 @@ export class WorkflowService {
1423
1784
  }
1424
1785
  let closed = 0;
1425
1786
  for (const cr of open) {
1426
- if (live.has(cr.sourceBranch))
1787
+ // Either end being gone makes the request unusable — the detail read
1788
+ // resolves the target first, so a missing target fails it just as hard
1789
+ // as a missing source, and no other path ever closes those.
1790
+ const missing = !live.has(cr.sourceBranch)
1791
+ ? cr.sourceBranch
1792
+ : !live.has(cr.targetBranch)
1793
+ ? cr.targetBranch
1794
+ : null;
1795
+ if (!missing)
1427
1796
  continue;
1428
1797
  const now = new Date();
1429
1798
  // Guarded on `state = 'open'`, so a merge or withdraw racing this call
@@ -1437,11 +1806,40 @@ export class WorkflowService {
1437
1806
  continue;
1438
1807
  this.prs.invalidateDetailCache(cr.number);
1439
1808
  this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
1440
- console.log(`[cr] closed change request #${cr.number}: source branch "${cr.sourceBranch}" no longer exists`);
1809
+ console.log(`[cr] closed change request #${cr.number}: branch "${missing}" no longer exists`);
1441
1810
  closed++;
1442
1811
  }
1443
1812
  return closed;
1444
1813
  }
1814
+ /**
1815
+ * The sweep above, on demand. Boot runs it once (`create-core-server`),
1816
+ * which clears requests stranded BEFORE the process started; a branch that
1817
+ * goes missing mid-uptime would otherwise keep its requests broken until
1818
+ * the next restart. Verbs that hit a failure which MIGHT mean a missing
1819
+ * branch kick it here.
1820
+ *
1821
+ * Detached, because the kicking verb is already surfacing its own error
1822
+ * and must not hold its response on a fetch. Coalesced, because the kick
1823
+ * sites are user actions that cluster when something is stranded (retry,
1824
+ * decline, delete) and one sweep answers all of them. The sweep proves
1825
+ * absence itself and fails safe by closing nothing, so a spurious kick
1826
+ * costs one fetch and never closes a live request.
1827
+ */
1828
+ sweepKick = null;
1829
+ kickDeletedBranchSweep() {
1830
+ if (this.sweepKick)
1831
+ return;
1832
+ this.sweepKick = this.closeChangeRequestsWithDeletedBranches()
1833
+ .then((n) => {
1834
+ if (n > 0) {
1835
+ console.log(`[cr] on-demand sweep closed ${n} stranded change request${n === 1 ? '' : 's'}`);
1836
+ }
1837
+ })
1838
+ .catch((err) => console.warn('[cr] on-demand deleted-branch sweep failed:', err))
1839
+ .finally(() => {
1840
+ this.sweepKick = null;
1841
+ });
1842
+ }
1445
1843
  /**
1446
1844
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
1447
1845
  * authorized set is:
@@ -1467,15 +1865,27 @@ export class WorkflowService {
1467
1865
  // The path list comes from the PR detail — fetch fresh so the rule
1468
1866
  // applies against the current head (a stale list would let a user
1469
1867
  // reject a CR whose files they no longer all own).
1470
- const detail = await this.prs.getPrDetail(number, {
1471
- fresh: true,
1472
- workspaceId,
1473
- viewerEmail: user.email,
1474
- // Only `files[].path` is read below; a patch per file would be one
1475
- // git subprocess each, generated for nothing, on every reject by a
1476
- // non-author.
1477
- patches: false,
1478
- });
1868
+ let detail;
1869
+ try {
1870
+ detail = await this.prs.getPrDetail(number, {
1871
+ fresh: true,
1872
+ workspaceId,
1873
+ viewerEmail: user.email,
1874
+ // Only `files[].path` is read below; a patch per file would be one
1875
+ // git subprocess each, generated for nothing, on every reject by a
1876
+ // non-author.
1877
+ patches: false,
1878
+ });
1879
+ }
1880
+ catch (err) {
1881
+ // The detail read resolves both branch tips (`resolvePrShas`), so a
1882
+ // request one of whose branches is gone fails HERE, on the verb that
1883
+ // exists to get rid of it. Kick the sweep (which proves the absence
1884
+ // and closes the stranded rows) and surface the failure; see
1885
+ // `kickDeletedBranchSweep`.
1886
+ this.kickDeletedBranchSweep();
1887
+ throw err;
1888
+ }
1479
1889
  const paths = detail?.files.map((f) => f.path) ?? [];
1480
1890
  if (paths.length > 0) {
1481
1891
  const writeMap = await this.accessControl.canWriteBatchAtRef(workspaceId, `origin/${baseBranch}`, user.email, paths);
@@ -1563,7 +1973,7 @@ export class WorkflowService {
1563
1973
  try {
1564
1974
  const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
1565
1975
  targetWorkspaceId = targetWorkspace.id;
1566
- await this.git.pull(targetWorkspace.id);
1976
+ await this.pullWorkspace(targetWorkspace.id);
1567
1977
  }
1568
1978
  catch (err) {
1569
1979
  // Still best-effort for the merge response (the merge already landed
@@ -1617,12 +2027,12 @@ export class WorkflowService {
1617
2027
  // until retirement finishes (and fails loudly on the missing branch,
1618
2028
  // retryable, instead of losing the branch mid-open).
1619
2029
  await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
1620
- const stillOpen = await this.db
1621
- .select({ number: changeRequests.number })
1622
- .from(changeRequests)
1623
- .where(and(eq(changeRequests.sourceBranch, sourceBranch), eq(changeRequests.state, 'open')))
1624
- .limit(1);
1625
- if (stillOpen.length > 0)
2030
+ // EITHER end (see `openChangeRequestOn`). The request being merged is
2031
+ // already `merged` by now, so it never matches itself; anything left
2032
+ // is a live request that still needs this branch — including one
2033
+ // proposing INTO it, which the source-only question used to miss.
2034
+ const stillOpen = await this.openChangeRequestOn(sourceBranch);
2035
+ if (stillOpen !== null)
1626
2036
  return;
1627
2037
  const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
1628
2038
  // The remote-tracking ref must be current, or deleteBranch's