@bevel-software/platform-core-backend 0.7.4 → 0.8.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 (246) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +38 -21
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  104. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/pending-commits.service.js +37 -9
  106. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  107. package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
  108. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  109. package/dist/modules/workflow/pending-commits.worker.js +63 -9
  110. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  111. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  112. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  113. package/dist/modules/workflow/workflow.service.js +6 -1
  114. package/dist/modules/workflow/workflow.service.js.map +1 -1
  115. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  116. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  117. package/dist/modules/workspace/kb-seed.service.js +43 -10
  118. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  119. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  120. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  121. package/dist/modules/workspace/plugins-migration.js +379 -0
  122. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  123. package/dist/modules/workspace/workspace.routes.js +3 -3
  124. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  125. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  126. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.service.js +83 -12
  128. package/dist/modules/workspace/workspace.service.js.map +1 -1
  129. package/dist/shared/kb-layout.test.js +3 -3
  130. package/dist/shared/kb-layout.test.js.map +1 -1
  131. package/dist/shared/utcp-namespace.d.ts +6 -27
  132. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  133. package/dist/shared/utcp-namespace.js +6 -63
  134. package/dist/shared/utcp-namespace.js.map +1 -1
  135. package/dist/shared/variable-refs.d.ts +42 -0
  136. package/dist/shared/variable-refs.d.ts.map +1 -0
  137. package/dist/shared/variable-refs.js +60 -0
  138. package/dist/shared/variable-refs.js.map +1 -0
  139. package/kb-template/.bevelignore +1 -1
  140. package/kb-template/AGENTS.md +88 -35
  141. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  142. package/kb-template/access.md +36 -36
  143. package/migrations/meta/0000_snapshot.json +1479 -1479
  144. package/package.json +5 -4
  145. package/src/assets.ts +25 -25
  146. package/src/core/core-ports.ts +106 -106
  147. package/src/core/create-core-server.ts +55 -9
  148. package/src/core/create-core-services.ts +48 -24
  149. package/src/index.ts +69 -69
  150. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  151. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  152. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  153. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  154. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  155. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  156. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  157. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  158. package/src/modules/access/access-control.interface.ts +8 -7
  159. package/src/modules/access/access-control.service.ts +7 -7
  160. package/src/modules/access/access-declarations.ts +5 -5
  161. package/src/modules/access/access-mutation.service.ts +3 -3
  162. package/src/modules/access/access-splice.ts +4 -4
  163. package/src/modules/access/access.routes.ts +20 -20
  164. package/src/modules/access/creator-access.ts +5 -5
  165. package/src/modules/access/roles-admin.service.ts +13 -2
  166. package/src/modules/admin/admin-access.routes.ts +29 -29
  167. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  168. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  169. package/src/modules/auth/rate-limit.ts +45 -45
  170. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  171. package/src/modules/code-mode/code-mode-names.ts +10 -36
  172. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  173. package/src/modules/database/connection.ts +15 -15
  174. package/src/modules/database/schema.ts +11 -11
  175. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  176. package/src/modules/mcp/mcp.service.ts +57 -435
  177. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  178. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  179. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  180. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  181. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  182. package/src/modules/plugins/index.ts +17 -0
  183. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  184. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  185. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  186. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  187. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  188. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  189. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  190. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  191. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  192. package/src/modules/skills/pending-skills.service.ts +7 -7
  193. package/src/modules/skills/skills.contract.ts +4 -4
  194. package/src/modules/skills/skills.service.ts +5 -5
  195. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  196. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  197. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  198. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  199. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  200. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  201. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  203. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  204. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  205. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  206. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  207. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  208. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  209. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +118 -0
  210. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  211. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  212. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  213. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  214. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  215. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  216. package/src/modules/workflow/git/git.service.ts +2 -2
  217. package/src/modules/workflow/pending-commits.service.ts +48 -12
  218. package/src/modules/workflow/pending-commits.worker.ts +64 -8
  219. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  220. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  221. package/src/modules/workflow/workflow-hooks.ts +101 -101
  222. package/src/modules/workflow/workflow.service.ts +13 -2
  223. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  224. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  225. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  226. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  227. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  228. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  229. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  230. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  231. package/src/modules/workspace/bevel-ignore.ts +66 -66
  232. package/src/modules/workspace/kb-seed.service.ts +38 -9
  233. package/src/modules/workspace/plugins-migration.ts +479 -0
  234. package/src/modules/workspace/session-sink.ts +25 -25
  235. package/src/modules/workspace/workspace.routes.ts +3 -3
  236. package/src/modules/workspace/workspace.service.ts +85 -14
  237. package/src/modules/workspace/workspace.tools.ts +922 -922
  238. package/src/shared/__tests__/join-request.test.ts +13 -13
  239. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  240. package/src/shared/kb-layout.test.ts +3 -3
  241. package/src/shared/utcp-namespace.ts +10 -68
  242. package/src/shared/variable-refs.ts +64 -0
  243. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  244. package/src/modules/groups/index.ts +0 -17
  245. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  246. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -139,8 +139,10 @@ describe('GitService.commitFile — skipValidator', () => {
139
139
  'knowledge-base',
140
140
  );
141
141
 
142
- const relPath = 'Purchasing/[Updated 03.09.2025] Purchasing_How to search_Product detail page.md';
143
- await fs.mkdir(path.join(repo, 'Purchasing'), { recursive: true });
142
+ // Shape mirrors the reported filename (bracketed status prefix carrying a
143
+ // date, underscores, spaces) without reproducing anyone's actual content.
144
+ const relPath = 'Handbook/[Updated 03.09.2025] Handbook_How to search_Detail page.md';
145
+ await fs.mkdir(path.join(repo, 'Handbook'), { recursive: true });
144
146
  await fs.writeFile(path.join(repo, relPath), 'content\n');
145
147
 
146
148
  const change = await svc.commitFile(workspaceId, USER, relPath);
@@ -168,15 +170,18 @@ describe('GitService.commitFile — skipValidator', () => {
168
170
  'knowledge-base',
169
171
  );
170
172
 
173
+ // Synthetic segments matching the SHAPE of real deep-KB naming (numbered
174
+ // prefixes, parenthesised abbreviations, kebab tails) — the property under
175
+ // test is only the total length, asserted below.
171
176
  const deepDir = path.join(
172
- 'Product',
177
+ 'Handbook',
173
178
  'Knowledge',
174
- '1. Service Commitment Management',
175
- '1.1 In - Architecture Layering (AL)',
176
- '0-Governance',
177
- '0.5-Layer-Group-Specific-Governance-Principles',
179
+ '1. First-Level Section With A Long Name',
180
+ '1.1 In - Second-Level Subsection (SL)',
181
+ '0-Reference',
182
+ '0.5-Third-Level-Reference-Naming-Convention',
178
183
  );
179
- const fileName = '0.5.5-Buyer-Side-Commercial-Agreement-Governance.md';
184
+ const fileName = '0.5.5-A-Deeply-Nested-Reference-Document-With-A-Long-Name.md';
180
185
  const relPath = path.posix.join(deepDir.split(path.sep).join('/'), fileName);
181
186
  expect(`Update ${relPath}`.length).toBeGreaterThan(200);
182
187
 
@@ -134,7 +134,7 @@ function fakeAccessControl(adminEmails: string[]): IAccessControl {
134
134
  canOwner: async () => notImpl(),
135
135
  invalidate: () => {},
136
136
  findEmailByHash: async () => null,
137
- kbPrincipals: async () => ({ groups: [], people: [] }),
137
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
138
138
  validateRolesYaml: () => ({ ok: true }),
139
139
  referencesToRole: async () => [],
140
140
  canWriteAtRef: async () => null,
@@ -64,7 +64,7 @@ function makeAccessControl(byRef: WritersByRefAndPath): IAccessControl {
64
64
  eligibleWritersAtRef: async () => null,
65
65
  eligibleWritersForPathsAtRef: async () => null,
66
66
  findEmailByHash: async () => null,
67
- kbPrincipals: async () => ({ groups: [], people: [] }),
67
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
68
68
  validateRolesYaml: () => ({ ok: true }),
69
69
  referencesToRole: async () => [],
70
70
  };
@@ -1047,12 +1047,12 @@ export class GitService implements IGitService {
1047
1047
  // protected pushes; that's defence-in-depth against local rebases or
1048
1048
  // `commit-tree` shenanigans that might bypass the commit-time gate.
1049
1049
  // `systemAuthorized` skips the gate for flows whose ENDPOINT is the
1050
- // authorization — group provisioning commits an access.md into a
1050
+ // authorization — plugin provisioning commits an access.md into a
1051
1051
  // folder that does not exist at origin yet, which this gate can only
1052
1052
  // ever read as "write: Admin". The provisioning service has already
1053
1053
  // decided the write is legitimate (unused name, exclusive create);
1054
1054
  // gating it here again just refuses every non-admin the product
1055
- // promised a group to.
1055
+ // promised a plugin to.
1056
1056
  if (isProtectedBranch(branch) && !opts?.systemAuthorized) {
1057
1057
  const touched = await this.unpushedTouchedPaths(cwd);
1058
1058
  await this.assertCanWriteAtRef(
@@ -276,18 +276,7 @@ export class PendingCommitsService {
276
276
  async claimNext(rawWorkspaceId: string, now: Date): Promise<PendingCommit | null> {
277
277
  // Match the canonical form `enqueue` stores (see `canonicalWorkspaceId`).
278
278
  const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
279
- // Backoff schedule as a SQL CASE so the gate runs server-side instead
280
- // of round-tripping the row to compute eligibility. Index in BACKOFF_MS
281
- // matches `attempts` directly — covers `attempts == 0` (a recovery-reset
282
- // row whose `lastAttemptedAt` is non-null; without this, it would fall
283
- // into ELSE = max backoff and be stuck waiting 30s after every recovery).
284
- const caseArms = BACKOFF_MS.map((ms, i) => sql`WHEN ${i} THEN ${ms}`);
285
- const lastMs = BACKOFF_MS[BACKOFF_MS.length - 1];
286
- const backoffMsExpr = sql`(CASE ${pendingCommits.attempts} ${sql.join(caseArms, sql` `)} ELSE ${lastMs} END)`;
287
- const readyExpr = or(
288
- isNull(pendingCommits.lastAttemptedAt),
289
- sql`${pendingCommits.lastAttemptedAt} + (${backoffMsExpr} || ' milliseconds')::interval <= ${now}`,
290
- );
279
+ const readyExpr = readyPredicate(now);
291
280
  const claimed = await this.db
292
281
  .update(pendingCommits)
293
282
  .set({ status: 'running', lastAttemptedAt: now })
@@ -306,6 +295,32 @@ export class PendingCommitsService {
306
295
  return claimed.length > 0 ? rowToPending(claimed[0]) : null;
307
296
  }
308
297
 
298
+ /**
299
+ * Whether another row is ready for `workspaceId` right now — a peek, with
300
+ * the same readiness rule `claimNext` uses but claiming nothing.
301
+ *
302
+ * The worker asks this AFTER claiming a row, to learn whether the one in
303
+ * hand is the last of a burst. Everything before the last can skip the
304
+ * advisory commit validator: it parses the whole KB to produce a report
305
+ * that is only logged, and re-parsing per file made a bulk change (a
306
+ * migration, an agent run) cost one full parse per commit.
307
+ */
308
+ async hasReadyRow(rawWorkspaceId: string, now: Date): Promise<boolean> {
309
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
310
+ const rows = await this.db
311
+ .select({ id: pendingCommits.id })
312
+ .from(pendingCommits)
313
+ .where(
314
+ and(
315
+ eq(pendingCommits.workspaceId, workspaceId),
316
+ eq(pendingCommits.status, 'pending'),
317
+ readyPredicate(now),
318
+ ),
319
+ )
320
+ .limit(1);
321
+ return rows.length > 0;
322
+ }
323
+
309
324
  /**
310
325
  * Commit + push landed. Drop the row.
311
326
  */
@@ -481,3 +496,24 @@ export class PendingCommitsService {
481
496
  }
482
497
  }
483
498
 
499
+
500
+ /**
501
+ * The "ready to attempt" gate, shared by `claimNext` and `hasReadyRow` so a
502
+ * peek can never disagree with the claim that follows it.
503
+ *
504
+ * Backoff is a SQL CASE so the gate runs server-side instead of round-tripping
505
+ * every row to compute eligibility. The index into `BACKOFF_MS` is `attempts`
506
+ * directly, not `attempts - 1`: that covers the recovery-reset case where
507
+ * `attempts` is 0 but `lastAttemptedAt` is non-null, which would otherwise
508
+ * fall into the default arm and idle for the maximum backoff after every
509
+ * recovery run.
510
+ */
511
+ function readyPredicate(now: Date) {
512
+ const caseArms = BACKOFF_MS.map((ms, i) => sql`WHEN ${i} THEN ${ms}`);
513
+ const lastMs = BACKOFF_MS[BACKOFF_MS.length - 1];
514
+ const backoffMsExpr = sql`(CASE ${pendingCommits.attempts} ${sql.join(caseArms, sql` `)} ELSE ${lastMs} END)`;
515
+ return or(
516
+ isNull(pendingCommits.lastAttemptedAt),
517
+ sql`${pendingCommits.lastAttemptedAt} + (${backoffMsExpr} || ' milliseconds')::interval <= ${now}`,
518
+ );
519
+ }
@@ -56,6 +56,16 @@ export const consoleSystemNoticeSink: ISystemNoticeSink = {
56
56
  */
57
57
  const POLL_INTERVAL_MS = 500;
58
58
 
59
+ /**
60
+ * Ceiling on rows drained from ONE workspace before the sweep moves on.
61
+ * Fairness, not throughput: the loop holds the single in-flight commit slot,
62
+ * so an unbounded drain would let one workspace's thousand-file migration
63
+ * stall every other user's save until it finished. At this size a normal
64
+ * bulk change (tens of files) still lands in one sweep, and a huge one gives
65
+ * way after a bounded stretch and resumes on the next pass.
66
+ */
67
+ const MAX_BURST_PER_WORKSPACE = 50;
68
+
59
69
  /**
60
70
  * The recovery-agent dispatcher the worker calls when a row exhausts its
61
71
  * transient budget. Pulled behind an interface so the unit tests can
@@ -99,6 +109,7 @@ export interface WorkflowCommitDriver {
99
109
  branch: string,
100
110
  path: string,
101
111
  user: AuthUser,
112
+ opts?: { skipValidation?: boolean },
102
113
  ): Promise<void>;
103
114
  }
104
115
 
@@ -176,18 +187,61 @@ export class PendingCommitsWorker {
176
187
  }
177
188
 
178
189
  /**
179
- * Run one drain pass: rotate through known workspaces, claim+process
180
- * one row from each. Exposed publicly so tests can step the loop
181
- * deterministically without driving `setTimeout`.
190
+ * Run one drain pass: rotate through known workspaces, draining each
191
+ * one's ready rows before moving on. Exposed publicly so tests can step
192
+ * the loop deterministically without driving `setTimeout`.
193
+ *
194
+ * Draining a BURST rather than a single row per sweep is what keeps a
195
+ * bulk change honest. One row per sweep, with a `POLL_INTERVAL_MS` sleep
196
+ * after every pass, capped a workspace at ~2 commits/second no matter how
197
+ * fast git was — so a migration or an agent run that touched a hundred
198
+ * files spent a minute of pure polling latency, and anything waiting on
199
+ * the queue to settle (a change request being applied) waited with it.
200
+ *
201
+ * A failing row cannot spin this loop: `markTransientFailure` leaves
202
+ * `lastAttemptedAt` set, so the backoff gate in `claimNext` refuses to
203
+ * hand the same row back within the same sweep.
182
204
  */
183
205
  async drainOnce(): Promise<void> {
184
206
  for (const workspace of this.deps.workspaces.knownWorkspaces()) {
185
207
  // Bail mid-sweep if stop() fired — the in-flight workspace gets
186
208
  // to finish but we don't start a new one.
187
209
  if (!this.running) return;
188
- const row = await this.deps.service.claimNext(workspace.id, this.deps.now());
189
- if (!row) continue;
190
- await this.processRow(row);
210
+ for (let drained = 0; drained < MAX_BURST_PER_WORKSPACE; drained += 1) {
211
+ if (!this.running) return;
212
+ const row = await this.deps.service.claimNext(workspace.id, this.deps.now());
213
+ if (!row) break;
214
+ // Is this the last commit of the burst? Everything before it can skip
215
+ // the advisory commit validator, which parses the whole KB to produce
216
+ // a report that is only logged. Running it per file made a bulk change
217
+ // pay one full parse per commit; running it on the last one reports
218
+ // the same end state once. Asked AFTER the claim, so the row in hand
219
+ // is already out of `pending` and cannot answer for itself.
220
+ //
221
+ // At the burst ceiling this IS the last commit of the pass, however
222
+ // many rows remain queued — so it must validate, or a backlog larger
223
+ // than the cap would end every sweep on a skipped validation. Known
224
+ // without asking, so the ceiling is checked BEFORE the peek rather
225
+ // than folded in after it: on a big backlog that would be one wasted
226
+ // round-trip per sweep, on the hot path this change exists to speed up.
227
+ let lastOfBurst = drained === MAX_BURST_PER_WORKSPACE - 1;
228
+ if (!lastOfBurst) {
229
+ // The peek is an OPTIMISATION, never a gate: the row is already
230
+ // claimed and `running`, and throwing here would abandon it in that
231
+ // state — nothing resets it, so the workspace would stop draining
232
+ // until the process restarted. A failed peek therefore means
233
+ // "assume last", costing one extra validation pass and nothing else.
234
+ try {
235
+ lastOfBurst = !(await this.deps.service.hasReadyRow(workspace.id, this.deps.now()));
236
+ } catch (peekErr) {
237
+ console.warn(
238
+ `[pending-commits] ready-row peek failed for ws=${workspace.id}; validating this commit: ${sanitizeError(peekErr)}`,
239
+ );
240
+ lastOfBurst = true;
241
+ }
242
+ }
243
+ await this.processRow(row, { skipValidation: !lastOfBurst });
244
+ }
191
245
  }
192
246
  }
193
247
 
@@ -226,7 +280,7 @@ export class PendingCommitsWorker {
226
280
  * - throws, transient exhausted, recovery budget remains → spawn agent.
227
281
  * - throws, recovery exhausted → markNeedsAttention + feedback notice.
228
282
  */
229
- private async processRow(row: PendingCommit): Promise<void> {
283
+ private async processRow(row: PendingCommit, opts?: { skipValidation?: boolean }): Promise<void> {
230
284
  const user: AuthUser = {
231
285
  // The worker doesn't have a real `users.id` for the commit author —
232
286
  // commit attribution flows through `git commit --author="Name <email>"`,
@@ -237,7 +291,9 @@ export class PendingCommitsWorker {
237
291
  name: row.authorName,
238
292
  };
239
293
  try {
240
- await this.deps.workflow.runPendingCommit(row.workspaceId, row.branch, row.path, user);
294
+ await this.deps.workflow.runPendingCommit(row.workspaceId, row.branch, row.path, user, {
295
+ skipValidation: opts?.skipValidation,
296
+ });
241
297
  await this.deps.service.markSucceeded(row.id);
242
298
  return;
243
299
  } catch (err) {
@@ -164,7 +164,7 @@ function makeAccessControl(byPath: EligibilityByPath): IAccessControl {
164
164
  return result;
165
165
  },
166
166
  findEmailByHash: async () => null,
167
- kbPrincipals: async () => ({ groups: [], people: [] }),
167
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
168
168
  validateRolesYaml: () => ({ ok: true }),
169
169
  referencesToRole: async () => [],
170
170
  };
@@ -53,7 +53,7 @@ function makeAccessControl(adminEmails: string[] = []): IAccessControl {
53
53
  eligibleWritersAtRef: async () => null,
54
54
  eligibleWritersForPathsAtRef: async () => null,
55
55
  findEmailByHash: async () => null,
56
- kbPrincipals: async () => ({ groups: [], people: [] }),
56
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
57
57
  validateRolesYaml: () => ({ ok: true }),
58
58
  referencesToRole: async () => [],
59
59
  };
@@ -1,101 +1,101 @@
1
- import type { ValidationReport } from '@bevel-software/platform-shared';
2
-
3
- /**
4
- * Workflow lifecycle hooks — the generic seam that replaced the
5
- * constructor-injected KB validator (and the core-owned ontology write block).
6
- * Core code invokes the hooks at fixed lifecycle points; the modules that OWN
7
- * the behavior (the enterprise kb/kb-graph modules today) register handlers in
8
- * the composition root, right after `createCoreServices` returns. Core
9
- * registers none, so a core-only deployment runs every hook point as a no-op.
10
- *
11
- * Two hook kinds with deliberately different failure semantics:
12
- *
13
- * - `commitValidation` — ADVISORY. Runs at commit time in `GitService`
14
- * (`commit` / `commitFile`), exactly where the injected `IKbValidator`
15
- * used to run. A returned report with `mustFix` entries is logged/surfaced
16
- * but NEVER blocks the commit, and a hook that throws is caught and logged
17
- * (see CLAUDE.md "Validation is advisory"). The signature mirrors the old
18
- * `runValidation(workspaceId)` shape so `KbValidatorService` registers
19
- * with a one-line adapter.
20
- *
21
- * - `preWrite` — BLOCKING. Runs before a gated agent write (file-write
22
- * tools) or a write-capable shell command; a hook that throws REJECTS the
23
- * operation (the error propagates to the tool caller). This is where the
24
- * enterprise ontology-session block registers; core registers nothing, so
25
- * core never blocks (touch TRACKING stays core — see
26
- * `session-ontology.gate.ts`).
27
- */
28
-
29
- /** What `GitService` knows at the advisory commit-validation point. */
30
- export interface CommitValidationContext {
31
- workspaceId: string;
32
- branch: string;
33
- /**
34
- * Repo-relative paths the commit will include — the full touched set for
35
- * `commit()`, a single element for the one-file `commitFile()` path.
36
- */
37
- paths: string[];
38
- }
39
-
40
- /**
41
- * Advisory commit-time validation. Return a report to have the caller log /
42
- * surface it (or `void` for "nothing to say"). Throwing is tolerated — the
43
- * caller catches and logs, never blocks the commit.
44
- */
45
- export type CommitValidationHook = (
46
- ctx: CommitValidationContext,
47
- ) => Promise<ValidationReport | void>;
48
-
49
- /**
50
- * What the pre-write gate knows when it consults the blocking hooks. The core
51
- * gate has already resolved all skip conditions (boundary flag off, neutral
52
- * path, non-agent caller, recovery bot, missing session id — see
53
- * `resolveGatedSession`), so by hook time there is always a session id.
54
- */
55
- export type PreWriteContext =
56
- /** A path-addressed content write (write_file/edit_file/mkdir/move/copy/unzip…). */
57
- | { kind: 'file-write'; sessionId: string; wsPath: string }
58
- /** A write-capable operation with no resolvable target path (execute_command). */
59
- | { kind: 'shell'; sessionId: string };
60
-
61
- /** Blocking pre-write hook: throw to reject the write; return to allow. */
62
- export type PreWriteHook = (ctx: PreWriteContext) => Promise<void>;
63
-
64
- /**
65
- * The registry instance. ONE per composition (created in
66
- * `createCoreServices`, shared by `GitService` and exposed as
67
- * `WorkflowService.hooks` for the composition root to register against).
68
- */
69
- export class WorkflowHooks {
70
- private readonly commitValidation: CommitValidationHook[] = [];
71
- private readonly preWrite: PreWriteHook[] = [];
72
-
73
- /** Register an advisory commit-time validation hook. */
74
- onCommitValidation(hook: CommitValidationHook): void {
75
- this.commitValidation.push(hook);
76
- }
77
-
78
- /** Register a blocking pre-write hook. */
79
- onPreWrite(hook: PreWriteHook): void {
80
- this.preWrite.push(hook);
81
- }
82
-
83
- /**
84
- * The registered commit-validation hooks, for the caller (`GitService`) to
85
- * iterate with its own advisory catch-and-log semantics.
86
- */
87
- commitValidationHooks(): readonly CommitValidationHook[] {
88
- return this.commitValidation;
89
- }
90
-
91
- /**
92
- * Run every blocking pre-write hook in registration order. The first throw
93
- * propagates and rejects the write; with no hooks registered (core-only)
94
- * this resolves immediately.
95
- */
96
- async runPreWrite(ctx: PreWriteContext): Promise<void> {
97
- for (const hook of this.preWrite) {
98
- await hook(ctx);
99
- }
100
- }
101
- }
1
+ import type { ValidationReport } from '@bevel-software/platform-shared';
2
+
3
+ /**
4
+ * Workflow lifecycle hooks — the generic seam that replaced the
5
+ * constructor-injected KB validator (and the core-owned ontology write block).
6
+ * Core code invokes the hooks at fixed lifecycle points; the modules that OWN
7
+ * the behavior (the enterprise kb/kb-graph modules today) register handlers in
8
+ * the composition root, right after `createCoreServices` returns. Core
9
+ * registers none, so a core-only deployment runs every hook point as a no-op.
10
+ *
11
+ * Two hook kinds with deliberately different failure semantics:
12
+ *
13
+ * - `commitValidation` — ADVISORY. Runs at commit time in `GitService`
14
+ * (`commit` / `commitFile`), exactly where the injected `IKbValidator`
15
+ * used to run. A returned report with `mustFix` entries is logged/surfaced
16
+ * but NEVER blocks the commit, and a hook that throws is caught and logged
17
+ * (see CLAUDE.md "Validation is advisory"). The signature mirrors the old
18
+ * `runValidation(workspaceId)` shape so `KbValidatorService` registers
19
+ * with a one-line adapter.
20
+ *
21
+ * - `preWrite` — BLOCKING. Runs before a gated agent write (file-write
22
+ * tools) or a write-capable shell command; a hook that throws REJECTS the
23
+ * operation (the error propagates to the tool caller). This is where the
24
+ * enterprise ontology-session block registers; core registers nothing, so
25
+ * core never blocks (touch TRACKING stays core — see
26
+ * `session-ontology.gate.ts`).
27
+ */
28
+
29
+ /** What `GitService` knows at the advisory commit-validation point. */
30
+ export interface CommitValidationContext {
31
+ workspaceId: string;
32
+ branch: string;
33
+ /**
34
+ * Repo-relative paths the commit will include — the full touched set for
35
+ * `commit()`, a single element for the one-file `commitFile()` path.
36
+ */
37
+ paths: string[];
38
+ }
39
+
40
+ /**
41
+ * Advisory commit-time validation. Return a report to have the caller log /
42
+ * surface it (or `void` for "nothing to say"). Throwing is tolerated — the
43
+ * caller catches and logs, never blocks the commit.
44
+ */
45
+ export type CommitValidationHook = (
46
+ ctx: CommitValidationContext,
47
+ ) => Promise<ValidationReport | void>;
48
+
49
+ /**
50
+ * What the pre-write gate knows when it consults the blocking hooks. The core
51
+ * gate has already resolved all skip conditions (boundary flag off, neutral
52
+ * path, non-agent caller, recovery bot, missing session id — see
53
+ * `resolveGatedSession`), so by hook time there is always a session id.
54
+ */
55
+ export type PreWriteContext =
56
+ /** A path-addressed content write (write_file/edit_file/mkdir/move/copy/unzip…). */
57
+ | { kind: 'file-write'; sessionId: string; wsPath: string }
58
+ /** A write-capable operation with no resolvable target path (execute_command). */
59
+ | { kind: 'shell'; sessionId: string };
60
+
61
+ /** Blocking pre-write hook: throw to reject the write; return to allow. */
62
+ export type PreWriteHook = (ctx: PreWriteContext) => Promise<void>;
63
+
64
+ /**
65
+ * The registry instance. ONE per composition (created in
66
+ * `createCoreServices`, shared by `GitService` and exposed as
67
+ * `WorkflowService.hooks` for the composition root to register against).
68
+ */
69
+ export class WorkflowHooks {
70
+ private readonly commitValidation: CommitValidationHook[] = [];
71
+ private readonly preWrite: PreWriteHook[] = [];
72
+
73
+ /** Register an advisory commit-time validation hook. */
74
+ onCommitValidation(hook: CommitValidationHook): void {
75
+ this.commitValidation.push(hook);
76
+ }
77
+
78
+ /** Register a blocking pre-write hook. */
79
+ onPreWrite(hook: PreWriteHook): void {
80
+ this.preWrite.push(hook);
81
+ }
82
+
83
+ /**
84
+ * The registered commit-validation hooks, for the caller (`GitService`) to
85
+ * iterate with its own advisory catch-and-log semantics.
86
+ */
87
+ commitValidationHooks(): readonly CommitValidationHook[] {
88
+ return this.commitValidation;
89
+ }
90
+
91
+ /**
92
+ * Run every blocking pre-write hook in registration order. The first throw
93
+ * propagates and rejects the write; with no hooks registered (core-only)
94
+ * this resolves immediately.
95
+ */
96
+ async runPreWrite(ctx: PreWriteContext): Promise<void> {
97
+ for (const hook of this.preWrite) {
98
+ await hook(ctx);
99
+ }
100
+ }
101
+ }
@@ -808,9 +808,20 @@ export class WorkflowService implements IWorkflowService {
808
808
  branch: string,
809
809
  targetPath: string,
810
810
  user: AuthUser,
811
- opts?: { systemAuthorized?: boolean },
811
+ opts?: { systemAuthorized?: boolean; skipValidation?: boolean },
812
812
  ): Promise<void> {
813
- const change = await this.git.commitFile(workspaceId, user, targetPath);
813
+ // `skipValidation` is the worker telling us this commit is not the last of
814
+ // a burst. The validator is advisory — it parses the whole KB to produce a
815
+ // report nothing reads but the log — so running it once on the burst's
816
+ // final state says the same thing as running it per file, for a fraction
817
+ // of the work. Nothing downstream depends on it having run.
818
+ const change = await this.git.commitFile(
819
+ workspaceId,
820
+ user,
821
+ targetPath,
822
+ undefined,
823
+ opts?.skipValidation,
824
+ );
814
825
  if (!change) {
815
826
  // No-op commit — the path was already clean. Usually a double-enqueue
816
827
  // or a save of bytes identical to HEAD, BUT a clean tree does NOT