@bevel-software/platform-core-backend 0.4.0 → 0.5.0

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 (305) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +157 -54
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +97 -5
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +393 -114
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
  259. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  260. package/src/modules/workflow/event-bus.ts +25 -0
  261. package/src/modules/workflow/file-lock.service.ts +17 -0
  262. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  263. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  264. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  265. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  266. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  267. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  268. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
  269. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  270. package/src/modules/workflow/git/branch-name.ts +5 -1
  271. package/src/modules/workflow/git/git.service.ts +172 -64
  272. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  273. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  274. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  275. package/src/modules/workflow/workflow.routes.ts +91 -13
  276. package/src/modules/workflow/workflow.service.ts +365 -19
  277. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  278. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  279. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  280. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  281. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  283. package/src/modules/workspace/bevel-ignore.ts +66 -62
  284. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  285. package/src/modules/workspace/kb-seed.service.ts +247 -53
  286. package/src/modules/workspace/workspace.routes.ts +54 -18
  287. package/src/modules/workspace/workspace.service.ts +45 -7
  288. package/src/modules/workspace/workspace.tools.ts +922 -917
  289. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  290. package/src/shared/__tests__/join-request.test.ts +96 -0
  291. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  292. package/src/shared/kb-layout.test.ts +2 -2
  293. package/src/test-setup.ts +18 -0
  294. package/kb-template/Agents/README.md +0 -38
  295. package/kb-template/CLAUDE.md +0 -419
  296. package/kb-template/Data/.gitkeep +0 -0
  297. package/kb-template/Data/README.md +0 -39
  298. package/kb-template/KnowledgeBase/README.md +0 -37
  299. package/kb-template/Pipelines/.gitkeep +0 -0
  300. package/kb-template/Pipelines/README.md +0 -38
  301. package/kb-template/Skills/.gitkeep +0 -0
  302. package/kb-template/Skills/README.md +0 -32
  303. package/kb-template/Tools/.gitkeep +0 -0
  304. package/kb-template/Tools/README.md +0 -42
  305. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -23,9 +23,11 @@ import { promisify } from 'node:util';
23
23
  import { isProtectedBranch } from '@bevel-software/platform-shared';
24
24
  import { and, eq } from 'drizzle-orm';
25
25
  import { changeRequests } from '../database/schema.js';
26
+ import { workspaceIdForBranch } from '../workspace/workspace.service.js';
26
27
  import { WorkflowHooks } from './workflow-hooks.js';
28
+ import { WorkspaceMutex } from './git/mutex.js';
27
29
  import { hashEmail } from '../../shared/hash-email.js';
28
- import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, WorkflowValidationError, } from './workflow.errors.js';
30
+ import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, WorkflowDomainError, WorkflowValidationError, } from './workflow.errors.js';
29
31
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
30
32
  import { AccessDeniedError } from '../access/access-errors.js';
31
33
  const execFileAsync = promisify(execFile);
