@bevel-software/platform-core-backend 0.4.1 → 0.5.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 (305) hide show
  1. package/THIRD-PARTY-NOTICES.md +6118 -0
  2. package/dist/core/core-ports.d.ts +15 -0
  3. package/dist/core/core-ports.d.ts.map +1 -1
  4. package/dist/core/core-ports.js.map +1 -1
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +40 -6
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +17 -1
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +153 -29
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core-config.d.ts +70 -21
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +134 -58
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +6 -0
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +74 -5
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +116 -15
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access-declarations.d.ts +93 -0
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  24. package/dist/modules/access/access-declarations.js +168 -0
  25. package/dist/modules/access/access-declarations.js.map +1 -0
  26. package/dist/modules/access/access-mutation.service.js +4 -4
  27. package/dist/modules/access/access-mutation.service.js.map +1 -1
  28. package/dist/modules/access/access-splice.d.ts +16 -1
  29. package/dist/modules/access/access-splice.d.ts.map +1 -1
  30. package/dist/modules/access/access-splice.js +40 -21
  31. package/dist/modules/access/access-splice.js.map +1 -1
  32. package/dist/modules/access/access.routes.d.ts.map +1 -1
  33. package/dist/modules/access/access.routes.js +51 -0
  34. package/dist/modules/access/access.routes.js.map +1 -1
  35. package/dist/modules/access/creator-access.d.ts +8 -0
  36. package/dist/modules/access/creator-access.d.ts.map +1 -1
  37. package/dist/modules/access/creator-access.js +21 -2
  38. package/dist/modules/access/creator-access.js.map +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  40. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  41. package/dist/modules/admin/admin-access.service.js +3 -2
  42. package/dist/modules/admin/admin-access.service.js.map +1 -1
  43. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  44. package/dist/modules/auth/auth.routes.js +50 -1
  45. package/dist/modules/auth/auth.routes.js.map +1 -1
  46. package/dist/modules/auth/auth.service.d.ts +39 -10
  47. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  48. package/dist/modules/auth/auth.service.js +38 -16
  49. package/dist/modules/auth/auth.service.js.map +1 -1
  50. package/dist/modules/database/core-schema.d.ts +124 -0
  51. package/dist/modules/database/core-schema.d.ts.map +1 -1
  52. package/dist/modules/database/core-schema.js +45 -0
  53. package/dist/modules/database/core-schema.js.map +1 -1
  54. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  55. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  56. package/dist/modules/groups/group-provision.service.js +288 -0
  57. package/dist/modules/groups/group-provision.service.js.map +1 -0
  58. package/dist/modules/groups/groups.contract.d.ts +106 -0
  59. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  60. package/dist/modules/groups/groups.contract.js +36 -0
  61. package/dist/modules/groups/groups.contract.js.map +1 -0
  62. package/dist/modules/groups/groups.routes.d.ts +42 -0
  63. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  64. package/dist/modules/groups/groups.routes.js +379 -0
  65. package/dist/modules/groups/groups.routes.js.map +1 -0
  66. package/dist/modules/groups/groups.service.d.ts +60 -0
  67. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  68. package/dist/modules/groups/groups.service.js +172 -0
  69. package/dist/modules/groups/groups.service.js.map +1 -0
  70. package/dist/modules/groups/index.d.ts +7 -0
  71. package/dist/modules/groups/index.d.ts.map +1 -0
  72. package/dist/modules/groups/index.js +6 -0
  73. package/dist/modules/groups/index.js.map +1 -0
  74. package/dist/modules/groups/join-proposals.d.ts +53 -0
  75. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  76. package/dist/modules/groups/join-proposals.js +67 -0
  77. package/dist/modules/groups/join-proposals.js.map +1 -0
  78. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  79. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  80. package/dist/modules/groups/join-requests.service.js +135 -0
  81. package/dist/modules/groups/join-requests.service.js.map +1 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  83. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  85. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  86. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  87. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  88. package/dist/modules/settings/deployment-settings.service.js +368 -0
  89. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  90. package/dist/modules/settings/setup.routes.d.ts +49 -0
  91. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  92. package/dist/modules/settings/setup.routes.js +292 -0
  93. package/dist/modules/settings/setup.routes.js.map +1 -0
  94. package/dist/modules/skills/index.d.ts +2 -1
  95. package/dist/modules/skills/index.d.ts.map +1 -1
  96. package/dist/modules/skills/index.js +1 -0
  97. package/dist/modules/skills/index.js.map +1 -1
  98. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  99. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  100. package/dist/modules/skills/pending-skills.service.js +147 -0
  101. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  102. package/dist/modules/skills/skills.contract.d.ts +32 -3
  103. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  104. package/dist/modules/skills/skills.contract.js +1 -1
  105. package/dist/modules/skills/skills.routes.d.ts +3 -2
  106. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  107. package/dist/modules/skills/skills.routes.js +18 -1
  108. package/dist/modules/skills/skills.routes.js.map +1 -1
  109. package/dist/modules/skills/skills.service.d.ts +22 -0
  110. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  111. package/dist/modules/skills/skills.service.js +24 -12
  112. package/dist/modules/skills/skills.service.js.map +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts +1 -1
  114. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  115. package/dist/modules/tool-manuals/index.js.map +1 -1
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  117. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  120. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  122. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  125. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  126. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  129. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  132. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  133. package/dist/modules/workflow/event-bus.d.ts +11 -0
  134. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  135. package/dist/modules/workflow/event-bus.js +22 -0
  136. package/dist/modules/workflow/event-bus.js.map +1 -1
  137. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  138. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  139. package/dist/modules/workflow/file-lock.service.js +16 -0
  140. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  142. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  143. package/dist/modules/workflow/git/branch-name.js +1 -1
  144. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  145. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  146. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  147. package/dist/modules/workflow/git/git.service.js +144 -52
  148. package/dist/modules/workflow/git/git.service.js.map +1 -1
  149. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  150. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  151. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  152. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  155. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  157. package/dist/modules/workflow/workflow.routes.js +95 -14
  158. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  159. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  160. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  161. package/dist/modules/workflow/workflow.service.js +381 -108
  162. package/dist/modules/workflow/workflow.service.js.map +1 -1
  163. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  164. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  165. package/dist/modules/workspace/bevel-ignore.js +5 -1
  166. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  167. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  168. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  169. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  170. package/dist/modules/workspace/kb-seed.service.js +228 -51
  171. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  173. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.routes.js +51 -18
  175. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  176. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  177. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.service.js +36 -9
  179. package/dist/modules/workspace/workspace.service.js.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.tools.js +8 -3
  182. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  183. package/dist/shared/kb-layout.test.js +2 -2
  184. package/dist/test-setup.d.ts +2 -0
  185. package/dist/test-setup.d.ts.map +1 -0
  186. package/dist/test-setup.js +18 -0
  187. package/dist/test-setup.js.map +1 -0
  188. package/kb-template/.bevelignore +6 -2
  189. package/kb-template/AGENTS.md +272 -0
  190. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  191. package/kb-template/access.md +36 -36
  192. package/migrations/0002_user_onboarding_done.sql +1 -0
  193. package/migrations/0003_deployment_settings.sql +9 -0
  194. package/migrations/meta/0001_snapshot.json +1486 -0
  195. package/migrations/meta/0002_snapshot.json +1493 -0
  196. package/migrations/meta/0003_snapshot.json +1552 -0
  197. package/migrations/meta/_journal.json +15 -1
  198. package/package.json +5 -4
  199. package/src/__tests__/branch-model.test.ts +79 -0
  200. package/src/__tests__/core-config.admin.test.ts +114 -0
  201. package/src/__tests__/database-url.test.ts +70 -0
  202. package/src/core/core-ports.ts +15 -0
  203. package/src/core/create-core-server.ts +55 -6
  204. package/src/core/create-core-services.ts +201 -36
  205. package/src/core-config.ts +139 -59
  206. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  207. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  208. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  209. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  210. package/src/modules/access/access-control.interface.ts +11 -0
  211. package/src/modules/access/access-control.service.ts +113 -15
  212. package/src/modules/access/access-declarations.ts +251 -0
  213. package/src/modules/access/access-mutation.service.ts +4 -4
  214. package/src/modules/access/access-splice.ts +64 -21
  215. package/src/modules/access/access.routes.ts +61 -0
  216. package/src/modules/access/creator-access.ts +22 -2
  217. package/src/modules/admin/admin-access.service.ts +4 -3
  218. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  219. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  220. package/src/modules/auth/auth.routes.ts +50 -1
  221. package/src/modules/auth/auth.service.ts +55 -17
  222. package/src/modules/database/core-schema.ts +46 -0
  223. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  224. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  225. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  226. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  227. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  228. package/src/modules/groups/group-provision.service.ts +328 -0
  229. package/src/modules/groups/groups.contract.ts +107 -0
  230. package/src/modules/groups/groups.routes.ts +433 -0
  231. package/src/modules/groups/groups.service.ts +193 -0
  232. package/src/modules/groups/index.ts +17 -0
  233. package/src/modules/groups/join-proposals.ts +114 -0
  234. package/src/modules/groups/join-requests.service.ts +185 -0
  235. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  236. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  237. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  238. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  239. package/src/modules/settings/deployment-settings.service.ts +419 -0
  240. package/src/modules/settings/setup.routes.ts +317 -0
  241. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  242. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  243. package/src/modules/skills/index.ts +3 -0
  244. package/src/modules/skills/pending-skills.service.ts +168 -0
  245. package/src/modules/skills/skills.contract.ts +34 -3
  246. package/src/modules/skills/skills.routes.ts +23 -2
  247. package/src/modules/skills/skills.service.ts +24 -16
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  251. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  252. package/src/modules/tool-manuals/index.ts +2 -0
  253. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  254. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  255. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  256. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  257. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -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 +5 -0
  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 +159 -62
  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 +353 -13
  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
