@bevel-software/platform-core-backend 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -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