@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  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.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -6,21 +6,25 @@ import { isAbsence } from '../../shared/fs.contract.js';
6
6
  import { assertOneSpecPerLine, isGitTimeout } from '../../shared/git.contract.js';
7
7
  import { hashEmail } from '../../shared/email-identity.js';
8
8
  import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
9
+ import { readRevSha } from '../kb-fs/index.js';
9
10
  import { DEFAULT_KB_LAYOUT, PLUGIN_MANIFEST_FILE, isPersonalPluginDir, pluginIdentityOf, platformRestoreDestination, } from '@bevel-software/platform-shared';
10
11
  import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
11
12
  import { synthesizePluginPrincipals } from '../access-model/plugin-principals.js';
12
13
  import { GROUPS_YAML, SYNCED_GROUPS_YAML, parseGroupsFile, } from '../access-model/group-files.js';
13
14
  import { ADMIN_CANONICAL, EVERYONE_CANONICAL, EVERYONE_DISPLAY, KNOWN_VERBS, ROLE_TOKEN_PREFIX, accessMdSelfEntries, canonicalEmail, canonicalRoleName, isAccessMdPath, mergeGroupsIntoRoles, parseAccessFile, parseOwnAccessEntries, parseRolesYaml, requiredVerbsFor, sourceVerbsFor, } from '../access-model/access-grammar.js';
14
15
  import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-sync-bot.js';
16
+ /** A full object id, which is what every at-ref gate resolves its ref to before reading. */
17
+ function isCommitSha(ref) {
18
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
19
+ }
15
20
  /**
16
21
  * Read many objects from a git repo in ONE `git cat-file --batch` process.
17
22
  * `specs` are `<ref>:<path>` lines; the result array is index-aligned with
18
- * them — the blob's text, or null when the spec is missing/ambiguous at the
19
- * ref or resolves to a non-blob (e.g. a directory). Output protocol per
20
- * request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
21
- * (the spec may itself contain spaces, hence the endsWith checks).
23
+ * them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
24
+ * or `<spec> missing\n` (the spec may itself contain spaces, hence the
25
+ * endsWith checks).
22
26
  */
23
- async function catFileBatch(runner, repoDir, specs) {
27
+ async function catFileBatchOutcomes(runner, repoDir, specs) {
24
28
  // Bytes, not text: every `<size>` in the header counts bytes, and the walk
25
29
  // below has to step by that count through content that is not all ASCII.
26
30
  // One spec per LINE is the protocol, and this walk depends on getting back
@@ -42,7 +46,7 @@ async function catFileBatch(runner, repoDir, specs) {
42
46
  const header = out.subarray(off, nl).toString('utf-8');
43
47
  off = nl + 1;
44
48
  if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
45
- results.push(null);
49
+ results.push({ kind: 'missing' });
46
50
  continue;
47
51
  }
48
52
  const parts = header.split(' ');
@@ -50,13 +54,25 @@ async function catFileBatch(runner, repoDir, specs) {
50
54
  if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
51
55
  throw new Error(`unexpected git cat-file header: ${header}`);
52
56
  }
53
- // Non-blob (a directory path resolves to a tree) → null, but the
54
- // payload still has to be skipped to stay aligned.
55
- results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
57
+ // A non-blob (a directory path resolves to a tree) is reported as such,
58
+ // and its payload still has to be skipped to stay aligned.
59
+ results.push(parts[1] === 'blob'
60
+ ? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
61
+ : { kind: 'non-blob', type: parts[1] });
56
62
  off += size + 1; // payload + trailing LF
57
63
  }
58
64
  return results;
59
65
  }
66
+ /**
67
+ * {@link catFileBatchOutcomes} for callers that want text or nothing: the
68
+ * blob's text, or null when the spec is missing at the ref or resolves to a
69
+ * non-blob — the own-entries reads, where a directory where a file was
70
+ * named simply carries no entries.
71
+ */
72
+ async function catFileBatch(runner, repoDir, specs) {
73
+ const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
74
+ return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
75
+ }
60
76
  function isBuiltInRole(canonicalRole) {
61
77
  return canonicalRole === EVERYONE_CANONICAL;
62
78
  }
