@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
@@ -49,3 +49,8 @@ export {
49
49
  } from './tool-def.js';
50
50
  export { ToolError, hasHttpStatus, type ToolContext, type ToolHandler } from './tool.contract.js';
51
51
  export type { JsonSchema, UtcpTool } from '../tool-registry/tool.contract.js';
52
+ export {
53
+ CAN_WRITE_CLAUSE,
54
+ defaultBranchWriteVerdicts,
55
+ type DefaultBranchWriteDeps,
56
+ } from './default-branch-write.js';
@@ -36,8 +36,8 @@ export interface ToolHandlerOptions {
36
36
  writeScope?: boolean;
37
37
  /**
38
38
  * How this route treats `branch`, when it must differ from what its
39
- * `toolDef` recorded. Only `execute_command` sets it (`own`): it falls back
40
- * to an internal caller's focused branch rather than being refused.
39
+ * `toolDef` recorded (`own`: the handler resolves it itself rather than
40
+ * being refused).
41
41
  */
42
42
  branch?: BranchHandling;
43
43
  }
@@ -41,8 +41,8 @@ export interface ToolContext {
41
41
  sessionId?: string;
42
42
  /**
43
43
  * The branch the in-process agent's workspace is focused on, from its internal
44
- * token. An internal-only workspace tool (e.g. `execute_command`) falls back to
45
- * it when the model omits `branch`, so the in-app chat agent acts on its own
44
+ * token. A tool may fall back to it when the model omits `branch` (`list_tool_setup`
45
+ * reads the focused branch's setup), so the in-app chat agent acts on its own
46
46
  * workspace even on a branch-less call. Only set for `source: 'internal'`;
47
47
  * external callers must name the branch explicitly (a present-but-invalid value
48
48
  * still fails closed regardless of this).
@@ -15,6 +15,7 @@ import type { UtcpTool } from '../tool.contract.js';
15
15
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
16
16
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
17
17
  import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
18
+ import { WorkerPatternMatcher } from '../../workspace/bounded-matcher.js';
18
19
  import { registerWorkflowTools } from '../../workflow/agent-tools/workflow.tools.js';
19
20
  import { registerPluginsTools } from '../../plugins/plugins.tools.js';
20
21
  import { registerSkillsTools } from '../../skills/skills.tools.js';
@@ -24,6 +25,7 @@ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
24
25
  import { UuidSessionSink } from '../../workspace/session-sink.js';
25
26
  import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
26
27
  import { CLIENT_SHORT_CUT, TOOL_DESCRIPTION_CAP, clientVisibleLength, firstSentenceEnd } from '../description-length.js';
28
+ import { CAN_WRITE_CLAUSE } from '../../tool-helpers/index.js';
27
29
  import {
28
30
  TOOL_PREFIX_CAP,
29
31
  sharedFileRules,
@@ -79,7 +81,7 @@ function servedMetaTools(external: readonly UtcpTool[]) {
79
81
  }
80
82
 
81
83
  /** Every tool Hexis itself registers, on both surfaces, deduplicated by name. */
82
- async function hexisTools(): Promise<UtcpTool[]> {
84
+ async function hexisTools(skills: Parameters<typeof registerSkillsTools>[4] = emptySkills): Promise<UtcpTool[]> {
83
85
  const registry = new ToolRegistry();
84
86
  const router = express.Router();
85
87
  const toolAuth = ((_req, _res, next) => next()) as unknown as ToolAuth;
@@ -109,10 +111,15 @@ async function hexisTools(): Promise<UtcpTool[]> {
109
111
  // mounted on the same terms as the upload one.
110
112
  undefined,
111
113
  unused(),
114
+ // Starter packs and the tree walk, not needed; then the grep matcher,
115
+ // mounted on the same terms (it spawns no worker until a search runs).
116
+ undefined,
117
+ undefined,
118
+ new WorkerPatternMatcher(),
112
119
  );
113
120
  registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true);
114
121
  registerPluginsTools(registry, kb);
115
- registerSkillsTools(registry, router, toolAuth, toolHandler, emptySkills);
122
+ registerSkillsTools(registry, router, toolAuth, toolHandler, skills);
116
123
  registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
117
124
  accessControl: unused(),
118
125
  variableStatus: unused(),
@@ -256,7 +263,6 @@ describe('every file tool opens with the guide-first sentence and carries no sha
256
263
  'move_file',
257
264
  'copy_file',
258
265
  'unzip',
259
- 'execute_command',
260
266
  'apply_file_upload',
261
267
  ];
262
268
 
@@ -391,3 +397,36 @@ describe('the shared rules describe the tools they name', () => {
391
397
  expect(rule.body).not.toContain('and unzip act on bytes of any kind');
392
398
  });
393
399
  });