@@ -49,12 +49,14 @@ import type { GitService } from './git/git.service.js';
49
49
  import type { PullRequestService } from './git/pull-request.service.js';
50
50
  import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
51
51
  import type { WorkspaceService } from '../workspace/workspace.service.js';
52
+ import { workspaceIdForBranch } from '../workspace/workspace.service.js';
52
53
  import type { IAccessControl } from '../access/access-control.interface.js';
53
54
  import { FileLockService } from './file-lock.service.js';
54
55
  import { PendingCommitsService } from './pending-commits.service.js';
55
56
  import type { WorkflowEventBus } from './event-bus.js';
56
57
  import type { FileChangeNotifier } from './file-change-notifier.js';
57
58
  import { WorkflowHooks } from './workflow-hooks.js';
59
+ import { WorkspaceMutex } from './git/mutex.js';
58
60
  import { hashEmail } from '../../shared/hash-email.js';
59
61
  import {
60
62
  ChangeRequestConflictsError,
@@ -62,6 +64,7 @@ import {
62
64
  RolesYamlPreservationError,
63
65
  PullRebaseConflictError,
64
66
  PushNeedsAgentResolutionError,
67
+ WorkflowDomainError,
65
68
  WorkflowValidationError,
66
69
  } from './workflow.errors.js';
67
70
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
@@ -267,13 +270,71 @@ export class WorkflowService implements IWorkflowService {
267
270
  return this.git.createBranch(workspaceId, name, fromBase);
268
271
  }
269
272
 
270
- deleteBranch(
273
+ /**
274
+ * Serialises everything that decides a branch's fate — opening a change
275
+ * request from it, deleting it, retiring it after a merge — by branch
276
+ * name. Without it, `retireMergedSourceBranch`'s check-then-delete could
277
+ * interleave with `openChangeRequest`'s check-then-insert (which spans a
278
+ * multi-second auto-merge and push) and delete a branch a request was
279
+ * being opened from. In-process only, which matches the deployment model:
280
+ * one server process owns the workspaces directory.
281
+ */
282
+ private readonly branchLifecycle = new WorkspaceMutex();
283
+
284
+ async deleteBranch(
271
285
  workspaceId: string,
272
286
  name: string,
273
287
  user: AuthUser,
274
- opts?: { onlyIfNoRemote?: boolean },
288
+ opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean },
275
289
  ): Promise<void> {
276
- return this.git.deleteBranch(workspaceId, name, user, opts);
290
+ return this.branchLifecycle.run(`branch:${name}`, async () => {
291
+ // A branch backing an OPEN change request is load-bearing: deleting it
292
+ // strands the request (and, in the propose flow's reset-on-reuse, would
293
+ // let one tab wipe the branch another tab just proposed from). The
294
+ // request has to be withdrawn or declined first. `systemCleanup` does
295
+ // its own open-request check under this same lock, and the legacy
296
+ // `onlyIfNoRemote` prune only fires when origin no longer has the ref.
297
+ if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
298
+ const open = await this.db
299
+ .select({ number: changeRequests.number })
300
+ .from(changeRequests)
301
+ .where(
302
+ and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')),
303
+ )
304
+ .limit(1);
305
+ if (open.length > 0) {
306
+ throw new WorkflowValidationError(
307
+ `Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`,
308
+ );
309
+ }
310
+ }
311
+ await this.deleteBranchUnlocked(workspaceId, name, user, opts);
312
+ });
313
+ }
314
+
315
+ /** The deletion itself — callers must hold the branch-lifecycle lock. */
316
+ private async deleteBranchUnlocked(
317
+ workspaceId: string,
318
+ name: string,
319
+ user: AuthUser,
320
+ opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean },
321
+ ): Promise<void> {
322
+ await this.git.deleteBranch(workspaceId, name, user, opts);
323
+ // Retire the deleted branch's own workspace clone, best-effort. A stale
324
+ // clone left on disk would be silently REUSED if the branch name is ever
325
+ // recreated (the workspace bootstrap short-circuits on an existing
326
+ // `.git`), resurrecting the deleted branch's content under a fresh ref.
327
+ try {
328
+ const branchWorkspaceId = workspaceIdForBranch(name);
329
+ if (await this.workspaceService.hasBootstrappedWorkspace(branchWorkspaceId)) {
330
+ await this.workspaceService.deleteWorkspace(branchWorkspaceId);
331
+ }
332
+ } catch (err) {
333
+ console.warn(
334
+ `[workflow] could not retire workspace clone of deleted branch "${name}":`,
335
+ err,
336
+ );
337
+ }
277
338
  }
