@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
@@ -319,4 +319,39 @@ describe('WorkflowEventBus', () => {
319
319
  expect(bus.size()).toBe(0);
320
320
  });
321
321
  });
322
+
323
+ describe('onEmit — the server-side tap', () => {
324
+ it('sees every event, unfiltered by SSE scope, and unsubscribes cleanly', () => {
325
+ const bus = new WorkflowEventBus();
326
+ const seen: string[] = [];
327
+ const off = bus.onEmit((e) => seen.push(e.kind));
328
+ // A workspace-scoped event with NO matching SSE session still reaches
329
+ // the tap — scope filtering exists for sessions, not in-process
330
+ // consumers (write-time cache invalidation must never miss one).
331
+ bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
332
+ expect(seen).toEqual(['fs-tree-changed']);
333
+ off();
334
+ bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
335
+ expect(seen).toEqual(['fs-tree-changed']);
336
+ });
337
+
338
+ it('a throwing listener is contained — emit never propagates it', () => {
339
+ const bus = new WorkflowEventBus();
340
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
341
+ bus.onEmit(() => {
342
+ throw new Error('bad listener');
343
+ });
344
+ const after: string[] = [];
345
+ bus.onEmit((e) => after.push(e.kind));
346
+ expect(() =>
347
+ bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' }),
348
+ ).not.toThrow();
349
+ // …and the listeners after the bad one still ran.
350
+ expect(after).toEqual(['fs-tree-changed']);
351
+ // The containment is LOGGED, not silent — a listener dying quietly
352
+ // would be undebuggable.
353
+ expect(warn).toHaveBeenCalledWith('[event-bus] onEmit listener threw:', 'bad listener');
354
+ warn.mockRestore();
355
+ });
356
+ });
322
357
  });
@@ -15,10 +15,17 @@ import { WorkflowService } from '../workflow.service.js';
15
15
  import { PullRebaseConflictError } from '../workflow.errors.js';
16
16
  import type { Database } from '../../database/connection.js';
17
17
 
18
- // The facade tests never exercise `openChangeRequest` (the only method that
19
- // touches the DB), so a bare stub is enough to satisfy the constructor.
20
- function makeDb(): Database {
21
- return {} as unknown as Database;
18
+ // `deleteBranch`'s open-request guard is the only DB touch these tests
19
+ // exercise: a chainable select→from→where→limit stub resolving `rows` covers
20
+ // it (rows default to "no open request").
21
+ function makeDb(rows: unknown[] = []): Database {
22
+ const chain = {
23
+ select: vi.fn(() => chain),
24
+ from: vi.fn(() => chain),
25
+ where: vi.fn(() => chain),
26
+ limit: vi.fn(async () => rows),
27
+ };
28
+ return chain as unknown as Database;
22
29
  }
23
30
 
24
31
  function makeWorkspaceService(): WorkspaceService {
@@ -29,6 +36,9 @@ function makeWorkspaceService(): WorkspaceService {
29
36
  getWorkspacePath: vi.fn().mockResolvedValue('/tmp/ws'),
30
37
  sweepOrphanedWorkspaces: vi.fn().mockResolvedValue({ removed: [] }),
31
38
  getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch) })),
39
+ ensureRemotesFetched: vi.fn().mockResolvedValue(undefined),
40
+ hasBootstrappedWorkspace: vi.fn().mockResolvedValue(false),
41
+ deleteWorkspace: vi.fn().mockResolvedValue(undefined),
32
42
  } as unknown as WorkspaceService;
33
43
  }
34
44
 
