@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
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isAbsence } from '../../shared/fs.contract.js';
4
+ import { gitDirOf } from './git-dir.js';
5
+
6
+ /**
7
+ * Where a ref points, read from the repository's own files instead of a
8
+ * `git rev-parse` process.
9
+ *
10
+ * A branch tip is a small file git maintains: `refs/remotes/origin/<branch>`
11
+ * as a loose file, or one line of `packed-refs`. Reading it costs a file read
12
+ * and no fork, and it is exactly as current as the last fetch — the same
13
+ * freshness a `rev-parse` has. A change-request listing resolves two tips per
14
+ * open request and the access gates resolve every ref they decide at; with a
15
+ * hundred open requests that was hundreds of forks per listing, each costing
16
+ * tens of milliseconds of the main thread on a server holding a large heap.
17
+ *
18
+ * What is read, in git's own order: a loose file under the common directory
19
+ * (a worktree shares `refs/` with the repository it belongs to), then
20
+ * `packed-refs`. `HEAD` and the per-worktree namespaces (`refs/worktree/`,
21
+ * `refs/bisect/`, `refs/rewritten/`) are a worktree's own: read loose from
22
+ * its git directory, never from the common directory, and never packed. A
23
+ * symbolic ref (`ref: refs/heads/main`) is followed. A tag is never decided
24
+ * here: its file may hold a tag object, and what that peels to is git's to
25
+ * say.
26
+ *
27
+ * The answer is one of three things, and the caller treats them differently:
28
+ * `{ sha }` is what git would answer too; `'missing'` means every file git
29
+ * would consult says there is no such ref, which is as authoritative as
30
+ * `rev-parse` answering nothing; `'unreadable'` means this reader could not
31
+ * decide — a repository it does not understand (one whose refs live in a
32
+ * reftable, say), a tag, a corrupt file, a name it refuses — and the caller
33
+ * asks git, which is authoritative. Never a guess: a read error is
34
+ * `'unreadable'`, not `'missing'`, so a flaky disk sends the caller to git
35
+ * rather than making a branch disappear.
36
+ *
37
+ * What the files guarantee about the object: git itself refuses to write a
38
+ * non-commit under `refs/heads/`, and `refs/remotes/origin/` mirrors the
39
+ * remote's `refs/heads/`, so a branch tip read here is a commit as surely
40
+ * as `rev-parse <branch>^{commit}` would make it one. A caller that holds
41
+ * an object id rather than a name, or a `<rev>^{...}` form, asks git.
42
+ */
43
+ export type RefFileAnswer = { sha: string } | 'missing' | 'unreadable';
44
+
45
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
46
+ const MAX_SYMREF_DEPTH = 5;
47
+ const PER_WORKTREE = /^refs\/(?:worktree|bisect|rewritten)\//;
48
+
49
+ /** A ref name this reader is willing to turn into a path under `.git`. */
50
+ function isSafeRefName(ref: string): boolean {
51
+ if (ref === 'HEAD') return true;
52
+ if (!ref.startsWith('refs/')) return false;
53
+ if (!/^[A-Za-z0-9._\-/@+]+$/.test(ref)) return false;
54
+ return !ref.split('/').some((segment) => segment === '' || segment === '.' || segment === '..');
55
+ }
56
+
57
+ /**
58
+ * One resolution's view of the repository: the two directories, and the
59
+ * `packed-refs` table read at most once however many candidates a name
60
+ * tries — the table can grow with the history, and a name that is missing
61
+ * as itself consults it for every prefix.
62
+ */
63
+ interface GitDirs {
64
+ /** The worktree's own git directory: `HEAD` lives here. */
65
+ gitDir: string;
66
+ /** Where `refs/` and `packed-refs` live: the repository, shared by every worktree. */
67
+ commonDir: string;
68
+ /** The `packed-refs` table, read on first need; null when it could not be read. */
69
+ packed?: Promise<Map<string, string> | null>;
70
+ }
71
+
72
+ async function gitDirsOf(repoDir: string): Promise<GitDirs | null> {
73
+ let gitDir: string | null;
74
+ try {
75
+ gitDir = await gitDirOf(repoDir);
76
+ } catch {
77
+ return null;
78
+ }
79
+ if (gitDir === null) return null;
80
+ let commonDir = gitDir;
81
+ try {
82
+ const pointer = (await fs.readFile(path.join(gitDir, 'commondir'), 'utf-8')).trim();
83
+ if (pointer) commonDir = path.resolve(gitDir, pointer);
84
+ } catch (err) {
85
+ if (!isAbsence(err)) return null;
86
+ }
87
+ // A repository whose refs live in a reftable (`extensions.refStorage`)
88
+ // keeps nothing this reader understands under `refs/` or in `packed-refs`:
89
+ // every name would read as missing. Not a repository for this reader.
90
+ try {
91
+ await fs.stat(path.join(commonDir, 'reftable'));
92
+ return null;
93
+ } catch (err) {
94
+ if (!isAbsence(err)) return null;
95
+ }
96
+ try {
97
+ if (/^\s*refstorage\s*=\s*reftable\s*$/im.test(await fs.readFile(path.join(commonDir, 'config'), 'utf-8'))) {
98
+ return null;
99
+ }
100
+ } catch (err) {
101
+ if (!isAbsence(err)) return null;
102
+ }
103
+ return { gitDir, commonDir };
104
+ }
105
+
106
+ /**
107
+ * `packed-refs`: `<sha> <name>` lines, `^<sha>` peel lines, `#` comments.
108
+ * Absent file → an empty table. A line that is none of those is a file this
109
+ * reader does not understand, and the whole table is null (unreadable): a
110
+ * record it skipped could be the one that was asked for.
111
+ */
112
+ async function readPackedRefs(commonDir: string): Promise<Map<string, string> | null> {
113
+ let text: string;
114
+ try {
115
+ text = await fs.readFile(path.join(commonDir, 'packed-refs'), 'utf-8');
116
+ } catch (err) {
117
+ return isAbsence(err) ? new Map() : null;
118
+ }
119
+ const table = new Map<string, string>();
120
+ for (const raw of text.split('\n')) {
121
+ const line = raw.trim();
122
+ if (!line || line.startsWith('#')) continue;
123
+ if (line.startsWith('^')) {
124
+ if (!SHA.test(line.slice(1))) return null;
125
+ continue;
126
+ }
127
+ const space = line.indexOf(' ');
128
+ const sha = space < 0 ? '' : line.slice(0, space);
129
+ const name = space < 0 ? '' : line.slice(space + 1).trim();
130
+ if (!SHA.test(sha) || !name) return null;
131
+ table.set(name, sha);
132
+ }
133
+ return table;
134
+ }
135
+
136
+ /**
137
+ * The file at `<dir>/<relative>`, read as git reads a loose ref: a symbolic
138
+ * ref is followed, an object id is the answer, anything else is not decided.
139
+ */
140
+ async function readLooseRef(dirs: GitDirs, dir: string, relative: string, depth: number): Promise<RefFileAnswer> {
141
+ let text: string;
142
+ try {
143
+ text = (await fs.readFile(path.join(dir, ...relative.split('/')), 'utf-8')).trim();
144
+ } catch (err) {
145
+ return isAbsence(err) ? 'missing' : 'unreadable';
146
+ }
147
+ if (text.startsWith('ref:')) {
148
+ const answer = await resolveIn(dirs, text.slice('ref:'.length).trim(), depth + 1);
149
+ // A symbolic ref whose target is gone is dangling, and git reports it
150
+ // as such; a packed entry under the symbolic ref's own name does not
151
+ // stand in for it. Git's to report, then.
152
+ return answer === 'missing' ? 'unreadable' : answer;
153
+ }
154
+ return SHA.test(text) ? { sha: text } : 'unreadable';
155
+ }
156
+
157
+ async function resolveIn(dirs: GitDirs, ref: string, depth: number): Promise<RefFileAnswer> {
158
+ if (depth > MAX_SYMREF_DEPTH) return 'unreadable';
159
+ if (!isSafeRefName(ref)) return 'unreadable';
160
+ // `HEAD` and the per-worktree namespaces (`refs/worktree/`, `refs/bisect/`,
161
+ // `refs/rewritten/`) are a worktree's own, kept loose in its git directory
162
+ // and never packed; everything else is the repository's.
163
+ const own = ref === 'HEAD' || PER_WORKTREE.test(ref);
164
+ const loose = await readLooseRef(dirs, own ? dirs.gitDir : dirs.commonDir, ref, depth);
165
+ if (loose !== 'missing') return decidedHere(ref, loose);
166
+ if (own) return 'missing';
167
+ const packed = await (dirs.packed ??= readPackedRefs(dirs.commonDir));
168
+ if (!packed) return 'unreadable';
169
+ const sha = packed.get(ref);
170
+ return sha ? decidedHere(ref, { sha }) : 'missing';
171
+ }
172
+
173
+ /**
174
+ * The files decide only where git itself guarantees the object is a commit:
175
+ * `HEAD` and `refs/heads/*`, which git refuses to point at anything else,
176
+ * and `refs/remotes/*`, fetched from a remote's `refs/heads/*` under the
177
+ * same refusal. Anything else that exists — a tag, a ref directly under
178
+ * `refs/` — may name a tree or a blob, which only git's `^{commit}` refuses,
179
+ * so it is git's to resolve. Anything that does not exist is as missing as
180
+ * any other ref, so a short name can move on to the branch git would find
181
+ * next.
182
+ */
183
+ function decidedHere(ref: string, answer: RefFileAnswer): RefFileAnswer {
184
+ if (answer !== 'missing' && answer !== 'unreadable' && !commitGuaranteed(ref)) return 'unreadable';
185
+ return answer;
186
+ }
187
+
188
+ function commitGuaranteed(ref: string): boolean {
189
+ return ref === 'HEAD' || ref.startsWith('refs/heads/') || ref.startsWith('refs/remotes/');
190
+ }
191
+
192
+ /**
193
+ * The commit a full ref name (`refs/...`) or `HEAD` points at in the
194
+ * repository behind `repoDir` (a clone, or a worktree of one). See the module
195
+ * doc for the three answers and what each means to the caller.
196
+ *
197
+ * "Missing" is what `rev-parse` answers nothing for, and `rev-parse` tries
198
+ * the same prefixes for a full name as for a short one (`refs/heads/x` is
199
+ * also looked for as `refs/heads/refs/heads/x`), so a name that is missing
200
+ * as itself but present under a prefix is git's to resolve.
201
+ */
202
+ export async function readRefSha(repoDir: string, ref: string): Promise<RefFileAnswer> {
203
+ const dirs = await gitDirsOf(repoDir);
204
+ if (!dirs) return 'unreadable';
205
+ const exact = await resolveIn(dirs, ref, 0);
206
+ if (exact !== 'missing') return exact;
207
+ for (const name of prefixedCandidates(ref)) {
208
+ if ((await resolveIn(dirs, name, 0)) !== 'missing') return 'unreadable';
209
+ }
210
+ return 'missing';
211
+ }
212
+
213
+ /**
214
+ * The names `git rev-parse` tries for `<name>` after `$GIT_DIR/<name>`
215
+ * itself, in git's order (gitrevisions(7)): `refs/<name>`,
216
+ * `refs/tags/<name>`, `refs/heads/<name>`, `refs/remotes/<name>`,
217
+ * `refs/remotes/<name>/HEAD`. The same for every spelling, a full `refs/...`
218
+ * name included.
219
+ */
220
+ function prefixedCandidates(name: string): string[] {
221
+ return [`refs/${name}`, `refs/tags/${name}`, `refs/heads/${name}`, `refs/remotes/${name}`, `refs/remotes/${name}/HEAD`];
222
+ }
223
+
224
+ /**
225
+ * Every name `git rev-parse` tries for a spelling, in git's order: the
226
+ * spelling itself at `$GIT_DIR/<name>` (which for `HEAD` and a full
227
+ * `refs/...` name is the ref file), then the prefixed forms. Null for a
228
+ * spelling this reader does not resolve — an object id or its abbreviation,
229
+ * a `<rev>^{...}` or `@{...}` form, an empty or option-looking name — which
230
+ * the caller sends to git.
231
+ */
232
+ function refNameCandidates(ref: string): string[] | null {
233
+ if (!ref || ref.startsWith('-')) return null;
234
+ if (/[\^~:@{}\\\s]/.test(ref)) return null;
235
+ if (/^[0-9a-f]{4,64}$/i.test(ref)) return null;
236
+ // `main-worktree/<ref>` and `worktrees/<name>/<ref>` reach another
237
+ // worktree's own refs (gitrevisions(7)): git's to resolve.
238
+ if (/^(?:main-worktree|worktrees)\//.test(ref)) return null;
239
+ return [ref, ...prefixedCandidates(ref)];
240
+ }
241
+
242
+ /**
243
+ * {@link readRefSha} for a name as a caller holds it (`main`, `origin/main`,
244
+ * `HEAD`, `refs/heads/main`): the first of git's candidates that resolves,
245
+ * starting where git starts, at `$GIT_DIR/<name>` itself. Whatever is found
246
+ * there (`MERGE_HEAD`, `FETCH_HEAD`, `AUTO_MERGE`, which may name a tree) is
247
+ * git's to resolve; only its absence lets the `refs/` candidates be read.
248
+ * `'missing'` only when every candidate is missing; `'unreadable'` as soon
249
+ * as one cannot be decided, because the undecided one might be the one git
250
+ * would pick.
251
+ */
252
+ export async function readRevSha(repoDir: string, rev: string): Promise<RefFileAnswer> {
253
+ const candidates = refNameCandidates(rev);
254
+ if (!candidates) return 'unreadable';
255
+ const dirs = await gitDirsOf(repoDir);
256
+ if (!dirs) return 'unreadable';
257
+ for (const name of candidates) {
258
+ const answer = name === rev ? await resolveAsItself(dirs, rev) : await resolveIn(dirs, name, 0);
259
+ if (answer !== 'missing') return answer;
260
+ }
261
+ return 'missing';
262
+ }
263
+
264
+ /**
265
+ * Git's first step for any spelling: `$GIT_DIR/<name>`. For `HEAD` and a
266
+ * full `refs/...` name that is the ref itself, read as such. For anything
267
+ * else it is a pseudoref (`MERGE_HEAD`, `AUTO_MERGE`, which may name a
268
+ * tree), and whatever exists there is git's to resolve.
269
+ */
270
+ async function resolveAsItself(dirs: GitDirs, rev: string): Promise<RefFileAnswer> {
271
+ if (rev === 'HEAD' || rev.startsWith('refs/')) return resolveIn(dirs, rev, 0);
272
+ if (!isSafeRefName(`refs/${rev}`)) return 'unreadable';
273
+ try {
274
+ await fs.stat(path.join(dirs.gitDir, ...rev.split('/')));
275
+ return 'unreadable';
276
+ } catch (err) {
277
+ return isAbsence(err) ? 'missing' : 'unreadable';
278
+ }
279
+ }
@@ -1,11 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { DEFAULT_BRANCH, joinBranchFor } from '@bevel-software/platform-shared';
3
- import {
4
- PluginJoinRequestJobs,
5
- JoinRequestNotReadyError,
6
- type JoinRequestJobsDeps,
7
- CLAIM_STALE_AFTER_MS,
8
- } from '../join-request-jobs.service.js';
3
+ import { DrizzleQueryError } from 'drizzle-orm';
4
+ import { PluginJoinRequestJobs, JoinRequestNotReadyError, type JoinRequestJobsDeps } from '../index.js';
5
+ // The claim window is not part of the module's surface: a test that moves
6
+ // the clock past it reads the number the service itself uses.
7
+ import { CLAIM_STALE_AFTER_MS } from '../join-request-jobs.service.js';
9
8
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
10
9
  import { FakeJoinRequestStore } from './fake-join-request-store.js';
11
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
@@ -146,6 +145,48 @@ describe('PluginJoinRequestJobs', () => {
146
145
  expect(row.failureReason).not.toContain('ghp_secret');
147
146
  });
148
147
 
148
+ it("logs a database failure as the database's reason, and shows the requester one stable sentence", async () => {
149
+ const h = harness();
150
+ // What Drizzle throws: its own error class, the SQL in the message, the
151
+ // driver's error as the cause.
152
+ h.workflow.openChangeRequest.mockRejectedValue(
153
+ new DrizzleQueryError(
154
+ 'insert into "change_requests" ("id", "number", "title", "body", "source_branch", "target_branch", "author_id", "status", "created_at", "updated_at") values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
155
+ [],
156
+ new Error('relation "change_requests" does not exist'),
157
+ ),
158
+ );
159
+ await h.jobs.start(pendingRow(h.store));
160
+
161
+ // The operator gets the reason, not the wrapper's cut-off SQL…
162
+ const logged = consoleError.mock.calls.map((c) => String(c[0]));
163
+ // (`printable` renders the reason as a JSON string, so its own quotes come escaped.)
164
+ expect(logged).toContainEqual(expect.stringMatching(/failed: "relation \\"change_requests\\" does not exist"/));
165
+ expect(logged.some((l) => l.includes('Failed query'))).toBe(false);
166
+ // …and the requester's card names no table.
167
+ const [row] = h.store.all();
168
+ expect(row.status).toBe('failed');
169
+ expect(row.failureReason).toBe('the request could not be recorded; an admin can find the reason in the server log');
170
+ });
171
+
172
+ it("shows the requester a wrapped failure's own words when it is not the database's", async () => {
173
+ const h = harness();
174
+ // The git work failing behind a wrapper of its own: the cause is what is
175
+ // logged, and what the requester reads, credentials stripped — the
176
+ // database's stable sentence would hide the detail they can act on.
177
+ h.workflow.openChangeRequest.mockRejectedValue(
178
+ new Error('opening the change request failed', {
179
+ cause: new Error('fatal: unable to access https://x:ghp_secret@github.com/acme/kb: could not resolve host'),
180
+ }),
181
+ );
182
+ await h.jobs.start(pendingRow(h.store));
183
+ const [row] = h.store.all();
184
+ expect(row.status).toBe('failed');
185
+ expect(row.failureReason).toContain('unable to access');
186
+ expect(row.failureReason).not.toContain('ghp_secret');
187
+ expect(row.failureReason).not.toContain('could not be recorded');
188
+ });
189
+
149
190
  it('fails the record rather than throwing when the plugin is gone', async () => {
150
191
  const h = harness({ target: async () => null });
151
192
  await expect(h.jobs.start(pendingRow(h.store))).resolves.toBeUndefined();
@@ -524,6 +565,32 @@ describe('PluginJoinRequestJobs', () => {
524
565
  expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
525
566
  });
526
567
 
568
+ it("a sweep that cannot read the table logs the database's reason, not the wrapper's cut-off SQL", async () => {
569
+ vi.useFakeTimers();
570
+ const warned = vi.spyOn(console, 'warn').mockImplementation(() => {});
571
+ try {
572
+ const h = harness();
573
+ // What Drizzle throws: its own error class, the SQL in the message, the driver's error underneath.
574
+ const wrapped = new DrizzleQueryError(
575
+ 'select "id", "requester_email", "requester_email_bidx", "requester_name", "plugin_key", "status", "failure_reason", "change_request_number", "claimed_at", "claim_token", "created_at" from "plugin_join_requests" where status = $1',
576
+ [],
577
+ new Error('Cannot use a pool after calling end on the pool'),
578
+ );
579
+ h.store.pending = async () => {
580
+ throw wrapped;
581
+ };
582
+ h.jobs.startSweeping(1000);
583
+ await vi.advanceTimersByTimeAsync(0);
584
+ h.jobs.stopSweeping();
585
+ const lines = warned.mock.calls.map((c) => String(c[0]));
586
+ expect(lines).toHaveLength(1);
587
+ expect(lines[0]).toContain('could not look at the join requests still owed: "Cannot use a pool after calling end on the pool"');
588
+ expect(lines[0]).not.toContain('Failed query');
589
+ } finally {
590
+ warned.mockRestore();
591
+ }
592
+ });
593
+
527
594
  it('proceeds past a createBranch failure only when the branch is really there', async () => {
528
595
  const h = harness();
529
596
  const branch = joinBranchFor(ALI, 'Finance');
@@ -4,6 +4,7 @@ import express from 'express';
4
4
  import { describe, it, expect, afterEach, vi } from 'vitest';
5
5
 
6
6
  import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import { AccessControlService } from '../../access/access-control.service.js';
7
8
  import type { ISkillService, SkillSummary } from '../../skills/skills.contract.js';
8
9
  import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
9
10
  import type { IPluginIndexService, PluginCatalogEntry } from '../plugins.contract.js';
@@ -62,6 +63,10 @@ interface HarnessOpts {
62
63
  /** What the org-wide `everyone` principal reads. */
63
64
  everyone?: string[];
64
65
  email?: string | null;
66
+ /** Whether the caller is an admin, who is shown every group. Defaults to true. */
67
+ admin?: boolean;
68
+ /** The groups the caller is a member of, which is all a non-admin is shown. */
69
+ memberOf?: string[];
65
70
  }
66
71
 
67
72
  /** The org-wide entry every answer opens with — empty unless `everyone` says otherwise. */
@@ -82,6 +87,12 @@ async function harness(opts: HarnessOpts = {}) {
82
87
  opts.team && group in opts.team ? tableVerdict(opts.team[group], paths) : null,
83
88
  ),
84
89
  canReadAsEveryoneBatch: vi.fn(async (_w: string, paths: string[]) => tableVerdict(opts.everyone, paths)),
90
+ holdsAdminRootWrite: vi.fn(async () => opts.admin ?? true),
91
+ groupsOfUser: vi.fn(async () => opts.memberOf ?? []),
92
+ // The real policy over the two stubs above: who sees which group is the
93
+ // resolver's one answer, not something a route (or this double) decides.
94
+ visibleGroups: (wsId: string, email: string, groups: readonly string[]) =>
95
+ AccessControlService.prototype.visibleGroups.call(accessControl, wsId, email, groups),
85
96
  } as unknown as IAccessControl;
86
97
  const index = { catalog: async () => CATALOG, invalidate: () => undefined } as IPluginIndexService;
87
98
  const skills = { listSkills: async () => SKILLS } as unknown as ISkillService;
@@ -242,6 +253,50 @@ describe('GET /api/teams', () => {
242
253
  );
243
254
  });
244
255
 
256
+ it("shows someone who is not an admin only the groups they are in, never another group's name", async () => {
257
+ const h = await harness({
258
+ groups: ['Sales Team', 'Finance Team'],
259
+ caller: EVERYTHING,
260
+ team: {
261
+ 'Sales Team': ['Plugins/GTM', 'Skills/Sales/outreach/SKILL.md', 'Plugins/GTM/mcp.json'],
262
+ 'Finance Team': ['Plugins/Finance', 'Skills/Finance/ledger/SKILL.md', 'Plugins/Finance/books.tool'],
263
+ },
264
+ admin: false,
265
+ memberOf: ['Sales Team'],
266
+ });
267
+ const res = await h.get();
268
+ expect(res.status).toBe(200);
269
+ expect(await res.json()).toEqual({
270
+ teams: [
271
+ NOTHING_ORG_WIDE,
272
+ { name: 'Sales Team', plugins: ['gtm'], skills: ['outreach'], tools: ['hubspot'] },
273
+ ],
274
+ });
275
+ // A group that is not listed is not asked about either.
276
+ expect(h.accessControl.canReadAsGroupBatch).not.toHaveBeenCalledWith(
277
+ expect.anything(),
278
+ 'Finance Team',
279
+ expect.anything(),
280
+ );
281
+ });
282
+
283
+ it('shows someone in no group, who is not an admin, Everyone alone', async () => {
284
+ const h = await harness({ groups: ['Sales Team'], caller: EVERYTHING, team: { 'Sales Team': [] }, admin: false });
285
+ expect(await (await h.get()).json()).toEqual({ teams: [NOTHING_ORG_WIDE] });
286
+ });
287
+
288
+ it('shows an admin every group, including ones they are not in', async () => {
289
+ const h = await harness({
290
+ groups: ['Sales Team', 'Finance Team'],
291
+ caller: EVERYTHING,
292
+ team: { 'Sales Team': [], 'Finance Team': [] },
293
+ admin: true,
294
+ memberOf: [],
295
+ });
296
+ const body = (await (await h.get()).json()) as { teams: { name: string }[] };
297
+ expect(body.teams.map((t) => t.name)).toEqual(['Everyone', 'Sales Team', 'Finance Team']);
298
+ });
299
+
245
300
  it('is 401 without a caller', async () => {
246
301
  const h = await harness({ email: null });
247
302
  expect((await h.get()).status).toBe(401);
@@ -4,6 +4,7 @@ import {
4
4
  type ChangeRequestState,
5
5
  type IWorkflowService,
6
6
  } from '@bevel-software/platform-shared';
7
+ import { DrizzleQueryError } from 'drizzle-orm';
7
8
  import { spliceGrant } from '../access-model/access-splice.js';
8
9
  import { sanitizeError } from '../workflow/sanitize-error.js';
9
10
  import { logger } from '../../shared/logging.js';
@@ -358,12 +359,12 @@ export class PluginJoinRequestJobs {
358
359
  */
359
360
  startSweeping(intervalMs: number = SWEEP_INTERVAL_MS): void {
360
361
  void this.sweep().catch((err: unknown) => {
361
- log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
362
+ log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
362
363
  });
363
364
  if (this.sweepTimer) return;
364
365
  this.sweepTimer = setInterval(() => {
365
366
  void this.sweep().catch((err: unknown) => {
366
- log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
367
+ log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
367
368
  });
368
369
  }, intervalMs);
369
370
  // Never a reason for the process to stay alive: every row is durable, and
@@ -398,7 +399,7 @@ export class PluginJoinRequestJobs {
398
399
  // wrap the whole attempt: the claim has to be refreshed for as long as
399
400
  // the work runs, and the work is the part that can take minutes.
400
401
  const live = await this.store.claim(record.id, CLAIM_STALE_AFTER_MS).catch((err: unknown) => {
401
- log.warn(`could not claim a join request: ${loggable(sanitizeError(err))}`);
402
+ log.warn(`could not claim a join request: ${loggable(sanitizeError(underlying(err)))}`);
402
403
  return null;
403
404
  });
404
405
  if (!live) {
@@ -426,7 +427,7 @@ export class PluginJoinRequestJobs {
426
427
  if (!still) held = false;
427
428
  return still ? 'held' : 'lost';
428
429
  } catch (err: unknown) {
429
- log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(err))}`);
430
+ log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(underlying(err)))}`);
430
431
  return 'unknown';
431
432
  }
432
433
  };
@@ -453,7 +454,10 @@ export class PluginJoinRequestJobs {
453
454
  if (state !== 'held') throw new ClaimLostError(state);
454
455
  });
455
456
  } catch (err) {
456
- const reason = sanitizeError(err);
457
+ // The cause when there is one (see `underlying`), for the log; a
458
+ // database's reason, where a wrapper's cut-off column list is not.
459
+ const cause = underlying(err);
460
+ const reason = sanitizeError(cause);
457
461
  // `pluginKey` is a persisted folder path, so it is escaped before it
458
462
  // reaches a log line: a folder carrying control characters could
459
463
  // otherwise write newlines of its own into the log.
@@ -473,22 +477,31 @@ export class PluginJoinRequestJobs {
473
477
  // claim held by a process that never really started.
474
478
  log.warn(`join request for ${key} postponed: ${loggable(reason)}`);
475
479
  await this.store.release(record.id, live.claimToken).catch((releaseErr: unknown) => {
476
- log.warn(`could not release that claim: ${loggable(sanitizeError(releaseErr))}`);
480
+ log.warn(`could not release that claim: ${loggable(sanitizeError(underlying(releaseErr)))}`);
477
481
  });
478
482
  return;
479
483
  }
480
484
  log.error(`join request for ${key} failed: ${loggable(reason)}`);
485
+ // What the requester reads on their card. The git work's own words
486
+ // are theirs to see (credentials stripped); a database's reason is
487
+ // not — it names tables and columns and is for the operator, who has
488
+ // it in the line above — so a database failure is recorded in one
489
+ // stable sentence. The database's failures are the ones the query
490
+ // layer wraps (`DrizzleQueryError`, its cause the driver's error);
491
+ // any other wrapped error keeps its own sanitised words, which may be
492
+ // what the requester needs to act on.
493
+ const shown = err instanceof DrizzleQueryError ? DATABASE_FAILURE_REASON : reason;
481
494
  await this.store
482
495
  // Named with the token, so this lands only if the row is still this
483
496
  // process's. A worker that overran the window and had its row taken
484
497
  // writes nothing here rather than stamping its failure on the attempt
485
498
  // that replaced it — which would show the requester a failure for
486
499
  // work that is, at that moment, succeeding.
487
- .markFailed(record.id, live.claimToken, reason)
500
+ .markFailed(record.id, live.claimToken, shown)
488
501
  // Nothing left to do if even that write fails: the row stays
489
502
  // `pending` and the next boot's sweep tries the whole thing again.
490
503
  .catch((writeErr: unknown) => {
491
- log.error(`could not record that failure: ${loggable(sanitizeError(writeErr))}`);
504
+ log.error(`could not record that failure: ${loggable(sanitizeError(underlying(writeErr)))}`);
492
505
  });
493
506
  } finally {
494
507
  held = false;
@@ -669,6 +682,30 @@ function loggable(reason: string): string {
669
682
  return printable(reason);
670
683
  }
671
684
 
685
+ /**
686
+ * The error behind a wrapped one. Drizzle reports a failed query as its own
687
+ * error whose message is the SQL and whose `cause` is the database's reason;
688
+ * the sanitizer caps a message at a couple of hundred characters, so logging
689
+ * the wrapper showed a cut-off column list and never the reason. The reason
690
+ * is what every LOG line this service writes needs — "the pool has been
691
+ * ended", "relation does not exist" — the sweep's warning, the claim and
692
+ * heartbeat warnings, the failure line. The requester's card is the one
693
+ * place it does not go: a wrapped failure is recorded there as
694
+ * `DATABASE_FAILURE_REASON`, since a table name is the operator's to read.
695
+ * An error with no cause (git's, the claim's own) is reported as it is.
696
+ */
697
+ function underlying(err: unknown): unknown {
698
+ const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
699
+ return cause instanceof Error ? cause : err;
700
+ }
701
+
702
+ /**
703
+ * The sentence a requester's card carries when the database, not the git
704
+ * work, refused the request: stable, and naming no table or column. The
705
+ * database's own words go to the server log beside it.
706
+ */
707
+ const DATABASE_FAILURE_REASON = 'the request could not be recorded; an admin can find the reason in the server log';
708
+
672
709
  /** Node's "there is no such file" errors, and only those. */
673
710
  function isFileAbsent(err: unknown): boolean {
674
711
  const code = (err as NodeJS.ErrnoException | null)?.code;
@@ -37,6 +37,10 @@ import type { IPluginIndexService, PluginCatalogEntry } from './plugins.contract
37
37
  * what `read: everyone` (or a public plugin) reaches. Same slice, same
38
38
  * caller gate, asked of the resolver as `canReadAsEveryoneBatch`. The name
39
39
  * cannot collide with a group's: `everyone` is reserved by the grammar.
40
+ *
41
+ * The groups listed after it are every group for an admin, and only the
42
+ * caller's own groups for anyone else (`groupsOfUser`), so the lens never
43
+ * names a group the caller has no part in.
40
44
  */
41
45
  export interface TeamAccess {
42
46
  name: string;
@@ -110,10 +114,19 @@ export function createTeamsRoutes(
110
114
  };
111
115
  };
112
116
 
117
+ // Which groups to list: the resolver's one answer to who sees which
118
+ // group (every group for an admin, only their own for anyone else), the
119
+ // same the share dialog offers and a grant may name. A group's NAME is
120
+ // information too: a deployment that gives each client, author or
121
+ // customer a group of their own would otherwise show every member the
122
+ // whole roster, even though the slices of groups they are not in come
123
+ // back empty.
124
+ const listed = await accessControl.visibleGroups(wsId, email, groups);
125
+
113
126
  const teams: TeamAccess[] = [
114
127
  slice(EVERYONE_TEAM, await accessControl.canReadAsEveryoneBatch(wsId, probes)),
115
128
  ];
116
- for (const name of groups) {
129
+ for (const name of listed) {
117
130
  const team = await accessControl.canReadAsGroupBatch(wsId, name, probes);
118
131
  if (team === null) continue; // gone between the two reads — nothing to say
119
132
  teams.push(slice(name, team));
@@ -10,13 +10,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
11
  import { createSetupRoutes } from '../setup.routes.js';
12
12
  import { DeploymentSettingsService } from '../deployment-settings.service.js';
13
- import { KbStartupRunner } from '../../workspace/startup/kb-startup-runner.js';
14
13
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
15
14
  import { TemplateFilesStep } from '../../workspace/startup/steps/template-files.step.js';
16
15
  import { NodeFs } from '../../kb-fs/node-fs.js';
17
16
  import { defaultKbTemplateDir } from '../../../assets.js';
18
17
  import type { Database } from '../../database/connection.js';
19
18
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
19
+ import { newKbStartupRunner } from '../../../__tests__/workspace-service.js';
20
20
 
21
21
  /**
22
22
  * The completing save and the REAL initialization phase, wired the way the
@@ -105,7 +105,7 @@ function boot() {
105
105
  // it: the completing save applies the admin's names to it, and the step
106
106
  // reads them from it when the phase runs.
107
107
  const kb = testKbContext();
108
- const runner = new KbStartupRunner({
108
+ const runner = newKbStartupRunner({
109
109
  gitRunner: new NodeGitRunner(),
110
110
  kbRepoUrl: () => upstream,
111
111
  workspacesRoot: path.join(root, 'workspaces'),
@@ -12,7 +12,6 @@ import { createSetupRoutes } from '../setup.routes.js';
12
12
  import { DeploymentSettingsService } from '../deployment-settings.service.js';
13
13
  import { ManagedRepository } from '../managed-repository.js';
14
14
  import { RepositorySource } from '../repository-source.js';
15
- import { KbStartupRunner } from '../../workspace/startup/kb-startup-runner.js';
16
15
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
17
16
  import { TemplateFilesStep } from '../../workspace/startup/steps/template-files.step.js';
18
17
  import { NodeFs } from '../../kb-fs/node-fs.js';
@@ -20,6 +19,7 @@ import { defaultKbTemplateDir } from '../../../assets.js';
20
19
  import type { Database } from '../../database/connection.js';
21
20
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
22
21
  import type { KbContext } from '../../../shared/kb-context.js';
22
+ import { newKbStartupRunner } from '../../../__tests__/workspace-service.js';
23
23
 
24
24
  /**
25
25
  * A repository the deployment keeps, and the REAL startup phase, wired the
@@ -89,7 +89,7 @@ function boot(kb: KbContext) {
89
89
  const gitRunner = new NodeGitRunner(60_000, source.credentials);
90
90
  const workspacesRoot = path.join(root, 'workspaces');
91
91
  const setAsideRoot = path.join(root, 'backups', 'replaced-working-copies');
92
- const runner = new KbStartupRunner({
92
+ const runner = newKbStartupRunner({
93
93
  gitRunner,
94
94
  kbRepoUrl: () => source.url(),
95
95
  workspacesRoot,