@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
@@ -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
  });
@@ -1,72 +1,82 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { PendingCommitsService } from '../pending-commits.service.js';
3
- import type { Database } from '../../database/connection.js';
4
-
5
- /**
6
- * `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
7
- * insert only when NO row exists for the (workspace, branch, path) in ANY
8
- * status. A plain `enqueue` would refresh an existing pending row and reset
9
- * its retry counters — and the dispatch path fires on every sync attempt,
10
- * so those resets would starve the worker's ladder and the recovery agent
11
- * would never spawn. A `needs_attention` row must also block re-entry (the
12
- * ladder already escalated to a human; more agent runs are just a loop).
13
- *
14
- * Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
15
- * behavior (claim fencing etc.) is covered elsewhere; this pins the
16
- * check-then-insert decision logic.
17
- */
18
- function makeFakeDb(existingCount: number) {
19
- const inserted: unknown[] = [];
20
- const selectChain: any = {};
21
- selectChain.from = vi.fn(() => selectChain);
22
- selectChain.where = vi.fn(() => selectChain);
23
- selectChain.then = (onF: any, onR: any) =>
24
- Promise.resolve([{ count: existingCount }]).then(onF, onR);
25
- const insertChain: any = {};
26
- insertChain.values = vi.fn((v: unknown) => {
27
- inserted.push(v);
28
- return insertChain;
29
- });
30
- insertChain.then = (onF: any, onR: any) => Promise.resolve(undefined).then(onF, onR);
31
- const db = {
32
- select: vi.fn(() => selectChain),
33
- insert: vi.fn(() => insertChain),
34
- } as unknown as Database;
35
- return { db, inserted };
36
- }
37
-
38
- const INPUT = {
39
- workspaceId: 'razvan.radulescu/feature',
40
- branch: 'razvan.radulescu/feature',
41
- path: 'PR-Overviews/PR-7.html',
42
- authorEmail: 'Alice@Example.com',
43
- authorName: 'Alice',
44
- };
45
-
46
- describe('PendingCommitsService.enqueueIfAbsent', () => {
47
- it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
48
- const { db, inserted } = makeFakeDb(0);
49
- const svc = new PendingCommitsService(db);
50
-
51
- await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
52
-
53
- expect(inserted).toHaveLength(1);
54
- expect(inserted[0]).toMatchObject({
55
- // Same canonicalization as `enqueue` — the worker claims per the
56
- // ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
57
- workspaceId: 'razvan.radulescu%2Ffeature',
58
- branch: 'razvan.radulescu/feature',
59
- path: 'PR-Overviews/PR-7.html',
60
- authorEmail: 'alice@example.com',
61
- authorName: 'Alice',
62
- });
63
- });
64
-
65
- it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
66
- const { db, inserted } = makeFakeDb(1);
67
- const svc = new PendingCommitsService(db);
68
-
69
- await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
70
- expect(inserted).toHaveLength(0);
71
- });
72
- });
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PendingCommitsService } from '../pending-commits.service.js';
3
+ import type { Database } from '../../database/connection.js';
4
+
5
+ /**
6
+ * `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
7
+ * insert only when NO row exists for the (workspace, branch, path) in ANY
8
+ * status. A plain `enqueue` would refresh an existing pending row and reset
9
+ * its retry counters — and the dispatch path fires on every sync attempt,
10
+ * so those resets would starve the worker's ladder and the recovery agent
11
+ * would never spawn. A `needs_attention` row must also block re-entry (the
12
+ * ladder already escalated to a human; more agent runs are just a loop).
13
+ *
14
+ * Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
15
+ * behavior (claim fencing etc.) is covered elsewhere; this pins the
16
+ * check-then-insert decision logic.
17
+ */
18
+ interface SelectChain extends PromiseLike<unknown> {
19
+ from: (...args: unknown[]) => SelectChain;
20
+ where: (...args: unknown[]) => SelectChain;
21
+ }
22
+
23
+ interface InsertChain extends PromiseLike<unknown> {
24
+ values: (...args: unknown[]) => InsertChain;
25
+ }
26
+
27
+ function makeFakeDb(existingCount: number) {
28
+ const inserted: unknown[] = [];
29
+ const selectChain: SelectChain = {
30
+ from: vi.fn(() => selectChain),
31
+ where: vi.fn(() => selectChain),
32
+ then: (onF, onR) => Promise.resolve([{ count: existingCount }]).then(onF, onR),
33
+ };
34
+ const insertChain: InsertChain = {
35
+ values: vi.fn((v: unknown) => {
36
+ inserted.push(v);
37
+ return insertChain;
38
+ }),
39
+ then: (onF, onR) => Promise.resolve(undefined).then(onF, onR),
40
+ };
41
+ const db = {
42
+ select: vi.fn(() => selectChain),
43
+ insert: vi.fn(() => insertChain),
44
+ } as unknown as Database;
45
+ return { db, inserted };
46
+ }
47
+
48
+ const INPUT = {
49
+ workspaceId: 'razvan.radulescu/feature',
50
+ branch: 'razvan.radulescu/feature',
51
+ path: 'PR-Overviews/PR-7.html',
52
+ authorEmail: 'Alice@Example.com',
53
+ authorName: 'Alice',
54
+ };
55
+
56
+ describe('PendingCommitsService.enqueueIfAbsent', () => {
57
+ it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
58
+ const { db, inserted } = makeFakeDb(0);
59
+ const svc = new PendingCommitsService(db);
60
+
61
+ await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
62
+
63
+ expect(inserted).toHaveLength(1);
64
+ expect(inserted[0]).toMatchObject({
65
+ // Same canonicalization as `enqueue` — the worker claims per the
66
+ // ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
67
+ workspaceId: 'razvan.radulescu%2Ffeature',
68
+ branch: 'razvan.radulescu/feature',
69
+ path: 'PR-Overviews/PR-7.html',
70
+ authorEmail: 'alice@example.com',
71
+ authorName: 'Alice',
72
+ });
73
+ });
74
+
75
+ it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
76
+ const { db, inserted } = makeFakeDb(1);
77
+ const svc = new PendingCommitsService(db);
78
+
79
+ await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
80
+ expect(inserted).toHaveLength(0);
81
+ });
82
+ });
@@ -12,12 +12,20 @@ import type { IAccessControl } from '../../access/access-control.interface.js';
12
12
  import { FileLockService } from '../file-lock.service.js';