@@ -130,7 +140,6 @@ function makeGit(): GitService {
130
140
  diffStat: vi.fn(),
131
141
  pendingChanges: vi.fn(),
132
142
  resolveForkBase: vi.fn(),
133
- revertCommit: vi.fn(),
134
143
  logForFile: vi.fn(),
135
144
  diffFileAtCommit: vi.fn(),
136
145
  diffFileBetweenBranches: vi.fn(),
@@ -412,6 +421,48 @@ describe('WorkflowService — file lock delegation', () => {
412
421
  expect(fileLocks.release).toHaveBeenCalledWith('w1', 'feat', 'Foo.md', expect.any(Object));
413
422
  });
414
423
 
424
+ it('deleteBranch refuses while the branch carries an open change request', async () => {
425
+ const git = makeGit();
426
+ // One open request rides the branch — deleting it would strand the request.
427
+ const svc = new WorkflowService(makeDb([{ number: 7 }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
428
+ await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).rejects.toThrow(/open change request \(#7\)/);
429
+ expect(git.deleteBranch).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it('deleteBranch deletes when the branch has no open change request', async () => {
433
+ const git = makeGit();
434
+ const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
435
+ await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).resolves.toBeUndefined();
436
+ expect(git.deleteBranch).toHaveBeenCalledWith('w1', 'feat/x', expect.objectContaining({ email: 'alice@example.com' }), undefined);
437
+ });
438
+
439
+ /**
440
+ * The lifecycle lock is what keeps merge-time retirement from deleting a
441
+ * branch mid-`openChangeRequest`. The full interleaving needs a real git
442
+ * repo + DB; what IS unit-testable is the lock's contract — two operations
443
+ * keyed on the same branch never overlap, the second fully waiting out the
444
+ * first.
445
+ */
446
+ it('serialises same-branch lifecycle operations', async () => {
447
+ const order: string[] = [];
448
+ const git = makeGit();
449
+ let releaseFirst!: () => void;
450
+ const gate = new Promise<void>((resolve) => { releaseFirst = resolve; });
451
+ vi.mocked(git.deleteBranch)
452
+ .mockImplementationOnce(async () => { order.push('first:start'); await gate; order.push('first:end'); })
453
+ .mockImplementationOnce(async () => { order.push('second'); });
454
+ const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
455
+ const first = svc.deleteBranch('w1', 'feat/x', makeUser());
456
+ const second = svc.deleteBranch('w1', 'feat/x', makeUser());
457
+ // Only release the gate once the first operation is provably inside its
458
+ // critical section — otherwise a non-serialised second could sneak
459
+ // through before 'first:start' and the assertion would not distinguish.
460
+ await vi.waitFor(() => { expect(order).toContain('first:start'); });
461
+ releaseFirst();
462
+ await Promise.all([first, second]);
463
+ expect(order).toEqual(['first:start', 'first:end', 'second']);
464
+ });
465
+
415
466
  it('releaseLock refuses when the caller does not hold the lock', async () => {
416
467
  const git = makeGit();
417
468
  const fileLocks = makeFileLockService(); // default: get → null
@@ -422,3 +473,198 @@ describe('WorkflowService — file lock delegation', () => {
422
473
  expect(git.commitFile).not.toHaveBeenCalled();
423
474
  });
424
475
  });
476
+
477
+ /**
478
+ * The reviewer's per-file scalpel, and the empty-close it can trigger. All
479
+ * collaborators stubbed — what's under test is the orchestration: the auth
480
+ * predicate, the restore→commit→push order, the lock discipline, and the
481
+ * authoritative emptiness re-check before anything closes.
482
+ */
483
+ describe('WorkflowService — revertChangeRequestFile / closeEmptyChangeRequest', () => {
484
+ const SUMMARY = { number: 7, base: 'main', branch: 'ali/x', state: 'open' };
485
+
486
+ function makeRevertGit(overrides: Record<string, unknown> = {}): GitService {
487
+ return Object.assign(makeGit(), {
488
+ pull: vi.fn().mockResolvedValue(undefined),
489
+ changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md', 'Docs/b.md']),
490
+ mergeBaseForPr: vi.fn().mockResolvedValue('mb-sha'),
491
+ restorePathFromRef: vi.fn().mockResolvedValue(undefined),
492
+ commitFile: vi.fn().mockResolvedValue({}),
493
+ push: vi.fn().mockResolvedValue(undefined),
494
+ ...overrides,
495
+ }) as unknown as GitService;
496
+ }
497
+
498
+ function makeHarness(opts: {
499
+ git?: GitService;
500
+ canWriteAtRef?: boolean | null;
501
+ prState?: string;
502
+ updateRows?: unknown[];
503
+ } = {}) {
504
+ const git = opts.git ?? makeRevertGit();
505
+ const prs = makePrs();
506
+ (prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
507
+ ...SUMMARY,
508
+ state: opts.prState ?? 'open',
509
+ });
510
+ const access = makeAccessControl();
511
+ (access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(
512
+ opts.canWriteAtRef ?? true,
513
+ );
514
+ const fileLocks = makeFileLockService();
515
+ // The chainable stub answers SELECTs with [] (no open request rows) and
516
+ // UPDATE...returning with `updateRows` (default: one row = the close won).
517
+ const updateRows = opts.updateRows ?? [{ id: 1 }];
518
+ const chain: Record<string, unknown> = {};
519
+ Object.assign(chain, {
520
+ select: vi.fn(() => chain),
521
+ from: vi.fn(() => chain),
522
+ where: vi.fn(() => chain),
523
+ limit: vi.fn(async () => []),
524
+ update: vi.fn(() => chain),
525
+ set: vi.fn(() => chain),
526
+ returning: vi.fn(async () => updateRows),
527
+ });
528
+ const db = chain as unknown as Database;
529
+ const svc = new WorkflowService(db, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, fileLocks, makePendingCommits(), 'knowledge-base');
530
+ return { svc, git, prs, access, fileLocks, db: chain };
531
+ }
532
+
533
+ it('restores the merge-base copy on the source branch: restore, commit (validator skipped), push — under the file lock', async () => {
534
+ const { svc, git, prs, fileLocks } = makeHarness();
535
+ const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
536
+
537
+ expect(git.restorePathFromRef).toHaveBeenCalledWith('ali%2Fx', 'mb-sha', 'Docs/a.md');
538
+ expect(git.commitFile).toHaveBeenCalledWith(
539
+ 'ali%2Fx',
540
+ expect.objectContaining({ email: 'alice@example.com' }),
541
+ 'Docs/a.md',
542
+ expect.stringContaining('change request #7'),
543
+ true,
544
+ );
545
+ expect(git.push).toHaveBeenCalled();
546
+ expect(fileLocks.acquire).toHaveBeenCalledWith(
547
+ 'ali%2Fx',
548
+ 'ali/x',
549
+ 'knowledge-base/Docs/a.md',
550
+ expect.anything(),
551
+ );
552
+ expect(fileLocks.release).toHaveBeenCalled();
553
+ expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
554
+ // Both diffs answered two paths → one revert leaves one.
555
+ expect(result).toEqual({ closed: false, remainingPaths: ['Docs/a.md', 'Docs/b.md'] });
556
+ });
557
+
558
+ it('refuses a caller who could not approve the file — same permission, both verbs', async () => {
559
+ const { svc, git } = makeHarness({ canWriteAtRef: false });
560
+ await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
561
+ status: 403,
562
+ });
563
+ expect(git.restorePathFromRef).not.toHaveBeenCalled();
564
+ expect(git.push).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it('refuses a path the request does not touch', async () => {
568
+ const { svc, git } = makeHarness();
569
+ await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/elsewhere.md')).rejects.toMatchObject({
570
+ status: 422,
571
+ });
572
+ expect(git.restorePathFromRef).not.toHaveBeenCalled();
573
+ });
574
+
575
+ it('refuses a request that is no longer open', async () => {
576
+ const { svc } = makeHarness({ prState: 'closed' });
577
+ await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
578
+ status: 422,
579
+ });
580
+ });
581
+
582
+ it('closes the request when the LAST file is reverted', async () => {
583
+ const git = makeRevertGit({
584
+ changedPathsForPr: vi
585
+ .fn()
586
+ // in order: the pre-revert list, the post-revert remainder, and the
587
+ // close path's own authoritative re-check.
588
+ .mockResolvedValueOnce(['Docs/a.md'])
589
+ .mockResolvedValueOnce([])
590
+ .mockResolvedValueOnce([]),
591
+ });
592
+ const { svc, db } = makeHarness({ git });
593
+ const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
594
+ expect(result).toEqual({ closed: true, remainingPaths: [] });
595
+ expect(db.update).toHaveBeenCalled();
596
+ });
597
+
598
+ it('closeEmptyChangeRequest never closes on a FAILED diff', async () => {
599
+ const git = makeRevertGit({
600
+ changedPathsForPr: vi.fn().mockRejectedValue(new Error('git down')),
601
+ });
602
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
603
+ const { svc, db } = makeHarness({ git });
604
+ await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
605
+ expect(db.update).not.toHaveBeenCalled();
606
+ warn.mockRestore();
607
+ });
608
+
609
+ it('closeEmptyChangeRequest leaves a request with real changes alone', async () => {
610
+ const { svc, db } = makeHarness();
611
+ await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
612
+ expect(db.update).not.toHaveBeenCalled();
613
+ });
614
+
615
+ it('closeEmptyChangeRequest closes an emptied request', async () => {
616
+ const git = makeRevertGit({ changedPathsForPr: vi.fn().mockResolvedValue([]) });
617
+ const { svc, db, prs } = makeHarness({ git });
618
+ await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(true);
619
+ expect(db.update).toHaveBeenCalled();
620
+ expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
621
+ });
622
+ });
623
+
624
+ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () => {
625
+ function makeDeleteHarness(opts: { isAdmin?: boolean; prState?: string } = {}) {
626
+ const git = Object.assign(makeGit(), {
627
+ changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md']),
628
+ }) as unknown as GitService;
629
+ const prs = makePrs();
630
+ (prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
631
+ number: 9,
632
+ base: 'main',
633
+ branch: 'mallory/spam',
634
+ state: opts.prState ?? 'open',
635
+ });
636
+ const access = makeAccessControl();
637
+ (access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.isAdmin ?? false);
638
+ const chain: Record<string, unknown> = {};
639
+ Object.assign(chain, {
640
+ select: vi.fn(() => chain),
641
+ from: vi.fn(() => chain),
642
+ where: vi.fn(() => chain),
643
+ limit: vi.fn(async () => []),
644
+ update: vi.fn(() => chain),
645
+ set: vi.fn(() => chain),
646
+ returning: vi.fn(async () => [{ id: 1 }]),
647
+ });
648
+ const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), 'knowledge-base');
649
+ return { svc, prs, db: chain, access };
650
+ }
651
+
652
+ it('refuses a non-admin with 403 and touches nothing', async () => {
653
+ const { svc, db } = makeDeleteHarness({ isAdmin: false });
654
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
655
+ expect(db.update).not.toHaveBeenCalled();
656
+ });
657
+
658
+ it('an admin closes the request (row flipped, cache dropped)', async () => {
659
+ const { svc, prs, db } = makeDeleteHarness({ isAdmin: true });
660
+ await svc.deleteChangeRequest(9, makeUser());
661
+ expect(db.update).toHaveBeenCalled();
662
+ expect(prs.invalidateDetailCache).toHaveBeenCalledWith(9);
663
+ });
664
+
665
+ it('refuses a merged request — applied history is not deletable', async () => {
666
+ const { svc, db } = makeDeleteHarness({ isAdmin: true, prState: 'merged' });
667
+ await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 422 });
668
+ expect(db.update).not.toHaveBeenCalled();
669
+ });
670
+ });
@@ -7,7 +7,9 @@ import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
7
7
  import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