400
+
401
+ /** A realistic catalog: forty skills with names as long as real ones, so the skill tools' name lists are full. */
402
+ const fortySkills = {
403
+ listSkills: async () =>
404
+ Array.from({ length: 40 }, (_, i) => ({
405
+ name: `specialist-skill-number-${i}`,
406
+ description: 'A skill.',
407
+ path: `Plugins/Everyone/specialist-skill-number-${i}`,
408
+ })),
409
+ } as unknown as Parameters<typeof registerSkillsTools>[4];
410
+
411
+ describe('the four tools that report canWrite say what `false` means', () => {
412
+ const FOUR = ['list_skills', 'get_skill', 'list_files', 'read_file'];
413
+
414
+ it.each([
415
+ ['an empty skill catalog', emptySkills],
416
+ ['a forty-skill catalog', fortySkills],
417
+ ])('carries the clause within the caps, with %s', async (_label, skills) => {
418
+ const tools = (await hexisTools(skills)).filter((t) => FOUR.includes(t.name));
419
+ expect(tools.map((t) => t.name).sort()).toEqual([...FOUR].sort());
420
+ for (const tool of tools) {
421
+ expect(tool.description, tool.name).toContain(CAN_WRITE_CLAUSE);
422
+ expect(clientVisibleLength(tool), tool.name).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
423
+ expect(firstSentenceEnd(tool.description!), tool.name).toBeLessThanOrEqual(CLIENT_SHORT_CUT);
424
+ }
425
+ });
426
+
427
+ it('keeps the clause ahead of the skill names a client may cut from the end', async () => {
428
+ for (const tool of (await hexisTools(fortySkills)).filter((t) => t.name === 'list_skills' || t.name === 'get_skill')) {
429
+ expect(tool.description!.indexOf(CAN_WRITE_CLAUSE), tool.name).toBeLessThan(tool.description!.indexOf('specialist-skill-number-0'));
430
+ }
431
+ });
432
+ });
@@ -66,10 +66,21 @@ function comparable(value: unknown): string {
66
66
 
67
67
  const dialect = new PgDialect();
68
68
  const TERM = /"file_locks"\."(\w+)"\s*(<=|>=|=|<|>)\s*\$(\d+)/g;
69
+ /** `inArray`: `"file_locks"."col" in ($1, $2)`. */
70
+ const IN_TERM = /"file_locks"\."(\w+)"\s+in\s*\(((?:\s*\$\d+\s*,?)+)\)/g;
69
71
 
70
72
  function predicateFor(condition: SQL | undefined): (row: LockRow) => boolean {
71
73
  if (!condition) return () => true;
72
- const { sql: text, params } = dialect.sqlToQuery(condition);
74
+ const { sql: rawText, params } = dialect.sqlToQuery(condition);
75
+ const sets = [...rawText.matchAll(IN_TERM)].map(([, column, list]) => {
76
+ const field = FIELD_FOR_COLUMN[column];
77
+ if (!field) {
78
+ throw new Error(`fake file_locks db has no field mapped for column "${column}": ${rawText}`);
79
+ }
80
+ const values = [...list.matchAll(/\$(\d+)/g)].map(([, index]) => comparable(params[Number(index) - 1]));
81
+ return { field, values };
82
+ });
83
+ const text = rawText.replace(IN_TERM, '');
73
84
  const terms = [...text.matchAll(TERM)].map(([, column, operator, index]) => {
74
85
  const field = FIELD_FOR_COLUMN[column];
75
86
  // A column the regex matched but FIELD_FOR_COLUMN does not know would
@@ -92,7 +103,7 @@ function predicateFor(condition: SQL | undefined): (row: LockRow) => boolean {
92
103
  return (row) =>
93
104
  terms.every(({ field, operator, value }) =>
94
105
  COMPARISONS[operator](comparable(row[field]), value),
95
- );
106
+ ) && sets.every(({ field, values }) => values.includes(comparable(row[field])));
96
107
  }
97
108
 
98
109
  function keyOf(row: Pick<LockRow, 'workspaceId' | 'branch' | 'path'>): string {
@@ -18,6 +18,7 @@ import { WorkflowService } from '../workflow.service.js';
18
18
  import { createWorkflowRoutes } from '../workflow.routes.js';
19
19
  import { makeFakeLockDb, type FakeLockDb } from './fake-file-lock-db.js';
20
20
  import { openChangeGate } from '../../../__tests__/open-change-gate.js';
21
+ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
21
22
 
22
23
  /**
23
24
  * The lock routes over the real service and a real lock store: acquire,
@@ -53,6 +54,10 @@ interface Harness {
53
54
  fake: FakeLockDb;
54
55
  enqueue: ReturnType<typeof vi.fn>;
55
56
  commitFile: ReturnType<typeof vi.fn>;
57
+ /** The service behind the routes, for the in-process callers (an agent's edits) that use it directly. */
58
+ workflow: IWorkflowService;
59
+ /** The read rule the status read consults; every file is readable unless a test says otherwise. */
60
+ canRead: ReturnType<typeof vi.fn>;
56
61
  /** Whose credentials the next request carries. */
57
62
  actAs: (user: AuthUser) => void;
58
63
  }
@@ -85,6 +90,7 @@ async function makeHarness(): Promise<Harness> {
85
90
  events,
86
91
  );
87
92
 
93
+ const canRead = vi.fn(async () => true);
88
94
  let current = ALICE;
89
95
  const authService = {
90
96
  getUserById: vi.fn(async (id: string) => (id === BOB.id ? BOB : ALICE)),
@@ -103,7 +109,7 @@ async function makeHarness(): Promise<Harness> {
103
109
  {} as unknown as WorkspaceService,
104
110
  authService,
105
111
  events,
106
- {} as unknown as IAccessControl,
112
+ { canRead } as unknown as IAccessControl,
107
113
  KB,
108
114
  ),
109
115
  );
@@ -117,6 +123,8 @@ async function makeHarness(): Promise<Harness> {
117
123
  fake,
118
124
  enqueue,
119
125
  commitFile,
126
+ workflow: workflow as unknown as IWorkflowService,
127
+ canRead,
120
128
  actAs: (user) => {
121
129
  current = user;
122
130
  },
@@ -225,6 +233,16 @@ describe('lock routes coordinate on one file identity', () => {
225
233
  });
226
234
  });
227
235
 
236
+ it('stores a lock taken through the route under the encoded workspace id the deletion gate asks with', async () => {
237
+ // Express hands the route the decoded `:id`; the deletion paths and an
238
+ // agent's edits ask with `workspaceIdForBranch(branch)`, the encoded one.
239
+ await acquire(CANONICAL);
240
+
241
+ expect(h.fake.rows().map((r) => r.workspaceId)).toEqual([WS]);
242
+ expect((await release(CANONICAL)).status).toBe(200);
243
+ expect(h.fake.rows()).toEqual([]);
244
+ });
245
+
228
246
  it('reads the status of a raw spelling after a canonical acquire', async () => {
229
247
  await acquire(CANONICAL);
230
248
 
@@ -304,3 +322,152 @@ describe('lock routes coordinate on one file identity', () => {
304
322
  });
305
323
  });
306
324
  });
