@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
@@ -127,6 +127,13 @@
127
127
  "when": 1791359019161,
128
128
  "tag": "0017_atlassian_account_links",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1792106401545,
135
+ "tag": "0018_agent_events_page",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
- }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-mcp-core": "0.29.0",
59
- "@bevel-software/platform-shared": "0.29.0"
58
+ "@bevel-software/platform-shared": "0.31.0",
59
+ "@bevel-software/platform-mcp-core": "0.31.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ /**
4
+ * The repository roots `testKbRepository` placed beside a suite's workspaces
5
+ * root (`<workspacesRoot>.repository`). A suite removes its fixture root, not
6
+ * what sits next to it, so `test-setup.ts` removes these once the file's
7
+ * suites are done. Kept free of imports so the setup file can load it without
8
+ * pulling in a module a suite means to mock.
9
+ */
10
+ const roots = new Set<string>();
11
+
12
+ export function recordTestRepositoryRoot(root: string): void {
13
+ roots.add(root);
14
+ }
15
+
16
+ export async function removeTestRepositoryRoots(): Promise<void> {
17
+ const pending = [...roots];
18
+ roots.clear();
19
+ await Promise.all(
20
+ pending.map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
21
+ );
22
+ }
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import { NodeGitRunner } from '../modules/workflow/git/node-git-runner.js';
3
+ import type { IGitRunner } from '../shared/git.contract.js';
4
+ import type { ITreeWalker } from '../shared/fs.contract.js';
5
+ import type { KbContext } from '../shared/kb-context.js';
6
+ import { KbRepository, repositoryDirFor } from '../modules/workspace/kb-repository.js';
7
+ import type { IKbRepository } from '../modules/workspace/kb-repository.contract.js';
8
+ import { WorkspaceService } from '../modules/workspace/workspace.service.js';
9
+ import { KbStartupRunner, type KbStartupRunnerOptions } from '../modules/workspace/startup/kb-startup-runner.js';
10
+ import { recordTestRepositoryRoot } from './test-repositories.js';
11
+
12
+ /**
13
+ * The repository a suite's services share when it builds them by hand: one
14
+ * next to the workspaces root (see `repositoryDirFor`), as the composition
15
+ * root would place it there for a deployment with no backups root. Removed
16
+ * once the file's suites are done (see `test-repositories.ts`): a suite's
17
+ * teardown removes its fixture root, not what sits beside it.
18
+ */
19
+ export function testKbRepository(
20
+ workspacesRoot: string,
21
+ kbDirName: string,
22
+ kbRepoUrl: string | (() => string),
23
+ gitRunner?: IGitRunner,
24
+ ): IKbRepository {
25
+ const dir = repositoryDirFor(workspacesRoot, kbDirName);
26
+ recordTestRepositoryRoot(path.dirname(dir));
27
+ return new KbRepository({
28
+ dir,
29
+ kbRepoUrl: typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl,
30
+ gitRunner: gitRunner ?? new NodeGitRunner(),
31
+ });
32
+ }
33
+
34
+ /**
35
+ * A WorkspaceService as a suite builds it: the constructor's arguments, with
36
+ * the repository — which only the composition root builds in the product —
37
+ * made here when the suite passes none.
38
+ */
39
+ export function newWorkspaceService(
40
+ workspacesRoot: string,
41
+ kbRepoUrl: string | (() => string),
42
+ kb: Pick<KbContext, 'kbDirName' | 'defaultBranch'>,
43
+ disk: ITreeWalker,
44
+ gitRunner: IGitRunner = new NodeGitRunner(),
45
+ setAsideRoot?: string,
46
+ aroundSetAside?: (workspaceId: string, move: () => Promise<void>) => Promise<void>,
47
+ repository?: IKbRepository,
48
+ ): WorkspaceService {
49
+ return new WorkspaceService(
50
+ workspacesRoot,
51
+ kbRepoUrl,
52
+ kb,
53
+ disk,
54
+ gitRunner,
55
+ setAsideRoot,
56
+ aroundSetAside,
57
+ repository ?? testKbRepository(workspacesRoot, kb.kbDirName, kbRepoUrl, gitRunner),
58
+ );
59
+ }
60
+
61
+ /** A KbStartupRunner as a suite builds it — the repository made here when the suite passes none. */
62
+ export function newKbStartupRunner(
63
+ opts: Omit<KbStartupRunnerOptions, 'repository'> & { repository?: IKbRepository },
64
+ ): KbStartupRunner {
65
+ return new KbStartupRunner({
66
+ ...opts,
67
+ repository: opts.repository ?? testKbRepository(opts.workspacesRoot, opts.kbDirName, opts.kbRepoUrl, opts.gitRunner),
68
+ });
69
+ }
@@ -5,6 +5,7 @@ import { createShutdown, stopCore, withStartupTask, type LeasedWorker } from '..
5
5
  import { holdCommitWorkerLease, holdable } from '../lifecycle.js';