8
8
  import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
9
9
 
10
- const PROTECTED_INLINE = [...PROTECTED_BRANCHES].map((b) => `\`${b}\``).join(' / ');
10
+ // A function, not a constant: the branch model is applied during boot, and a
11
+ // module-scope capture would freeze this at the empty set that exists before it.
12
+ const protectedInline = () => [...PROTECTED_BRANCHES].map((b) => `\`${b}\``).join(' / ');
11
13
 
12
14
  // Output sub-schemas for the git-aliased workflow payloads (Branch, Change,
13
15
  // ChangeRequestComment, …). Defined once and referenced by the individual tool
@@ -268,7 +270,7 @@ export function registerWorkflowTools(
268
270
  name: 'create_branch',
269
271
  description:
270
272
  'Create a new unprotected draft named `name`, forked from the existing branch `branch`. The ' +
271
- `backend rejects protected names (${PROTECTED_INLINE}) and filesystem-invalid names — surface ` +
273
+ `backend rejects protected names (${protectedInline()}) and filesystem-invalid names — surface ` +
272
274
  'those errors verbatim. Convention: `<email-localpart>/<kebab-slug>`. Does NOT switch the ' +
273
275
  'agent onto the new draft.',
274
276
  // `branch` here is the fork BASE — its own meaning — so skip the generic
@@ -91,6 +91,21 @@ export class WorkflowEventBus {
91
91
  private nextId = 1;
92
92
  private readonly buffer: WorkflowEvent[] = [];
93
93
  private readonly subscribers = new Map<string, Subscriber>();
94
+ private readonly emitListeners = new Set<(event: WorkflowEvent) => void>();
95
+
96
+ /**
97
+ * Server-side tap: called for EVERY emitted event, before the per-session
98
+ * scope filtering (which exists for SSE fan-out, not for in-process
99
+ * consumers). The composition root uses this for write-time cache
100
+ * invalidation — the routes emit `fs-tree-changed` the moment bytes hit a
101
+ * working tree, long before the async commit reaches `FileChangeNotifier`.
102
+ * Best-effort like SSE dispatch: a throwing listener is logged, never
103
+ * propagated to the emitter. Returns the unsubscribe.
104
+ */
105
+ onEmit(listener: (event: WorkflowEvent) => void): () => void {
106
+ this.emitListeners.add(listener);
107
+ return () => this.emitListeners.delete(listener);
108
+ }
94
109
 
95
110
  /**
96
111
  * Stamp `payload` with `id` + `ts`, append to the ring buffer, then fan
@@ -108,6 +123,16 @@ export class WorkflowEventBus {
108
123
  if (this.buffer.length > BUFFER_CAPACITY) {
109
124
  this.buffer.shift();
110
125
  }
126
+ for (const listener of this.emitListeners) {
127
+ try {
128
+ listener(event);
129
+ } catch (err) {
130
+ console.warn(
131
+ '[event-bus] onEmit listener threw:',
132
+ err instanceof Error ? err.message : err,
133
+ );
134
+ }
135
+ }
111
136
  // Single-line emit log with all the fields most useful for tracing a
112
137
  // failed delivery: which event, which scope it'll filter on, and the
113
138
  // total subscriber pool size before fan-out. Per-subscriber matched/
@@ -266,4 +266,21 @@ export class FileLockService {
266
266
  }
267
267
  return rowToFileLock(row);
268
268
  }
269
+
270
+ /**
271
+ * Is ANY unexpired lock held anywhere in this workspace? Feeds the git
272
+ * status sanity check: a held lock means a mutation is mid-flight — the
273
+ * file may already be changed (or deleted) on disk while its commit is only
274
+ * queued at RELEASE — so a dirty tree during that window is expected, not
275
+ * the "missed lock-release commit" the loud warning is for. Expired rows
276
+ * don't count (their holder is gone; they explain nothing).
277
+ */
278
+ async hasAnyActive(workspaceId: string): Promise<boolean> {
279
+ const rows = await this.db
280
+ .select({ path: fileLocks.path })
281
+ .from(fileLocks)
282
+ .where(and(eq(fileLocks.workspaceId, workspaceId), gt(fileLocks.expiresAt, new Date())))
283
+ .limit(1);
284
+ return rows.length > 0;
285
+ }
269
286
  }
@@ -232,55 +232,6 @@ describe('GitService — commit gate uses HEAD (not working tree)', () => {
232
232
  });
233
233
  });
