@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
@@ -23,6 +23,28 @@
23
23
  * never be keyed on a path no file verb would accept. See
24
24
  * `canonicalFileIdentity`.
25
25
  *
26
+ * Workspace identity works the same way. The lock routes pass the `:id`
27
+ * path param, which Express URL-decodes, so a slashed branch's workspace
28
+ * arrives as `alice/feature`; the deletion gate and the git status probe ask
29
+ * with the encoded `alice%2Ffeature`. Every method keys rows on
30
+ * `canonicalWorkspaceId`, so both spellings are one workspace and a held file
31
+ * is seen whichever spelling asks.
32
+ *
33
+ * Unlike raw path spellings (below), rows written under the decoded workspace id before this
34
+ * landed are still honoured until they go: every read, heartbeat and release
35
+ * also matches that spelling, and an acquire is refused while a live row
36
+ * under it holds the same file. Otherwise a lock held across the upgrade
37
+ * would be invisible for up to its TTL — to the deletion gate and to a second
38
+ * editor alike. Nothing new is ever written under it, so once the last such
39
+ * row has expired or been released the extra spelling matches nothing.
40
+ *
41
+ * This covers rows LEFT by the previous version, not rows it is still
42
+ * writing: a previous-version process never looks at a canonical row, so no
43
+ * check here could make the two contend while both run. They never do — one
44
+ * server process owns the workspaces directory (see `branchLifecycle` in the
45
+ * workflow service; this service's deletion gate is in-process for the same
46
+ * reason), and an upgrade stops the old process before the new one starts.
47
+ *
26
48
  * There is no transition handling for rows written under a raw spelling
27
49
  * before this landed: such a row is now unreachable by name and expires on
28
50
  * its own TTL. For one deploy an in-flight edit's lock can linger up to the
@@ -35,12 +57,13 @@
35
57
  * bounded by concurrent editors, not history, so lazy GC is fine.
36
58
  */
37
59
 
38
- import { and, eq, gt, lte } from 'drizzle-orm';
60
+ import { and, eq, gt, inArray, lte } from 'drizzle-orm';
39
61
  import type { Database } from '../database/connection.js';
40
62
  import { fileLocks } from '../database/schema.js';
41
63
  import type { AcquireLockResult, AuthUser, FileLock } from '@bevel-software/platform-shared';
42
64
  import { WorkflowValidationError } from '../../shared/domain-errors.js';
43
65
  import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
66
+ import { branchForWorkspaceId, canonicalWorkspaceId } from '../../shared/workspace-id.js';
44
67
 
45
68
  /**
46
69
  * Lock lifetime without a heartbeat. The client is expected to heartbeat
@@ -51,6 +74,17 @@ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
51
74
  */
52
75
  const LOCK_TTL_MS = 60_000;
53
76
 
