@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
@@ -41,6 +41,7 @@ const log = logger('pending-commits');
41
41
  import type { Database } from '../database/connection.js';
42
42
  import { canonicalEmail } from '../../shared/email-identity.js';
43
43
  import { pendingCommits } from '../database/schema.js';
44
+ import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
44
45
 
45
46
  /**
46
47
  * Transient-failure budget per recovery cycle. After this many consecutive
@@ -80,29 +81,9 @@ export const N_RECOVERY = 3;
80
81
  */
81
82
  export const BACKOFF_MS = [1_000, 5_000, 30_000];
82
83
 
83
- /**
84
- * Canonicalize a workspace id so an enqueued row and the worker's claim always
85
- * key on the SAME string.
86
- *
87
- * The id reaches `enqueue` via a route `:id` path param, which Express
88
- * URL-decodes — so a slashed feature branch arrives as `alice/feature`. But the
89
- * worker claims per `WorkspaceService.knownWorkspaces()`, whose ids are
90
- * `encodeURIComponent(branch)` → `alice%2Ffeature`. Those strings differ, so
91
- * `WHERE workspace_id = …` never matched and the row was never drained —
92
- * silently stranding every human save on a feature branch (protected branches
93
- * have no `/`, so encoded == decoded and they were unaffected).
94
- *
95
- * `encodeURIComponent(decodeURIComponent(id))` is idempotent and collapses both
96
- * forms to the encoded id `knownWorkspaces()` uses. A malformed `%` sequence
97
- * (which `decodeURIComponent` would throw on) is left untouched.
98
- */
99
- export function canonicalWorkspaceId(id: string): string {
100
- try {
101
- return encodeURIComponent(decodeURIComponent(id));
102
- } catch {
103
- return id;
104
- }
105
- }
84
+ // Lives in `shared/workspace-id.ts`, beside the encoding it canonicalises to;
85
+ // re-exported for this module's existing importers.
86
+ export { canonicalWorkspaceId };
106
87
 
