@bevel-software/platform-core-backend 0.10.0 → 0.11.1

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 (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -3,7 +3,9 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
6
+ import { LocalFilesystem } from '@mastra/core/workspace';
6
7
  import { LockingFilesystem } from '../locking-filesystem.js';
8
+ import { PushNeedsAgentResolutionError } from '../workflow.errors.js';
7
9
 
8
10
  const USER: AuthUser = {
9
11
  id: 'user-1',
@@ -27,6 +29,7 @@ function makeWorkflow(): IWorkflowService {
27
29
  }),
28
30
  releaseLock: vi.fn().mockResolvedValue(null),
29
31
  releaseLockNoCommit: vi.fn().mockResolvedValue(undefined),
32
+ releaseLockUntouched: vi.fn().mockResolvedValue(undefined),
30
33
  heartbeatLock: vi.fn(),
31
34
  getLock: vi.fn(),
32
35
  commitFileWhileLocked: vi.fn(),
@@ -280,6 +283,39 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
280
283
  expect(emitted).toHaveLength(0);
281
284
  });
282
285
 
286
+ it('a POST-commit push failure releases WITH commit-on-release (arms the worker retry), never the discard path', async () => {
287
+ // PushNeedsAgentResolutionError means the commit LANDED and only the push
288
+ // needs help. Routing it through releaseLockNoCommit would leave the
289
+ // landed commit with no retry vehicle — the next identical write would
290
+ // no-op against the committed bytes and the change stays unpublished
291
+ // forever. The batch must releaseLock (the enqueued release commit no-ops
292
+ // on the clean tree and the worker's unpushed-commits check re-runs the
293
+ // push ladder) and still propagate the error.
294
+ const workflow = makeWorkflow();
295
+ (workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
296
+ new PushNeedsAgentResolutionError('feat', 'A.md', 'non-fast-forward', 'rebase failed'),
297
+ );
298
+ const emitted: unknown[] = [];
299
+ const fsLayer = new LockingFilesystem(
300
+ { basePath: root, contained: true },
301
+ {
302
+ workflow,
303
+ workspaceId: 'ws-feat',
304
+ branch: 'feat',
305
+ user: USER,
306
+ fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
307
+ },
308
+ );
309
+
310
+ await expect(
311
+ fsLayer.writeFiles([{ path: 'A.md', content: 'x' }], 'batch'),
312
+ ).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
313
+
314
+ expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'A.md', USER);
315
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
316
+ expect(emitted).toHaveLength(0);
317
+ });
318
+
283
319
  it('emits nothing on a no-op commit (clean tree)', async () => {
284
320
  const workflow = makeWorkflow();
285
321
  (workflow.commitChanges as ReturnType<typeof vi.fn>).mockResolvedValue(null);
@@ -431,6 +467,306 @@ describe('LockingFilesystem — creator read grants on creation', () => {
431
467
  expect(creatorAccess.noteAccessFileWritten).toHaveBeenCalledWith('ws-feat');
432
468
  });
433
469
 
470
+ it('a NO-OP seed stays OUT of the commit scope (its dirty bytes must never ride this batch)', async () => {
471
+ // The seed's access.md already carries the grant — apply() returns the
472
+ // current bytes unchanged. The lock is still taken (and released), but the
473
+ // path must NOT be passed to commitChanges: on the shared workspace it may
474
+ // be dirty from ANOTHER save whose commit is still queued, and scoping the
475
+ // batch to a merely-locked path would sweep those bytes in under this
476
+ // batch's author/summary.
477
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
478
+ const seedContent = '---\nread:\n - Alice <alice@example.com>\n---\n';
479
+ await fs.writeFile(path.join(root, 'KnowledgeBase/Mine/access.md'), seedContent);
480
+ const workflow = makeWorkflow();
481
+ const creatorAccess = makeCreatorAccess();
482
+ creatorAccess.planForCreate.mockResolvedValue({
483
+ kind: 'seed-access-md',
484
+ wsRelPath: 'KnowledgeBase/Mine/access.md',
485
+ apply: (current: string) => current, // grant already present → no-op
486
+ });
487
+ const emitted: { paths: string[] }[] = [];
488
+ const fsLayer = new LockingFilesystem(
489
+ { basePath: root, contained: true },
490
+ {
491
+ workflow,
492
+ workspaceId: 'ws-feat',
493
+ branch: 'feat',
494
+ user: USER,
495
+ creatorAccess,
496
+ fileChanges: { emit: (c: { paths: string[] }) => emitted.push(c) } as never,
497
+ },
498
+ );
499
+ await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
500
+
501
+ // The seed's lock cycle still ran (acquired + released)...
502
+ const locked = (workflow.acquireLock as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[2]);
503
+ expect(locked).toContain('KnowledgeBase/Mine/access.md');
504
+ // ...but the commit is scoped to the caller's path only.
505
+ const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
506
+ expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
507
+ expect(emitted[0].paths).toEqual(['KnowledgeBase/Mine/doc.md']);
508
+ // The seeded file is untouched.
509
+ expect(await fs.readFile(path.join(root, 'KnowledgeBase/Mine/access.md'), 'utf-8')).toBe(
510
+ seedContent,
511
+ );
512
+ });
513
+
514
+ it('a NO-OP seed lock releases UNTOUCHED — a prior save\'s queued bytes must survive as-is', async () => {
515
+ // The prior-save-pending scenario: a previous save on the access.md
516
+ // released its lock via releaseLock, so its bytes are DIRTY on the shared
517
+ // workspace with the commit still queued. This batch then takes the seed
518
+ // lock, finds the grant already present (apply() no-ops), and commits
519
+ // only its own paths. Releasing the seed lock with NO-COMMIT semantics
520
+ // would git-discard the path back to HEAD — silently destroying the prior
521
+ // save. Releasing it with COMMIT-ON-RELEASE would be almost as bad: the
522
+ // enqueue refreshes the existing pending row's author to THIS user and
523
+ // resets its retry ladder — the prior save's bytes would publish under
524
+ // the wrong name. The seed lock must release UNTOUCHED (drop the lock
525
+ // row, leave disk and queue exactly as they are).
526
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
527
+ const priorSave = '---\nread:\n - Alice <alice@example.com>\n---\nprior queued bytes\n';
528
+ await fs.writeFile(path.join(root, 'KnowledgeBase/Mine/access.md'), priorSave);
529
+ const workflow = makeWorkflow();
530
+ const creatorAccess = makeCreatorAccess();
531
+ creatorAccess.planForCreate.mockResolvedValue({
532
+ kind: 'seed-access-md',
533
+ wsRelPath: 'KnowledgeBase/Mine/access.md',
534
+ apply: (current: string) => current, // grant already present → no-op
535
+ });
536
+ const fsLayer = new LockingFilesystem(
537
+ { basePath: root, contained: true },
538
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
539
+ );
540
+ await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
541
+
542
+ // The batch's OWN committed path releases no-commit (clean — discard no-ops)...
543
+ expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith(
544
+ 'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
545
+ );
546
+ // ...but the merely-locked seed path releases UNTOUCHED — never the
547
+ // enqueue, never the discard.
548
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith(
549
+ 'ws-feat', 'feat', 'KnowledgeBase/Mine/access.md', USER,
550
+ );
551
+ expect(workflow.releaseLock).not.toHaveBeenCalled();
552
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
553
+ 'ws-feat', 'feat', 'KnowledgeBase/Mine/access.md', USER,
554
+ );
555
+ });
556
+
557
+ it('locks acquired before an acquire CONTENTION release UNTOUCHED (nothing written yet)', async () => {
558
+ // First path acquires, second is contended: nothing has been written, so
559
+ // the first path may hold ONLY someone else's still-queued bytes — the
560
+ // unwind must neither discard them nor enqueue them under this user.
561
+ const workflow = makeWorkflow();
562
+ (workflow.acquireLock as ReturnType<typeof vi.fn>)
563
+ .mockResolvedValueOnce({ acquired: true, lock: { holderUserId: 'user-1', holderName: 'Alice' } })
564
+ .mockResolvedValue({ acquired: false, lock: { holderUserId: 'bob', holderName: 'Bob' } });
565
+ const fsLayer = new LockingFilesystem(
566
+ { basePath: root, contained: true },
567
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
568
+ );
569
+ await expect(
570
+ fsLayer.writeFiles(
571
+ [
572
+ { path: 'A.md', content: 'a' },
573
+ { path: 'B.md', content: 'b' },
574
+ ],
575
+ 'batch',
576
+ ),
577
+ ).rejects.toThrow(/locked by Bob/);
578
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', 'A.md', USER);
579
+ expect(workflow.releaseLock).not.toHaveBeenCalled();
580
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
581
+ }, 10_000);
582
+
583
+ it('a seed write that dies MID-WRITE restores the pre-image and releases UNTOUCHED', async () => {
584
+ // The partial-write hazard: super.writeFile throws after touching disk,
585
+ // leaving bytes that are neither the old content nor the new. The seed
586
+ // loop read the pre-image under this very lock, so it can put it back —
587
+ // after which the path is byte-identical to before the batch and must
588
+ // release untouched (a prior save's queued bytes, if any, survive).
589
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
590
+ const priorBytes = 'prior queued bytes\n';
591
+ const seedPath = 'KnowledgeBase/Mine/access.md';
592
+ await fs.writeFile(path.join(root, seedPath), priorBytes);
593
+ const workflow = makeWorkflow();
594
+ const creatorAccess = makeCreatorAccess();
595
+ creatorAccess.planForCreate.mockResolvedValue({
596
+ kind: 'seed-access-md',
597
+ wsRelPath: seedPath,
598
+ apply: (current: string) => current + 'read: Alice\n',
599
+ });
600
+ const fsLayer = new LockingFilesystem(
601
+ { basePath: root, contained: true },
602
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
603
+ );
604
+ // Fail the SEED write once (simulating a mid-write death that left
605
+ // partial bytes); every other write — including the restore — passes
606
+ // through to the real filesystem.
607
+ const original = LocalFilesystem.prototype.writeFile;
608
+ let failedOnce = false;
609
+ const spy = vi
610
+ .spyOn(LocalFilesystem.prototype, 'writeFile')
611
+ .mockImplementation(async function (this: unknown, p, c, o) {
612
+ if (p === seedPath && !failedOnce) {
613
+ failedOnce = true;
614
+ await original.call(this as LocalFilesystem, p, 'PARTIAL');
615
+ throw new Error('disk exploded mid-write');
616
+ }
617
+ return original.call(this as LocalFilesystem, p, c, o);
618
+ });
619
+ try {
620
+ await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
621
+ } finally {
622
+ spy.mockRestore();
623
+ }
624
+ // Pre-image restored — the partial bytes did not outlive the batch.
625
+ expect(await fs.readFile(path.join(root, seedPath), 'utf-8')).toBe(priorBytes);
626
+ // The failed seed stays OUT of the commit scope and releases untouched.
627
+ const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
628
+ expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
629
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
630
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
631
+ 'ws-feat', 'feat', seedPath, USER,
632
+ );
633
+ expect(workflow.releaseLock).not.toHaveBeenCalled();
634
+ });
635
+
636
+ it('a LANDED seed rolls back to its pre-image when the batch commit fails — grant bytes must not outlive the batch uncommitted', async () => {
637
+ // The gap this pins: the seed write SUCCEEDED (path in `touched`) but
638
+ // `commitChanges` then failed, so nothing of the batch was committed.
639
+ // Releasing the seed untouched would leave its uncommitted grant bytes
640
+ // on disk as if they were real; discarding to HEAD would destroy a prior
641
+ // save's queued bytes. The right restore is the pre-image read under the
642
+ // seed's own lock.
643
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
644
+ const priorBytes = 'prior queued bytes\n';
645
+ const seedPath = 'KnowledgeBase/Mine/access.md';
646
+ await fs.writeFile(path.join(root, seedPath), priorBytes);
647
+ const workflow = makeWorkflow();
648
+ (workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
649
+ new Error('commit exploded'),
650
+ );
651
+ const creatorAccess = makeCreatorAccess();
652
+ creatorAccess.planForCreate.mockResolvedValue({
653
+ kind: 'seed-access-md',
654
+ wsRelPath: seedPath,
655
+ apply: (current: string) => current + 'read: Alice\n',
656
+ });
657
+ const fsLayer = new LockingFilesystem(
658
+ { basePath: root, contained: true },
659
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
660
+ );
661
+ await expect(
662
+ fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
663
+ ).rejects.toThrow('commit exploded');
664
+ // The landed grant was rolled back to the pre-image, byte-identical…
665
+ expect(await fs.readFile(path.join(root, seedPath), 'utf-8')).toBe(priorBytes);
666
+ // …and the seed releases UNTOUCHED (the prior queued bytes are not this
667
+ // batch's to discard), while the caller's own write releases via discard.
668
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
669
+ expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith(
670
+ 'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
671
+ );
672
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
673
+ 'ws-feat', 'feat', seedPath, USER,
674
+ );
675
+ });
676
+
677
+ it('a seed whose write AND restore both fail releases with DISCARD — partial bytes must never land', async () => {
678
+ // The double-failure residual: the seed write died mid-write and even the
679
+ // pre-image restore failed, so the path holds known-partial bytes. The
680
+ // release must reset it to HEAD (releaseLockNoCommit) — never enqueue the
681
+ // corrupt bytes as a commit, never leave them for the next acquirer.
682
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
683
+ const seedPath = 'KnowledgeBase/Mine/access.md';
684
+ await fs.writeFile(path.join(root, seedPath), 'prior\n');
685
+ const workflow = makeWorkflow();
686
+ const creatorAccess = makeCreatorAccess();
687
+ creatorAccess.planForCreate.mockResolvedValue({
688
+ kind: 'seed-access-md',
689
+ wsRelPath: seedPath,
690
+ apply: (current: string) => current + 'read: Alice\n',
691
+ });
692
+ const fsLayer = new LockingFilesystem(
693
+ { basePath: root, contained: true },
694
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
695
+ );
696
+ const original = LocalFilesystem.prototype.writeFile;
697
+ const spy = vi
698
+ .spyOn(LocalFilesystem.prototype, 'writeFile')
699
+ .mockImplementation(async function (this: unknown, p, c, o) {
700
+ if (p === seedPath) throw new Error('disk exploded'); // seed write AND restore
701
+ return original.call(this as LocalFilesystem, p, c, o);
702
+ });
703
+ try {
704
+ await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
705
+ } finally {
706
+ spy.mockRestore();
707
+ }
708
+ // The unrecoverable seed path releases via the discard, everything else
709
+ // untouched-or-no-commit as usual; the batch itself still succeeds.
710
+ expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
711
+ expect(workflow.releaseLockUntouched).not.toHaveBeenCalledWith(
712
+ 'ws-feat', 'feat', seedPath, USER,
713
+ );
714
+ expect(workflow.releaseLock).not.toHaveBeenCalled();
715
+ const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
716
+ expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
717
+ });
718
+
719
+ it('push-retry unwind: committed paths re-arm via releaseLock, a merely-locked seed stays untouched', async () => {
720
+ // PushNeedsAgentResolutionError with a no-op seed in the batch: the
721
+ // committed caller path must release commit-on-release (that enqueued row
722
+ // IS the push-retry vehicle), while the seed that never wrote must NOT be
723
+ // swept into the same enqueue — its path may carry a prior save's queued
724
+ // row that a fresh enqueue would re-attribute and reset.
725
+ await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
726
+ const seedPath = 'KnowledgeBase/Mine/access.md';
727
+ await fs.writeFile(path.join(root, seedPath), 'grant already present\n');
728
+ const workflow = makeWorkflow();
729
+ (workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
730
+ new PushNeedsAgentResolutionError('feat', '(batch)', 'non-fast-forward', 'rebase failed'),
731
+ );
732
+ const creatorAccess = makeCreatorAccess();
733
+ creatorAccess.planForCreate.mockResolvedValue({
734
+ kind: 'seed-access-md',
735
+ wsRelPath: seedPath,
736
+ apply: (current: string) => current, // no-op — merely locked
737
+ });
738
+ const fsLayer = new LockingFilesystem(
739
+ { basePath: root, contained: true },
740
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
741
+ );
742
+ await expect(
743
+ fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
744
+ ).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
745
+ expect(workflow.releaseLock).toHaveBeenCalledWith(
746
+ 'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
747
+ );
748
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
749
+ expect(workflow.releaseLock).not.toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
750
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
751
+ });
752
+
753
+ it('a LANDED seed rides the commit scope with the caller paths', async () => {
754
+ const workflow = makeWorkflow();
755
+ const creatorAccess = makeCreatorAccess();
756
+ creatorAccess.planForCreate.mockResolvedValue({
757
+ kind: 'seed-access-md',
758
+ wsRelPath: 'KnowledgeBase/Mine/access.md',
759
+ apply: () => '---\nread:\n - Alice <alice@example.com>\n---\n',
760
+ });
761
+ const fsLayer = new LockingFilesystem(
762
+ { basePath: root, contained: true },
763
+ { workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
764
+ );
765
+ await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
766
+ const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
767
+ expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md', 'KnowledgeBase/Mine/access.md']);
768
+ });
769
+
434
770
  it('a planner failure never blocks the write', async () => {
435
771
  const workflow = makeWorkflow();
436
772
  const creatorAccess = makeCreatorAccess();
@@ -37,7 +37,7 @@ function noopAccessControl(): IAccessControl {
37
37
  canDownload: vi.fn(), canOwner: vi.fn(), eligibleOwners: vi.fn(), eligibleDownloaders: vi.fn(),
38
38
  eligibleWriters: vi.fn(), eligibleWriterEmails: vi.fn(), eligibleOwnerEmails: vi.fn(),
39
39
  grantSources: vi.fn(), invalidate: vi.fn(), findEmailByHash: vi.fn(), kbPrincipals: vi.fn(),
40
- validateRolesYaml: vi.fn(), referencesToRole: vi.fn(), canWriteAtRef: vi.fn(),
40
+ validateRolesYaml: vi.fn(), canWriteAtRef: vi.fn(),
41
41
  canWriteBatchAtRef: vi.fn(), eligibleWritersAtRef: vi.fn(), eligibleWritersForPathsAtRef: vi.fn(),
42
42
  } as unknown as IAccessControl;
43
43
  }
@@ -175,6 +175,38 @@ describe('WorkflowService.commitFileWhileLocked', () => {
175
175
  expect(emitSpy).not.toHaveBeenCalled();
176
176
  });
177
177
 
178
+ it('REFUSES a coordination hold — it is not write possession', async () => {
179
+ // A coordination hold skipped the write-authorization gate on acquire
180
+ // (pure mutex, see acquireLock). Letting the same holder checkpoint
181
+ // bytes through it would publish to a path — machine-owned files
182
+ // included — whose write rule they never passed.
183
+ const git = makeGit();
184
+ const locks = {
185
+ ...makeFileLocks(USER.id),
186
+ get: vi.fn().mockResolvedValue({
187
+ branch: 'feat/x',
188
+ path: 'foo.md',
189
+ holderUserId: USER.id,
190
+ holderName: 'Alice',
191
+ mode: 'coordination',
192
+ acquiredAt: '',
193
+ lastHeartbeatAt: '',
194
+ expiresAt: '',
195
+ }),
196
+ } as unknown as FileLockService;
197
+ const svc = makeFacade(git, locks, events);
198
+
199
+ const err = await svc
200
+ .commitFileWhileLocked('ws-1', 'feat/x', 'foo.md', USER)
201
+ .then(() => null)
202
+ .catch((e: unknown) => e);
203
+ expect(err).toBeInstanceOf(WorkflowValidationError);
204
+ expect((err as WorkflowValidationError).payload?.kind).toBe('coordination-hold');
205
+ expect(git.commitFile).not.toHaveBeenCalled();
206
+ expect(git.push).not.toHaveBeenCalled();
207
+ expect(emitSpy).not.toHaveBeenCalled();
208
+ });
209
+
178
210
  it('still emits file-changed when push fails with a non-recoverable error (commit landed locally)', async () => {
179
211
  // Autosave is best-effort on push. If we can't ship the bytes upstream
180
212
  // (e.g. transient auth failure), we still emit `file-changed` so the
@@ -95,7 +95,6 @@ function makeAccessControl(): IAccessControl {
95
95
  findEmailByHash: vi.fn().mockResolvedValue(null),
96
96
  kbPrincipals: vi.fn().mockResolvedValue({ plugins: [], people: [] }),
97
97
  validateRolesYaml: vi.fn().mockReturnValue({ ok: true }),
98
- referencesToRole: vi.fn().mockResolvedValue([]),
99
98
  canWriteAtRef: vi.fn().mockResolvedValue(null),
100
99
  canWriteBatchAtRef: vi.fn().mockResolvedValue(null),
101
100
  eligibleWritersAtRef: vi.fn().mockResolvedValue(null),
@@ -387,7 +386,19 @@ describe('WorkflowService — file lock delegation', () => {
387
386
  const fileLocks = makeFileLockService();
388
387
  const svc = new WorkflowService(makeDb(), makeGit(), makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), fileLocks, makePendingCommits(), 'knowledge-base');
389
388
  await svc.acquireLock('w1', 'b', 'p', makeUser());
390
- expect(fileLocks.acquire).toHaveBeenCalledWith('w1', 'b', 'p', expect.objectContaining({ email: 'alice@example.com' }));
389
+ expect(fileLocks.acquire).toHaveBeenCalledWith('w1', 'b', 'p', expect.objectContaining({ email: 'alice@example.com' }), undefined);
390
+ });
391
+
392
+ it('acquireLock forwards the coordination flag so the lock row persists its mode', async () => {
393
+ // The mode must reach the store: an in-memory-only distinction would let
394
+ // a coordination hold masquerade as an edit lock on the very next read
395
+ // (which is how the write paths decide what the holder may do).
396
+ const fileLocks = makeFileLockService();
397
+ const svc = new WorkflowService(makeDb(), makeGit(), makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), fileLocks, makePendingCommits(), 'knowledge-base');
398
+ await svc.acquireLock('w1', 'b', 'p', makeUser(), { coordination: true });
399
+ expect(fileLocks.acquire).toHaveBeenCalledWith(
400
+ 'w1', 'b', 'p', expect.objectContaining({ email: 'alice@example.com' }), { coordination: true },
401
+ );
391
402
  });
392
403
 
393
404
  it('getLock delegates to FileLockService.get', async () => {
@@ -47,7 +47,10 @@ const CHANGE: Change = {
47
47
  committedAt: '2026-04-20T00:00:00.000Z',
48
48
  };
49
49
 
50
- function makeFileLocks(holderUserId: string): FileLockService {
50
+ function makeFileLocks(
51
+ holderUserId: string,
52
+ mode: 'edit' | 'coordination' = 'edit',
53
+ ): FileLockService {
51
54
  return {
52
55
  acquire: vi.fn(),
53
56
  heartbeat: vi.fn(),
@@ -57,6 +60,7 @@ function makeFileLocks(holderUserId: string): FileLockService {
57
60
  path: 'foo.md',
58
61
  holderUserId,
59
62
  holderName: 'Alice',
63
+ mode,
60
64
  acquiredAt: '',
61
65
  lastHeartbeatAt: '',
62
66
  expiresAt: '',
@@ -75,6 +79,7 @@ function makePending(): PendingCommitsService {
75
79
  markNeedsAttention: vi.fn().mockResolvedValue(undefined),
76
80
  listNeedsAttention: vi.fn().mockResolvedValue([]),
77
81
  countPending: vi.fn().mockResolvedValue(0),
82
+ hasLiveRowFor: vi.fn().mockResolvedValue(false),
78
83
  startupReconcile: vi.fn().mockResolvedValue(undefined),
79
84
  } as unknown as PendingCommitsService;
80
85
  }
@@ -93,6 +98,7 @@ function makeGit(overrides: Partial<{
93
98
  let pushCalls = 0;
94
99
  return {
95
100
  commitFile: vi.fn().mockResolvedValue(commitFileResult),
101
+ discardPath: vi.fn().mockResolvedValue(undefined),
96
102
  hasUnpushedCommits: vi.fn().mockResolvedValue(overrides.hasUnpushedCommits ?? false),
97
103
  push: vi.fn().mockImplementation(async () => {
98
104
  pushCalls++;
@@ -241,6 +247,138 @@ describe('WorkflowService.releaseLock — new (enqueue, no synchronous commit)',
241
247
  // is still held, and we'd lie to any SSE subscriber by emitting.
242
248
  expect(emitSpy).not.toHaveBeenCalled();
243
249
  });
250
+
251
+ it('REFUSES to release a coordination hold — never enqueues, never drops the row', async () => {
252
+ // A coordination hold was acquired PAST the write-authorization gate on
253
+ // the promise that nothing gets written under it. If releaseLock treated
254
+ // it like an edit lock, the enqueued release commit would publish
255
+ // whatever bytes sit on disk — on machine-owned paths included — under
256
+ // this caller's name. The sanctioned release is releaseLockNoCommit.
257
+ const git = makeGit();
258
+ const locks = makeFileLocks(USER.id, 'coordination');
259
+ const pending = makePending();
260
+ const svc = makeFacade(git, locks, pending, events);
261
+
262
+ const err = await svc
263
+ .releaseLock('ws-1', 'feat/x', 'foo.md', USER)
264
+ .then(() => null)
265
+ .catch((e: unknown) => e);
266
+ expect(err).toBeInstanceOf(WorkflowValidationError);
267
+ expect((err as WorkflowValidationError).payload?.kind).toBe('coordination-hold');
268
+ expect(pending.enqueue).not.toHaveBeenCalled();
269
+ expect(locks.release).not.toHaveBeenCalled();
270
+ expect(emitSpy).not.toHaveBeenCalled();
271
+ });
272
+ });
273
+
274
+ describe('WorkflowService.releaseLockUntouched — drop the row, leave disk AND queue alone', () => {
275
+ let events: WorkflowEventBus;
276
+ let emitSpy: MockInstance;
277
+
278
+ beforeEach(() => {
279
+ events = new WorkflowEventBus();
280
+ emitSpy = vi.spyOn(events, 'emit');
281
+ });
282
+
283
+ it('releases the lock without enqueueing or discarding, emits lock-released only', async () => {
284
+ const git = makeGit();
285
+ const locks = makeFileLocks(USER.id);
286
+ const pending = makePending();
287
+ const svc = makeFacade(git, locks, pending, events);
288
+
289
+ await svc.releaseLockUntouched('ws-1', 'feat/x', 'foo.md', USER);
290
+
291
+ expect(locks.release).toHaveBeenCalledTimes(1);
292
+ // The whole point: a prior save's dirty bytes on this path must survive
293
+ // (no discard) without being re-attributed to this caller (no enqueue).
294
+ expect(pending.enqueue).not.toHaveBeenCalled();
295
+ expect(git.discardPath).not.toHaveBeenCalled();
296
+ expect(git.commitFile).not.toHaveBeenCalled();
297
+ expect(git.push).not.toHaveBeenCalled();
298
+ expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual(['lock-released']);
299
+ });
300
+
301
+ it('no-ops (idempotent) when the caller does not hold the lock', async () => {
302
+ const git = makeGit();
303
+ const locks = makeFileLocks('someone-else');
304
+ const pending = makePending();
305
+ const svc = makeFacade(git, locks, pending, events);
306
+
307
+ await expect(
308
+ svc.releaseLockUntouched('ws-1', 'feat/x', 'foo.md', USER),
309
+ ).resolves.toBeUndefined();
310
+ expect(locks.release).not.toHaveBeenCalled();
311
+ expect(emitSpy).not.toHaveBeenCalled();
312
+ });
313
+ });
314
+
315
+ describe('WorkflowService.releaseLockNoCommit — coordination-hold discard guard', () => {
316
+ let events: WorkflowEventBus;
317
+
318
+ beforeEach(() => {
319
+ events = new WorkflowEventBus();
320
+ });
321
+
322
+ it('discards as usual on an EDIT hold, without consulting the queue', async () => {
323
+ const git = makeGit();
324
+ const pending = makePending();
325
+ const svc = makeFacade(git, makeFileLocks(USER.id), pending, events);
326
+
327
+ await svc.releaseLockNoCommit('ws-1', 'feat/x', 'foo.md', USER);
328
+
329
+ expect(git.discardPath).toHaveBeenCalledWith('ws-1', 'foo.md');
330
+ expect(pending.hasLiveRowFor).not.toHaveBeenCalled();
331
+ });
332
+
333
+ it('discards a coordination hold when the queue has NO live row (wipes smuggled bytes)', async () => {
334
+ const git = makeGit();
335
+ const pending = makePending();
336
+ const emitSpy = vi.spyOn(events, 'emit');
337
+ const svc = makeFacade(git, makeFileLocks(USER.id, 'coordination'), pending, events);
338
+
339
+ await svc.releaseLockNoCommit('ws-1', 'feat/x', 'foo.md', USER);
340
+
341
+ expect(pending.hasLiveRowFor).toHaveBeenCalledWith('ws-1', 'feat/x', 'foo.md');
342
+ expect(git.discardPath).toHaveBeenCalledWith('ws-1', 'foo.md');
343
+ // The disk really changed here, so watchers are told.
344
+ expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
345
+ 'lock-released',
346
+ 'file-changed',
347
+ ]);
348
+ });
349
+
350
+ it('SKIPS the discard on a coordination hold when a live queued row exists (a prior save owns the bytes)', async () => {
351
+ // The coordination holder never legitimately wrote the path, so dirty
352
+ // bytes covered by a live pending-commit row belong to an EARLIER save
353
+ // whose commit hasn't drained yet — discarding to HEAD would silently
354
+ // destroy that landed edit.
355
+ const git = makeGit();
356
+ const pending = makePending();
357
+ (pending.hasLiveRowFor as ReturnType<typeof vi.fn>).mockResolvedValue(true);
358
+ const emitSpy = vi.spyOn(events, 'emit');
359
+ const svc = makeFacade(git, makeFileLocks(USER.id, 'coordination'), pending, events);
360
+
361
+ await svc.releaseLockNoCommit('ws-1', 'feat/x', 'foo.md', USER);
362
+
363
+ expect(git.discardPath).not.toHaveBeenCalled();
364
+ // The lock still releases — only the discard is skipped. And since the
365
+ // disk did NOT change, no file-changed goes out: announcing one would
366
+ // false-refresh every watcher of a file that is exactly as it was.
367
+ expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
368
+ 'lock-released',
369
+ ]);
370
+ });
371
+ });
372
+
373
+ describe('WorkflowService.hasQueuedCommit', () => {
374
+ it('delegates to the pending-commits queue per (workspace, branch, path)', async () => {
375
+ const pending = makePending();
376
+ (pending.hasLiveRowFor as ReturnType<typeof vi.fn>).mockResolvedValue(true);
377
+ const svc = makeFacade(makeGit(), makeFileLocks(USER.id), pending, new WorkflowEventBus());
378
+
379
+ await expect(svc.hasQueuedCommit('ws-1', 'feat/x', 'foo.md')).resolves.toBe(true);
380
+ expect(pending.hasLiveRowFor).toHaveBeenCalledWith('ws-1', 'feat/x', 'foo.md');
381
+ });
244
382
  });
245
383
 
246
384
  describe('WorkflowService.runPendingCommit — worker entry point', () => {
@@ -38,6 +38,9 @@ function rowToFileLock(row: typeof fileLocks.$inferSelect): FileLock {
38
38
  path: row.path,
39
39
  holderUserId: row.holderUserId,
40
40
  holderName: row.holderName,
41
+ // The column carries a NOT NULL 'edit' default, so rows predating the
42
+ // mode column read back as plain edit locks — the strictest reading.
43
+ mode: row.mode === 'coordination' ? 'coordination' : 'edit',
41
44
  acquiredAt: row.acquiredAt.toISOString(),
42
45
  lastHeartbeatAt: row.lastHeartbeatAt.toISOString(),
43
46
  expiresAt: row.expiresAt.toISOString(),
@@ -58,12 +61,21 @@ export class FileLockService {
58
61
  * "Locked by X" without a follow-up call.
59
62
  * - Existing row, live, same user: refresh TTL + `{ acquired: true }`
60
63
  * so re-acquiring your own lock is idempotent.
64
+ *
65
+ * `opts.coordination` stamps the row's `mode` as `'coordination'` — a
66
+ * pure-mutex hold that grants no write authority (see
67
+ * `IWorkflowService.acquireLock`). The mode is persisted so the workflow
68
+ * service's write paths can refuse to treat the hold as write possession
69
+ * for the row's whole lifetime; a takeover of an expired row re-stamps the
70
+ * mode, so a stale coordination row can't launder a later edit acquire
71
+ * (or vice versa).
61
72
  */
62
73
  async acquire(
63
74
  workspaceId: string,
64
75
  branch: string,
65
76
  targetPath: string,
66
77
  user: AuthUser,
78
+ opts?: { coordination?: boolean },
67
79
  ): Promise<AcquireLockResult> {
68
80
  const now = new Date();
69
81
  const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);
@@ -96,6 +108,7 @@ export class FileLockService {
96
108
  // `heartbeat()` (which has its own same-user UPDATE), and the
97
109
  // editor's save flow detects "I already hold it" via `getLock` in
98
110
  // `workspace.routes.withLock` rather than re-acquiring.
111
+ const mode = opts?.coordination ? 'coordination' : 'edit';
99
112
  const upsertResult = await this.db
100
113
  .insert(fileLocks)
101
114
  .values({
@@ -104,6 +117,7 @@ export class FileLockService {
104
117
  path: targetPath,
105
118
  holderUserId: user.id,
106
119
  holderName: user.name,
120
+ mode,
107
121
  acquiredAt: now,
108
122
  lastHeartbeatAt: now,
109
123
  expiresAt,
@@ -113,6 +127,7 @@ export class FileLockService {
113
127
  set: {
114
128
  holderUserId: user.id,
115
129
  holderName: user.name,
130
+ mode,
116
131
  acquiredAt: now,
117
132
  lastHeartbeatAt: now,
118
133
  expiresAt,