13
13
  import { PendingCommitsService } from '../pending-commits.service.js';
14
14
  import { WorkflowService } from '../workflow.service.js';
15
+ import { PullRebaseConflictError } from '../workflow.errors.js';
15
16
  import type { Database } from '../../database/connection.js';
16
17
 
17
- // The facade tests never exercise `openChangeRequest` (the only method that
18
- // touches the DB), so a bare stub is enough to satisfy the constructor.
19
- function makeDb(): Database {
20
- 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;
21
29
  }
22
30
 
23
31
  function makeWorkspaceService(): WorkspaceService {
@@ -28,6 +36,9 @@ function makeWorkspaceService(): WorkspaceService {
28
36
  getWorkspacePath: vi.fn().mockResolvedValue('/tmp/ws'),
29
37
  sweepOrphanedWorkspaces: vi.fn().mockResolvedValue({ removed: [] }),
30
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),
31
42
  } as unknown as WorkspaceService;
32
43
  }
33
44
 
@@ -50,6 +61,7 @@ function makePendingCommits(): PendingCommitsService {
50
61
  // behavior is covered by `pending-commits.service.test.ts`.
51
62
  return {
52
63
  enqueue: vi.fn().mockResolvedValue(undefined),
64
+ enqueueIfAbsent: vi.fn().mockResolvedValue(true),
53
65
  claimNext: vi.fn().mockResolvedValue(null),
54
66
  markSucceeded: vi.fn().mockResolvedValue(undefined),
55
67
  markTransientFailure: vi.fn().mockResolvedValue(undefined),
@@ -128,7 +140,6 @@ function makeGit(): GitService {
128
140
  diffStat: vi.fn(),
129
141
  pendingChanges: vi.fn(),
130
142
  resolveForkBase: vi.fn(),
131
- revertCommit: vi.fn(),
132
143
  logForFile: vi.fn(),
133
144
  diffFileAtCommit: vi.fn(),
134
145
  diffFileBetweenBranches: vi.fn(),
@@ -327,6 +338,48 @@ describe('WorkflowService — change request delegation + cache invalidation', (
327
338
  );
328
339
  expect(prs.invalidateDetailCache).toHaveBeenCalledWith(4);
329
340
  });
341
+
342
+ it('mergeChangeRequest still merges when the post-merge pull conflicts, and queues recovery on the TARGET workspace', async () => {
343
+ const prs = makePrs();
344
+ const reviewWorkflow = makeReviewWorkflow();
345
+ const mergeResult = { prNumber: 4, sha: 'abc', mergedAt: 't' };
346
+ (reviewWorkflow.mergePr as ReturnType<typeof vi.fn>).mockResolvedValue(mergeResult);
347
+ (prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({ branch: 'alice/feat', base: 'main' });
348
+
349
+ const git = makeGit();
350
+ const conflict = new PullRebaseConflictError(
351
+ 'main',
352
+ // Deliberately unsorted — the representative row must be the smallest.
353
+ ['b-second.md', 'a-first.md'],
354
+ 'git rebase failed: could not apply deadbee',
355
+ );
356
+ (git.pull as ReturnType<typeof vi.fn>).mockRejectedValue(conflict);
357
+ const workspaceService = makeWorkspaceService();
358
+ // Return an id that ISN'T derivable from the branch name, pinning that
359
+ // the dispatch uses the id `getOrCreateForBranch` returned rather than
360
+ // re-deriving it.
361
+ (workspaceService.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockResolvedValue({
362
+ id: 'resolved-target-ws',
363
+ });
364
+ const pending = makePendingCommits();
365
+ const svc = new WorkflowService(makeDb(), git, prs, reviewWorkflow, workspaceService, makeAccessControl(), makeFileLockService(), pending, 'knowledge-base');
366
+
367
+ const outcome = await svc.mergeChangeRequest(
368
+ 4, makeUser(), 'sha', [], 'open', 'PR title', 'main', 'w1', { bypass: true },
369
+ );
370
+
371
+ // The merge already landed on origin — a stuck target workspace must not
372
+ // fail the response.
373
+ expect(outcome).toEqual({ kind: 'merged', result: mergeResult });
374
+ expect(pending.enqueueIfAbsent).toHaveBeenCalledTimes(1);
375
+ expect(pending.enqueueIfAbsent).toHaveBeenCalledWith({
376
+ workspaceId: 'resolved-target-ws',
377
+ branch: 'main',
378
+ path: 'a-first.md',
379
+ authorEmail: 'alice@example.com',
380
+ authorName: 'Alice',
381
+ });
382
+ });
330
383
  });
331
384
 
332
385
  describe('WorkflowService — file lock delegation', () => {
@@ -368,6 +421,48 @@ describe('WorkflowService — file lock delegation', () => {
368
421
  expect(fileLocks.release).toHaveBeenCalledWith('w1', 'feat', 'Foo.md', expect.any(Object));
369
422
  });
370
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
+
371
466
  it('releaseLock refuses when the caller does not hold the lock', async () => {
372
467
  const git = makeGit();
373
468
  const fileLocks = makeFileLockService(); // default: get → null
@@ -378,3 +473,198 @@ describe('WorkflowService — file lock delegation', () => {
378
473
  expect(git.commitFile).not.toHaveBeenCalled();
379
474
  });
380
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
  }