@@ -1107,22 +1123,49 @@ export class AccessControlService {
1107
1123
  *
1108
1124
  * This is what makes the change-request gates affordable. One detail read
1109
1125
  * plus one approval click runs six at-ref gates, and before this cache
1110
- * every one of them rebuilt the model from git: an `ls-tree -r` plus a
1111
- * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
1112
- * builds per click was the lag behind the approve checkmark.
1126
+ * every one of them rebuilt the model from git. A build is two git calls
1127
+ * (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
1128
+ * file the model reads), and each call forks the server process, which
1129
+ * costs tens of milliseconds on a server holding a large heap.
1113
1130
  *
1114
- * Bounded FIFO, so a long-lived server holds the last few tips rather than
1115
- * one model per commit it ever gated against. A build that found no usable
1116
- * roles.yaml is deliberately NOT cached, and a build that saw any git read
1117
- * fail never becomes a model at all: it throws AccessUnreadableError and the
1118
- * gate fails closed for that call. Both are what a transient failure looks
1119
- * like, and pinning either to a commit would turn one hiccup into a wrong
1120
- * verdict, denying or granting, until the base branch moved. Concurrent
1121
- * misses on the same commit share one build via `atRefInFlight`.
1131
+ * Bounded FIFO. The bound is sized for a listing that resolves access at
1132
+ * every open change request's two refs: one commit per open request plus
1133
+ * the targets, and a knowledge base with a hundred open requests was
1134
+ * evicting in a cycle under the old bound of 64, rebuilding every model on
1135
+ * every listing — thousands of forks, minutes of the main thread, and
1136
+ * every other request waiting behind them. A commit's tree never changes,
1137
+ * so a model is good for as long as it is held; the bound only caps memory.
1138
+ *
1139
+ * A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
1140
+ * read answers "missing" for it from git itself, which is as final as the
1141
+ * commit. What is never cached is a build that saw a git read FAIL: it
1142
+ * throws AccessUnreadableError and the gate fails closed for that call, and
1143
+ * pinning that to a commit would turn one hiccup into a wrong verdict until
1144
+ * the branch moved. Concurrent misses on the same commit share one build
1145
+ * via `atRefInFlight`.
1122
1146
  */
1123
1147
  atRefCache = new Map();
1148
+ atRefNoRoles = new Set();
1124
1149
  atRefInFlight = new Map();
1125
- static AT_REF_CACHE_MAX = 64;
1150
+ static AT_REF_CACHE_MAX = 512;
1151
+ /**
1152
+ * A path's own access entries AT A COMMIT, keyed by commit + path. A blob
1153
+ * at a commit never changes, so neither does what it declares for itself:
1154
+ * no TTL, no invalidation, and no workspace in the key (every clone holds
1155
+ * the same objects). This is the read every at-ref gate makes after the
1156
+ * model: one `cat-file --batch` per call for the paths asked, which a
1157
+ * change-request listing made once per ref per listing for the same paths
1158
+ * at the same commits. Bounded FIFO; the bound only caps memory.
1159
+ */
1160
+ ownEntriesAtCommit = new Map();
1161
+ static OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
1162
+ /** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
1163
+ static TREE_INDEX_MAX_PATHS = 20_000;
1164
+ /** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
1165
+ static TREE_INDEX_BUDGET = 500_000;
1166
+ treeIndexMaxPaths;
1167
+ treeIndexBudget;
1168
+ treePathsHeld = 0;
1126
1169
  /**
1127
1170
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
1128
1171
  * Held on the service rather than read per-model because it comes from the
@@ -1151,13 +1194,22 @@ export class AccessControlService {
1151
1194
  * `deploymentOwners`: the fixtures that construct this service directly
1152
1195
  * run on the default layout.
1153
1196
  */
1154
- kb = { layout: DEFAULT_KB_LAYOUT }) {
1197
+ kb = { layout: DEFAULT_KB_LAYOUT },
1198
+ /**
1199
+ * How much of the commits' trees the at-ref models may hold (see
1200
+ * `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
1201
+ * deployment's; a suite lowers them to reach the bounds with a small
1202
+ * repository.
1203
+ */
1204
+ limits = {}) {
1155
1205
  this.workspaceService = workspaceService;
1156
1206
  this.kbDirName = kbDirName;
1157
1207
  this.disk = disk;
1158
1208
  this.gitRunner = gitRunner;
1159
1209
  this.kb = kb;
1160
1210
  this.deploymentOwners = new Set(deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)));