@@ -221,8 +223,53 @@ export class WorkflowService {
221
223
  createBranch(workspaceId, name, fromBase) {
222
224
  return this.git.createBranch(workspaceId, name, fromBase);
223
225
  }
224
- deleteBranch(workspaceId, name, user, opts) {
225
- return this.git.deleteBranch(workspaceId, name, user, opts);
226
+ /**
227
+ * Serialises everything that decides a branch's fate — opening a change
228
+ * request from it, deleting it, retiring it after a merge — by branch
229
+ * name. Without it, `retireMergedSourceBranch`'s check-then-delete could
230
+ * interleave with `openChangeRequest`'s check-then-insert (which spans a
231
+ * multi-second auto-merge and push) and delete a branch a request was
232
+ * being opened from. In-process only, which matches the deployment model:
233
+ * one server process owns the workspaces directory.
234
+ */
235
+ branchLifecycle = new WorkspaceMutex();
236
+ async deleteBranch(workspaceId, name, user, opts) {
237
+ return this.branchLifecycle.run(`branch:${name}`, async () => {
238
+ // A branch backing an OPEN change request is load-bearing: deleting it
239
+ // strands the request (and, in the propose flow's reset-on-reuse, would
240
+ // let one tab wipe the branch another tab just proposed from). The
241
+ // request has to be withdrawn or declined first. `systemCleanup` does
242
+ // its own open-request check under this same lock, and the legacy
243
+ // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
244
+ if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
245
+ const open = await this.db
246
+ .select({ number: changeRequests.number })
247
+ .from(changeRequests)
248
+ .where(and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')))
249
+ .limit(1);
250
+ if (open.length > 0) {
251
+ throw new WorkflowValidationError(`Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`);
252
+ }
253
+ }
254
+ await this.deleteBranchUnlocked(workspaceId, name, user, opts);
255
+ });
256
+ }
257
+ /** The deletion itself — callers must hold the branch-lifecycle lock. */
258
+ async deleteBranchUnlocked(workspaceId, name, user, opts) {
259
+ await this.git.deleteBranch(workspaceId, name, user, opts);
260
+ // Retire the deleted branch's own workspace clone, best-effort. A stale
261
+ // clone left on disk would be silently REUSED if the branch name is ever
262
+ // recreated (the workspace bootstrap short-circuits on an existing
263
+ // `.git`), resurrecting the deleted branch's content under a fresh ref.
264
+ try {
265
+ const branchWorkspaceId = workspaceIdForBranch(name);
266
+ if (await this.workspaceService.hasBootstrappedWorkspace(branchWorkspaceId)) {
267
+ await this.workspaceService.deleteWorkspace(branchWorkspaceId);
268
+ }
269
+ }
270
+ catch (err) {
271
+ console.warn(`[workflow] could not retire workspace clone of deleted branch "${name}":`, err);
272
+ }
226
273
  }
227
274
  // No `switchBranch` here on purpose. Under the per-branch workspace model,
228
275
  // switching branches is a frontend navigation that triggers a workspace
@@ -269,10 +316,13 @@ export class WorkflowService {
269
316
  * `needs_attention` row: once the ladder has escalated to a human, more
270
317
  * agent runs on the same divergence are just a loop.
271
318
  *
272
- * One row, first conflicted path as the representative: the recovery
319
+ * One row, smallest conflicted path as the representative: the recovery
273
320
  * agent's job is "bring the BRANCH into a pushable state", so one ladder
274
321
  * per divergence — not one per conflicted file, which would multiply
275
- * agent runs for a single underlying conflict.
322
+ * agent runs for a single underlying conflict. Sorted so the choice is
323
+ * stable across retries regardless of how the caller ordered the paths —
324
+ * a shifting representative would slip past `enqueueIfAbsent`'s
325
+ * per-(workspace, branch, path) dedup and start a second ladder.
276
326
  *
277
327
  * Best-effort by design — the caller is already surfacing the conflict
278
328
  * error; a queue hiccup must not mask it.
@@ -282,7 +332,7 @@ export class WorkflowService {
282
332
  const queued = await this.pendingCommits.enqueueIfAbsent({
283
333
  workspaceId,
284
334
  branch: err.branch,
285
- path: err.conflictedPaths[0],
335
+ path: [...err.conflictedPaths].sort()[0],
286
336
  authorEmail: user?.email ?? RECOVERY_BOT_EMAIL,
287
337
  authorName: user?.name ?? RECOVERY_BOT_NAME,
288
338
  });
@@ -337,15 +387,20 @@ export class WorkflowService {
337
387
  listChangesForFile(workspaceId, path, limit) {
338
388
  return this.git.logForFile(workspaceId, path, limit);
339
389
  }
340
- revertChange(workspaceId, user, sha) {
341
- return this.git.revertCommit(workspaceId, user, sha);
342
- }
343
390
  compareFile(workspaceId, path, fromBranch, toBranch) {
344
391
  return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
345
392
  }
346
393
  showFileAtChange(workspaceId, path, sha) {
347
394
  return this.git.diffFileAtCommit(workspaceId, path, sha);
348
395
  }
396
+ /**
397
+ * Before/after file contents for one commit (`<sha>^` vs `<sha>`). Powers
398
+ * the rendered-markdown history view; `showFileAtChange` keeps serving the
399
+ * raw-patch view for non-markdown files.
400
+ */
401
+ fileAtChange(workspaceId, path, sha) {
402
+ return this.git.fileContentsAtCommit(workspaceId, path, sha);
403
+ }
349
404
  /**
350
405
  * Resolve write permission for `targetPath` against the access tree at
351
406
  * `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
@@ -581,7 +636,7 @@ export class WorkflowService {
581
636
  * retry, hand off to the recovery agent, or escalate. Emits
582
637
  * `file-changed` on success so frontends see the new sha.
583
638
  */
584
- async runPendingCommit(workspaceId, branch, targetPath, user) {
639
+ async runPendingCommit(workspaceId, branch, targetPath, user, opts) {
585
640
  const change = await this.git.commitFile(workspaceId, user, targetPath);
586
641
  if (!change) {
587
642
  // No-op commit — the path was already clean. Usually a double-enqueue
@@ -597,11 +652,11 @@ export class WorkflowService {
597
652
  // and the worker's ladder takes over). Nothing to emit either way —
598
653
  // there's no new sha.
599
654
  if (await this.git.hasUnpushedCommits(workspaceId)) {
600
- await this.pushWithRecovery(workspaceId, branch, targetPath, user);
655
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
601
656
  }
602
657
  return;
603
658
  }
604
- await this.pushWithRecovery(workspaceId, branch, targetPath, user);
659
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
605
660
  this.events?.emit({
606
661
  kind: 'file-changed',
607
662
  workspaceId,
@@ -621,9 +676,9 @@ export class WorkflowService {
621
676
  * still fails." The full rationale lives at the call site in
622
677
  * `releaseLock`; this method is the de-duplicated body.
623
678
  */
624
- async pushWithRecovery(workspaceId, branch, targetPath, user) {
679
+ async pushWithRecovery(workspaceId, branch, targetPath, user, opts) {
625
680
  try {
626
- await this.git.push(workspaceId, user);
681
+ await this.git.push(workspaceId, user, opts);
627
682
  }
628
683
  catch (firstPushErr) {
629
684
  const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
@@ -633,7 +688,7 @@ export class WorkflowService {
633
688
  if (looksLikeNonFastForward) {
634
689
  try {
635
690
  await this.git.pull(workspaceId);
636
- await this.git.push(workspaceId, user);
691
+ await this.git.push(workspaceId, user, opts);
637
692
  recovered = true;
638
693
  console.log(`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`);
639
694
  }
@@ -714,8 +769,8 @@ export class WorkflowService {
714
769
  listChangeRequests(opts) {
715
770
  return this.prs.listOpenPrs(opts);
716
771
  }
717
- listChangeRequestsAuthoredBy(emailOrLogin) {
718
- return this.prs.listPrsAuthoredBy(emailOrLogin);
772
+ listChangeRequestsAuthoredBy(emailOrLogin, opts) {
773
+ return this.prs.listPrsAuthoredBy(emailOrLogin, opts);
719
774
  }
720
775
  listChangeRequestsForUser(workspaceId, email, opts) {
721
776
  return this.prs.listPrsForOwnerEmail(workspaceId, email, opts);
@@ -758,103 +813,111 @@ export class WorkflowService {
758
813
  if (isProtectedBranch(input.sourceBranch)) {
759
814
  throw new WorkflowValidationError(`Cannot open a change request *from* a protected branch ("${input.sourceBranch}").`, { kind: 'source-is-protected', sourceBranch: input.sourceBranch });
760
815
  }
761
- // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
762
- // parallel is still allowed). `listOpenPrs` is cached for 30s so we
763
- // force-fresh to catch a CR that opened just now.
764
- const openCrs = await this.prs.listOpenPrs({ fresh: true });
765
- const duplicate = openCrs.find((cr) => cr.branch === input.sourceBranch && cr.base === input.targetBranch);
766
- if (duplicate) {
767
- throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, duplicate.number);
768
- }
769
- // Auto-merge target → source. `mergeFromOrigin` enforces "current
770
- // branch == source" and "clean working tree" before attempting; both
771
- // failures surface as structured workflow errors that pass straight
772
- // through to the caller.
773
- const mergeOutcome = await this.git.mergeFromOrigin(workspaceId, input.sourceBranch, input.targetBranch, user);
774
- if (mergeOutcome.kind === 'conflicts') {
775
- throw new ChangeRequestConflictsError(input.sourceBranch, input.targetBranch, mergeOutcome.paths);
776
- }
777
- // Push the source — `gh pr create` against an unpushed branch returns
778
- // "head branch does not exist on remote". Push *after* the auto-merge
779
- // so the remote sees the merge commit too.
780
- await this.git.push(workspaceId, user);
781
- const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
782
- const cwd = path.join(workspacePath, this.kbDirName);
783
- // Touched paths between target and source — basis for the
784
- // affected-owners block we append to the CR body. We compute it
785
- // ourselves rather than waiting for GitHub to expose `pr.files`
786
- // (which only populates after the PR exists). Three-dot range
787
- // matches what `gh pr create` will eventually show as the diff.
788
- const touchedPaths = await this.listChangedPathsBetweenBranches(cwd, input.targetBranch, input.sourceBranch);
789
- const affectedOwnersBlock = await this.buildAffectedOwnersBlock(workspaceId, input.targetBranch, touchedPaths);
790
- // The change request is a plain DB row now — no provider PR. Author is a
791
- // real column (no hidden body marker needed, since nothing runs as a shared
792
- // service account anymore).
793
- const description = input.description?.trim() ?? '';
794
- const body = [description, affectedOwnersBlock].filter((s) => s.length > 0).join('\n\n');
795
- let prNumber;
796
- try {
797
- const [row] = await this.db
798
- .insert(changeRequests)
799
- .values({
800
- sourceBranch: input.sourceBranch,
801
- targetBranch: input.targetBranch,
802
- title: input.title.trim(),
803
- body,
804
- authorEmail: user.email.trim().toLowerCase(),
805
- authorName: user.name,
806
- })
807
- .returning({ number: changeRequests.number });
808
- prNumber = row.number;
809
- }
810
- catch (err) {
811
- // The partial unique index (source, target WHERE state='open') is the
812
- // race-proof backstop for the pre-check above: a concurrent open of the
813
- // same pair lands here. Surface it as the same duplicate error, with the
814
- // winning CR's number. Any OTHER unique violation (e.g. the identity
815
- // `change_requests_number_unq`) is a genuine integrity fault and falls
816
- // through to the generic path below rather than being mislabeled.
817
- if (isOpenPairViolation(err)) {
818
- const [existing] = await this.db
819
- .select({ number: changeRequests.number })
820
- .from(changeRequests)
821
- .where(and(eq(changeRequests.sourceBranch, input.sourceBranch), eq(changeRequests.targetBranch, input.targetBranch), eq(changeRequests.state, 'open')))
822
- .limit(1);
823
- // Normally the conflicting open CR is right there. If it vanished (the
824
- // rival was merged/closed in the microsecond between the conflict and
825
- // this re-query), don't fabricate a #0 — report it as a transient race.
826
- if (!existing) {
827
- throw new WorkflowValidationError(`Change request for "${input.sourceBranch}" → "${input.targetBranch}" ` +
828
- 'raced with a concurrent open/close; retry.', { kind: 'open-change-request-failed' });
816
+ // The whole open sequence — uniqueness check through row insert — runs
817
+ // under the source branch's lifecycle lock. `retireMergedSourceBranch`
818
+ // takes the same lock around its own check-then-delete, so a merge
819
+ // cleanup can never race into the multi-second gap between this
820
+ // method's "no open request" answer and its row landing, and delete the
821
+ // branch out from under a request being opened.
822
+ return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
823
+ // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
824
+ // parallel is still allowed). `listOpenPrs` is cached for 30s so we
825
+ // force-fresh to catch a CR that opened just now.
826
+ const openCrs = await this.prs.listOpenPrs({ fresh: true });
827
+ const duplicate = openCrs.find((cr) => cr.branch === input.sourceBranch && cr.base === input.targetBranch);
828
+ if (duplicate) {
829
+ throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, duplicate.number);
830
+ }
831
+ // Auto-merge target → source. `mergeFromOrigin` enforces "current
832
+ // branch == source" and "clean working tree" before attempting; both
833
+ // failures surface as structured workflow errors that pass straight
834
+ // through to the caller.
835
+ const mergeOutcome = await this.git.mergeFromOrigin(workspaceId, input.sourceBranch, input.targetBranch, user);
836
+ if (mergeOutcome.kind === 'conflicts') {
837
+ throw new ChangeRequestConflictsError(input.sourceBranch, input.targetBranch, mergeOutcome.paths);
838
+ }
839
+ // Push the source — `gh pr create` against an unpushed branch returns
840
+ // "head branch does not exist on remote". Push *after* the auto-merge
841
+ // so the remote sees the merge commit too.
842
+ await this.git.push(workspaceId, user);
843
+ const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
844
+ const cwd = path.join(workspacePath, this.kbDirName);
845
+ // Touched paths between target and source — basis for the
846
+ // affected-owners block we append to the CR body. We compute it
847
+ // ourselves rather than waiting for GitHub to expose `pr.files`
848
+ // (which only populates after the PR exists). Three-dot range
849
+ // matches what `gh pr create` will eventually show as the diff.
850
+ const touchedPaths = await this.listChangedPathsBetweenBranches(cwd, input.targetBranch, input.sourceBranch);
851
+ const affectedOwnersBlock = await this.buildAffectedOwnersBlock(workspaceId, input.targetBranch, touchedPaths);
852
+ // The change request is a plain DB row now — no provider PR. Author is a
853
+ // real column (no hidden body marker needed, since nothing runs as a shared
854
+ // service account anymore).
855
+ const description = input.description?.trim() ?? '';
856
+ const body = [description, affectedOwnersBlock].filter((s) => s.length > 0).join('\n\n');
857
+ let prNumber;
858
+ try {
859
+ const [row] = await this.db
860
+ .insert(changeRequests)
861
+ .values({
862
+ sourceBranch: input.sourceBranch,
863
+ targetBranch: input.targetBranch,
864
+ title: input.title.trim(),
865
+ body,
866
+ authorEmail: user.email.trim().toLowerCase(),
867
+ authorName: user.name,
868
+ })
869
+ .returning({ number: changeRequests.number });
870
+ prNumber = row.number;
871
+ }
872
+ catch (err) {
873
+ // The partial unique index (source, target WHERE state='open') is the
874
+ // race-proof backstop for the pre-check above: a concurrent open of the
875
+ // same pair lands here. Surface it as the same duplicate error, with the
876
+ // winning CR's number. Any OTHER unique violation (e.g. the identity
877
+ // `change_requests_number_unq`) is a genuine integrity fault and falls
878
+ // through to the generic path below rather than being mislabeled.
879
+ if (isOpenPairViolation(err)) {
880
+ const [existing] = await this.db
881
+ .select({ number: changeRequests.number })
882
+ .from(changeRequests)
883
+ .where(and(eq(changeRequests.sourceBranch, input.sourceBranch), eq(changeRequests.targetBranch, input.targetBranch), eq(changeRequests.state, 'open')))
884
+ .limit(1);
885
+ // Normally the conflicting open CR is right there. If it vanished (the
886
+ // rival was merged/closed in the microsecond between the conflict and
887
+ // this re-query), don't fabricate a #0 — report it as a transient race.
888
+ if (!existing) {
889
+ throw new WorkflowValidationError(`Change request for "${input.sourceBranch}" → "${input.targetBranch}" ` +
890
+ 'raced with a concurrent open/close; retry.', { kind: 'open-change-request-failed' });
891
+ }
892
+ throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, existing.number);
829
893
  }
830
- throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, existing.number);
894
+ // Non-uniqueness failure AFTER the auto-merge + push already advanced the
895
+ // source branch: no `change_requests` row was created, but the source now
896
+ // carries the auto-merge commit. This is safely retryable — re-running
897
+ // openChangeRequest finds no duplicate, re-runs the (idempotent, already
898
+ // up-to-date) auto-merge, and inserts the row.
899
+ const rawMsg = err instanceof Error ? err.message : String(err);
900
+ throw new WorkflowValidationError(`Failed to open change request: ${redactTokens(rawMsg)}`, { kind: 'open-change-request-failed' });
831
901
  }
832
- // Non-uniqueness failure AFTER the auto-merge + push already advanced the
833
- // source branch: no `change_requests` row was created, but the source now
834
- // carries the auto-merge commit. This is safely retryable — re-running
835
- // openChangeRequest finds no duplicate, re-runs the (idempotent, already
836
- // up-to-date) auto-merge, and inserts the row.
837
- const rawMsg = err instanceof Error ? err.message : String(err);
838
- throw new WorkflowValidationError(`Failed to open change request: ${redactTokens(rawMsg)}`, { kind: 'open-change-request-failed' });
839
- }
840
- this.prs.invalidateDetailCache(prNumber);
841
- const detail = await this.prs.getPrDetail(prNumber, {
842
- fresh: true,
843
- workspaceId,
844
- viewerEmail: user.email,
845
- });
846
- if (!detail) {
847
- throw new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`);
848
- }
849
- this.events?.emit({
850
- kind: 'change-request-opened',
851
- number: prNumber,
852
- source: input.sourceBranch,
853
- target: input.targetBranch,
854
- authorIdHash: hashEmail(user.email),
855
- title: input.title,
902
+ this.prs.invalidateDetailCache(prNumber);
903
+ const detail = await this.prs.getPrDetail(prNumber, {
904
+ fresh: true,
905
+ workspaceId,
906
+ viewerEmail: user.email,
907
+ });
908
+ if (!detail) {
909
+ throw new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`);
910
+ }
911
+ this.events?.emit({
912
+ kind: 'change-request-opened',
913
+ number: prNumber,
914
+ source: input.sourceBranch,
915
+ target: input.targetBranch,
916
+ authorIdHash: hashEmail(user.email),
917
+ title: input.title,
918
+ });
919
+ return detail;
856
920
  });
857
- return detail;
858
921
  }
859
922
  /**
860
923
  * Refresh an existing change request from its target — re-runs the
@@ -937,6 +1000,164 @@ export class WorkflowService {
937
1000
  });
938
1001
  return approvals;
939
1002
  }
1003
+ /**
1004
+ * Decline ONE file of an open change request: restore its merge-base
1005
+ * version on the SOURCE branch (commit + push), so the file drops out of
1006
+ * the request's three-dot diff — the same mechanics
1007
+ * `preserveBaseRolesYaml` uses to neutralise roles.yaml, offered as a
1008
+ * reviewer's verb. Accept-or-revert per file is how a reviewer takes the
1009
+ * good half of a proposal without rejecting the whole thing.
1010
+ *
1011
+ * Authority: exactly the approval predicate — an eligible approver for the
1012
+ * file per the access tree on `origin/<base>`. Declining takes the same
1013
+ * permission as accepting.
1014
+ *
1015
+ * When the LAST file is reverted the request proposes nothing: it closes
1016
+ * itself and its source branch is retired, exactly as a merge would have
1017
+ * retired it — a shell of a request pointing at an empty diff serves
1018
+ * nobody (see the zero-files dialog state this replaces).
1019
+ */
1020
+ async revertChangeRequestFile(number, user, repoRelPath) {
1021
+ const summary = await this.prs.getPr(number);
1022
+ if (!summary)
1023
+ throw new WorkflowDomainError('change request not found', 404);
1024
+ if (summary.state !== 'open') {
1025
+ throw new WorkflowDomainError('This change request is no longer open.', 422);
1026
+ }
1027
+ const baseBranch = summary.base;
1028
+ const headBranch = summary.branch;
1029
+ const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
1030
+ // Best-effort freshen of the source checkout: the diff below reads origin
1031
+ // refs, but the restore commits from the working tree — a stale tree
1032
+ // would push non-fast-forward and fail loudly anyway; this just makes
1033
+ // that rare.
1034
+ await this.git.pull(ws.id).catch(() => undefined);
1035
+ // The verb acts on the request as it is NOW — never a cached file list.
1036
+ const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1037
+ if (!paths.includes(repoRelPath)) {
1038
+ throw new WorkflowDomainError('That file is not part of this change request.', 422);
1039
+ }
1040
+ const canApprove = await this.accessControl.canWriteAtRef(ws.id, `origin/${baseBranch}`, user.email, repoRelPath);
1041
+ if (canApprove !== true) {
1042
+ throw new WorkflowDomainError('Only an eligible approver of this file can revert it — declining takes the same permission as accepting.', 403);
1043
+ }
1044
+ const mergeBase = await this.git.mergeBaseForPr(ws.id, baseBranch, headBranch);
1045
+ if (!mergeBase) {
1046
+ throw new WorkflowDomainError('These branches share no history to revert to.', 422);
1047
+ }
1048
+ // Same per-file lock every other editor of this path takes — a concurrent
1049
+ // save must not race the restore between write and commit.
1050
+ const lockPath = `${this.kbDirName}/${repoRelPath}`;
1051
+ const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
1052
+ if (!lock.acquired) {
1053
+ throw new WorkflowDomainError(`${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`, 409);
1054
+ }
1055
+ try {
1056
+ await this.git.restorePathFromRef(ws.id, mergeBase, repoRelPath);
1057
+ await this.git.commitFile(ws.id, user, repoRelPath, `Revert ${repoRelPath} (declined in change request #${number})`, true);
1058
+ await this.git.push(ws.id, user);
1059
+ }
1060
+ finally {
1061
+ // Committed inline — drop the lock row directly rather than enqueueing
1062
+ // a duplicate commit through releaseLock.
1063
+ await this.fileLocks.release(ws.id, headBranch, lockPath, user);
1064
+ }
1065
+ this.prs.invalidateDetailCache(number);
1066
+ const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1067
+ if (remaining.length > 0) {
1068
+ return { closed: false, remainingPaths: remaining };
1069
+ }
1070
+ // Every change declined → the request proposes nothing.
1071
+ await this.closeEmptyChangeRequest(number, user);
1072
+ return { closed: true, remainingPaths: [] };
1073
+ }
1074
+ /**
1075
+ * DELETE a change request outright: close it (whatever its diff says) and
1076
+ * retire its source branch — the request, its proposal, and the branch that
1077
+ * carried it are gone in one verb. Admin-only, resolved the same way every
1078
+ * other admin check here is (write on `roles.yaml` at `origin/<base>`,
1079
+ * never a local ref): this is the moderation verb for a shared deployment,
1080
+ * stronger than reject (which the author or the files' owners can do, and
1081
+ * which leaves the branch for a second round).
1082
+ */
1083
+ async deleteChangeRequest(number, user) {
1084
+ const summary = await this.prs.getPr(number);
1085
+ if (!summary)
1086
+ throw new WorkflowDomainError('change request not found', 404);
1087
+ if (summary.state === 'merged') {
1088
+ throw new WorkflowDomainError('This change request has already been applied.', 422);
1089
+ }
1090
+ const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1091
+ await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
1092
+ const isAdmin = await this.accessControl.canWriteAtRef(ws.id, `origin/${summary.base}`, user.email, 'roles.yaml');
1093
+ if (isAdmin !== true) {
1094
+ throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
1095
+ }
1096
+ // Close first (idempotent: an already-closed request just skips to the
1097
+ // branch retirement). Guarded on `open` so a concurrent merge wins.
1098
+ const now = new Date();
1099
+ const updated = await this.db
1100
+ .update(changeRequests)
1101
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
1102
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
1103
+ .returning({ id: changeRequests.id });
1104
+ if (updated.length > 0) {
1105
+ this.prs.invalidateDetailCache(number);
1106
+ this.events?.emit({ kind: 'change-request-rejected', number });
1107
+ }
1108
+ else {
1109
+ const fresh = await this.prs.getPr(number);
1110
+ if (fresh?.state === 'merged') {
1111
+ throw new WorkflowDomainError('This change request has already been applied.', 422);
1112
+ }
1113
+ }
1114
+ await this.retireMergedSourceBranch(number, summary.base, user);
1115
+ }
1116
+ /**
1117
+ * Close an open change request whose diff has EMPTIED — everything it
1118
+ * proposed has since landed on the target, been reverted, or been undone on
1119
+ * its own branch — and retire its source branch, exactly as a merge would
1120
+ * have. A request pointing at an empty diff serves nobody: reviewers never
1121
+ * see it (zero touched paths routes to no one) and the author sees a shell.
1122
+ *
1123
+ * Called after the last per-file revert, and as a lazy reconcile whenever a
1124
+ * detail read observes an open request with no files (the join-requests
1125
+ * pattern: derived state, settled on observation). The emptiness check here
1126
+ * is AUTHORITATIVE — recomputed, and a diff failure aborts rather than
1127
+ * closes, so a transient git error can never eat a live request.
1128
+ *
1129
+ * Returns true when THIS call closed it.
1130
+ */
1131
+ async closeEmptyChangeRequest(number, user) {
1132
+ const summary = await this.prs.getPr(number);
1133
+ if (!summary || summary.state !== 'open')
1134
+ return false;
1135
+ let paths;
1136
+ try {
1137
+ const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
1138
+ paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
1139
+ }
1140
+ catch (err) {
1141
+ console.warn(`[cr] empty-check for change request #${number} failed — leaving it open:`, err);
1142
+ return false;
1143
+ }
1144
+ if (paths.length > 0)
1145
+ return false;
1146
+ // Guard on `state = 'open'` so a concurrent merge or withdraw wins the
1147
+ // race and this becomes a no-op.
1148
+ const now = new Date();
1149
+ const updated = await this.db
1150
+ .update(changeRequests)
1151
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
1152
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
1153
+ .returning({ id: changeRequests.id });
1154
+ if (updated.length === 0)
1155
+ return false;
1156
+ this.prs.invalidateDetailCache(number);
1157
+ this.events?.emit({ kind: 'change-request-rejected', number });
1158
+ await this.retireMergedSourceBranch(number, summary.base, user);
1159
+ return true;
1160
+ }
940
1161
  /**
941
1162
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
942
1163
  * authorized set is:
@@ -1050,25 +1271,83 @@ export class WorkflowService {
1050
1271
  // this a read right after a merge misses the just-merged change. Best-effort:
1051
1272
  // the merge already succeeded on origin, so a pull hiccup must not fail the
1052
1273
  // response (a later fetch/pull reconciles).
1274
+ let targetWorkspaceId;
1053
1275
  try {
1054
1276
  const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
1277
+ targetWorkspaceId = targetWorkspace.id;
1055
1278
  await this.git.pull(targetWorkspace.id);
1056
1279
  }
1057
1280
  catch (err) {
1058
1281
  // Still best-effort for the merge response (the merge already landed
1059
1282
  // on origin) — but a rebase CONFLICT here means the target workspace
1060
1283
  // is stranded (local commits vs origin, never self-heals), so queue
1061
- // the background recovery ladder before shrugging.
1062
- if (err instanceof PullRebaseConflictError) {
1063
- const targetWorkspaceId = encodeURIComponent(baseBranch);
1284
+ // the background recovery ladder before shrugging. A conflict can
1285
+ // only have come from the pull, so the workspace id is always set on
1286
+ // this arm — the guard just satisfies the narrowing.
1287
+ if (err instanceof PullRebaseConflictError && targetWorkspaceId) {
1064
1288
  await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
1065
1289
  }
1066
1290
  console.warn(`[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`, err);
1067
1291
  }
1068
1292
  this.prs.invalidateDetailCache(number);
1069
1293
  this.events?.emit({ kind: 'change-request-merged', number });
1294
+ // AFTER the event: the applying UI is waiting on `change-request-merged`,
1295
+ // and branch retirement is git IO it must never wait behind.
1296
+ await this.retireMergedSourceBranch(number, baseBranch, user);
1070
1297
  return { kind: 'merged', result };
1071
1298
  }
1299
+ /**
1300
+ * Best-effort retirement of a merged change request's source branch — the
1301
+ * standard "delete branch on merge" a git host performs, done here because
1302
+ * this app IS the host. Everything the branch carried is on the target
1303
+ * now, and leaving it behind has a real cost: the propose flow REUSES an
1304
+ * existing branch by name, so a leftover base goes stale against the
1305
+ * target and seeds the next proposal with outdated text (or, worse,
1306
+ * re-proposes withdrawn commits).
1307
+ *
1308
+ * Refuses quietly when the branch is protected or still carries another
1309
+ * OPEN request. Runs from the TARGET's workspace (guaranteed not to be
1310
+ * checked out on the branch being deleted) with `systemCleanup`, since the
1311
+ * merger need not be the branch's author — the merge itself was the
1312
+ * authorization. Never throws: the merge has already succeeded, and a
1313
+ * cleanup failure only means the next proposal falls back to branch reuse.
1314
+ */
1315
+ async retireMergedSourceBranch(number, baseBranch, user) {
1316
+ try {
1317
+ const rows = await this.db
1318
+ .select({ sourceBranch: changeRequests.sourceBranch })
1319
+ .from(changeRequests)
1320
+ .where(eq(changeRequests.number, number))
1321
+ .limit(1);
1322
+ const sourceBranch = rows[0]?.sourceBranch;
1323
+ if (!sourceBranch || isProtectedBranch(sourceBranch))
1324
+ return;
1325
+ // The open-check and the deletion hold the branch's lifecycle lock
1326
+ // TOGETHER — `openChangeRequest` holds the same lock across its own
1327
+ // check-then-insert, so a request being opened from this branch either
1328
+ // lands its row before the check below (retirement backs off) or waits
1329
+ // until retirement finishes (and fails loudly on the missing branch,
1330
+ // retryable, instead of losing the branch mid-open).
1331
+ await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
1332
+ const stillOpen = await this.db
1333
+ .select({ number: changeRequests.number })
1334
+ .from(changeRequests)
1335
+ .where(and(eq(changeRequests.sourceBranch, sourceBranch), eq(changeRequests.state, 'open')))
1336
+ .limit(1);
1337
+ if (stillOpen.length > 0)
1338
+ return;
1339
+ const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
1340
+ // The remote-tracking ref must be current, or deleteBranch's
1341
+ // "does origin still have it?" probe skips the remote delete.
1342
+ await this.workspaceService.ensureRemotesFetched(targetWs.id).catch(() => { });
1343
+ await this.deleteBranchUnlocked(targetWs.id, sourceBranch, user, { systemCleanup: true });
1344
+ console.log(`[merge] retired merged source branch "${sourceBranch}"`);
1345
+ });
1346
+ }
1347
+ catch (err) {
1348
+ console.warn(`[merge] could not retire the merged source branch of change request #${number} (non-fatal):`, err);
1349
+ }
1350
+ }
1072
1351
  /**
1073
1352
  * Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
1074
1353
  * `roles.yaml` differs from the base branch's, restore the base version on the