6
6
  import { BevelSecretsVariableLoader, registerBevelSecretsVariableLoader } from '../../modules/secrets-vault/secrets-variable-loader.js';
7
7
  import type { AdvisoryLease } from '../../modules/database/advisory-lock.js';
8
+ import { WorkerPatternMatcher } from '../../modules/workspace/bounded-matcher.js';
8
9
 
9
10
  /** A lease the suite scripts: what each `tryAcquire` answers, and a way to lose it. */
10
11
  function fakeLease(answers: boolean[]) {
@@ -537,6 +538,21 @@ describe('stopCore', () => {
537
538
  ]);
538
539
  });
539
540
 
541
+ it('ends the grep matcher\'s workers, so an evicted graph leaves no search running behind it', async () => {
542
+ const g = graph();
543
+ const patternMatcher = new WorkerPatternMatcher();
544
+ // A search holding a worker for its whole 5 s budget when the graph is stopped.
545
+ const running = patternMatcher.search('^(a+)+$', '').matchingLines([`${'a'.repeat(39)}!`], 1);
546
+ await new Promise((r) => setTimeout(r, 100));
547
+ const started = Date.now();
548
+ await stopCore({ ...g.core, patternMatcher } as never);
549
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
550
+ expect(Date.now() - started).toBeLessThan(1_000);
551
+ // Closed for good: nothing after the stop starts a worker.
552
+ await expect(patternMatcher.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({ status: 503 });
553
+ expect(g.order.at(-1)).toBe('db.end');
554
+ });
555
+
540
556
  it('does not wait past the budget for a worker that never stops, and still ends the pool', async () => {
541
557
  const g = graph();
542
558
  const core = { ...g.core, commitWorker: { stop: () => new Promise<void>(() => undefined) } };
@@ -12,9 +12,9 @@ const log = logger('catalog-revision');
12
12
  * skill the default branch serves them, right now.
13
13
  *
14
14
  * It exists for a client that holds a long-lived connection and cannot be told
15
- * anything: the local `hexis-mcp` server. The hosted endpoint is stateless, so
16
- * every request there already rebuilds its surface from the live registry
17
- * (`mcp.routes.stateless.test.ts` pins that). A process on someone's laptop
15
+ * anything: the local `hexis-mcp` server. The hosted endpoint reads it too, on
16
+ * every request, to pick the caller's cached tool client: a new revision is a
17
+ * new build (`mcp.surface-cache.test.ts` pins that). A process on someone's laptop
18
18
  * registered the deployment's manual ONCE, at startup, and would otherwise
19
19
  * serve that startup's toolset until it is restarted. Polling this tells it —
20
20
  * cheaply, and without a second way for the catalogs to be read — exactly when
@@ -470,12 +470,13 @@ export async function createCoreServer(
470
470
  // `get_skill`. Warnings only; it never refuses a save.
471
471
  const allowedToolsChecker = new AllowedToolsChecker(core.toolRegistry, core.toolManualService, core.kb);
472
472
  // `delete_branch` sees change requests as `get_change_request` does.
473
- registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th, core.kb, changeRequestScope(core.accessControl, core.kb).maySee);
473
+ // The history tools take the per-file read the file itself takes (`accessControl`).
474
+ registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th, core.kb, changeRequestScope(core.accessControl, core.kb).maySee, core.accessControl, agentAccessGate);
474
475
  // The five read tools over change requests. Separate from the workflow tools
475
476
  // because they are the only ones that gate their whole payload on the
476
477
  // caller's read access, so they take the access service and nothing else.
477
478
  registerChangeRequestReadTools(core.toolRegistry, toolsRouter, ta, th, core.accessControl, core.kb);
478
- const workspaceTools = registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore, core.agentGuide, core.agentDownloadStore, core.starterPackService, core.disk);
479
+ const workspaceTools = registerWorkspaceTools(core.toolRegistry, toolsRouter, ta, th, core.spillStore, core.docExtractService, core.accessControl, core.kb, agentAccessGate, core.routineWritePolicy, core.sessionSink, allowedToolsChecker, core.changeGate, core.agentUploadStore, core.agentGuide, core.agentDownloadStore, core.starterPackService, core.disk, core.patternMatcher);
479
480
  // `open_page` — the knowledge-base page shown inside a chat. Registered
480
481
  // here, on the same router as the file tools, because it answers with
481
482
  // `read_file`'s own read: the read hook, the access gate and the
@@ -17,6 +17,8 @@ import { getDb, type Database } from '../modules/database/connection.js';
17
17
  import { runCoreMigrations } from '../modules/database/migrate.js';
18
18
  import { coreMigrationsDir } from '../assets.js';
19
19
  import { WorkspaceService } from '../modules/workspace/workspace.service.js';
20
+ import { KbRepository, repositoryDirFor } from '../modules/workspace/kb-repository.js';
21
+ import type { IKbRepository } from '../modules/workspace/kb-repository.contract.js';
20
22
  import { AccountLinkService } from '../modules/embed/account-link.service.js';
21
23
  import type { EmbedService } from '../modules/embed/embed.service.js';
22
24
  import { composeEmbedService } from './compose-embed.js';
@@ -62,6 +64,8 @@ import { SpillStore } from '../modules/workspace/spill-store.js';
62
64
  import { AgentUploadStore, assertUploadsRootOutsideWorkspaces } from '../modules/workspace/agent-upload.store.js';
63
65
  import { AgentDownloadStore, type IAgentDownloadStore } from '../modules/workspace/agent-download.store.js';
64
66
  import { createDownloadFetcherIdentifier } from '../modules/workspace/agent-download.routes.js';
67
+ import { WorkerPatternMatcher } from '../modules/workspace/bounded-matcher.js';
68
+ import type { IPatternMatcher } from '../modules/workspace/pattern-matcher.contract.js';
65
69
  import type { Request } from 'express';
66
70
  import { DocExtractService } from '../modules/workspace/file-readers/doc-extract.service.js';
67
71
  import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
@@ -113,7 +117,7 @@ import { ConnectionProbeService } from '../modules/connection-probe/index.js';
113
117
  import { GitService } from '../modules/workflow/git/git.service.js';
114
118
  import { NodeGitRunner } from '../modules/workflow/git/node-git-runner.js';
115
119
  import { PullRequestService } from '../modules/workflow/git/pull-request.service.js';
116
- import { WorkspaceMutex } from '../modules/kb-fs/mutex.js';
120
+ import { WorkspaceIdMutex, WorkspaceMutex } from '../modules/kb-fs/mutex.js';
117
121
  import { assertGitVersion } from '../modules/workflow/git/git-version.js';
118
122
  import { DiffService } from '../modules/diff/diff.service.js';
119
123
  import { ReviewWorkflowService } from '../modules/workflow/review-workflow/review-workflow.service.js';
@@ -243,6 +247,8 @@ export interface CoreServices {
243
247
  agentUploadStore: AgentUploadStore;
244
248
  /** The bytes `request_file_download` captured, held until their one-time link is fetched or expires. */
245
249
  agentDownloadStore: IAgentDownloadStore;
250
+ /** Runs a caller's `grep` pattern off the request thread under a budget; its workers end when the graph stops. */
251
+ patternMatcher: IPatternMatcher;
246
252
  /**
247
253
  * Every user a download fetch identifies itself as, by its bearer and its
248
254
  * session cookie — none when it carries none that verifies — so the
@@ -570,6 +576,32 @@ export async function createCoreServices(
570
576
  * Under the backups root: a persistent volume of its own, and one nothing
571
577
  * sweeps (see the runner's `reconcileClonesWithConfiguredRepository`).
572
578
  */
579
+ // Shared mutex so git and diff operations on the same workspace serialize
580
+ // against each other — a backup-reseed races a concurrent commit otherwise.
581
+ // Keyed by the CANONICAL workspace id: a route's Express-decoded `:id` and
582
+ // a tool's encoded one reserve the same clone (see `WorkspaceIdMutex`).
583
+ const workspaceMutex = new WorkspaceIdMutex();
584
+ /**
585
+ * The knowledge base's ONE repository: every branch workspace is a worktree
586
+ * of it (see `kb-repository.ts`). One instance, handed to the workspace
587
+ * service, the startup phase and (through the workspace service) the git
588
+ * layer, so one fetch serves every workspace and the writes to its one
589
+ * config file are serialized in one place. Git operations in the worktrees
590
+ * are not serialized against each other beyond the workspace mutex: git's
591
+ * own locks cover a repository shared by worktrees.
592
+ *
593
+ * Under the backups root, like the set-aside copies: a persistent volume in
594
+ * every shipped compose file, so no new mount is needed, and outside the
595
+ * workspaces root, where the orphan sweep never sees it. The folder name
596
+ * starts with a dot, which no branch — and so no backup ledger's workspace
597
+ * id — can. On hexis-cloud every tenant has its own backups root: one
598
+ * repository per tenant.
599
+ */
600
+ const kbRepository: IKbRepository = new KbRepository({
601
+ dir: repositoryDirFor(config.workspacesRoot, kbDirName, path.join(config.backupsRoot, '.kb-repository')),
602
+ kbRepoUrl: () => repositorySource.url(),
603
+ gitRunner,
604
+ });
573
605
  const replacedWorkingCopiesRoot = path.join(config.backupsRoot, 'replaced-working-copies');
574
606
  /**
575
607
  * What must leave with a working copy that is set aside, whoever sets it
@@ -635,6 +667,7 @@ export async function createCoreServices(
635
667
  );
636
668
  });
637
669
  }),
670
+ kbRepository,
638
671
  );
639
672
  // The KB startup phase: every seeding, scaffolding and migration concern,
640
673
  // run through one runner at the deployment's quiet moments (boot + setup
@@ -672,6 +705,9 @@ export async function createCoreServices(
672
705
  // The same folder the workspace service sets aside into — see
673
706
  // `replacedWorkingCopiesRoot` above for why the two must agree.
674
707
  setAsideRoot: replacedWorkingCopiesRoot,
708
+ // The one repository every branch workspace is a worktree of — the same
709
+ // instance the workspace service holds (see `kbRepository` above).
710
+ repository: kbRepository,
675
711
  kbDirName,
676
712
  templateDir: config.kbTemplateDir,
677
713
  defaultBranch: () => kb.defaultBranch,
@@ -741,6 +777,7 @@ export async function createCoreServices(
741
777
  'captured download bytes wait there for their one-time links, so a root inside a workspace would let the ' +
742
778
  'file tools read them. Point AGENT_UPLOADS_ROOT at a directory whose parent is outside WORKSPACES_ROOT.',
743
779
  });
780
+ const patternMatcher = new WorkerPatternMatcher();
744
781
  const agentDownloadStore = new AgentDownloadStore({
745
782
  root: agentDownloadsRoot,
746
783
  publicBaseUrl: config.publicBackendUrl,
@@ -827,9 +864,6 @@ export async function createCoreServices(
827
864
  // registers its listener here, after this returns (see `AccountHooks`).
828
865
  const accountHooks = new AccountHooks();
829
866
 
830
- // Shared mutex so git and diff operations on the same workspace serialize
831
- // against each other — a backup-reseed races a concurrent commit otherwise.
832
- const workspaceMutex = new WorkspaceMutex();
833
867
  // Workflow lifecycle hooks — the ONE registry this composition shares
834
868
  // between GitService (advisory commit validation), the agent-access gate
835
869
  // (the blocking agentRead / preWrite hooks), and the composition root,
@@ -1598,6 +1632,7 @@ export async function createCoreServices(
1598
1632
  spillStore,
1599
1633
  agentUploadStore,
1600
1634
  agentDownloadStore,
1635
+ patternMatcher,
1601
1636
  agentDownloadFetcher,
1602
1637
  docExtractService,
1603
1638
  accessControl,
@@ -448,6 +448,12 @@ export interface CoreStopDeps {
448
448
  agentUploadStore?: { stopSweeping(): void; drainSweep(): Promise<void> };
449
449
  /** The agent download store, whose sweep deletes captured bytes nobody fetched — stopped for the same reason. */
450
450
  agentDownloadStore?: { stopSweeping(): void; drainSweep(): Promise<void> };
451
+ /**
452
+ * The grep pattern matcher, whose worker threads are this graph's own: ended
453
+ * here so a graph a tenant host evicts leaves no worker behind. Optional: a
454
+ * caller that has not built one passes nothing.
455
+ */
456
+ patternMatcher?: { close(): Promise<void> };
451
457
  /**
452
458
  * The workflow service, for the tidy-up it runs after each sweep (closing
453
459
  * empty requests, removing leftover branches): detached from whatever
@@ -581,6 +587,10 @@ async function releaseCore(deps: CoreStopDeps, remaining: () => number, log: (m:
581
587
  if (deps.workflowService) {
582
588
  await bounded(deps.workflowService.drainTidy(), remaining(), 'finishing the branch tidy-up', log);
583
589
  }
590
+ // Its workers end with the graph; a search still running is answered busy.
591
+ if (deps.patternMatcher) {
592
+ await bounded(deps.patternMatcher.close(), remaining(), 'ending the search workers', log);
593
+ }
584
594
  await bounded(deps.commitWorker.stop(), remaining(), 'stopping the commit worker', log);
585
595
  if (deps.secretsScope !== undefined) unregisterBevelSecretsVariableLoader(deps.secretsScope);
586
596
  await bounded(closeDb(deps.db), remaining(), 'ending the database pool', log);
@@ -129,6 +129,11 @@ function lsTreeBuilds(): number {
129
129
  return spawnLog.filter((argv) => argv.includes('ls-tree')).length;
130
130
  }
131
131
 
132
+ /** Ref resolutions through git: none, since the ref files answer. */
133
+ function revParses(): number {
134
+ return spawnLog.filter((argv) => argv.includes('rev-parse')).length;
135
+ }
136
+
132
137
  /** Batched reads of a commit's access tree: one per build. */
133
138
  function catFileBatches(): number {
134
139
  return spawnLog.filter((argv) => argv.includes('cat-file')).length;
@@ -190,6 +195,7 @@ describe('AccessControlService — at-ref model cache', () => {
190
195
  let repo: string;
191
196
  let other: string;
192
197
  let svc: AccessControlService;
198
+ let stub: WorkspaceService;
193
199
  const workspaceId = 'ws-atref-cache';
194
200
  const admin = 'razvan@bevel.software';
195
201
  const DOC = 'Team/Doc.md';
@@ -242,7 +248,7 @@ describe('AccessControlService — at-ref model cache', () => {
242
248
  await git(other, 'config', 'user.email', 'other@example.com');
243
249
  await git(other, 'config', 'user.name', 'Other');
244
250
 
245
- const stub = {
251
+ stub = {
246
252
  getWorkspacePath: async () => workspaceDir,
247
253
  // The real service TTLs this at 30s; the stub always fetches so a push
248
254
  // from `other` is visible on the next gate call.
@@ -386,9 +392,11 @@ describe('AccessControlService — at-ref model cache', () => {
386
392
  // The seed holds roles.yaml and two access.md files; a read per file was
387
393
  // what the build used to cost, and what a server forking a large heap
388
394
  // cannot afford.
395
+ // One listing and one batch build the model; a second batch reads the
396
+ // asked path's own entries, once per commit and path (see the memo test).
389
397
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
390
398
  expect(lsTreeBuilds()).toBe(1);
391
- expect(catFileBatches()).toBe(1);
399
+ expect(catFileBatches()).toBe(2);
392
400
  expect(fileShows()).toBe(0);
393
401
  // Both access files were read through the batch: Team's denial is seen.
394
402
  await pushFromOther('deny Admin in Team', () =>
@@ -396,7 +404,7 @@ describe('AccessControlService — at-ref model cache', () => {
396
404
  );
397
405
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(false);
398
406
  expect(lsTreeBuilds()).toBe(2);
399
- expect(catFileBatches()).toBe(2);
407
+ expect(catFileBatches()).toBe(4);
400
408
  expect(fileShows()).toBe(0);
401
409
  });
402
410
 
@@ -534,7 +542,139 @@ describe('AccessControlService — at-ref model cache', () => {
534
542
  // ...and nothing was pinned: the next call, with git answering in full,
535
543
  // builds again and answers.
536
544
  expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
537
- expect(catFileBatches()).toBe(batchesBefore + 2);
545
+ // Three batches: the refused build, then the build that answered and the
546
+ // asked path's own entries.
547
+ expect(catFileBatches()).toBe(batchesBefore + 3);
548
+ });
549
+
550
+ it('resolves refs from the ref files and remembers own entries at a commit: a repeated gate call starts no git', async () => {
551
+ // First call at this commit: the model is built (two git calls) and the
552
+ // asked path's own entries are read (one batch). The refs themselves —
553
+ // `origin/main`, and the candidates a short name tries — come from the
554
+ // ref files, so no `rev-parse` runs at all.
555
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
556
+ expect(revParses()).toBe(0);
557
+ expect(lsTreeBuilds()).toBe(1);
558
+ expect(catFileBatches()).toBe(2);
559
+
560
+ // The same gate again, and the single-path gate for the same path: the
561
+ // model and the own entries are both remembered, so apart from the
562
+ // fixture's own fetch nothing is started.
563
+ spawnLog.length = 0;
564
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
565
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
566
+ expect(await svc.canWriteAtRef(workspaceId, 'main', admin, DOC)).toBe(true);
567
+ expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
568
+
569
+ // A path not asked before at this commit is read, and only that path.
570
+ spawnLog.length = 0;
571
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC, QUOTED_DOC])).toEqual(
572
+ new Map([
573
+ [DOC, true],
574
+ [QUOTED_DOC, false],
575
+ ]),
576
+ );
577
+ expect(catFileBatches()).toBe(1);
578
+ expect(revParses()).toBe(0);
579
+ });
580
+
581
+ it('asks git for a ref the files cannot decide, instead of reading it as missing', async () => {
582
+ // An annotated tag names a tag object, not a commit; the reader steps
583
+ // aside and the gate resolves it through `rev-parse <tag>^{commit}` —
584
+ // one process, the answer git gives, never "no such ref".
585
+ await git(repo, 'tag', '-a', 'v1', '-m', 'v1', 'origin/main');
586
+ expect(await svc.canWriteAtRef(workspaceId, 'v1', admin, DOC)).toBe(true);
587
+ expect(revParses()).toBe(1);
588
+ expect(lsTreeBuilds()).toBe(1);
589
+ // The same commit under its branch name: resolved from the files, the
590
+ // model already held.
591
+ spawnLog.length = 0;
592
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
593
+ expect(revParses()).toBe(0);
594
+ expect(lsTreeBuilds()).toBe(0);
595
+ });
596
+
597
+ it('knows from the tree that a path is absent at a commit, and reads nothing for it', async () => {
598
+ // A change request that adds a file asks about it at the base, where it
599
+ // does not exist yet. The model's listing already named every path the
600
+ // commit carries, so the absence is a fact about the commit: remembered,
601
+ // and not read — on the first call or any later one.
602
+ const NEW = 'Team/New.md';
603
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
604
+ spawnLog.length = 0;
605
+ expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [NEW])).toEqual(new Map([[NEW, true]]));
606
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
607
+ expect(catFileBatches()).toBe(0);
608
+ expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
609
+ });
610
+
611
+ it("keeps a commit's tree up to the cap and not past it, as an absent path's cost shows", async () => {
612
+ // The seed carries seven paths. A service that may hold seven keeps the
613
+ // tree, so an absent path is answered without a read; one that may hold
614
+ // six does not keep it, so the same path is read on every call.
615
+ const NEW = 'Team/New.md';
616
+ const limited = (treeIndexMaxPaths: number) =>
617
+ new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, { treeIndexMaxPaths });
618
+ const keeps = limited(7);
619
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
620
+ spawnLog.length = 0;
621
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
622
+ expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
623
+ expect(catFileBatches()).toBe(0);
624
+ const drops = limited(6);
625
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
626
+ spawnLog.length = 0;
627
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
628
+ expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
629
+ expect(catFileBatches()).toBe(2);
630
+ });
631
+
632
+ it('lets the oldest models give their tree up past the budget, and they still answer', async () => {
633
+ // Seven paths at the first commit, eight at the second, a budget of ten:
634
+ // remembering the second puts the first past the budget. The first
635
+ // model keeps answering gates without a rebuild; only an absent path at
636
+ // it is read again, while at the second it costs nothing.
637
+ const NEW = 'Team/New.md';
638
+ const budgeted = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, {
639
+ treeIndexBudget: 10,
640
+ });
641
+ const first = await git(repo, 'rev-parse', 'origin/main');
642
+ expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
643
+ await pushFromOther('a later page', () => fs.writeFile(path.join(other, 'Team/Later.md'), '# later\n'));
644
+ expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
645
+ const second = await git(repo, 'rev-parse', 'origin/main');
646
+ expect(second).not.toBe(first);
647
+ spawnLog.length = 0;
648
+ expect(await budgeted.canWriteAtRef(workspaceId, second, admin, NEW)).toBe(true);
649
+ expect(lsTreeBuilds()).toBe(0);
650
+ expect(catFileBatches()).toBe(0);
651
+ spawnLog.length = 0;
652
+ expect(await budgeted.canWriteAtRef(workspaceId, first, admin, NEW)).toBe(true);
653
+ expect(lsTreeBuilds()).toBe(0);
654
+ expect(catFileBatches()).toBe(1);
655
+ });
656
+
657
+ it('remembers what a blob declares for itself, never a blob the batch could not read', async () => {
658
+ const SECRET = 'Team/Secret.md';
659
+ await pushFromOther('a page that denies the admin for itself', () =>
660
+ fs.writeFile(path.join(other, SECRET), '---\nwrite:\n - deny Admin\n---\n# secret\n'),
661
+ );
662
+ // Build the model first, so the next batch is the own-entries read.
663
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
664
+ // The own-entries batch loses the blob (a lost object reads as missing,
665
+ // exactly like an absent path): answered as no own entries, as before —
666
+ // the inherited write stands — but NOT remembered for the commit.
667
+ injected.rewriteNext = (argv) => (argv.includes('cat-file') ? rewriteBatchRecord(/:Team\/Secret\.md$/, 'missing') : null);
668
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(true);
669
+ expect(injected.rewriteNext).toBeNull();
670
+ // The next call reads the blob again, and its own denial now holds...
671
+ spawnLog.length = 0;
672
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
673
+ expect(catFileBatches()).toBe(1);
674
+ // ...and that answer, a fact about the commit, is remembered.
675
+ spawnLog.length = 0;
676
+ expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
677
+ expect(catFileBatches()).toBe(0);
538
678
  });
539
679
 
540
680
  it('holds the models of far more commits than one listing resolves', async () => {
@@ -161,3 +161,76 @@ describe('canReadAsGroupBatch', () => {
161
161
  expect(verdict?.get('Plugins/GTM')).toBe(false);
162
162
  });
163
163
  });
164
+
165
+ describe('groupsOfUser', () => {
166
+ let root: string;
167
+ const workspaceId = 'ws-groups-of-user-1';
168
+
169
+ beforeEach(async () => {
170
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-of-user-'));
171
+ });
172
+ afterEach(async () => {
173
+ await fs.rm(root, { recursive: true, force: true });
174
+ });
175
+
176
+ async function makeService() {
177
+ const workspaceDir = path.join(root, workspaceId);
178
+ const repo = path.join(workspaceDir, KB_DIR);
179
+ await fs.mkdir(repo, { recursive: true });
180
+ // `lee@x.io` holds Leads, the role that lists `group:Sales Team`, without
181
+ // being in the group: the role reaches the group, not the other way round.
182
+ await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML.replace(' - group:Sales Team\n', ' - group:Sales Team\n - lee@x.io\n'));
183
+ await fs.writeFile(
184
+ path.join(repo, 'groups.yaml'),
185
+ `groups:\n Sales Team:\n - sam@x.io\n - sue@x.io\n Design:\n - dee@x.io\n - sue@x.io\n`,
186
+ );
187
+ await fs.writeFile(path.join(repo, 'access.md'), rules('write:\n - Admin\n'));
188
+ return new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR, new NodeFs());
189
+ }
190
+
191
+ it('names the groups a person is in, as groups.yaml spells them', async () => {
192
+ const svc = await makeService();
193
+ expect(await svc.groupsOfUser(workspaceId, 'sam@x.io')).toEqual(['Sales Team']);
194
+ expect((await svc.groupsOfUser(workspaceId, 'sue@x.io')).sort()).toEqual(['Design', 'Sales Team']);
195
+ });
196
+
197
+ it('matches the email in any case', async () => {
198
+ const svc = await makeService();
199
+ expect(await svc.groupsOfUser(workspaceId, 'Sam@X.io')).toEqual(['Sales Team']);
200
+ });
201
+
202
+ it('is empty for someone in no group, however many roles they hold', async () => {
203
+ const svc = await makeService();
204
+ expect(await svc.groupsOfUser(workspaceId, 'eng@x.io')).toEqual([]);
205
+ expect(await svc.groupsOfUser(workspaceId, 'admin@x.io')).toEqual([]);
206
+ });
207
+
208
+ it('does not count holding a role that names a group as being in it', async () => {
209
+ const svc = await makeService();
210
+ expect(await svc.groupsOfUser(workspaceId, 'lee@x.io')).toEqual([]);
211
+ });
212
+
213
+ describe('visibleGroups: the one answer the Groups tab, the share dialog and a grant use', () => {
214
+ // The active source's groups, in the order a caller happens to hold them.
215
+ const listed = ['Sales Team', 'Design'];
216
+
217
+ it('is every group, as given, for an admin', async () => {
218
+ const svc = await makeService();
219
+ expect(await svc.visibleGroups(workspaceId, 'admin@x.io', listed)).toEqual(listed);
220
+ });
221
+
222
+ it('is only the groups a person is in, in the order given, for anyone else', async () => {
223
+ const svc = await makeService();
224
+ expect(await svc.visibleGroups(workspaceId, 'sue@x.io', listed)).toEqual(['Sales Team', 'Design']);
225
+ expect(await svc.visibleGroups(workspaceId, 'sam@x.io', listed)).toEqual(['Sales Team']);
226
+ expect(await svc.visibleGroups(workspaceId, 'dee@x.io', listed)).toEqual(['Design']);
227
+ });
228
+
229
+ it('is empty for someone in no group, a role that names a group included', async () => {
230
+ const svc = await makeService();
231
+ expect(await svc.visibleGroups(workspaceId, 'eng@x.io', listed)).toEqual([]);
232
+ expect(await svc.visibleGroups(workspaceId, 'lee@x.io', listed)).toEqual([]);
233
+ expect(await svc.visibleGroups(workspaceId, 'admin@x.io', [])).toEqual([]);
234
+ });
235
+ });
236
+ });