1211
+ this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
1212
+ this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
1161
1213
  }
1162
1214
  /**
1163
1215
  * Drop a workspace's cached model + frontmatter memo. Call after operations
@@ -1272,6 +1324,24 @@ export class AccessControlService {
1272
1324
  });
1273
1325
  return result;
1274
1326
  }
1327
+ async groupsOfUser(workspaceId, userEmail) {
1328
+ const model = await this.loadModel(workspaceId);
1329
+ const email = canonicalEmail(userEmail);
1330
+ const names = [];
1331
+ for (const principal of model.roles.byCanonical.values()) {
1332
+ if (principal.kind === 'group' && principal.emails.has(email))
1333
+ names.push(principal.displayName);
1334
+ }
1335
+ return names;
1336
+ }
1337
+ async visibleGroups(workspaceId, userEmail, groups) {
1338
+ if (groups.length === 0)
1339
+ return [];
1340
+ if (await this.holdsAdminRootWrite(workspaceId, userEmail))
1341
+ return [...groups];
1342
+ const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
1343
+ return groups.filter((g) => mine.has(g));
1344
+ }
1275
1345
  async canWriteBatch(workspaceId, userEmail, relativePaths) {
1276
1346
  const model = await this.loadModel(workspaceId);
1277
1347
  const repoDir = await this.repoDir(workspaceId);
@@ -1650,7 +1720,7 @@ export class AccessControlService {
1650
1720
  if (!loaded)
1651
1721
  return null;
1652
1722
  const repoDir = await this.repoDir(workspaceId);
1653
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1723
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
1654
1724
  return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
1655
1725
  }
1656
1726
  async canReadAtRef(workspaceId, ref, userEmail, relativePath) {
@@ -1658,7 +1728,7 @@ export class AccessControlService {
1658
1728
  if (!loaded)
1659
1729
  return null;
1660
1730
  const repoDir = await this.repoDir(workspaceId);
1661
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1731
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
1662
1732
  return canReadResolved(loaded.model, userEmail, relativePath, own);
1663
1733
  }
1664
1734
  async canWriteBatchAtRef(workspaceId, ref, userEmail, relativePaths) {
@@ -1678,7 +1748,7 @@ export class AccessControlService {
1678
1748
  const repoDir = await this.repoDir(workspaceId);
1679
1749
  // One `git cat-file --batch` for the whole path set — a per-path `git
1680
1750
  // show` spawn made CR owner-routing take minutes on large change sets.
1681
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1751
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
1682
1752
  const result = new Map();
1683
1753
  for (const p of relativePaths) {
1684
1754
  const machineOwned = machineAnswers.get(p);
@@ -1695,7 +1765,7 @@ export class AccessControlService {
1695
1765
  return null;
1696
1766
  const repoDir = await this.repoDir(workspaceId);
1697
1767
  // One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
1698
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1768
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
1699
1769
  const result = new Map();
1700
1770
  for (const p of relativePaths) {
1701
1771
  result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
@@ -1714,7 +1784,7 @@ export class AccessControlService {
1714
1784
  if (!loaded)
1715
1785
  return null;
1716
1786
  const repoDir = await this.repoDir(workspaceId);
1717
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
1787
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
1718
1788
  return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
1719
1789
  }
1720
1790
  async holdsAdminRootWrite(workspaceId, userEmail) {
@@ -1806,7 +1876,7 @@ export class AccessControlService {
1806
1876
  return null;
1807
1877
  const repoDir = await this.repoDir(workspaceId);
1808
1878
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
1809
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1879
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
1810
1880
  for (const p of relativePaths) {
1811
1881
  if (result.has(p))
1812
1882
  continue;
@@ -2001,9 +2071,14 @@ export class AccessControlService {
2001
2071
  * grant themselves rights by editing a file's frontmatter in a branch the
2002
2072
  * gate isn't reading from.
2003
2073
  */