325
+
326
+ /**
327
+ * A slashed branch's workspace has two spellings: the lock routes get the
328
+ * `:id` Express decoded (`feat/x`), while an agent's edits and the deletion
329
+ * gate use the encoded id (`feat%2Fx`). They are one workspace — one clone,
330
+ * one branch — so they are one lock: a file a person holds in the app is held
331
+ * against an agent's edit too, and the other way round.
332
+ */
333
+ describe('lock routes coordinate on one workspace identity', () => {
334
+ let h: Harness;
335
+
336
+ beforeEach(async () => {
337
+ h = await makeHarness();
338
+ });
339
+ afterEach(async () => {
340
+ await close(h.server);
341
+ });
342
+
343
+ const url = (suffix: string) => `${h.baseUrl}/api/workspace/${WS}/workflow/locks${suffix}`;
344
+ const send = (method: string, suffix: string, body: unknown) =>
345
+ fetch(url(suffix), {
346
+ method,
347
+ headers: { 'content-type': 'application/json' },
348
+ body: JSON.stringify(body),
349
+ });
350
+ const acquire = () => send('POST', '', { branch: BRANCH, path: CANONICAL });
351
+ const release = () => send('DELETE', '', { branch: BRANCH, path: CANONICAL });
352
+ const heartbeat = () => send('POST', '/heartbeat', { branch: BRANCH, path: CANONICAL });
353
+ const status = () =>
354
+ fetch(`${url('')}?branch=${encodeURIComponent(BRANCH)}&path=${encodeURIComponent(CANONICAL)}`);
355
+
356
+ it("holds a file taken in the app against an agent's edit, and frees it on release", async () => {
357
+ expect(await (await acquire()).json()).toMatchObject({ acquired: true });
358
+
359
+ await expect(h.workflow.acquireLock(workspaceIdForBranch(BRANCH), BRANCH, CANONICAL, BOB)).resolves.toMatchObject({
360
+ acquired: false,
361
+ lock: { holderUserId: ALICE.id },
362
+ });
363
+
364
+ expect((await release()).status).toBe(200);
365
+ await expect(h.workflow.acquireLock(workspaceIdForBranch(BRANCH), BRANCH, CANONICAL, BOB)).resolves.toMatchObject({
366
+ acquired: true,
367
+ });
368
+ });
369
+
370
+ it("holds a file an agent took against the app, which reads its status", async () => {
371
+ await h.workflow.acquireLock(workspaceIdForBranch(BRANCH), BRANCH, CANONICAL, ALICE);
372
+
373
+ expect(await (await status()).json()).toMatchObject({ lock: { holderUserId: ALICE.id } });
374
+ h.actAs(BOB);
375
+ expect(await (await acquire()).json()).toMatchObject({ acquired: false, lock: { holderUserId: ALICE.id } });
376
+ expect(h.fake.rows()).toHaveLength(1);
377
+ });
378
+
379
+ it('answers a caller who may not read the file the same refusal whether or not anyone holds it', async () => {
380
+ // An agent's lock is found from the app's spelling, so the status read
381
+ // must not name its holder to someone the file is hidden from.
382
+ h.canRead.mockImplementation(async (_ws: string, email: string) => email !== BOB.email);
383
+ await h.workflow.acquireLock(workspaceIdForBranch(BRANCH), BRANCH, CANONICAL, ALICE);
384
+ h.actAs(BOB);
385
+
386
+ const rawStatus = () =>
387
+ fetch(`${url('')}?branch=${encodeURIComponent(BRANCH)}&path=${encodeURIComponent(RAW)}`);
388
+ const held = await status();
389
+ const heldRaw = await rawStatus();
390
+ expect(held.status).toBe(403);
391
+ expect(heldRaw.status).toBe(403);
392
+ const heldBody = await held.json();
393
+ expect(JSON.stringify(heldBody)).not.toContain(ALICE.id);
394
+
395
+ h.actAs(ALICE);
396
+ expect((await release()).status).toBe(200);
397
+ h.actAs(BOB);
398
+ const free = await status();
399
+ expect(free.status).toBe(403);
400
+ expect(await free.json()).toEqual(heldBody);
401
+ // The raw spelling is judged as the file it names, not let through as non-KB.
402
+ expect(h.canRead).toHaveBeenCalledWith(BRANCH, BOB.email, 'x/a.md');
403
+ });
404
+
405
+ it('judges a path without the KB prefix as the repository file it names', async () => {
406
+ // The git layer reads `x/a.md` as `${KB}/x/a.md`, so the alias is no way
407
+ // round the read rule.
408
+ h.canRead.mockImplementation(async (_ws: string, email: string) => email !== BOB.email);
409
+ h.actAs(BOB);
410
+
411
+ const alias = await fetch(`${url('')}?branch=${encodeURIComponent(BRANCH)}&path=${encodeURIComponent('x/a.md')}`);
412
+
413
+ expect(alias.status).toBe(403);
414
+ expect(h.canRead).toHaveBeenCalledWith(BRANCH, BOB.email, 'x/a.md');
415
+ });
416
+
417
+ it('leaves an unslashed workspace id as it is', async () => {
418
+ const res = await fetch(`${h.baseUrl}/api/workspace/main/workflow/locks`, {
419
+ method: 'POST',
420
+ headers: { 'content-type': 'application/json' },
421
+ body: JSON.stringify({ branch: 'main', path: CANONICAL }),
422
+ });
423
+ expect(res.status).toBe(200);
424
+ expect(h.fake.rows().map((r) => r.workspaceId)).toEqual(['main']);
425
+ });
426
+
427
+ describe('a lock the previous version left under the decoded id', () => {
428
+ /** A row as the previous version wrote it through these routes. */
429
+ const seedLegacy = (expiresInMs: number) => {
430
+ const now = Date.now();
431
+ h.fake.seed({
432
+ workspaceId: BRANCH,
433
+ branch: BRANCH,
434
+ path: CANONICAL,
435
+ holderUserId: ALICE.id,
436
+ holderName: ALICE.name,
437
+ mode: 'edit',
438
+ acquiredAt: new Date(now - 1_000),
439
+ lastHeartbeatAt: new Date(now - 1_000),
440
+ expiresAt: new Date(now + expiresInMs),
441
+ });
442
+ };
443
+
444
+ it("while live: the app reads it, another editor and an agent's edit are refused, its holder heartbeats and releases it", async () => {
445
+ seedLegacy(30_000);
446
+
447
+ expect(await (await status()).json()).toMatchObject({ lock: { holderUserId: ALICE.id } });
448
+ h.actAs(BOB);
449
+ expect(await (await acquire()).json()).toMatchObject({ acquired: false, lock: { holderUserId: ALICE.id } });
450
+ await expect(h.workflow.acquireLock(workspaceIdForBranch(BRANCH), BRANCH, CANONICAL, BOB)).resolves.toMatchObject({
451
+ acquired: false,
452
+ });
453
+ // No second row was written beside it.
454
+ expect(h.fake.rows().map((r) => r.workspaceId)).toEqual([BRANCH]);
455
+
456
+ h.actAs(ALICE);
457
+ const beat = await heartbeat();
458
+ expect(beat.status).toBe(200);
459
+ expect(new Date((await beat.json()).expiresAt).getTime()).toBeGreaterThan(Date.now() + 30_000);
460
+ expect(await (await release()).json()).toEqual({ queued: true });
461
+ expect(h.fake.rows()).toEqual([]);
462
+ });
463
+
464
+ it('once expired: it holds nothing, and the file is taken under the encoded id', async () => {
465
+ seedLegacy(-1_000);
466
+
467
+ expect(await (await status()).json()).toMatchObject({ lock: null });
468
+ h.actAs(BOB);
469
+ expect(await (await acquire()).json()).toMatchObject({ acquired: true, lock: { holderUserId: BOB.id } });
470
+ expect(h.fake.rows().map((r) => r.workspaceId)).toEqual([WS]);
471
+ });
472
+ });
473
+ });
@@ -20,6 +20,7 @@ import { WorkflowHooks } from '../workflow-hooks.js';
20
20
  import { WorkflowService } from '../workflow.service.js';