107
88
  export interface PendingCommit {
108
89
  id: string;
@@ -65,7 +65,7 @@ export interface AgentOperationContext {
65
65
  sessionId?: string;
66
66
  /**
67
67
  * The workspace-relative path the operation targets. Absent for a
68
- * write-capable operation with no resolvable target (`execute_command`).
68
+ * write-capable operation with no resolvable target.
69
69
  */
70
70
  wsPath?: string;
71
71
  /** The branch (draft) whose workspace the operation acts on. */
@@ -40,6 +40,7 @@ import type { WorkflowEventBus } from './event-bus.js';
40
40
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
41
41
  import { assertValidRelativePath } from '../kb-fs/branch-name.js';
42
42
  import { domainErrorBody } from '../../shared/http-errors.js';
43
+ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
43
44
  import '../auth/auth.middleware.js'; // Express Request augmentation
44
45
 
45
46
  function toHttpError(
@@ -501,6 +502,8 @@ export function createWorkflowRoutes(
501
502
  // answer for that same input, and `toHttpError` passes it through unchanged.
502
503
  // Nothing is canonicalised at this layer on purpose: a route-level fix would
503
504
  // leave any other caller of the service coordinating on its own identity.
505
+ // (The status read canonicalises once for its read gate only; the lookup
506
+ // still takes the caller's spelling.)
504
507
 
505
508
  router.post('/workspace/:id/workflow/locks', async (req, res) => {
506
509
  const user = await requireUser(req, res);
@@ -589,7 +592,8 @@ export function createWorkflowRoutes(
589
592
  });
590
593
 
591
594
  router.get('/workspace/:id/workflow/locks', async (req, res) => {
592
- if (!(await requireUser(req, res))) return;
595
+ const user = await requireUser(req, res);
596
+ if (!user) return;
593
597
  const branch = typeof req.query.branch === 'string' ? req.query.branch : '';
594
598
  const targetPath = typeof req.query.path === 'string' ? req.query.path : '';
595
599
  if (!branch || !targetPath) {
@@ -597,6 +601,25 @@ export function createWorkflowRoutes(
597
601
  return;
598
602
  }
599
603
  try {
604
+ // A lock names who is editing the file, so a caller who may not read
605
+ // the file is refused before the lookup: the answer is the same whether
606
+ // or not it is held. The gate judges the canonical spelling, which is
607
+ // the one the lock is found under (`./kb//x.md` would otherwise slip
608
+ // past the KB-prefix test), and a refused spelling keeps the file
609
+ // verbs' status. A path without the KB prefix is not exempted as
610
+ // non-KB: the git layer also takes the repo-relative form (`GTM/x.md`
611
+ // for `knowledge-base/GTM/x.md`, see `requireReadPermission`), so it is
612
+ // judged as the repository file it would name.
613
+ const canonical = canonicalFileIdentity(targetPath);
614
+ const readable = await accessControl.canRead(
615
+ req.params.id,
616
+ user.email,
617
+ toKbRelative(canonical, kbDirName) ?? canonical,
618
+ );
619
+ if (!readable) {
620
+ res.status(403).json({ error: `You don't have permission to read "${targetPath}".` });
621
+ return;
622
+ }
600
623
  res.json({ lock: await workflow.getLock(req.params.id, branch, targetPath) });
601
624
  } catch (err) {
602
625
  const { status, body } = toHttpError(err);
@@ -542,7 +542,7 @@ export class WorkflowService implements IWorkflowService {
542
542
  // One line per deletion, whoever asked: the commit is what a restore
543
543
  // starts from, and for a deletion made in the app or by the cleanup this
544
544
  // line is the only trace there is.
545
- // By id, never by address: log lines carry no email (the release's own promise).
545
+ // By id: log lines carry no email addresses.
546
546
  const actor = opts?.systemCleanup ? 'system' : user.id;
547
547
  log.info(`branch deleted by ${actor}: "${name}" at ${lastCommit ?? '(no commit)'}`);
548
548
  // Retire the deleted branch's own workspace clone, best-effort. A stale
@@ -629,6 +629,7 @@ export class WorkflowService implements IWorkflowService {
629
629
  */
630
630
  private async closeIfEmpty(
631
631
  number: number,
632
+ // `actor` is a user id or `system`, never an address: it goes into a log line.
632
633
  opts: { reason: string; actor: string; workspaceId?: string; fresh?: boolean },
633
634
  ): Promise<{ base: string } | null> {
634
635
  const summary = await this.prs.getPr(number);
@@ -3360,7 +3361,7 @@ export class WorkflowService implements IWorkflowService {
3360
3361
  * Returns true when THIS call closed it.
3361
3362
  */
3362
3363
  async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
3363
- const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.email });
3364
+ const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.id });
3364
3365
  if (!closed) return false;
3365
3366
  await this.retireMergedSourceBranch(number, closed.base, user);
3366
3367
  return true;
@@ -22,7 +22,6 @@ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
22
22
  import { ToolDescriptionNotes } from '../agent-access.gate.js';
23
23
  import { SpillStore } from '../spill-store.js';
24
24
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
25
- import { WorkspaceService } from '../workspace.service.js';
26
25
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
27
26
  import { AgentDownloadStore, DOWNLOAD_TOKEN_REFUSAL } from '../agent-download.store.js';
28
27
  import { attachmentDisposition, createAgentDownloadRoutes, createDownloadFetcherIdentifier } from '../agent-download.routes.js';
@@ -31,6 +30,7 @@ import jwt from 'jsonwebtoken';
31
30
  import { buildDownload, DOWNLOAD_PERMISSION_REQUIRED, NOT_FOUND } from '../agent-download.builder.js';
32
31
  import { READ_ONLY_CODE, type IWriteAccess } from '../../write-access/write-access.js';
33
32
  import { sharedFileRules } from '../../agent-instructions/shared-file-rules.js';
33
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
34
34
 
35
35
  /**
36
36
  * `request_file_download` end to end, over the REAL access resolver on a
@@ -136,7 +136,7 @@ async function start(options: StartOptions = {}): Promise<Started> {
136
136
  await mkdir(dirname(abs), { recursive: true });
137
137
  await writeFile(abs, contents);
138
138
  }
139
- const workspaceService = new WorkspaceService(
139
+ const workspaceService = newWorkspaceService(
140
140
  root,
141
141
  'https://example.invalid/kb.git',
142
142
  testKbContext({ kbDirName: KB }),
@@ -0,0 +1,213 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { WorkerPatternMatcher, type WorkerPatternMatcherOptions } from '../bounded-matcher.js';
4
+ import { PatternTooExpensiveError } from '../pattern-matcher.contract.js';
5
+
6
+ /** The QA finding's pattern, and a 40-character line it backtracks on for ever. */
7
+ const CATASTROPHIC = '^(a+)+$';
8
+ const LONG_LINE = `${'a'.repeat(39)}!`;
9
+ /** The ticket's per-search budget (Razvan, 2026-10-15). */
10
+ const FIVE_SECONDS = 5_000;
11
+
12
+ let matchers: WorkerPatternMatcher[] = [];
13
+ /** A matcher of the test's own, closed after it — the defaults unless a test says otherwise. */
14
+ function matcher(opts?: WorkerPatternMatcherOptions): WorkerPatternMatcher {
15
+ const m = new WorkerPatternMatcher(opts);
16
+ matchers.push(m);
17
+ return m;
18
+ }
19
+ afterEach(async () => {
20
+ await Promise.all(matchers.map((m) => m.close()));
21
+ matchers = [];
22
+ });
23
+
24
+ /**
25
+ * The longest the event loop went without running a timer while `work` ran.
26
+ * A pattern run on the loop would show the whole match time here.
27
+ */
28
+ async function longestStall(work: () => Promise<unknown>): Promise<number> {
29
+ let last = Date.now();
30
+ let worst = 0;
31
+ const tick = setInterval(() => {
32
+ const now = Date.now();
33
+ worst = Math.max(worst, now - last);
34
+ last = now;
35
+ }, 10);
36
+ try {
37
+ await work().catch(() => {});
38
+ } finally {
39
+ clearInterval(tick);
40
+ }
41
+ return Math.max(worst, Date.now() - last);
42
+ }
43
+
44
+ describe('WorkerPatternMatcher', () => {
45
+ it('stops a catastrophic pattern over a 40-character line with the worded 400 in its 5 s budget, and never stalls the loop', async () => {
46
+ expect(LONG_LINE).toHaveLength(40);
47
+ const started = Date.now();
48
+ let caught: unknown;
49
+ const stall = await longestStall(async () => {
50
+ try {
51
+ await matcher().search(CATASTROPHIC, '').matchingLines([LONG_LINE], 200);
52
+ } catch (err) {
53
+ caught = err;
54
+ }
55
+ });
56
+ const took = Date.now() - started;
57
+ expect(caught).toBeInstanceOf(PatternTooExpensiveError);
58
+ expect((caught as PatternTooExpensiveError).status).toBe(400);
59
+ expect((caught as Error).message).toMatch(/^Pattern too expensive: /);
60
+ expect((caught as Error).message).toContain('simpler pattern');
61
+ expect((caught as Error).message).toContain("this search's 5 s budget");
62
+ expect(took).toBeGreaterThanOrEqual(FIVE_SECONDS - 100);
63
+ expect(took).toBeLessThan(FIVE_SECONDS + 2_000);
64
+ expect(stall).toBeLessThan(500);
65
+ });
66
+
67
+ it('answers ordinary patterns with exactly the lines RegExp#test matches', async () => {
68
+ const m = matcher();
69
+ const lines = ['hello world', 'Getting started', 'aaa', 'line 42', '', 'foo|bar', 'bar baz', LONG_LINE];
70
+ for (const [source, flags] of [
71
+ ['world', ''],
72
+ ['^aaa$', ''],
73
+ ['getting started', 'i'],
74
+ ['[0-9]+', ''],
75
+ ['foo|baz', ''],
76
+ ['^$', ''],
77
+ ['a+!$', ''],
78
+ ] as const) {
79
+ const re = new RegExp(source, flags);
80
+ const expected = lines.flatMap((l, i) => (re.test(l) ? [i] : []));
81
+ expect(await m.search(source, flags).matchingLines(lines, 1000), `/${source}/${flags}`).toEqual(expected);
82
+ }
83
+ });
84
+
85
+ it('returns only the first `limit` matches', async () => {
86
+ expect(await matcher().search('x', '').matchingLines(['x', 'y', 'x', 'x'], 2)).toEqual([0, 2]);
87
+ });
88
+
89
+ it('refuses a sizing that could never serve a search when it is built, not when a search hangs on it', () => {
90
+ expect(() => new WorkerPatternMatcher({ poolMax: 0 })).toThrow(/poolMax must be an integer from 1 /);
91
+ // Past the longest delay setTimeout honours, the timer would fire after 1 ms and stop every search.
92
+ expect(() => new WorkerPatternMatcher({ budgetMs: 2_147_483_647 })).toThrow(/budgetMs must be an integer from 1 to 2147483397/);
93
+ expect(() => new WorkerPatternMatcher({ poolMax: 1.5 })).toThrow(RangeError);
94
+ expect(() => new WorkerPatternMatcher({ budgetMs: 0 })).toThrow(/budgetMs/);
95
+ expect(() => new WorkerPatternMatcher({ maxWaiting: -1 })).toThrow(/maxWaiting/);
96
+ expect(() => new WorkerPatternMatcher({ batchChars: Number.NaN })).toThrow(/batchChars/);
97
+ expect(() => new WorkerPatternMatcher({ batchLines: 0 })).toThrow(/batchLines/);
98
+ expect(() => matcher({ maxWaiting: 0 })).not.toThrow();
99
+ });
100
+
101
+ it('serves an ordinary search under the largest budget the timer can hold', async () => {
102
+ expect(await matcher({ budgetMs: 2_147_483_647 - 250 }).search('b', '').matchingLines(['a', 'b'], 10)).toEqual([1]);
103
+ });
104
+
105
+ it('refuses invalid syntax with the 400 grep always gave', () => {
106
+ expect(() => matcher().search('(', '')).toThrow(/^Invalid regex: /);
107
+ });
108
+
109
+ it('refuses a pattern too long to compile on the request thread, and accepts one at the limit', () => {
110
+ const m = matcher({ maxPatternLength: 10 });
111
+ expect(() => m.search('a'.repeat(11), '')).toThrow(/^Pattern too long: /);
112
+ expect(() => m.search('a'.repeat(10), '')).not.toThrow();
113
+ });
114
+
115
+ it('hands a large file over in batches and answers the same line numbers, up to `limit`', async () => {
116
+ const m = matcher({ batchChars: 100 });
117
+ const filler = 'x'.repeat(9);
118
+ const lines = Array.from({ length: 250 }, (_, i) => (i % 7 === 0 ? `hit ${i}` : filler));
119
+ const expected = lines.flatMap((l, i) => (l.startsWith('hit') ? [i] : []));
120
+ expect(expected.at(-1)! * filler.length).toBeGreaterThan(200);
121
+ expect(await m.search('^hit', '').matchingLines(lines, 10_000)).toEqual(expected);
122
+ expect(await m.search('^hit', '').matchingLines(lines, 3)).toEqual(expected.slice(0, 3));
123
+ });
124
+
125
+ it('caps a batch by its line count too, so a file of empty lines never goes over in one message', async () => {
126
+ const m = matcher({ batchLines: 100 });
127
+ const lines = Array.from({ length: 1_050 }, (_, i) => (i % 97 === 0 ? 'hit' : ''));
128
+ const expected = lines.flatMap((l, i) => (l === 'hit' ? [i] : []));
129
+ const posted = vi.spyOn(Worker.prototype, 'postMessage');
130
+ try {
131
+ expect(await m.search('^hit$', '').matchingLines(lines, 10_000)).toEqual(expected);
132
+ expect(await m.search('^$', '').matchingLines(lines, 10_000)).toHaveLength(lines.length - expected.length);
133
+ const postedLines = posted.mock.calls.map(([msg]) => ((msg as { lines?: string[] }).lines ?? []).length);
134
+ expect(postedLines.filter((n) => n > 0)).toHaveLength(2 * 11);
135
+ expect(Math.max(...postedLines)).toBeLessThanOrEqual(100);
136
+ } finally {
137
+ posted.mockRestore();
138
+ }
139
+ });
140
+
141
+ it('hands a line longer than one batch over in shared memory, never in a message, and matches it exactly', async () => {
142
+ const m = matcher({ batchChars: 100 });
143
+ // A lone surrogate included: the line must reach the worker code unit for code unit.
144
+ const long = `${'x'.repeat(1_000)}\uD800 tail ${'y'.repeat(500)}`;
145
+ const lines = ['short', long, 'z'.repeat(400), 'end tail'];
146
+ const posted = vi.spyOn(Worker.prototype, 'postMessage');
147
+ try {
148
+ for (const pattern of ['tail', '\\uD800 tail', '^x+\\uD800', 'y{500}$', '^z+$', 'nowhere']) {
149
+ const re = new RegExp(pattern);
150
+ const expected = lines.flatMap((l, i) => (re.test(l) ? [i] : []));
151
+ expect(await m.search(pattern, '').matchingLines(lines, 10_000)).toEqual(expected);
152
+ }
153
+ const postedChars = posted.mock.calls.map(([msg]) =>
154
+ ((msg as { lines?: string[] }).lines ?? []).reduce((n, l) => n + l.length, 0),
155
+ );
156
+ expect(postedChars.length).toBeGreaterThan(0);
157
+ expect(Math.max(...postedChars)).toBeLessThanOrEqual(100);
158
+ } finally {
159
+ posted.mockRestore();
160
+ }
161
+ });
162
+
163
+ it('refuses a search with 503 once the queue for a worker is full, rather than holding its lines', async () => {
164
+ const m = matcher({ budgetMs: 500, poolMax: 2, maxWaiting: 2 });
165
+ // Every worker held by a pattern that runs out its budget, the queue filled behind them.
166
+ const holding = Promise.allSettled(Array.from({ length: 2 }, () => m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 1)));
167
+ await new Promise((r) => setTimeout(r, 100));
168
+ const queued = Array.from({ length: 2 }, () => m.search('b', '').matchingLines(['b'], 1));
169
+ await expect(m.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({
170
+ status: 503,
171
+ details: { code: 'search_busy' },
172
+ });
173
+ // The queued searches are served once the workers come free.
174
+ expect(await Promise.all(queued)).toEqual(queued.map(() => [0]));
175
+ expect((await holding).every((r) => r.status === 'rejected')).toBe(true);
176
+ });
177
+
178
+ it('stops two expensive searches at once, and a search started meanwhile still answers', async () => {
179
+ const m = matcher({ budgetMs: 500 });
180
+ const results = await Promise.allSettled([
181
+ m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 10),
182
+ m.search('(a|aa)+$', '').matchingLines([`${'a'.repeat(60)}!`], 10),
183
+ m.search('aaa', '').matchingLines(['xaaay'], 10),
184
+ ]);
185
+ expect(results[0]).toMatchObject({ status: 'rejected', reason: expect.any(PatternTooExpensiveError) });
186
+ expect(results[1]).toMatchObject({ status: 'rejected', reason: expect.any(PatternTooExpensiveError) });
187
+ expect(results[2]).toEqual({ status: 'fulfilled', value: [0] });
188
+ // The terminated workers were replaced: the pool still serves.
189
+ expect(await m.search('b', '').matchingLines(['a', 'b'], 10)).toEqual([1]);
190
+ });
191
+
192
+ it('ends its workers on close: a search running then, and one after, is answered busy', async () => {
193
+ const m = matcher();
194
+ const running = m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 1);
195
+ await new Promise((r) => setTimeout(r, 100));
196
+ const started = Date.now();
197
+ await m.close();
198
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
199
+ expect(Date.now() - started).toBeLessThan(1_000);
200
+ await expect(m.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({ status: 503 });
201
+ });
202
+
203
+ it('answers busy at once when closed while a long line is still being written to shared memory', async () => {
204
+ // A line of a million characters written ten at a time: many turns of the loop to close in.
205
+ const m = matcher({ budgetMs: 3_000, batchChars: 10 });
206
+ const running = m.search('nowhere', '').matchingLines(['a'.repeat(1_000_000)], 1);
207
+ await new Promise((r) => setTimeout(r, 20));
208
+ const started = Date.now();
209
+ await m.close();
210
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
211
+ expect(Date.now() - started).toBeLessThan(1_000);
212
+ });
213
+ });
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import AdmZip from 'adm-zip';
7
7
  import express from 'express';
8
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { afterEach, beforeEach, describe, expect, it, vi, afterAll } from 'vitest';
9
9
  import { testKbContext } from '../../../__tests__/kb-context.js';
10
10
  import type { IWorkflowService } from '@bevel-software/platform-shared';
11
11
  import { GIT_INTERNALS_MESSAGE, GitInternalsError } from '../../../shared/domain-errors.js';
@@ -35,6 +35,12 @@ import { createDiffRoutes } from '../../diff/diff.routes.js';
35
35
  import { DiffService } from '../../diff/diff.service.js';
36
36
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
37
37
  import { WorkspaceService } from '../workspace.service.js';
38
+ import { WorkerPatternMatcher } from '../bounded-matcher.js';
39
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
40
+
41
+ /** The grep matcher this file's harness wires, closed once the file is done. */
42
+ const patternMatcher = new WorkerPatternMatcher();
43
+ afterAll(() => patternMatcher.close());
38
44
 
39
45
  /**
40
46
  * Security regression: the repository's internal git folder is unreachable
@@ -244,7 +250,7 @@ describe('workspace tools refuse the git folder', () => {
244
250
 
245
251
  beforeEach(async () => {
246
252
  workflow = makeWorkflow();
247
- const service = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
253
+ const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
248
254
  unzipSpy = vi.spyOn(service, 'unzipFile');
249
255
  bootstrappedSpy = vi.spyOn(service, 'hasBootstrappedWorkspace');
250
256
  const lockingFs = new LockingFilesystem(
@@ -282,6 +288,15 @@ describe('workspace tools refuse the git folder', () => {
282
288
  { recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
283
289
  new RoutineWritePolicyService(),
284
290
  {} as never,
291
+ // Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
292
+ undefined,
293
+ undefined,
294
+ undefined,
295
+ undefined,
296
+ undefined,
297
+ undefined,
298
+ undefined,
299
+ patternMatcher,
285
300
  );
286
301
  app.use('/api', router);
287
302
  ({ server, baseUrl } = await listen(app));
@@ -419,7 +434,7 @@ describe('workspace routes refuse the git folder', () => {
419
434
 
420
435
  beforeEach(async () => {
421
436
  workflow = makeWorkflow();
422
- service = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
437
+ service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
423
438
  const app = express();
424
439
  app.use(express.json());
425
440
  app.use('/api', (req, _res, next) => {
@@ -566,7 +581,7 @@ describe('WorkspaceService refuses the git folder on its own', () => {
566
581
  let service: WorkspaceService;
567
582
 
568
583
  beforeEach(() => {
569
- service = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
584
+ service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
570
585
  });
571
586
 
572
587
  afterEach(async () => {
@@ -623,7 +638,7 @@ describe('WorkspaceService refuses the git folder on its own', () => {
623
638
  return { stdout: '.git\n', stderr: '', code: 0 };
624
639
  }),
625
640
  };
626
- const refService = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs(), gitRunner as never);
641
+ const refService = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs(), gitRunner as never);
627
642
 
628
643
  for (const [form, raw] of Object.entries(fileForms('config'))) {
629
644
  if (!form.startsWith('symlinked')) continue;
@@ -679,7 +694,7 @@ describe('the route guard on its own', () => {
679
694
  beforeEach(async () => {
680
695
  reached = 0;
681
696
  userId = USER.id;
682
- const service = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
697
+ const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
683
698
  const app = express();
684
699
  // The server's own order: the guard ahead of the body parser, then again
685
700
  // on the parsed body, then again once the caller is known.
@@ -779,7 +794,7 @@ describe('DiffService refuses the git folder on its own', () => {
779
794
  let diffService: DiffService;
780
795
 
781
796
  beforeEach(() => {
782
- const service = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
797
+ const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
783
798
  diffService = new DiffService(service, new WorkspaceMutex(), root, join(root, 'backups'), KB, new NodeFs());
784
799
  });
785
800