234
234
 
235
- describe('GitService — revert gate uses HEAD', () => {
236
- let root: string;
237
- const workspaceId = 'ws-revert-gate';
238
-
239
- beforeEach(async () => {
240
- root = await mkTmpRoot();
241
- });
242
- afterEach(async () => {
243
- await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
244
- });
245
-
246
- it('passes ref=HEAD when gating a revert', async () => {
247
- const { ac, calls } = recordingAccessControl();
248
- const { svc, repo } = await makeSvc(root, workspaceId, ac);
249
- await commitFile(repo, 'Knowledge/Foo.md', 'a\n', 'seed');
250
- await commitFile(repo, 'Knowledge/Foo.md', 'b\n', 'second');
251
- const { stdout: sha } = await execFileAsync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
252
- encoding: 'utf-8',
253
- });
254
-
255
- // We only care that the gate is invoked with `HEAD`; downstream
256
- // behaviour of `git revert` (which may or may not succeed for unrelated
257
- // environmental reasons — author parsing, signing config, etc.) is out
258
- // of scope for this test.
259
- await svc.revertCommit(workspaceId, USER, sha.trim()).catch(() => undefined);
260
-
261
- const gateCalls = calls.filter((c) => c.method === 'canWriteBatchAtRef');
262
- expect(gateCalls).toHaveLength(1);
263
- expect(gateCalls[0].ref).toBe('HEAD');
264
- expect(gateCalls[0].paths).toContain('Knowledge/Foo.md');
265
- });
266
-
267
- it('refuses with AccessDeniedError when the gate denies', async () => {
268
- const { ac } = recordingAccessControl({
269
- canWriteBatchAtRef: (_ref, _email, paths) => new Map(paths.map((p) => [p, false])),
270
- });
271
- const { svc, repo } = await makeSvc(root, workspaceId, ac);
272
- await commitFile(repo, 'Knowledge/Foo.md', 'a\n', 'seed');
273
- await commitFile(repo, 'Knowledge/Foo.md', 'b\n', 'second');
274
- const { stdout: sha } = await execFileAsync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
275
- encoding: 'utf-8',
276
- });
277
-
278
- await expect(svc.revertCommit(workspaceId, USER, sha.trim())).rejects.toBeInstanceOf(
279
- AccessDeniedError,
280
- );
281
- });
282
- });
283
-
284
235
  describe('GitService — push gate uses origin/<branch> (not HEAD or working tree)', () => {
285
236
  let root: string;
286
237
  const workspaceId = 'ws-push-gate';
@@ -327,6 +278,15 @@ describe('GitService — push gate uses origin/<branch> (not HEAD or working tre
327
278
  expect(gateCalls[0].paths).toContain('Knowledge/Foo.md');
328
279
  });
329
280
 
281
+ it('systemAuthorized skips the gate entirely, and the push still lands', async () => {
282
+ // The group-provisioning path: its endpoint IS the authorization (any
283
+ // signed-in user may claim an unused name under Groups/), and the gate
284
+ // could only read the new folder's chain at origin as `write: Admin`.
285
+ const { svc, calls } = await setupWithOrigin();
286
+ await svc.push(workspaceId, USER, { systemAuthorized: true });
287
+ expect(calls.filter((c) => c.method === 'canWriteBatchAtRef')).toHaveLength(0);
288
+ });
289
+
330
290
  it('refuses the push when origin/<branch> rules deny the caller', async () => {
331
291
  const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
332
292
  const originDir = path.join(root, 'origin.git');
@@ -38,6 +38,14 @@ async function seedWorkspace(root: string, workspaceId: string) {
38
38
  const repo = path.join(workspaceDir, 'knowledge-base');
39
39
  await fs.mkdir(workspaceDir, { recursive: true });
40
40
  await runGit(root, ['clone', upstream, repo]);
41
+ // Repo-local identity, because `GitService` spawns its own git children
42
+ // with the AMBIENT env — `runGit`'s `GIT_COMMITTER_*` vars never reach
43
+ // them. A dev machine hides this behind a global git config; a CI runner
44
+ // has none, so anything committing through the service (here:
45
+ // `commitFile`) fails with "Committer identity unknown". Same reason
46
+ // `git.service.commitFile.test.ts` configures its fixture this way.
47
+ await runGit(repo, ['config', 'user.email', 'test@bevel.local']);
48
+ await runGit(repo, ['config', 'user.name', 'Test Runner']);
41
49
  return { upstream, repo };
42
50
  }
43
51
 
@@ -121,4 +129,97 @@ describe('GitService.changedFilesForPr / resolvePrShas', () => {
121
129
  expect(headSha).toMatch(/^[0-9a-f]{40}$/);
122
130
  expect(baseSha).not.toBe(headSha);
123
131
  });
132
+
133
+ /**
134
+ * roles.yaml can never change through a merge — `preserveBaseRolesYaml`
135
+ * restores the base copy onto the source before every merge — so the review
136
+ * surface must not list it as changed: the claim would be false, the empty
137
+ * diff reads as a bug, and its per-file approval would gate the merge on a
138
+ * change that cannot land. Counts stay aligned with the surviving files.
139
+ */
140
+ it('excludes roles.yaml from the changed-file list and the touched paths', async () => {
141
+ const { repo } = await seedWorkspace(root, workspaceId);
142
+ await runGit(repo, ['checkout', '-b', 'mallory/self-promote']);
143
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - mallory@x.com\n');
144
+ await fs.writeFile(path.join(repo, 'honest.md'), 'real change\nsecond line\n');
145
+ await runGit(repo, ['add', '-A']);
146
+ await runGit(repo, ['commit', '-m', 'work + attempted escalation']);
147
+ await runGit(repo, ['push', '-u', 'origin', 'mallory/self-promote']);
148
+
149
+ const git = new GitService(
150
+ stubWorkspaceService(workspaceId, repo),
151
+ new WorkflowHooks(),
152
+ 'knowledge-base',
153
+ );
154
+
155
+ const files = await git.changedFilesForPr(
156
+ workspaceId,
157
+ 'current-company-state',
158
+ 'mallory/self-promote',
159
+ );
160
+ expect(files.map((f) => f.path)).toEqual(['honest.md']);
161
+ // The counts filter moved in step with the statuses filter.
162
+ expect(files[0].additions).toBe(2);
163
+
164
+ const paths = await git.changedPathsForPr(
165
+ workspaceId,
166
+ 'current-company-state',
167
+ 'mallory/self-promote',
168
+ );
169
+ expect(paths).toEqual(['honest.md']);
170
+ });
171
+
172
+ /**
173
+ * The per-file revert's two primitives: the merge-base a revert restores
174
+ * from, and the restore itself — byte-exact via git, with "absent at the
175
+ * merge-base" meaning deletion (the revert of an added file).
176
+ */
177
+ it('mergeBaseForPr + restorePathFromRef restore a modified file and delete an added one', async () => {
178
+ const { repo } = await seedWorkspace(root, workspaceId);
179
+ await runGit(repo, ['checkout', '-b', 'alice/feature']);
180
+ await fs.writeFile(path.join(repo, 'base.md'), 'rewritten\n');
181
+ await fs.writeFile(path.join(repo, 'added.md'), 'brand new\n');
182
+ await runGit(repo, ['add', '-A']);
183
+ await runGit(repo, ['commit', '-m', 'feature work']);
184
+ await runGit(repo, ['push', '-u', 'origin', 'alice/feature']);
185
+
186
+ const git = new GitService(
187
+ stubWorkspaceService(workspaceId, repo),
188
+ new WorkflowHooks(),
189
+ 'knowledge-base',
190
+ );
191
+
192
+ const mergeBase = await git.mergeBaseForPr(workspaceId, 'current-company-state', 'alice/feature');
193
+ expect(mergeBase).toMatch(/^[0-9a-f]{40}$/);
194
+
195
+ await git.restorePathFromRef(workspaceId, mergeBase!, 'base.md');
196
+ // Normalized: a Windows dev box with core.autocrlf smudges the checkout
197
+ // to CRLF; the blob git commits back is what matters, not the smudge.
198
+ const restored = await fs.readFile(path.join(repo, 'base.md'), 'utf8');
199
+ expect(restored.replace(/\r\n/g, '\n')).toBe('base\n');
200
+
201
+ // Simulate the wreckage a previously-failed attempt leaves behind: a
202
+ // STAGED deletion (index entry gone). The restore must self-heal it —
203
+ // without the index realignment, the commit below fatals on `git add`.
204
+ await runGit(repo, ['rm', '--force', '--quiet', '--', 'added.md']);
205
+
206
+ await git.restorePathFromRef(workspaceId, mergeBase!, 'added.md');
207
+ await expect(fs.access(path.join(repo, 'added.md'))).rejects.toThrow();
208
+
209
+ // The chain that actually failed in production: committing the DELETION
210
+ // through commitFile, whose `git add -- <path>` can only stage it while
211
+ // the index still tracks the path — a `git rm`-based restore broke here
212
+ // with "pathspec did not match any files".
213
+ const committed = await git.commitFile(
214
+ workspaceId,
215
+ { id: 'u1', email: 'reviewer@x.com', name: 'Reviewer' },
216
+ 'added.md',
217
+ 'Revert added.md (declined in change request #1)',
218
+ true,
219
+ );
220
+ expect(committed).not.toBeNull();
221
+ await expect(
222
+ execFileAsync('git', ['-C', repo, 'cat-file', '-e', 'HEAD:added.md']),
223
+ ).rejects.toThrow();
224
+ });
124
225
  });