77
+ /**
78
+ * The workspace ids a lock row of canonical workspace `workspaceId` can sit
79
+ * under: the canonical one every write uses, then — for a slashed branch —
80
+ * the decoded one rows were written under before workspace ids were
81
+ * canonicalised. See the module comment.
82
+ */
83
+ function workspaceSpellings(workspaceId: string): string[] {
84
+ const legacy = branchForWorkspaceId(workspaceId);
85
+ return legacy === workspaceId ? [workspaceId] : [workspaceId, legacy];
86
+ }
87
+
54
88
  function rowToFileLock(row: typeof fileLocks.$inferSelect): FileLock {
55
89
  return {
56
90
  branch: row.branch,
@@ -125,13 +159,14 @@ export class FileLockService {
125
159
  * (or vice versa).
126
160
  */
127
161
  async acquire(
128
- workspaceId: string,
162
+ rawWorkspaceId: string,
129
163
  branch: string,
130
164
  rawPath: string,
131
165
  user: AuthUser,
132
166
  opts?: { coordination?: boolean },
133
167
  ): Promise<AcquireLockResult> {
134
168
  const targetPath = canonicalFileIdentity(rawPath);
169
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
135
170
  if (this.closing.has(branch)) {
136
171
  throw new WorkflowValidationError(`"${branch}" is being deleted, so "${targetPath}" cannot be held for editing.`, {
137
172
  kind: 'branch-being-deleted',
@@ -190,6 +225,25 @@ export class FileLockService {
190
225
  // `heartbeat()` (which has its own same-user UPDATE), and the
191
226
  // editor's save flow detects "I already hold it" via `getLock` in
192
227
  // `workspace.routes.withLock` rather than re-acquiring.
228
+ // A live row under the pre-canonical spelling holds this file as surely
229
+ // as one under the canonical id; the upsert below cannot see it.
230
+ const legacy = workspaceSpellings(workspaceId).slice(1);
231
+ if (legacy.length > 0) {
232
+ const [held] = await this.db
233
+ .select()
234
+ .from(fileLocks)
235
+ .where(
236
+ and(
237
+ inArray(fileLocks.workspaceId, legacy),
238
+ eq(fileLocks.branch, branch),
239
+ eq(fileLocks.path, targetPath),
240
+ gt(fileLocks.expiresAt, now),
241
+ ),
242
+ )
243
+ .limit(1);
244
+ if (held) return { acquired: false, lock: rowToFileLock(held) };
245
+ }
246
+
193
247
  const mode = opts?.coordination ? 'coordination' : 'edit';
194
248
  const upsertResult = await this.db
195
249
  .insert(fileLocks)
@@ -255,12 +309,13 @@ export class FileLockService {
255
309
  * current holder.
256
310
  */
257
311
  async heartbeat(
258
- workspaceId: string,
312
+ rawWorkspaceId: string,
259
313
  branch: string,
260
314
  rawPath: string,
261
315
  user: AuthUser,
262
316
  ): Promise<FileLock> {
263
317
  const targetPath = canonicalFileIdentity(rawPath);
318
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
264
319
  const now = new Date();
265
320
  const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);
266
321
  // The expiry guard (`expiresAt > now`) matters because an expired
@@ -279,7 +334,7 @@ export class FileLockService {
279
334
  .set({ lastHeartbeatAt: now, expiresAt })
280
335
  .where(
281
336
  and(
282
- eq(fileLocks.workspaceId, workspaceId),
337
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
283
338
  eq(fileLocks.branch, branch),
284
339
  eq(fileLocks.path, targetPath),
285
340
  eq(fileLocks.holderUserId, user.id),
@@ -302,17 +357,18 @@ export class FileLockService {
302
357
  * over by someone else — that's not an error from the caller's POV).
303
358
  */
304
359
  async release(
305
- workspaceId: string,
360
+ rawWorkspaceId: string,
306
361
  branch: string,
307
362
  rawPath: string,
308
363
  user: AuthUser,
309
364
  ): Promise<void> {
310
365
  const targetPath = canonicalFileIdentity(rawPath);
366
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
311
367
  await this.db
312
368
  .delete(fileLocks)
313
369
  .where(
314
370
  and(
315
- eq(fileLocks.workspaceId, workspaceId),
371
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
316
372
  eq(fileLocks.branch, branch),
317
373
  eq(fileLocks.path, targetPath),
318
374
  eq(fileLocks.holderUserId, user.id),
@@ -326,25 +382,30 @@ export class FileLockService {
326
382
  * are purged here lazily.
327
383
  */
328
384
  async get(
329
- workspaceId: string,
385
+ rawWorkspaceId: string,
330
386
  branch: string,
331
387
  rawPath: string,
332
388
  ): Promise<FileLock | null> {
333
389
  const targetPath = canonicalFileIdentity(rawPath);
390
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
334
391
  const rows = await this.db
335
392
  .select()
336
393
  .from(fileLocks)
337
394
  .where(
338
395
  and(
339
- eq(fileLocks.workspaceId, workspaceId),
396
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
340
397
  eq(fileLocks.branch, branch),
341
398
  eq(fileLocks.path, targetPath),
342
399
  ),
343
- )
344
- .limit(1);
345
- if (rows.length === 0) return null;
346
- const row = rows[0];
347
- if (row.expiresAt.getTime() <= Date.now()) {
400
+ );
401
+ // At most one row per spelling; the canonical one first.
402
+ rows.sort((x, y) => Number(x.workspaceId !== workspaceId) - Number(y.workspaceId !== workspaceId));
403
+ let live: (typeof rows)[number] | null = null;
404
+ for (const row of rows) {
405
+ if (row.expiresAt.getTime() > Date.now()) {
406
+ live ??= row;
407
+ continue;
408
+ }
348
409
  // Lazy GC — drop the stale row so the next `acquire` doesn't have
349
410
  // to bypass it. Bind the delete to this exact row's `expiresAt` so
350
411
  // a concurrent acquire that wrote a fresh row in the gap between
@@ -356,15 +417,14 @@ export class FileLockService {
356
417
  .delete(fileLocks)
357
418
  .where(
358
419
  and(
359
- eq(fileLocks.workspaceId, workspaceId),
420
+ eq(fileLocks.workspaceId, row.workspaceId),
360
421
  eq(fileLocks.branch, branch),
361
422
  eq(fileLocks.path, targetPath),
362
423
  eq(fileLocks.expiresAt, row.expiresAt),
363
424
  ),
364
425
  );
365
- return null;
366
426
  }
367
- return rowToFileLock(row);
427
+ return live ? rowToFileLock(live) : null;
368
428
  }
369
429
 
370
430
  /**
@@ -406,11 +466,12 @@ export class FileLockService {
406
466
  * the "missed lock-release commit" the loud warning is for. Expired rows
407
467
  * don't count (their holder is gone; they explain nothing).
408
468
  */
409
- async hasAnyActive(workspaceId: string): Promise<boolean> {
469
+ async hasAnyActive(rawWorkspaceId: string): Promise<boolean> {
470
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
410
471
  const rows = await this.db
411
472
  .select({ path: fileLocks.path })
412
473
  .from(fileLocks)
413
- .where(and(eq(fileLocks.workspaceId, workspaceId), gt(fileLocks.expiresAt, new Date())))
474
+ .where(and(inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)), gt(fileLocks.expiresAt, new Date())))
414
475
  .limit(1);
415
476
  return rows.length > 0;
416
477
  }
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
9
+ import { WorkflowHooks } from '../../workflow-hooks.js';
10
+ import { GitService } from '../git.service.js';
11
+ import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
12
+
13
+ /**
14
+ * Every git process the service starts, by argv, so a test can say what a
15
+ * change-request read is allowed to cost. The fixture's own git runs through
16
+ * `execFile` and is not logged.
17
+ */
18
+ const { spawnLog } = vi.hoisted(() => ({ spawnLog: [] as string[][] }));
19
+
20
+ vi.mock('node:child_process', async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import('node:child_process')>();
22
+ const spawn = ((file: string, args: string[], options: unknown) => {
23
+ if (file === 'git') spawnLog.push(args ?? []);
24
+ return (actual.spawn as unknown as (...a: unknown[]) => unknown)(file, args, options);
25
+ }) as unknown as typeof actual.spawn;
26
+ return { ...actual, spawn };
27
+ });
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ async function runGit(cwd: string, args: string[]): Promise<void> {
32
+ await execFileAsync('git', args, {
33
+ cwd,
34
+ env: {
35
+ ...process.env,
36
+ GIT_AUTHOR_NAME: 'Test',
37
+ GIT_AUTHOR_EMAIL: 't@x.com',
38
+ GIT_COMMITTER_NAME: 'Test',
39
+ GIT_COMMITTER_EMAIL: 't@x.com',
40
+ },
41
+ });
42
+ }
43
+
44
+ const spawnsOf = (subcommand: string) => spawnLog.filter((argv) => argv.includes(subcommand)).length;
45
+
46
+ /**
47
+ * What a change request's changed paths cost: the tips come from the ref
48
+ * files and the diff is read from git ONCE per pair of commits. A listing
49
+ * that nothing has moved under asks git for nothing; a push moves a tip,
50
+ * and that request alone is diffed again.
51
+ */
52
+ describe('a change request is diffed once per pair of commits', () => {
53
+ let root: string;
54
+ let upstream: string;
55
+ let repo: string;
56
+ let other: string;
57
+ let svc: GitService;
58
+ const workspaceId = 'ws-main';
59
+ const secondWorkspaceId = 'ws-two';
60
+
61
+ beforeEach(async () => {
62
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-changed-memo-'));
63
+ upstream = path.join(root, 'upstream.git');
64
+ await runGit(root, ['init', '--bare', '-b', 'main', upstream]);
65
+ const seed = path.join(root, '.seed');
66
+ await fs.mkdir(seed);
67
+ await runGit(seed, ['init', '-b', 'main']);
68
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
69
+ await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
70
+ await runGit(seed, ['add', '-A']);
71
+ await runGit(seed, ['commit', '-m', 'init']);
72
+ await runGit(seed, ['checkout', '-b', 'feature']);
73
+ await fs.writeFile(path.join(seed, 'one.md'), 'one\n');
74
+ await runGit(seed, ['add', '-A']);
75
+ await runGit(seed, ['commit', '-m', 'one']);
76
+ await runGit(seed, ['push', 'origin', 'main', 'feature']);
77
+ const workspaceDir = path.join(root, workspaceId);
78
+ repo = path.join(workspaceDir, 'knowledge-base');
79
+ await fs.mkdir(workspaceDir, { recursive: true });
80
+ await runGit(root, ['clone', upstream, repo]);
81
+ other = path.join(root, 'other');
82
+ await runGit(root, ['clone', upstream, other]);
83
+ // A second workspace of the same knowledge base, with its own reservation.
84
+ const secondDir = path.join(root, secondWorkspaceId);
85
+ await fs.mkdir(secondDir, { recursive: true });
86
+ await runGit(root, ['clone', upstream, path.join(secondDir, 'knowledge-base')]);
87
+ const workspace = {
88
+ getWorkspacePath: async (id: string) => {
89
+ if (id === workspaceId) return workspaceDir;
90
+ if (id === secondWorkspaceId) return secondDir;
91
+ throw new Error(`unexpected workspace ${id}`);
92
+ },
93
+ } as unknown as WorkspaceService;
94
+ svc = new GitService(
95
+ workspace,
96
+ new WorkflowHooks(),
97
+ testKbContext({ branchModel: { defaultBranch: 'main', protectedBranches: ['main'] } }),
98
+ new WorkspaceMutex(),
99
+ );
100
+ spawnLog.length = 0;
101
+ });
102
+
103
+ afterEach(async () => {
104
+ await fs.rm(root, { recursive: true, force: true });
105
+ });
106
+
107
+ it('reads the tips from the ref files, diffs once, and asks git nothing the second time', async () => {
108
+ const first = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
109
+ expect(first.paths).toEqual(['one.md']);
110
+ expect(first.pairs).toEqual([{ path: 'one.md' }]);
111
+ expect(spawnsOf('diff')).toBe(1);
112
+ expect(spawnsOf('rev-parse')).toBe(0);
113
+
114
+ spawnLog.length = 0;
115
+ const second = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
116
+ expect(second).toEqual(first);
117
+ expect(spawnLog).toEqual([]);
118
+
119
+ // The access-check flavour reads the same pair: the memo answers it too.
120
+ spawnLog.length = 0;
121
+ expect(await svc.changedPathsForPr(workspaceId, 'main', 'feature', { fetch: false, forAccessCheck: true })).toEqual(['one.md']);
122
+ expect(spawnLog).toEqual([]);
123
+ });
124
+
125
+ it('diffs again, once, when a push moves a tip', async () => {
126
+ await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
127
+ // Someone else pushes to feature; the workspace fetches as a listing's
128
+ // whole-clone refresh does, which rewrites the ref file.
129
+ await runGit(other, ['checkout', 'feature']);
130
+ await fs.writeFile(path.join(other, 'two.md'), 'two\n');
131
+ await runGit(other, ['add', '-A']);
132
+ await runGit(other, ['commit', '-m', 'two']);
133
+ await runGit(other, ['push', 'origin', 'feature']);
134
+ await runGit(repo, ['fetch', 'origin']);
135
+
136
+ spawnLog.length = 0;
137
+ const after = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
138
+ expect(after.paths.sort()).toEqual(['one.md', 'two.md']);
139
+ expect(spawnsOf('diff')).toBe(1);
140
+ expect(spawnsOf('rev-parse')).toBe(0);
141
+
142
+ spawnLog.length = 0;
143
+ expect(await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false })).toEqual(after);
144
+ expect(spawnLog).toEqual([]);
145
+ });
146
+
147
+ it('shares one diff between concurrent reads of a pair nobody has seen, even from two workspaces', async () => {
148
+ // Two workspaces take two reservations, so nothing serialises the two
149
+ // reads but the in-flight diff itself: both answer, one `diff` runs.
150
+ const [a, b] = await Promise.all([
151
+ svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false }),
152
+ svc.changedPathsAndPairsForPr(secondWorkspaceId, 'main', 'feature', { fetch: false }),
153
+ ]);
154
+ expect(a).toEqual(b);
155
+ expect(a.paths).toEqual(['one.md']);
156
+ expect(spawnsOf('diff')).toBe(1);
157
+ });
158
+
159
+ it('still asks git about a branch the ref files do not hold, and answers as before', async () => {
160
+ // A branch this clone has never fetched: the files say missing for both
161
+ // of its names, which is rev-parse's answer too — and `fetch: false`
162
+ // then turns into the per-request fetch the caller was promised.
163
+ await runGit(other, ['checkout', '-b', 'later']);
164
+ await fs.writeFile(path.join(other, 'three.md'), 'three\n');
165
+ await runGit(other, ['add', '-A']);
166
+ await runGit(other, ['commit', '-m', 'three']);
167
+ await runGit(other, ['push', 'origin', 'later']);
168
+
169
+ spawnLog.length = 0;
170
+ const got = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'later', { fetch: false });
171
+ expect(got.paths).toEqual(['three.md']);
172
+ expect(spawnsOf('fetch')).toBe(1);
173
+ expect(spawnsOf('diff')).toBe(1);
174
+ });
175
+ });
@@ -100,3 +100,45 @@ describe('GitService.readFileAtRef', () => {
100
100
  ).rejects.toThrow();
101
101
  });
102
102
  });
103
+
104
+ describe('GitService.commitAtRef', () => {
105
+ let root: string;
106
+ const workspaceId = 'ws-commit-at-ref-1';
107
+
108
+ beforeEach(async () => {
109
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-commit-at-ref-'));
110
+ });
111
+ afterEach(async () => {
112
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
113
+ });
114
+
115
+ function makeSvc(workspaceDir: string) {
116
+ return new GitService(
117
+ stubWorkspaceService(workspaceId, workspaceDir),
118
+ new WorkflowHooks(),
119
+ testKbContext({ kbDirName: PROCESS_MAP_DIR }),
120
+ );
121
+ }
122
+
123
+ it('answers the commit a ref names, and null for a ref that names none', async () => {
124
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
125
+ const svc = makeSvc(workspaceDir);
126
+ // Unborn HEAD: a repository with no commit yet names none.
127
+ expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBeNull();
128
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles: {}\n');
129
+ await runGit(repo, ['add', 'roles.yaml']);
130
+ await runGit(repo, ['commit', '-m', 'add roles']);
131
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo });
132
+ expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBe(stdout.trim());
133
+ expect(await svc.commitAtRef(workspaceId, 'origin/does-not-exist')).toBeNull();
134
+ });
135
+
136
+ it('THROWS on a failure that is not "names no commit", such as no repository there', async () => {
137
+ // The workspace folder exists; the clone inside it does not. Git fails
138
+ // with "not a git repository", which must not read as an empty one.
139
+ const workspaceDir = path.join(root, workspaceId);
140
+ await fs.mkdir(path.join(workspaceDir, PROCESS_MAP_DIR), { recursive: true });
141
+ const svc = makeSvc(workspaceDir);
142
+ await expect(svc.commitAtRef(workspaceId, 'HEAD')).rejects.toThrow();
143
+ });
144
+ });