@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -41,7 +41,12 @@ async function makeHarness(): Promise<Harness> {
41
41
  const accessControl = { canRead, canReadAtRef } as unknown as IAccessControl;
42
42
  const workflow = {
43
43
  listChangesForFile: vi.fn(async () => []),
44
- compareFile: vi.fn(async () => ''),
44
+ compareFile: vi.fn(async (_w: string, _p: string, from: string, to: string, authorize?: (s: { from: string[]; to: string[] }) => Promise<void>) => {
45
+ // Each branch resolves to its served commit and, for `b`, a second one (a
46
+ // local head past the published tip): the verdict is asked at both.
47
+ await authorize?.({ from: [`sha-of-${from}`], to: [`sha-of-${to}`, `other-sha-of-${to}`] });
48
+ return { diff: '', from: `sha-of-${from}`, to: `sha-of-${to}` };
49
+ }),
45
50
  showFileAtChange: vi.fn(async () => ''),
46
51
  fileAtChange: vi.fn(async () => ({ baseline: null, current: 'x' })),
47
52
  };
@@ -113,23 +118,29 @@ describe('history routes enforce the read model', () => {
113
118
  expect(h.workflow.listChangesForFile).not.toHaveBeenCalled();
114
119
  });
115
120
 
116
- it('compare-file authorizes at every ref the comparison could serve', async () => {
117
- // The comparison prefers a local ref (and the working tree) over
118
- // `origin/<branch>`, so BOTH candidates are authorized per branch.
121
+ it('compare-file authorizes at the two commits the diff was served from, never at a ref', async () => {
122
+ // The diff is made first, pinned to the commit each side was read at,
123
+ // and authorized at those before any of it is answered: a branch ref a
124
+ // fetch moves meanwhile cannot have swapped in content the verdict never
125
+ // saw.
119
126
  h = await makeHarness();
120
127
  const res = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
121
128
  expect(res.status).toBe(200);
122
129
  const refs = h.canReadAtRef.mock.calls.map((c) => c[1]).sort();
123
- expect(refs).toEqual(['a', 'b', 'origin/a', 'origin/b']);
130
+ expect(refs).toEqual(['other-sha-of-b', 'sha-of-a', 'sha-of-b']);
124
131
 
125
- // Any RESOLVABLE ref denying the read refuses the diff.
126
- h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === 'b' ? false : true));
127
- expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
132
+ // Any commit a side resolves to denying the read refuses the diff — the
133
+ // served one or the other (a grant at the served commit never outruns a
134
+ // denial at the branch's other tip) — disclosing none of it.
135
+ for (const denying of ['sha-of-a', 'sha-of-b', 'other-sha-of-b']) {
136
+ h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === denying ? false : true));
137
+ const denied = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
138
+ expect(denied.status, denying).toBe(403);
139
+ expect(await denied.json()).not.toHaveProperty('diff');
140
+ }
128
141
 
129
- // A branch none of whose refs resolve cannot be authorized: refused.
130
- h.canReadAtRef.mockImplementation(async (_w: string, ref: string) =>
131
- ref.endsWith('b') ? null : true,
132
- );
142
+ // A commit whose rules cannot be read grants nothing: refused.
143
+ h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === 'sha-of-b' ? null : true));
133
144
  expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
134
145
  });
135
146
 
@@ -24,6 +24,7 @@ import type { WorkflowEventBus } from '../event-bus.js';
24
24
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
25
25
  import type { Database } from '../../database/connection.js';
26
26
  import { openChangeGate } from '../../../__tests__/open-change-gate.js';
27
+ import { ReadOnBranchDeniedError } from '../read-on-branch.js';
27
28
  import { hashEmail } from '../../../shared/email-identity.js';
28
29
 
29
30
  // `deleteBranch`'s open-request guard is the only DB touch these tests
@@ -274,6 +275,248 @@ describe('WorkflowService — branch delegation', () => {
274
275
  });
275
276
  });
276
277
 