21
21
  import type { WorkflowEventBus } from '../event-bus.js';
22
22
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
23
+ import { makeFakeLockDb } from './fake-file-lock-db.js';
23
24
  import {
24
25
  BranchAuthorshipError,
25
26
  BranchDeleteBlockedError,
@@ -112,7 +113,6 @@ interface Harness {
112
113
  };
113
114
  fetch: ReturnType<typeof vi.fn>;
114
115
  deleted: string[];
115
- fileLocks: FileLockService;
116
116
  /** What the service announced to connected tabs. */
117
117
  emitted: { kind: string; number?: number }[];
118
118
  hasAnyActive: MockInstance<FileLockService['hasAnyActive']>;
@@ -137,6 +137,8 @@ function harness(opts: {
137
137
  changes?: Record<string, string[] | 'error'>;
138
138
  /** Branches whose deletion fails at the host (the leased push is refused). */
139
139
  deleteFails?: string[];
140
+ /** Hold locks in a real (in-memory) lock store instead of answering from `savesLanding`. */
141
+ realLocks?: boolean;
140
142
  }): Harness {
141
143
  const deleted: string[] = [];
142
144
  const emitted: { kind: string; number?: number }[] = [];
@@ -175,8 +177,10 @@ function harness(opts: {
175
177
  const landing = new Set((opts.savesLanding ?? []).map(workspaceIdForBranch));
176
178
  const queued = new Set((opts.commitsQueued ?? []).map(workspaceIdForBranch));
177
179
  // The real service, for its gate on acquiring; what is held is mocked.
178
- const fileLocks = new FileLockService({} as Database);
179
- const hasAnyActive = vi.spyOn(fileLocks, 'hasAnyActive').mockImplementation(async (ws: string) => landing.has(ws));
180
+ const fileLocks = new FileLockService(opts.realLocks ? makeFakeLockDb().db : ({} as Database));
181
+ const hasAnyActive = opts.realLocks
182
+ ? vi.spyOn(fileLocks, 'hasAnyActive')
183
+ : vi.spyOn(fileLocks, 'hasAnyActive').mockImplementation(async (ws: string) => landing.has(ws));
180
184
  const svc = new WorkflowService(
181
185
  fakeDb(rows),
182
186
  git as unknown as GitService,
@@ -209,7 +213,7 @@ function harness(opts: {
209
213
  openChangeGate(),
210
214
  { emit: (e: { kind: string; number?: number }) => void emitted.push(e) } as unknown as WorkflowEventBus,
211
215
  );
212
- return { svc, git, fetch, deleted, fileLocks, emitted, hasAnyActive };
216
+ return { svc, git, fetch, deleted, emitted, hasAnyActive };
213
217
  }
214
218
 
215
219
  const merged = (number: number, source: string, sha: string | null): CrRow => ({
@@ -352,7 +356,7 @@ describe('deleteBranchChecked — an agent deletes a branch', () => {
352
356
  h.hasAnyActive.mockResolvedValue(false);
353
357
  let during: unknown = null;
354
358
  h.git.deleteBranch.mockImplementationOnce(async () => {
355
- during = await h.fileLocks.acquire(workspaceIdForBranch('ali/draft'), 'ali/draft', 'KnowledgeBase/f.md', BOB).catch((e: unknown) => e);
359
+ during = await h.svc.acquireLock(workspaceIdForBranch('ali/draft'), 'ali/draft', 'KnowledgeBase/f.md', BOB).catch((e: unknown) => e);
356
360
  return { lastCommit: 'a1' };
357
361
  });
358
362
  await h.svc.deleteBranchChecked(ADMIN, 'ali/draft');
@@ -560,6 +564,46 @@ describe('a change request that proposes nothing never blocks a deletion', () =>
560
564
  expect(rows.map((r) => r.state)).toEqual(['open', 'open']);
561
565
  });
562
566
 
567
+ it('names the person by id, never by email address, on the close, reopen and deletion lines', async () => {
568
+ // Log lines carry no email addresses: who acted is the user's id.
569
+ const rows = [open(21, 'ali/sync', DEFAULT)];
570
+ const h = harness({
571
+ rows,
572
+ branches: { 'ali/sync': clean('s1'), 'ali/old-draft': clean('a1') },
573
+ changes: { 'ali/sync': [] },
574
+ deleteFails: ['ali/sync'],
575
+ });
576
+ const lines: string[] = [];
577
+ const spy = vi.spyOn(console, 'log').mockImplementation((line: unknown) => void lines.push(String(line)));
578
+ try {
579
+ await expect(h.svc.deleteBranchChecked(ADMIN, 'ali/sync')).rejects.toThrow(/stale info/);
580
+ await h.svc.deleteBranchChecked(ADMIN, 'ali/old-draft');
581
+ } finally {
582
+ spy.mockRestore();
583
+ }
584
+ const pick = (needle: string) => lines.filter((l) => l.includes(needle));
585
+ expect(pick('closed change request #21')).toEqual([expect.stringContaining(' by u-ana: ')]);
586
+ expect(pick('reopened change request #21')).toEqual([expect.stringContaining(' for u-ana: ')]);
587
+ expect(pick('branch deleted by')).toEqual(['[workflow] branch deleted by u-ana: "ali/old-draft" at a1']);
588
+ for (const line of lines) expect(line).not.toContain('@');
589
+ });
590
+
591
+ it("names the person by id on the close made when a request's page is opened", async () => {
592
+ const rows = [open(23, 'ali/page', DEFAULT)];
593
+ const h = harness({ rows, branches: { 'ali/page': clean('p1') }, changes: { 'ali/page': [] } });
594
+ const lines: string[] = [];
595
+ const spy = vi.spyOn(console, 'log').mockImplementation((line: unknown) => void lines.push(String(line)));
596
+ try {
597
+ await expect(h.svc.closeEmptyChangeRequest(23, BOB)).resolves.toBe(true);
598
+ } finally {
599
+ spy.mockRestore();
600
+ }
601
+ expect(lines.filter((l) => l.includes('closed change request #23'))).toEqual([
602
+ expect.stringContaining(' by u-bob: '),
603
+ ]);
604
+ for (const line of lines) expect(line).not.toContain('@');
605
+ });
606
+
563
607
  it('a request the caller may not see is not in the preview at all, and is refused on without its number or link', async () => {
564
608
  // #21 is empty and #25 live; the caller can see neither.
565
609
  const rows = [open(21, 'bob/sync', DEFAULT), open(25, 'ali/secret', 'bob/sync')];
@@ -656,7 +700,7 @@ describe('closeEmptyOpenChangeRequests — the background close', () => {
656
700
  const h = harness({ rows, branches: { 'ali/empty': clean('e1') }, changes: { 'ali/empty': [] } });
657
701
  let during: unknown = null;
658
702
  h.git.deleteBranch.mockImplementationOnce(async () => {
659
- during = await h.fileLocks.acquire(workspaceIdForBranch('ali/empty'), 'ali/empty', 'KnowledgeBase/f.md', BOB).catch((e: unknown) => e);
703
+ during = await h.svc.acquireLock(workspaceIdForBranch('ali/empty'), 'ali/empty', 'KnowledgeBase/f.md', BOB).catch((e: unknown) => e);
660
704
  return { lastCommit: 'e1' };
661
705
  });
662
706
  await h.svc.closeEmptyOpenChangeRequests();
@@ -902,6 +946,58 @@ const GIT_ENV = {
902
946
  const git = async (cwd: string, args: string[]) =>
903
947
  (await execFileAsync('git', args, { cwd, env: GIT_ENV })).stdout.trim();
904
948
 
949
+ /**
950
+ * A file held through the app: the lock route hands the service the `:id`
951
+ * Express decoded, so a slashed branch's workspace arrives as `ali/draft`,
952
+ * not the `ali%2Fdraft` the deletion paths ask with. Over a real lock store,
953
+ * every deletion path has to see that hold.
954
+ */
955
+ describe('a file held through the app on a slashed branch', () => {
956
+ const holdAsTheRouteDoes = (h: Harness, branch: string) =>
957
+ h.svc.acquireLock(branch, branch, 'KnowledgeBase/f.md', BOB);
958
+
959
+ it('refuses the agent delete, with or without discardUnmerged', async () => {
960
+ const h = harness({ realLocks: true, branches: { 'ali/draft': { exists: true, lastCommit: 'a3', unmergedCommits: 1 } } });
961
+ await expect(holdAsTheRouteDoes(h, 'ali/draft')).resolves.toMatchObject({ acquired: true });
962
+ for (const discardUnmerged of [false, true]) {
963
+ const err = await refusal(h.svc.deleteBranchChecked(ADMIN, 'ali/draft', { discardUnmerged }));
964
+ expect((err as BranchDeleteBlockedError).payload).toMatchObject({ reason: 'saves-landing' });
965
+ }
966
+ expect(h.deleted).toEqual([]);
967
+ });
968
+
969
+ it('keeps an empty request into the branch being deleted open, and refuses on it', async () => {
970
+ const rows = [open(22, 'ali/feature', 'ali/sync')];
971
+ const h = harness({ realLocks: true, rows, branches: { 'ali/sync': clean('s1') }, changes: { 'ali/feature': [] } });
972
+ await holdAsTheRouteDoes(h, 'ali/feature');
973
+ const err = await refusal(h.svc.deleteBranchChecked(ADMIN, 'ali/sync'));
974
+ expect((err as BranchDeleteBlockedError).payload).toMatchObject({ reason: 'open-change-request', number: 22 });
975
+ expect(rows[0]!.state).toBe('open');
976
+ expect(h.deleted).toEqual([]);
977
+ });
978
+
979
+ it('the background close leaves the request open and its source branch in place', async () => {
980
+ const rows = [open(31, 'ali/saving', DEFAULT)];
981
+ const h = harness({ realLocks: true, rows, admins: [], branches: { 'ali/saving': clean('e1') }, changes: { 'ali/saving': [] } });
982
+ await holdAsTheRouteDoes(h, 'ali/saving');
983
+ await expect(h.svc.closeEmptyOpenChangeRequests()).resolves.toBe(0);
984
+ expect(rows[0]!.state).toBe('open');
985
+ expect(h.deleted).toEqual([]);
986
+ });
987
+
988
+ it('the leftover cleanup leaves the branch in place', async () => {
989
+ const h = harness({
990
+ realLocks: true,
991
+ rows: [merged(9, 'ali/saving', 'm9')],
992
+ branches: { 'ali/saving': clean('t9') },
993
+ ancestry: [['t9', 'm9']],
994
+ });
995
+ await holdAsTheRouteDoes(h, 'ali/saving');
996
+ await expect(h.svc.retireLeftoverMergedBranches()).resolves.toBe(0);
997
+ expect(h.deleted).toEqual([]);
998
+ });
999
+ });
1000
+
905
1001
  describe('deleteBranchChecked over real git — a delete made from another workspace', () => {
906
1002
  let root: string;
907
1003
  beforeEach(async () => {