278
339
 
279
340
  // No `switchBranch` here on purpose. Under the per-branch workspace model,
@@ -420,10 +481,6 @@ export class WorkflowService implements IWorkflowService {
420
481
  return this.git.logForFile(workspaceId, path, limit);
421
482
  }
422
483
 
423
- revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change> {
424
- return this.git.revertCommit(workspaceId, user, sha);
425
- }
426
-
427
484
  compareFile(
428
485
  workspaceId: string,
429
486
  path: string,
@@ -437,6 +494,19 @@ export class WorkflowService implements IWorkflowService {
437
494
  return this.git.diffFileAtCommit(workspaceId, path, sha);
438
495
  }
439
496
 
497
+ /**
498
+ * Before/after file contents for one commit (`<sha>^` vs `<sha>`). Powers
499
+ * the rendered-markdown history view; `showFileAtChange` keeps serving the
500
+ * raw-patch view for non-markdown files.
501
+ */
502
+ fileAtChange(
503
+ workspaceId: string,
504
+ path: string,
505
+ sha: string,
506
+ ): Promise<{ baseline: string | null; current: string | null }> {
507
+ return this.git.fileContentsAtCommit(workspaceId, path, sha);
508
+ }
509
+
440
510
  /**
441
511
  * Resolve write permission for `targetPath` against the access tree at
442
512
  * `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
@@ -738,6 +808,7 @@ export class WorkflowService implements IWorkflowService {
738
808
  branch: string,
739
809
  targetPath: string,
740
810
  user: AuthUser,
811
+ opts?: { systemAuthorized?: boolean },
741
812
  ): Promise<void> {
742
813
  const change = await this.git.commitFile(workspaceId, user, targetPath);
743
814
  if (!change) {
@@ -754,11 +825,11 @@ export class WorkflowService implements IWorkflowService {
754
825
  // and the worker's ladder takes over). Nothing to emit either way —
755
826
  // there's no new sha.
756
827
  if (await this.git.hasUnpushedCommits(workspaceId)) {
757
- await this.pushWithRecovery(workspaceId, branch, targetPath, user);
828
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
758
829
  }
759
830
  return;
760
831
  }
761
- await this.pushWithRecovery(workspaceId, branch, targetPath, user);
832
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
762
833
  this.events?.emit({
763
834
  kind: 'file-changed',
764
835
  workspaceId,
@@ -784,9 +855,10 @@ export class WorkflowService implements IWorkflowService {
784
855
  branch: string,
785
856
  targetPath: string,
786
857
  user: AuthUser,
858
+ opts?: { systemAuthorized?: boolean },
787
859
  ): Promise<void> {
788
860
  try {
789
- await this.git.push(workspaceId, user);
861
+ await this.git.push(workspaceId, user, opts);
790
862
  } catch (firstPushErr) {
791
863
  const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
792
864
  const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
@@ -795,7 +867,7 @@ export class WorkflowService implements IWorkflowService {
795
867
  if (looksLikeNonFastForward) {
796
868
  try {
797
869
  await this.git.pull(workspaceId);
798
- await this.git.push(workspaceId, user);
870
+ await this.git.push(workspaceId, user, opts);
799
871
  recovered = true;
800
872
  console.log(
801
873
  `[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
@@ -905,8 +977,11 @@ export class WorkflowService implements IWorkflowService {
905
977
  return this.prs.listOpenPrs(opts);
906
978
  }
907
979
 
908
- listChangeRequestsAuthoredBy(emailOrLogin: string): Promise<ChangeRequest[]> {
909
- return this.prs.listPrsAuthoredBy(emailOrLogin);
980
+ listChangeRequestsAuthoredBy(
981
+ emailOrLogin: string,
982
+ opts?: { fresh?: boolean },
983
+ ): Promise<ChangeRequest[]> {
984
+ return this.prs.listPrsAuthoredBy(emailOrLogin, opts);
910
985
  }
911
986
 
912
987
  listChangeRequestsForUser(
@@ -965,6 +1040,13 @@ export class WorkflowService implements IWorkflowService {
965
1040
  );
966
1041
  }
967
1042
 
1043
+ // The whole open sequence — uniqueness check through row insert — runs
1044
+ // under the source branch's lifecycle lock. `retireMergedSourceBranch`
1045
+ // takes the same lock around its own check-then-delete, so a merge
1046
+ // cleanup can never race into the multi-second gap between this
1047
+ // method's "no open request" answer and its row landing, and delete the
1048
+ // branch out from under a request being opened.
1049
+ return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
968
1050
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
969
1051
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
970
1052
  // force-fresh to catch a CR that opened just now.
@@ -1109,6 +1191,7 @@ export class WorkflowService implements IWorkflowService {
1109
1191
  title: input.title,
1110
1192
  });
1111
1193
  return detail;
1194
+ });
1112
1195
  }
1113
1196
 
1114
1197
  /**
@@ -1262,6 +1345,197 @@ export class WorkflowService implements IWorkflowService {
1262
1345
  return approvals;
1263
1346
  }
1264
1347
 
1348
+ /**
1349
+ * Decline ONE file of an open change request: restore its merge-base
1350
+ * version on the SOURCE branch (commit + push), so the file drops out of
1351
+ * the request's three-dot diff — the same mechanics
1352
+ * `preserveBaseRolesYaml` uses to neutralise roles.yaml, offered as a
1353
+ * reviewer's verb. Accept-or-revert per file is how a reviewer takes the
1354
+ * good half of a proposal without rejecting the whole thing.
1355
+ *
1356
+ * Authority: exactly the approval predicate — an eligible approver for the
1357
+ * file per the access tree on `origin/<base>`. Declining takes the same
1358
+ * permission as accepting.
1359
+ *
1360
+ * When the LAST file is reverted the request proposes nothing: it closes
1361
+ * itself and its source branch is retired, exactly as a merge would have
1362
+ * retired it — a shell of a request pointing at an empty diff serves
1363
+ * nobody (see the zero-files dialog state this replaces).
1364
+ */
1365
+ async revertChangeRequestFile(
1366
+ number: number,
1367
+ user: AuthUser,
1368
+ repoRelPath: string,
1369
+ ): Promise<{ closed: boolean; remainingPaths: string[] }> {
1370
+ const summary = await this.prs.getPr(number);
1371
+ if (!summary) throw new WorkflowDomainError('change request not found', 404);
1372
+ if (summary.state !== 'open') {
1373
+ throw new WorkflowDomainError('This change request is no longer open.', 422);
1374
+ }
1375
+ const baseBranch = summary.base;
1376
+ const headBranch = summary.branch;
1377
+
1378
+ const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
1379
+ // Best-effort freshen of the source checkout: the diff below reads origin
1380
+ // refs, but the restore commits from the working tree — a stale tree
1381
+ // would push non-fast-forward and fail loudly anyway; this just makes
1382
+ // that rare.
1383
+ await this.git.pull(ws.id).catch(() => undefined);
1384
+
1385
+ // The verb acts on the request as it is NOW — never a cached file list.
1386
+ const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1387
+ if (!paths.includes(repoRelPath)) {
1388
+ throw new WorkflowDomainError('That file is not part of this change request.', 422);
1389
+ }
1390
+
1391
+ const canApprove = await this.accessControl.canWriteAtRef(
1392
+ ws.id,
1393
+ `origin/${baseBranch}`,
1394
+ user.email,
1395
+ repoRelPath,
1396
+ );
1397
+ if (canApprove !== true) {
1398
+ throw new WorkflowDomainError(
1399
+ 'Only an eligible approver of this file can revert it — declining takes the same permission as accepting.',
1400
+ 403,
1401
+ );
1402
+ }
1403
+
1404
+ const mergeBase = await this.git.mergeBaseForPr(ws.id, baseBranch, headBranch);
1405
+ if (!mergeBase) {
1406
+ throw new WorkflowDomainError('These branches share no history to revert to.', 422);
1407
+ }
1408
+
1409
+ // Same per-file lock every other editor of this path takes — a concurrent
1410
+ // save must not race the restore between write and commit.
1411
+ const lockPath = `${this.kbDirName}/${repoRelPath}`;
1412
+ const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
1413
+ if (!lock.acquired) {
1414
+ throw new WorkflowDomainError(
1415
+ `${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`,
1416
+ 409,
1417
+ );
1418
+ }
1419
+ try {
1420
+ await this.git.restorePathFromRef(ws.id, mergeBase, repoRelPath);
1421
+ await this.git.commitFile(
1422
+ ws.id,
1423
+ user,
1424
+ repoRelPath,
1425
+ `Revert ${repoRelPath} (declined in change request #${number})`,
1426
+ true, // skipValidator — this restores an already-validated base version
1427
+ );
1428
+ await this.git.push(ws.id, user);
1429
+ } finally {
1430
+ // Committed inline — drop the lock row directly rather than enqueueing
1431
+ // a duplicate commit through releaseLock.
1432
+ await this.fileLocks.release(ws.id, headBranch, lockPath, user);
1433
+ }
1434
+ this.prs.invalidateDetailCache(number);
1435
+
1436
+ const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
1437
+ if (remaining.length > 0) {
1438
+ return { closed: false, remainingPaths: remaining };
1439
+ }
1440
+ // Every change declined → the request proposes nothing.
1441
+ await this.closeEmptyChangeRequest(number, user);
1442
+ return { closed: true, remainingPaths: [] };
1443
+ }
1444
+
1445
+ /**
1446
+ * DELETE a change request outright: close it (whatever its diff says) and
1447
+ * retire its source branch — the request, its proposal, and the branch that
1448
+ * carried it are gone in one verb. Admin-only, resolved the same way every
1449
+ * other admin check here is (write on `roles.yaml` at `origin/<base>`,
1450
+ * never a local ref): this is the moderation verb for a shared deployment,
1451
+ * stronger than reject (which the author or the files' owners can do, and
1452
+ * which leaves the branch for a second round).
1453
+ */
1454
+ async deleteChangeRequest(number: number, user: AuthUser): Promise<void> {
1455
+ const summary = await this.prs.getPr(number);
1456
+ if (!summary) throw new WorkflowDomainError('change request not found', 404);
1457
+ if (summary.state === 'merged') {
1458
+ throw new WorkflowDomainError('This change request has already been applied.', 422);
1459
+ }
1460
+
1461
+ const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1462
+ await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
1463
+ const isAdmin = await this.accessControl.canWriteAtRef(
1464
+ ws.id,
1465
+ `origin/${summary.base}`,
1466
+ user.email,
1467
+ 'roles.yaml',
1468
+ );
1469
+ if (isAdmin !== true) {
1470
+ throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
1471
+ }
1472
+
1473
+ // Close first (idempotent: an already-closed request just skips to the
1474
+ // branch retirement). Guarded on `open` so a concurrent merge wins.
1475
+ const now = new Date();
1476
+ const updated = await this.db
1477
+ .update(changeRequests)
1478
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
1479
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
1480
+ .returning({ id: changeRequests.id });
1481
+ if (updated.length > 0) {
1482
+ this.prs.invalidateDetailCache(number);
1483
+ this.events?.emit({ kind: 'change-request-rejected', number });
1484
+ } else {
1485
+ const fresh = await this.prs.getPr(number);
1486
+ if (fresh?.state === 'merged') {
1487
+ throw new WorkflowDomainError('This change request has already been applied.', 422);
1488
+ }
1489
+ }
1490
+ await this.retireMergedSourceBranch(number, summary.base, user);
1491
+ }
1492
+
1493
+ /**
1494
+ * Close an open change request whose diff has EMPTIED — everything it
1495
+ * proposed has since landed on the target, been reverted, or been undone on
1496
+ * its own branch — and retire its source branch, exactly as a merge would
1497
+ * have. A request pointing at an empty diff serves nobody: reviewers never
1498
+ * see it (zero touched paths routes to no one) and the author sees a shell.
1499
+ *
1500
+ * Called after the last per-file revert, and as a lazy reconcile whenever a
1501
+ * detail read observes an open request with no files (the join-requests
1502
+ * pattern: derived state, settled on observation). The emptiness check here
1503
+ * is AUTHORITATIVE — recomputed, and a diff failure aborts rather than
1504
+ * closes, so a transient git error can never eat a live request.
1505
+ *
1506
+ * Returns true when THIS call closed it.
1507
+ */
1508
+ async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
1509
+ const summary = await this.prs.getPr(number);
1510
+ if (!summary || summary.state !== 'open') return false;
1511
+ let paths: string[];
1512
+ try {
1513
+ const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
1514
+ paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
1515
+ } catch (err) {
1516
+ console.warn(
1517
+ `[cr] empty-check for change request #${number} failed — leaving it open:`,
1518
+ err,
1519
+ );
1520
+ return false;
1521
+ }
1522
+ if (paths.length > 0) return false;
1523
+
1524
+ // Guard on `state = 'open'` so a concurrent merge or withdraw wins the
1525
+ // race and this becomes a no-op.
1526
+ const now = new Date();
1527
+ const updated = await this.db
1528
+ .update(changeRequests)
1529
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
1530
+ .where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
1531
+ .returning({ id: changeRequests.id });
1532
+ if (updated.length === 0) return false;
1533
+ this.prs.invalidateDetailCache(number);
1534
+ this.events?.emit({ kind: 'change-request-rejected', number });
1535
+ await this.retireMergedSourceBranch(number, summary.base, user);
1536
+ return true;
1537
+ }
1538
+
1265
1539
  /**
1266
1540
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
1267
1541
  * authorized set is:
@@ -1443,9 +1717,75 @@ export class WorkflowService implements IWorkflowService {
1443
1717
  }
1444
1718
  this.prs.invalidateDetailCache(number);
1445
1719
  this.events?.emit({ kind: 'change-request-merged', number });
1720
+ // AFTER the event: the applying UI is waiting on `change-request-merged`,
1721
+ // and branch retirement is git IO it must never wait behind.
1722
+ await this.retireMergedSourceBranch(number, baseBranch, user);
1446
1723
  return { kind: 'merged', result };
1447
1724
  }
1448
1725
 
1726
+ /**
1727
+ * Best-effort retirement of a merged change request's source branch — the
1728
+ * standard "delete branch on merge" a git host performs, done here because
1729
+ * this app IS the host. Everything the branch carried is on the target
1730
+ * now, and leaving it behind has a real cost: the propose flow REUSES an
1731
+ * existing branch by name, so a leftover base goes stale against the
1732
+ * target and seeds the next proposal with outdated text (or, worse,
1733
+ * re-proposes withdrawn commits).
1734
+ *
1735
+ * Refuses quietly when the branch is protected or still carries another
1736
+ * OPEN request. Runs from the TARGET's workspace (guaranteed not to be
1737
+ * checked out on the branch being deleted) with `systemCleanup`, since the
1738
+ * merger need not be the branch's author — the merge itself was the
1739
+ * authorization. Never throws: the merge has already succeeded, and a
1740
+ * cleanup failure only means the next proposal falls back to branch reuse.
1741
+ */
1742
+ private async retireMergedSourceBranch(
1743
+ number: number,
1744
+ baseBranch: string,
1745
+ user: AuthUser,
1746
+ ): Promise<void> {
1747
+ try {
1748
+ const rows = await this.db
1749
+ .select({ sourceBranch: changeRequests.sourceBranch })
1750
+ .from(changeRequests)
1751
+ .where(eq(changeRequests.number, number))
1752
+ .limit(1);
1753
+ const sourceBranch = rows[0]?.sourceBranch;
1754
+ if (!sourceBranch || isProtectedBranch(sourceBranch)) return;
1755
+ // The open-check and the deletion hold the branch's lifecycle lock
1756
+ // TOGETHER — `openChangeRequest` holds the same lock across its own
1757
+ // check-then-insert, so a request being opened from this branch either
1758
+ // lands its row before the check below (retirement backs off) or waits
1759
+ // until retirement finishes (and fails loudly on the missing branch,
1760
+ // retryable, instead of losing the branch mid-open).
1761
+ await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
1762
+ const stillOpen = await this.db
1763
+ .select({ number: changeRequests.number })
1764
+ .from(changeRequests)
1765
+ .where(
1766
+ and(
1767
+ eq(changeRequests.sourceBranch, sourceBranch),
1768
+ eq(changeRequests.state, 'open'),
1769
+ ),
1770
+ )
1771
+ .limit(1);
1772
+ if (stillOpen.length > 0) return;
1773
+
1774
+ const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
1775
+ // The remote-tracking ref must be current, or deleteBranch's
1776
+ // "does origin still have it?" probe skips the remote delete.
1777
+ await this.workspaceService.ensureRemotesFetched(targetWs.id).catch(() => {});
1778
+ await this.deleteBranchUnlocked(targetWs.id, sourceBranch, user, { systemCleanup: true });
1779
+ console.log(`[merge] retired merged source branch "${sourceBranch}"`);
1780
+ });
1781
+ } catch (err) {
1782
+ console.warn(
1783
+ `[merge] could not retire the merged source branch of change request #${number} (non-fatal):`,
1784
+ err,
1785
+ );
1786
+ }
1787
+ }
1788
+
1449
1789
  /**
1450
1790
  * Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
1451
1791
  * `roles.yaml` differs from the base branch's, restore the base version on the