@bevel-software/platform-core-backend 0.10.0 → 0.11.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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +165 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +174 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- 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(),
|
|
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(
|
|
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,
|