2004
- async readOwnEntriesAtRef(repoDir, ref, relativePath) {
2074
+ async readOwnEntriesAtRef(repoDir, ref, relativePath, tree = null) {
2005
2075
  if (relativePath === 'roles.yaml')
2006
2076
  return null;
2077
+ // At a commit (what every gate passes: `resolvedRef`), the batched read
2078
+ // and its memo answer; a single `git show` stays for a symbolic ref.
2079
+ if (isCommitSha(ref)) {
2080
+ return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
2081
+ }
2007
2082
  const text = await this.showAtRef(repoDir, ref, relativePath);
2008
2083
  if (text === null)
2009
2084
  return null;
@@ -2019,13 +2094,31 @@ export class AccessControlService {
2019
2094
  * `access.md`/`roles.yaml` (which carry no own-entries) resolve to null,
2020
2095
  * mirroring the single-path helper.
2021
2096
  */
2022
- async readOwnEntriesAtRefBatch(repoDir, ref, relativePaths) {
2097
+ async readOwnEntriesAtRefBatch(repoDir, ref, relativePaths, tree = null) {
2023
2098
  const result = new Map();
2024
2099
  const wanted = [];
2100
+ const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
2025
2101
  for (const p of relativePaths) {
2026
- if (p === 'roles.yaml')
2102
+ if (p === 'roles.yaml') {
2103
+ result.set(p, null);
2104
+ continue;
2105
+ }
2106
+ const key = `${ref}\0${p}`;
2107
+ if (memo?.has(key)) {
2108
+ result.set(p, memo.get(key) ?? null);
2109
+ continue;
2110
+ }
2111
+ // A path the commit's tree does not carry has no own entries, as a
2112
+ // fact about the commit: remembered, and not read. (A change request
2113
+ // that adds a file asks about it at the base, where it does not exist
2114
+ // yet; one that deletes a file asks at the head.)
2115
+ if (tree && !tree.has(p)) {
2027
2116
  result.set(p, null);
2028
- else if (!wanted.includes(p))
2117
+ if (memo)
2118
+ this.rememberOwnEntriesAtCommit(key, null);
2119
+ continue;
2120
+ }
2121
+ if (!wanted.includes(p))
2029
2122
  wanted.push(p);
2030
2123
  }
2031
2124
  if (wanted.length === 0)
@@ -2033,13 +2126,30 @@ export class AccessControlService {
2033
2126
  const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
2034
2127
  wanted.forEach((p, i) => {
2035
2128
  const text = texts[i];
2036
- if (text === null)
2129
+ if (text === null) {
2130
+ // Absent at the commit (when no tree was there to say so first), or
2131
+ // a blob git could not read: the batch says "missing" for both, and
2132
+ // only the first is a fact about the commit. Answered as no own
2133
+ // entries, as before, but never remembered.
2037
2134
  result.set(p, null);
2038
- else
2039
- result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
2135
+ return;
2136
+ }
2137
+ const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
2138
+ result.set(p, own);
2139
+ // What a blob declares for itself — including nothing — is a fact about the commit.
2140
+ if (memo)
2141
+ this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
2040
2142
  });
2041
2143
  return result;
2042
2144
  }
2145
+ rememberOwnEntriesAtCommit(key, value) {
2146
+ this.ownEntriesAtCommit.set(key, value);
2147
+ if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
2148
+ const oldest = this.ownEntriesAtCommit.keys().next().value;
2149
+ if (oldest !== undefined)
2150
+ this.ownEntriesAtCommit.delete(oldest);
2151
+ }
2152
+ }
2043
2153
  /**
2044
2154
  * Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
2045
2155
  * for short branch names, matching the heuristic in
@@ -2083,27 +2193,39 @@ export class AccessControlService {
2083
2193
  /**
2084
2194
  * List every `access.md` path that exists at any depth as of `ref`.
2085
2195
  * The access tree is structure-agnostic: any `access.md` participates
2086
- * regardless of where it sits. Uses `git ls-tree -r --name-only`.
2087
- * `'error'` when git failed: a model built without its access files is
2088
- * not the model, and the caller must not cache it as one.
2196
+ * regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
2197
+ * puts a NUL between paths and prints them as they are, where the default
2198
+ * output C-quotes a path with a non-ASCII or special character, which would
2199
+ * then name nothing when read back. `'error'` when git failed: a model
2200
+ * built without its access files is not the model, and the caller must not
2201
+ * cache it as one.
2089
2202
  */
2090
2203
  async listAccessFilesAtRef(repoDir, ref) {
2091
2204
  try {
2092
- const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
2205
+ const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
2093
2206
  const out = [];
2094
2207
  const pluginDirs = [];
2208
+ const all = [];
2209
+ // The fixed-name sources the tree carries (roles.yaml, the groups
2210
+ // files): a read of one of these that comes back empty is "absent"
2211
+ // only when the tree does not name it.
2212
+ const named = new Set();
2095
2213
  const { pluginsDir } = this.kb.layout;
2096
- for (const line of stdout.split('\n')) {
2097
- const p = line.trim();
2214
+ for (const p of stdout.split('\0')) {
2098
2215
  if (!p)
2099
2216
  continue;
2217
+ // One past the cap is enough to know the tree is not kept.
2218
+ if (all.length <= this.treeIndexMaxPaths)
2219
+ all.push(p);
2100
2220
  if (p === 'access.md' || p.endsWith('/access.md'))
2101
2221
  out.push(p);
2222
+ else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML)
2223
+ named.add(p);
2102
2224
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2103
2225
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2104
2226
  }
2105
2227
  }
2106
- return { accessFiles: out, pluginDirs };
2228
+ return { accessFiles: out, pluginDirs, named, all };
2107
2229
  }
2108
2230
  catch {
2109
2231
  return 'error';
@@ -2148,6 +2270,10 @@ export class AccessControlService {
2148
2270
  const hit = this.atRefCache.get(key);
2149
2271
  if (hit)
2150
2272
  return hit;
2273
+ // A commit known to carry no roles.yaml: the next candidate, without a
2274
+ // build (see `atRefNoRoles`).
2275
+ if (this.atRefNoRoles.has(key))
2276
+ continue;
2151
2277
  // Registered BEFORE the first await on the build path, so concurrent
2152
2278
  // misses on one commit share a single build instead of all racing past
2153
2279
  // an empty in-flight check together.
@@ -2157,6 +2283,8 @@ export class AccessControlService {
2157
2283
  .then((built) => {
2158
2284
  if (typeof built !== 'string')
2159
2285
  this.rememberAtRef(key, built);
2286
+ else if (built === 'no-roles')
2287
+ this.rememberNoRolesAtRef(key);
2160
2288
  return built;
2161
2289
  })
2162
2290
  .finally(() => {
@@ -2178,6 +2306,16 @@ export class AccessControlService {
2178
2306
  async revParseCommit(repoDir, ref) {
2179
2307
  if (!ref || ref.startsWith('-'))
2180
2308
  return null;
2309
+ // The ref files first (`readRevSha`): the names git would try, in git's
2310
+ // order, read without a process. "Missing" from the files is what
2311
+ // rev-parse answers nothing for; "unreadable" — a spelling the reader
2312
+ // does not resolve, a tag it cannot peel, a file it could not read — is
2313
+ // git's to decide, as the authority.
2314
+ const fromFiles = await readRevSha(repoDir, ref);
2315
+ if (fromFiles === 'missing')
2316
+ return null;
2317
+ if (fromFiles !== 'unreadable')
2318
+ return fromFiles.sha;
2181
2319
  try {
2182
2320
  const { stdout } = await this.gitRunner.run(repoDir, [
2183
2321
  'rev-parse',
@@ -2199,10 +2337,32 @@ export class AccessControlService {
2199
2337
  }
2200
2338
  rememberAtRef(key, value) {
2201
2339
  this.atRefCache.set(key, value);
2340
+ this.treePathsHeld += value.tree?.size ?? 0;
2202
2341
  if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
2203
2342
  const oldest = this.atRefCache.keys().next().value;
2204
- if (oldest !== undefined)
2343
+ if (oldest !== undefined) {
2344
+ this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
2205
2345
  this.atRefCache.delete(oldest);
2346
+ }
2347
+ }
2348
+ // The trees are a budget of their own, across every held model: past
2349
+ // it, the oldest models keep their model and let their tree go — the
2350
+ // gate still answers, absence is just no longer remembered for them.
2351
+ for (const held of this.atRefCache.values()) {
2352
+ if (this.treePathsHeld <= this.treeIndexBudget)
2353
+ break;
2354
+ if (!held.tree)
2355
+ continue;
2356
+ this.treePathsHeld -= held.tree.size;
2357
+ held.tree = null;
2358
+ }
2359
+ }
2360
+ rememberNoRolesAtRef(key) {
2361
+ this.atRefNoRoles.add(key);
2362
+ if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
2363
+ const oldest = this.atRefNoRoles.values().next().value;
2364
+ if (oldest !== undefined)
2365
+ this.atRefNoRoles.delete(oldest);
2206
2366
  }
2207
2367
  }
2208
2368
  /**
@@ -2212,6 +2372,15 @@ export class AccessControlService {
2212
2372
  * means the commit carries no roles.yaml (the caller tries its next
2213
2373
  * candidate); `'malformed'` means it does but it doesn't parse (the caller
2214
2374
  * answers null, as it always has).
2375
+ *
2376
+ * TWO git calls, whatever the tree holds: one `ls-tree` to learn which
2377
+ * files the model is made of, then one `cat-file --batch` that reads all of
2378
+ * them — roles.yaml, the groups sources, every access.md and every plugin
2379
+ * manifest. It used to be one `git show` per file, and each git call forks
2380
+ * the server process, which on a server holding a large heap costs tens of
2381
+ * milliseconds of the main thread per call: a knowledge base with a few
2382
+ * dozen access files paid a second or more per commit, times every ref a
2383
+ * listing resolves.
2215
2384
  */
2216
2385
  async buildModelAtCommit(repoDir, commit, label) {
2217
2386
  // Every read below tells "absent" apart from "git failed". A failure
@@ -2220,20 +2389,76 @@ export class AccessControlService {
2220
2389
  // and the caller fails closed (AccessUnreadableError, a 503) until a
2221
2390
  // retry reads the tree in full.
2222
2391
  const tag = `${label}@${commit.slice(0, 7)}`;
2392
+ const listed = await this.listAccessFilesAtRef(repoDir, commit);
2393
+ if (listed === 'error') {
2394
+ logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2395
+ throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2396
+ }
2397
+ const wanted = [
2398
+ 'roles.yaml',
2399
+ SYNCED_GROUPS_YAML,
2400
+ GROUPS_YAML,
2401
+ ...listed.accessFiles,
2402
+ ...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
2403
+ ];
2404
+ let outcomes;
2405
+ try {
2406
+ outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
2407
+ }
2408
+ catch (err) {
2409
+ // The operational signal: one line per failed build, naming the commit,
2410
+ // so a run of these is visible in the logs.
2411
+ logger(`access@${tag}`).warn(`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`);
2412
+ throw new AccessUnreadableError(label, 'access tree (cat-file)');
2413
+ }
2414
+ const fetched = new Map();
2415
+ wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
2416
+ const refuse = (relativePath, why) => {
2417
+ logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
2418
+ throw new AccessUnreadableError(label, relativePath);
2419
+ };
2420
+ // What the build reads. "Missing" from the batch is "absent" only for a
2421
+ // groups source the tree does not name. A path the tree names — an
2422
+ // access.md, a plugin manifest, or a groups file the listing carried —
2423
+ // that comes back missing has a blob git could not read (a lost object),
2424
+ // and one that is not a file is a tree the model cannot be read from:
2425
+ // either way the build fails closed rather than drop what that file
2426
+ // would deny. A path the build did not prefetch (none today) is read on
2427
+ // its own, told apart from a git failure as before.
2428
+ const inTree = (relativePath) => listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
2223
2429
  const read = async (relativePath) => {
2224
- const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2225
- if (outcome.kind === 'error') {
2226
- // The operational signal: one line per failed read, naming the commit
2227
- // and the path, so a run of these is visible in the logs.
2228
- logger(`access@${tag}`).warn(`git read of ${relativePath} failed; refusing to decide from a partial tree`);
2229
- throw new AccessUnreadableError(label, relativePath);
2430
+ const got = fetched.get(relativePath);
2431
+ if (got === undefined) {
2432
+ const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2433
+ if (outcome.kind === 'error')
2434
+ refuse(relativePath, 'could not be read');
2435
+ return outcome.kind === 'text' ? outcome.text : null;
2230
2436
  }
2231
- return outcome.kind === 'text' ? outcome.text : null;
2437
+ if (got.kind === 'blob')
2438
+ return got.text;
2439
+ if (got.kind === 'non-blob')
2440
+ return refuse(relativePath, `is a ${got.type}, not a file`);
2441
+ if (!inTree(relativePath))
2442
+ return null;
2443
+ return refuse(relativePath, 'is in the tree but its blob could not be read');
2232
2444
  };
2233
- const rolesYaml = await read('roles.yaml');
2234
- if (rolesYaml === null)
2445
+ // roles.yaml decides the build's outcome, so its states are read
2446
+ // directly. Absent from the tree is "no roles here" (the caller tries
2447
+ // its next candidate ref). In the tree but unreadable refuses the build:
2448
+ // answering "no roles" for it would be remembered for the commit and
2449
+ // would send a short ref through to origin, which may grant. A directory
2450
+ // of that name is as malformed as a file that does not parse (the caller
2451
+ // answers null, and never falls through to a ref that might grant what
2452
+ // this one does not).
2453
+ const roles = fetched.get('roles.yaml') ?? { kind: 'missing' };
2454
+ if (roles.kind === 'missing') {
2455
+ if (listed.named.has('roles.yaml'))
2456
+ refuse('roles.yaml', 'is in the tree but its blob could not be read');
2235
2457
  return 'no-roles';
2236
- const rolesParsed = parseRolesYaml(rolesYaml);
2458
+ }
2459
+ if (roles.kind === 'non-blob')
2460
+ return 'malformed';
2461
+ const rolesParsed = parseRolesYaml(roles.text);
2237
2462
  if (!rolesParsed.ok)
2238
2463
  return 'malformed';
2239
2464
  // Same group loading as the working-tree model, read AT THE COMMIT — the
@@ -2253,11 +2478,6 @@ export class AccessControlService {
2253
2478
  for (const w of mergeWarnings)
2254
2479
  logger(`access@${tag}`).warn(w);
2255
2480
  const accessFiles = new Map();
2256
- const listed = await this.listAccessFilesAtRef(repoDir, commit);
2257
- if (listed === 'error') {
2258
- logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2259
- throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2260
- }
2261
2481
  for (const p of listed.accessFiles) {
2262
2482
  const text = await read(p);
2263
2483
  if (text === null)
@@ -2299,6 +2519,10 @@ export class AccessControlService {
2299
2519
  deploymentOwners: this.deploymentOwners,
2300
2520
  },
2301
2521
  resolvedRef: commit,
2522
+ // The listing already named every path; held so an own-entries read
2523
+ // can tell absence from a lost blob, unless the tree is too large to
2524
+ // hold for every cached commit (then absence is simply not remembered).
2525
+ tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
2302
2526
  };
2303
2527
  }
2304
2528
  }