278
+ describe('WorkflowService.updateBranch', () => {
279
+ const user = { id: 'u1', email: 'ada@x.io', name: 'Ada' };
280
+ /** A git double whose merge answers `outcome`, with the source on the remote. */
281
+ const gitThatMerges = (outcome: unknown) => {
282
+ const git = makeGit();
283
+ (git as unknown as { remoteBranchExists: unknown }).remoteBranchExists = vi.fn().mockResolvedValue(true);
284
+ (git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn().mockResolvedValue(outcome);
285
+ return git;
286
+ };
287
+ const build = (git: GitService, pending = makePendingCommits()) =>
288
+ new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), pending, testKbContext(), openChangeGate());
289
+
290
+ it('refuses a protected branch before asking git anything', async () => {
291
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
292
+ const err = await build(git).updateBranch('target-company-state', user).catch((e: unknown) => e);
293
+ expect(err).toBeInstanceOf(WorkflowDomainError);
294
+ expect((err as WorkflowDomainError).status).toBe(403);
295
+ expect((err as WorkflowDomainError).payload).toMatchObject({ kind: 'protected-update-target' });
296
+ expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
297
+ expect(git.push).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('refuses resolutions that come without a write gate, before asking git anything', async () => {
301
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
302
+ const err = await build(git)
303
+ .updateBranch('ada%2Fdraft', user, { from: 'target-company-state', resolutions: [{ path: 'A.md', content: 'x' }] } as never)
304
+ .catch((e: unknown) => e);
305
+ expect(err).toBeInstanceOf(Error);
306
+ expect((err as Error).message).toMatch(/validateWrite/);
307
+ expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it('refuses an update that would land an open change request from `from` into the draft, as the merge tool refuses it', async () => {
311
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
312
+ const open = [{ number: 5, sourceBranch: 'target-company-state', targetBranch: 'ada/draft', state: 'open' }];
313
+ const svc = new WorkflowService(makeDb(open), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
314
+ const err = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' }).catch((e: unknown) => e);
315
+ expect(err).toMatchObject({ status: 409, payload: { kind: 'open-change-request-blocks-merge', number: 5 } });
316
+ expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
317
+
318
+ // The other direction — the draft's own request, open INTO `from` — is
319
+ // how a draft under review stays current: never blocked.
320
+ const reverse = [{ number: 6, sourceBranch: 'ada/draft', targetBranch: 'target-company-state', state: 'open' }];
321
+ const syncing = new WorkflowService(makeDb(reverse), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
322
+ expect(await syncing.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' })).toMatchObject({ kind: 'merged' });
323
+ });
324
+
325
+ /**
326
+ * A merge that signals when the git layer is entered and finishes only when
327
+ * released: what the lock tests hold the workspace with. No timing — the
328
+ * test waits for the signal, never for a clock.
329
+ */
330
+ const heldMerge = (sha: string) => {
331
+ let enter: () => void = () => {};
332
+ let release: () => void = () => {};
333
+ const entered = new Promise<void>((resolve) => { enter = resolve; });
334
+ const merge = () =>
335
+ new Promise((resolve) => {
336
+ release = () => resolve({ kind: 'merged', sha, from: 'target-company-state', resolvedPaths: [] });
337
+ enter();
338
+ });
339
+ return { merge, entered, release: () => release() };
340
+ };
341
+ /** Every settled promise has run its continuations: a macrotask boundary, not a guess at a duration. */
342
+ const settled = () => new Promise<void>((r) => setImmediate(r));
343
+
344
+ it("holds the draft's lifecycle key around the merge, as the merge tool does, so nothing opens a request or recreates the branch mid-merge", async () => {
345
+ const git = gitThatMerges(null);
346
+ const held = heldMerge('m');
347
+ (git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn(held.merge);
348
+ (git.createBranch as ReturnType<typeof vi.fn>).mockResolvedValue({ name: 'ada/draft' });
349
+ const svc = build(git);
350
+ const order: string[] = [];
351
+ const updating = svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' }).then((o) => { order.push('merged'); return o; });
352
+ await held.entered;
353
+ const creating = svc.createBranch('ada%2Fdraft', 'ada/draft').then(() => { order.push('created'); });
354
+ // Without the key, the create (a resolved double) would be done by now.
355
+ await settled();
356
+ expect(order).toEqual([]);
357
+ held.release();
358
+ expect(await updating).toMatchObject({ kind: 'merged' });
359
+ await creating;
360
+ expect(order).toEqual(['merged', 'created']);
361
+ });
362
+
363
+ it("takes no key on a protected `from`: two drafts refreshing from the default branch do not wait on each other", async () => {
364
+ const git = gitThatMerges(null);
365
+ const held = heldMerge('m');
366
+ const merges: string[] = [];
367
+ (git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn((workspaceId: string) => {
368
+ merges.push(workspaceId);
369
+ return workspaceId === 'ada%2Fdraft'
370
+ ? held.merge()
371
+ : Promise.resolve({ kind: 'merged', sha: 'n', from: 'target-company-state', resolvedPaths: [] });
372
+ });
373
+ const svc = build(git);
374
+ const first = svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
375
+ await held.entered;
376
+ // The second draft's refresh from the same protected branch completes
377
+ // while the first still holds its merge (a key on `from` would hang it).
378
+ expect(await svc.updateBranch('bob%2Fdraft', user, { from: 'target-company-state' })).toMatchObject({ kind: 'merged', sha: 'n' });
379
+ expect(merges).toEqual(['ada%2Fdraft', 'bob%2Fdraft']);
380
+ held.release();
381
+ expect(await first).toMatchObject({ kind: 'merged', sha: 'm' });
382
+ });
383
+
384
+ it('merges the named branch into the draft as the user, published inside the merge\'s own reservation', async () => {
385
+ const git = gitThatMerges({ kind: 'merged', sha: 'merge-1', from: 'target-company-state', resolvedPaths: ['A.md'] });
386
+ const svc = build(git);
387
+ const resolutions = [{ path: 'A.md', content: 'resolved\n' }];
388
+ const outcome = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state', resolutions, validateWrite: async () => {} });
389
+ expect(outcome).toEqual({ kind: 'merged', sha: 'merge-1', from: 'target-company-state', resolvedPaths: ['A.md'] });
390
+ const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
391
+ expect(merge).toHaveBeenCalledWith(
392
+ 'ada%2Fdraft',
393
+ 'ada/draft',
394
+ 'target-company-state',
395
+ user,
396
+ expect.objectContaining({ subject: 'Merge target-company-state into ada/draft', resolutions, publish: expect.any(Function) }),
397
+ );
398
+ expect((merge.mock.calls[0] as unknown[])[4]).toMatchObject({ body: expect.stringContaining('A.md') });
399
+ // The git layer publishes while it still holds the workspace; no second push from here.
400
+ expect(git.push).not.toHaveBeenCalled();
401
+ });
402
+
403
+ it("publishes through the one push policy every workflow push takes, over the merge's own moves", async () => {
404
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
405
+ await build(git).updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
406
+ const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
407
+ type Ops = { push: () => Promise<void>; pullRebase: () => Promise<{ treeChanged: boolean }> };
408
+ const { publish } = (merge.mock.calls[0] as unknown[])[4] as { publish: (ops: Ops) => Promise<void> };
409
+
410
+ // A rejection: replayed onto the moved branch (the merge kept) and pushed again.
411
+ const push = vi.fn().mockRejectedValueOnce(new Error('! [rejected] non-fast-forward')).mockResolvedValue(undefined);
412
+ const pullRebase = vi.fn().mockResolvedValue({ treeChanged: true });
413
+ await publish({ push, pullRebase });
414
+ expect(push).toHaveBeenCalledTimes(2);
415
+ expect(pullRebase).toHaveBeenCalledTimes(1);
416
+ // The workspace's own reserved moves are never taken from here: the merge holds the workspace.
417
+ expect(git.push).not.toHaveBeenCalled();
418
+ expect(git.pull).not.toHaveBeenCalled();
419
+
420
+ // A replay that conflicts: the hand-off every refused share answers with,
421
+ // naming the branch — no single file is at issue — and never a conflicted
422
+ // path, which the caller may not be allowed to see.
423
+ const conflict = new PullRebaseConflictError('ada/draft', ['Secret/Plan.md'], 'replay conflicted');
424
+ const diverged = await publish({
425
+ push: vi.fn().mockRejectedValue(new Error('! [rejected] non-fast-forward')),
426
+ pullRebase: vi.fn().mockRejectedValue(conflict),
427
+ }).catch((e: unknown) => e);
428
+ expect(diverged).toBeInstanceOf(PushNeedsAgentResolutionError);
429
+ expect(diverged as PushNeedsAgentResolutionError).toMatchObject({ branch: 'ada/draft', path: 'ada/draft', cause: 'diverged' });
430
+ expect((diverged as Error).message).not.toContain('Secret/Plan.md');
431
+ expect(JSON.stringify((diverged as WorkflowDomainError).payload)).not.toContain('Secret/Plan.md');
432
+
433
+ // A host that refuses: the same hand-off, `refused`, no replay attempted.
434
+ const untouched = vi.fn();
435
+ const refused = await publish({ push: vi.fn().mockRejectedValue(new Error('remote: permission denied')), pullRebase: untouched }).catch(
436
+ (e: unknown) => e,
437
+ );
438
+ expect(refused).toBeInstanceOf(PushNeedsAgentResolutionError);
439
+ expect(refused as PushNeedsAgentResolutionError).toMatchObject({ branch: 'ada/draft', path: 'ada/draft', cause: 'refused' });
440
+ expect(untouched).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it('authorizes each resolution inside the merge: readable on both branches as fetched, and the draft write rule every save takes', async () => {
444
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: ['A.md'] });
445
+ const access = makeAccessControl();
446
+ (access.canReadAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(true);
447
+ const gate = openChangeGate();
448
+ const asked: unknown[][] = [];
449
+ gate.assertMayChange = async (...args: unknown[]) => {
450
+ asked.push(args);
451
+ };
452
+ const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), testKbContext(), gate);
453
+ const validated: unknown[][] = [];
454
+ const validateWrite = vi.fn(async (...args: unknown[]) => { validated.push(args); });
455
+ await svc.updateBranch('ada%2Fdraft', user, {
456
+ from: 'target-company-state',
457
+ resolutions: [{ path: 'knowledge-base/A.md', content: 'x' }, { path: 'B.md', choose: 'theirs' }],
458
+ validateWrite,
459
+ });
460
+ const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
461
+ const opts = (merge.mock.calls[0] as unknown[])[4] as { authorizeResolutions: (m: unknown) => Promise<void> };
462
+ await opts.authorizeResolutions({ ours: 'o', theirs: 't' });
463
+ // The caller's pre-disk write gate is asked with the workspace path and
464
+ // the content a resolution brings; a side kept whole brings none.
465
+ expect(validated).toEqual([['knowledge-base/A.md', 'x']]);
466
+ // Read at the two COMMITS the merge is of — never at a branch ref — then the change gate.
467
+ expect(access.canReadAtRef).toHaveBeenCalledWith('ada%2Fdraft', 'o', 'ada@x.io', 'A.md');
468
+ expect(access.canReadAtRef).toHaveBeenCalledWith('ada%2Fdraft', 't', 'ada@x.io', 'A.md');
469
+ expect((access.canReadAtRef as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1])).not.toContain('origin/target-company-state');
470
+ // The gate takes the workspace path, as every save hands it one — a
471
+ // repository-relative path would read as outside the knowledge base.
472
+ expect(asked).toEqual([['ada%2Fdraft', 'ada@x.io', 'knowledge-base/A.md', 'file'], ['ada%2Fdraft', 'ada@x.io', 'knowledge-base/B.md', 'file']]);
473
+
474
+ // The write gate's refusal refuses the update (a roles.yaml that would not parse, say).
475
+ validateWrite.mockRejectedValueOnce(new WorkflowDomainError('roles.yaml would be invalid', 422));
476
+ await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toMatchObject({ status: 422 });
477
+
478
+ // Not readable at the draft's commit: refused before the change gate is asked.
479
+ (access.canReadAtRef as ReturnType<typeof vi.fn>).mockImplementation(async (_ws: string, ref: string) => ref !== 'o');
480
+ asked.length = 0;
481
+ await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toBeInstanceOf(ReadOnBranchDeniedError);
482
+ expect(asked).toEqual([]);
483
+ // A commit whose rules cannot be read grants nothing.
484
+ (access.canReadAtRef as ReturnType<typeof vi.fn>).mockImplementation(async (_ws: string, ref: string) => (ref === 't' ? null : true));
485
+ await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toBeInstanceOf(ReadOnBranchDeniedError);
486
+ expect(asked).toEqual([]);
487
+ });
488
+
489
+ it('takes the draft\'s own shared state when no source is named, and publishes nothing on conflicts', async () => {
490
+ const conflicts = { kind: 'conflicts', from: 'ada/draft', mergeBase: 'b', ours: 'o', theirs: 't', files: [], unresolved: ['A.md'] };
491
+ const git = gitThatMerges(conflicts);
492
+ const outcome = await build(git).updateBranch('ada%2Fdraft', user);
493
+ expect(outcome).toBe(conflicts);
494
+ const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
495
+ expect(merge).toHaveBeenCalledWith('ada%2Fdraft', 'ada/draft', 'ada/draft', user, expect.objectContaining({ resolutions: [] }));
496
+ expect(git.push).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it('answers pending-commits, merging nothing, while writes on the source are still being committed', async () => {
500
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
501
+ const pending = makePendingCommits();
502
+ (pending.queuedOnBranch as ReturnType<typeof vi.fn>).mockImplementation(async (branch: string) =>
503
+ branch === 'target-company-state' ? { queued: 2, stuck: 0, needsAttention: null } : { queued: 0, stuck: 0, needsAttention: null },
504
+ );
505
+ const svc = build(git, pending);
506
+ svc.mergeWaitForPendingCommits = { timeoutMs: 30, pollMs: 5 };
507
+ const outcome = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
508
+ expect(outcome).toMatchObject({ kind: 'pending-commits', branch: 'target-company-state', pending: 2 });
509
+ expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
510
+ });
511
+
512
+ it('refuses a source the shared repository does not have', async () => {
513
+ const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
514
+ (git as unknown as { remoteBranchExists: unknown }).remoteBranchExists = vi.fn().mockResolvedValue(false);
515
+ await expect(build(git).updateBranch('ada%2Fdraft', user, { from: 'nobody/such' })).rejects.toThrow(/No branch named "nobody\/such"/);
516
+ expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
517
+ });
518
+ });
519
+
277
520
  describe('WorkflowService — change delegation', () => {
278
521
  it('commitChange delegates to git.commit', async () => {
279
522
  const git = makeGit();
@@ -298,12 +541,15 @@ describe('WorkflowService — change delegation', () => {
298
541
 
299
542
  it('compareFile delegates to git.diffFileBetweenBranches', async () => {
300
543
  const git = makeGit();
301
- (git.diffFileBetweenBranches as ReturnType<typeof vi.fn>).mockResolvedValue('@@ diff');
544
+ (git.diffFileBetweenBranches as ReturnType<typeof vi.fn>).mockResolvedValue({ diff: '@@ diff', from: 'a1', to: 'b1' });
302
545
 
303
546
  const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
304
- const diff = await svc.compareFile('w1', 'Foo.md', 'a', 'b');
305
- expect(diff).toBe('@@ diff');
306
- expect(git.diffFileBetweenBranches).toHaveBeenCalledWith('w1', 'Foo.md', 'a', 'b');
547
+ // The caller's verdict travels down to the git layer, which asks it at
548
+ // the pinned commits; those commits travel back with the diff.
549
+ const authorize = vi.fn(async () => {});
550
+ const compared = await svc.compareFile('w1', 'Foo.md', 'a', 'b', authorize);
551
+ expect(compared).toEqual({ diff: '@@ diff', from: 'a1', to: 'b1' });
552
+ expect(git.diffFileBetweenBranches).toHaveBeenCalledWith('w1', 'Foo.md', 'a', 'b', authorize);
307
553
  });
308
554
  });
309
555