@bevel-software/platform-core-backend 0.12.0 → 0.13.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.
- package/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -452,7 +452,12 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
452
452
|
await expect(svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER)).rejects.toBeInstanceOf(
|
|
453
453
|
PushNeedsAgentResolutionError,
|
|
454
454
|
);
|
|
455
|
-
|
|
455
|
+
// `file-changed` still does NOT go out — other clients would refetch a
|
|
456
|
+
// SHA that isn't on origin yet. What DOES go out is the sync banner: the
|
|
457
|
+
// commit is sitting locally, and someone has to be able to see that.
|
|
458
|
+
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
459
|
+
'git-sync-failed',
|
|
460
|
+
]);
|
|
456
461
|
});
|
|
457
462
|
|
|
458
463
|
it('recovers from non-fast-forward push via pull --rebase + retry, emits file-changed exactly once', async () => {
|
|
@@ -481,10 +486,12 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
481
486
|
);
|
|
482
487
|
expect(git.pull).not.toHaveBeenCalled();
|
|
483
488
|
expect(git.push).toHaveBeenCalledTimes(1);
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
expect(emitSpy).
|
|
489
|
+
// `file-changed` still does NOT go out — other clients would refetch a
|
|
490
|
+
// SHA that isn't on origin yet. What DOES go out is the sync banner: the
|
|
491
|
+
// commit is sitting locally, and someone has to be able to see that.
|
|
492
|
+
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
493
|
+
'git-sync-failed',
|
|
494
|
+
]);
|
|
488
495
|
});
|
|
489
496
|
|
|
490
497
|
it('throws PushNeedsAgentResolutionError when pull --rebase itself fails (textbook conflict case)', async () => {
|
|
@@ -500,7 +507,12 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
500
507
|
expect((err as PushNeedsAgentResolutionError).path).toBe('foo.md');
|
|
501
508
|
expect(git.pull).toHaveBeenCalledTimes(1);
|
|
502
509
|
expect(git.push).toHaveBeenCalledTimes(1);
|
|
503
|
-
|
|
510
|
+
// `file-changed` still does NOT go out — other clients would refetch a
|
|
511
|
+
// SHA that isn't on origin yet. What DOES go out is the sync banner: the
|
|
512
|
+
// commit is sitting locally, and someone has to be able to see that.
|
|
513
|
+
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
514
|
+
'git-sync-failed',
|
|
515
|
+
]);
|
|
504
516
|
});
|
|
505
517
|
|
|
506
518
|
it('throws PushNeedsAgentResolutionError when post-pull retry also fails (origin moved twice)', async () => {
|
|
@@ -512,7 +524,160 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
512
524
|
);
|
|
513
525
|
expect(git.pull).toHaveBeenCalledTimes(1);
|
|
514
526
|
expect(git.push).toHaveBeenCalledTimes(2);
|
|
515
|
-
|
|
527
|
+
// `file-changed` still does NOT go out — other clients would refetch a
|
|
528
|
+
// SHA that isn't on origin yet. What DOES go out is the sync banner: the
|
|
529
|
+
// commit is sitting locally, and someone has to be able to see that.
|
|
530
|
+
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
531
|
+
'git-sync-failed',
|
|
532
|
+
]);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe('WorkflowService — git-sync visibility events', () => {
|
|
537
|
+
let events: WorkflowEventBus;
|
|
538
|
+
let emitSpy: MockInstance;
|
|
539
|
+
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
events = new WorkflowEventBus();
|
|
542
|
+
emitSpy = vi.spyOn(events, 'emit');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
function kinds(): string[] {
|
|
546
|
+
return emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
it('stays silent while pushes succeed — the healthy path must not broadcast', async () => {
|
|
550
|
+
// A push runs on every save. If the healthy case announced itself, every
|
|
551
|
+
// keystroke-driven autosave would fan an event out to every session on
|
|
552
|
+
// the branch to say that nothing happened.
|
|
553
|
+
const svc = makeFacade(makeGit(), makeFileLocks(USER.id), makePending(), events);
|
|
554
|
+
|
|
555
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
556
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
557
|
+
|
|
558
|
+
expect(kinds()).toEqual(['file-changed', 'file-changed']);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('carries the branch and a sanitised reason for the banner', async () => {
|
|
562
|
+
const svc = makeFacade(
|
|
563
|
+
makeGit({ pushBehavior: 'auth-fail' }),
|
|
564
|
+
makeFileLocks(USER.id),
|
|
565
|
+
makePending(),
|
|
566
|
+
events,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
await expect(svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER)).rejects.toBeInstanceOf(
|
|
570
|
+
PushNeedsAgentResolutionError,
|
|
571
|
+
);
|
|
572
|
+
expect(emitSpy.mock.calls[0][0]).toMatchObject({
|
|
573
|
+
kind: 'git-sync-failed',
|
|
574
|
+
workspaceId: 'ws-1',
|
|
575
|
+
branch: 'feat/x',
|
|
576
|
+
});
|
|
577
|
+
expect((emitSpy.mock.calls[0][0] as { reason: string }).reason).toContain(
|
|
578
|
+
'Authentication failed',
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('re-announces on every failure, so a session joining mid-outage still learns of it', async () => {
|
|
583
|
+
// The banner has no other way in: a client that connects after the first
|
|
584
|
+
// failure missed the only event that would have told it.
|
|
585
|
+
// `pushAfterPullBehavior` is what the shared mock serves from the second
|
|
586
|
+
// push onward, so failing it too is how one broken remote is simulated
|
|
587
|
+
// across repeated saves rather than just the first.
|
|
588
|
+
const svc = makeFacade(
|
|
589
|
+
makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' }),
|
|
590
|
+
makeFileLocks(USER.id),
|
|
591
|
+
makePending(),
|
|
592
|
+
events,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
for (let i = 0; i < 2; i += 1) {
|
|
596
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
expect(kinds()).toEqual(['git-sync-failed', 'git-sync-failed']);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('emits git-sync-recovered once when a push finally lands, then goes quiet again', async () => {
|
|
603
|
+
const failing = makeGit({ pushBehavior: 'auth-fail' });
|
|
604
|
+
const svc = makeFacade(failing, makeFileLocks(USER.id), makePending(), events);
|
|
605
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
|
|
606
|
+
expect(kinds()).toEqual(['git-sync-failed']);
|
|
607
|
+
|
|
608
|
+
// Same service instance — the failing/healthy state is per-workspace and
|
|
609
|
+
// lives on it, so recovery has to be observed through the same object.
|
|
610
|
+
const healthy = makeGit();
|
|
611
|
+
const recovered = makeFacade(healthy, makeFileLocks(USER.id), makePending(), events);
|
|
612
|
+
Object.assign(svc as unknown as Record<string, unknown>, {
|
|
613
|
+
git: (recovered as unknown as { git: GitService }).git,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
617
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
618
|
+
|
|
619
|
+
// Recovery announced exactly once; the second healthy push says nothing.
|
|
620
|
+
expect(kinds()).toEqual([
|
|
621
|
+
'git-sync-failed',
|
|
622
|
+
'git-sync-recovered',
|
|
623
|
+
'file-changed',
|
|
624
|
+
'file-changed',
|
|
625
|
+
]);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('clears a failure recorded under the encoded id when the decoded id recovers', async () => {
|
|
629
|
+
// HTTP routes hand the service Express-decoded ids (user/feat); internal
|
|
630
|
+
// callers hand the encoded form (user%2Ffeat). The tracker must treat
|
|
631
|
+
// them as one workspace, or a failure recorded by one caller can never
|
|
632
|
+
// be cleared by the other's success and the banner sticks forever.
|
|
633
|
+
const svc = makeFacade(
|
|
634
|
+
makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' }),
|
|
635
|
+
makeFileLocks(USER.id),
|
|
636
|
+
makePending(),
|
|
637
|
+
events,
|
|
638
|
+
);
|
|
639
|
+
await svc.runPendingCommit('user%2Ffeat', 'user/feat', 'foo.md', USER).catch(() => undefined);
|
|
640
|
+
|
|
641
|
+
Object.assign(svc as unknown as Record<string, unknown>, { git: makeGit() });
|
|
642
|
+
await svc.runPendingCommit('user/feat', 'user/feat', 'foo.md', USER);
|
|
643
|
+
|
|
644
|
+
const sync = emitSpy.mock.calls
|
|
645
|
+
.map((c) => c[0] as { kind: string; workspaceId: string })
|
|
646
|
+
.filter((e) => e.kind.startsWith('git-sync'));
|
|
647
|
+
// Both events carry the canonical (decoded) id, and the recovery fired.
|
|
648
|
+
expect(sync).toEqual([
|
|
649
|
+
expect.objectContaining({ kind: 'git-sync-failed', workspaceId: 'user/feat' }),
|
|
650
|
+
expect.objectContaining({ kind: 'git-sync-recovered', workspaceId: 'user/feat' }),
|
|
651
|
+
]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('tracks workspaces independently — one recovering neither clears nor suppresses another', async () => {
|
|
655
|
+
const failingGit = () => makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' });
|
|
656
|
+
const svc = makeFacade(failingGit(), makeFileLocks(USER.id), makePending(), events);
|
|
657
|
+
|
|
658
|
+
// Both workspaces broken.
|
|
659
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
|
|
660
|
+
await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
|
|
661
|
+
|
|
662
|
+
// ws-1 recovers; ws-2 is still broken and must (a) not be cleared by
|
|
663
|
+
// ws-1's recovery and (b) still announce its own next failure.
|
|
664
|
+
Object.assign(svc as unknown as Record<string, unknown>, { git: makeGit() });
|
|
665
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
666
|
+
Object.assign(svc as unknown as Record<string, unknown>, { git: failingGit() });
|
|
667
|
+
await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
|
|
668
|
+
|
|
669
|
+
expect(
|
|
670
|
+
emitSpy.mock.calls.map((c) => [
|
|
671
|
+
(c[0] as { kind: string }).kind,
|
|
672
|
+
(c[0] as { workspaceId: string }).workspaceId,
|
|
673
|
+
]),
|
|
674
|
+
).toEqual([
|
|
675
|
+
['git-sync-failed', 'ws-1'],
|
|
676
|
+
['git-sync-failed', 'ws-2'],
|
|
677
|
+
['git-sync-recovered', 'ws-1'],
|
|
678
|
+
['file-changed', 'ws-1'],
|
|
679
|
+
['git-sync-failed', 'ws-2'],
|
|
680
|
+
]);
|
|
516
681
|
});
|
|
517
682
|
});
|
|
518
683
|
|
|
@@ -42,6 +42,13 @@ const workflowService = {
|
|
|
42
42
|
calls.push(['createBranch', ws, name]);
|
|
43
43
|
return { name, isProtected: false, ahead: 0, behind: 0, hasRemote: true };
|
|
44
44
|
},
|
|
45
|
+
acquireLock: async (ws: string, branch: string, path: string) => {
|
|
46
|
+
calls.push(['acquireLock', ws, branch, path]);
|
|
47
|
+
return { acquired: true, lock: { branch, path, holderUserId: 'user-A', holderName: 'N' } };
|
|
48
|
+
},
|
|
49
|
+
releaseLock: async (ws: string, branch: string, path: string) => {
|
|
50
|
+
calls.push(['releaseLock', ws, branch, path]);
|
|
51
|
+
},
|
|
45
52
|
} as never;
|
|
46
53
|
const events = {
|
|
47
54
|
emit: (p: unknown) => {
|
|
@@ -52,15 +59,18 @@ const events = {
|
|
|
52
59
|
|
|
53
60
|
const internalToken = new InternalTokenService({ secret: 's' });
|
|
54
61
|
let httpServer: HttpServer | undefined;
|
|
62
|
+
/** The registry the tools were mounted into, so a test can inspect their definitions. */
|
|
63
|
+
let registryRef: ToolRegistry | undefined;
|
|
55
64
|
|
|
56
65
|
async function start(): Promise<string> {
|
|
57
66
|
const registry = new ToolRegistry();
|
|
67
|
+
registryRef = registry;
|
|
58
68
|
const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
|
|
59
69
|
const resolve = createToolContextResolver({ authService, workspaceService, workflowService, events, kbDirName: 'knowledge-base', creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} } });
|
|
60
70
|
const toolHandler = createToolHandlerFactory(resolve);
|
|
61
71
|
|
|
62
72
|
const router = express.Router();
|
|
63
|
-
registerWorkflowTools(registry, router, toolAuth, toolHandler);
|
|
73
|
+
registerWorkflowTools(registry, router, toolAuth, toolHandler, 'knowledge-base');
|
|
64
74
|
router.use(createManualRoutes(registry, toolAuth));
|
|
65
75
|
|
|
66
76
|
const app = express();
|
|
@@ -190,3 +200,52 @@ describe('registerWorkflowTools', () => {
|
|
|
190
200
|
expect(external).not.toContain('switch_branch'); // internal-only
|
|
191
201
|
});
|
|
192
202
|
});
|
|
203
|
+
|
|
204
|
+
describe('save_file and the repository folder', () => {
|
|
205
|
+
// `save_file` commits whatever is on disk at `path` through the lock
|
|
206
|
+
// protocol, bypassing the locking filesystem's own guard. A path without the
|
|
207
|
+
// clone-folder prefix names a file git can never see, so it is refused here,
|
|
208
|
+
// before a lock is taken, with the same corrected-path message the write
|
|
209
|
+
// tools give.
|
|
210
|
+
it('refuses a repo-relative path before taking any lock', async () => {
|
|
211
|
+
const base = await start();
|
|
212
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), {
|
|
213
|
+
path: 'KnowledgeBase/Reviews/PR-12.html',
|
|
214
|
+
branch: WS,
|
|
215
|
+
});
|
|
216
|
+
expect(res.status).toBe(400);
|
|
217
|
+
const body = (await res.json()) as { error: string };
|
|
218
|
+
expect(body.error).toContain('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
219
|
+
expect(calls.some((c) => c[0] === 'acquireLock')).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('answers a missing path with a 400, not a crash', async () => {
|
|
223
|
+
const base = await start();
|
|
224
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), { branch: WS });
|
|
225
|
+
expect(res.status).toBe(400);
|
|
226
|
+
const body = (await res.json()) as { error: string };
|
|
227
|
+
expect(body.error).toMatch(/path/i);
|
|
228
|
+
expect(calls.some((c) => c[0] === 'acquireLock')).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('schedules a prefixed path as before', async () => {
|
|
232
|
+
const base = await start();
|
|
233
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), {
|
|
234
|
+
path: 'knowledge-base/KnowledgeBase/Reviews/PR-12.html',
|
|
235
|
+
branch: WS,
|
|
236
|
+
});
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(await res.json()).toMatchObject({ saved: true, queued: true });
|
|
239
|
+
expect(calls).toContainEqual(['acquireLock', WS, WS, 'knowledge-base/KnowledgeBase/Reviews/PR-12.html']);
|
|
240
|
+
expect(calls).toContainEqual(['releaseLock', WS, WS, 'knowledge-base/KnowledgeBase/Reviews/PR-12.html']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('describes the prefix on its path input', async () => {
|
|
244
|
+
await start();
|
|
245
|
+
const tools = await registryRef!.listInternal();
|
|
246
|
+
const def = tools.find((t) => t.name === 'save_file');
|
|
247
|
+
// `toolDef` wraps a tool's inputs under a single `body` property.
|
|
248
|
+
const body = (def!.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
|
|
249
|
+
expect(body.properties.path.description).toContain('`knowledge-base/`');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -6,6 +6,7 @@ import { toolDef, withBranchInput } from '../../tool-helpers/tool-def.js';
|
|
|
6
6
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
7
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
8
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
|
+
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
9
10
|
|
|
10
11
|
// A function, not a constant: the branch model is applied during boot, and a
|
|
11
12
|
// module-scope capture would freeze this at the empty set that exists before it.
|
|
@@ -118,6 +119,8 @@ export function registerWorkflowTools(
|
|
|
118
119
|
router: Router,
|
|
119
120
|
toolAuth: RequestHandler,
|
|
120
121
|
toolHandler: ToolHandlerFactory,
|
|
122
|
+
/** The clone folder at the workspace root; `save_file` refuses a path outside it. */
|
|
123
|
+
kbDirName: string,
|
|
121
124
|
): void {
|
|
122
125
|
const mount = (spec: {
|
|
123
126
|
name: string;
|
|
@@ -221,7 +224,13 @@ export function registerWorkflowTools(
|
|
|
221
224
|
'`{ saved: false, reason }` rather than throwing. The commit lands asynchronously.',
|
|
222
225
|
inputs: {
|
|
223
226
|
type: 'object',
|
|
224
|
-
properties: {
|
|
227
|
+
properties: {
|
|
228
|
+
path: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
minLength: 1,
|
|
231
|
+
description: `Workspace-relative path, as write_file expects: starts with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`).`,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
225
234
|
required: ['path'],
|
|
226
235
|
additionalProperties: false,
|
|
227
236
|
},
|
|
@@ -238,6 +247,14 @@ export function registerWorkflowTools(
|
|
|
238
247
|
handler: async (args, ctx: ToolContext) => {
|
|
239
248
|
const path = args.path as string;
|
|
240
249
|
const branch = args.branch as string;
|
|
250
|
+
// This tool commits whatever is on disk at `path` through the lock
|
|
251
|
+
// protocol, bypassing the locking filesystem's own guard. A path without
|
|
252
|
+
// the clone-folder prefix names a file git can never see: refuse it
|
|
253
|
+
// before a lock is taken, with the same corrected-path message.
|
|
254
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
255
|
+
throw new ToolError('`path` is required and must be a non-empty string.', 400);
|
|
256
|
+
}
|
|
257
|
+
assertInsideRepo(path, kbDirName);
|
|
241
258
|
const workspaceId = workspaceIdForBranch(branch);
|
|
242
259
|
const acquired = await ctx.workflowService.acquireLock(workspaceId, branch, path, ctx.user);
|
|
243
260
|
if (!acquired.acquired) {
|
|
@@ -130,6 +130,62 @@ describe('GitService.changedFilesForPr / resolvePrShas', () => {
|
|
|
130
130
|
expect(baseSha).not.toBe(headSha);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A change-request detail resolves the SHAs (which fetches both refs) and
|
|
135
|
+
* then lists the files. `at` pins that listing to the commits just
|
|
136
|
+
* resolved: no second fetch, and the file list describes exactly the head
|
|
137
|
+
* the approvals pin against even when a newer push has landed on origin in
|
|
138
|
+
* between. The default keeps refreshing first.
|
|
139
|
+
*/
|
|
140
|
+
it('`at` pins the diff to the resolved commits; the default refreshes origin/* first', async () => {
|
|
141
|
+
const { upstream, repo } = await seedWorkspace(root, workspaceId);
|
|
142
|
+
// The branch is authored in ANOTHER clone, so this workspace only ever
|
|
143
|
+
// knows it as origin/alice/feature — the production shape, where the
|
|
144
|
+
// base-branch workspace answers for every request without a local copy.
|
|
145
|
+
const other = path.join(root, 'other');
|
|
146
|
+
await runGit(root, ['clone', upstream, other]);
|
|
147
|
+
await runGit(other, ['checkout', '-b', 'alice/feature']);
|
|
148
|
+
await fs.writeFile(path.join(other, 'added.md'), 'hello\n');
|
|
149
|
+
await runGit(other, ['add', '-A']);
|
|
150
|
+
await runGit(other, ['commit', '-m', 'feature work']);
|
|
151
|
+
await runGit(other, ['push', '-u', 'origin', 'alice/feature']);
|
|
152
|
+
|
|
153
|
+
const git = new GitService(
|
|
154
|
+
stubWorkspaceService(workspaceId, repo),
|
|
155
|
+
new WorkflowHooks(),
|
|
156
|
+
'knowledge-base',
|
|
157
|
+
);
|
|
158
|
+
// What the detail does first: resolve (and fetch) the two SHAs.
|
|
159
|
+
const pinned = await git.resolvePrShas(workspaceId, 'current-company-state', 'alice/feature');
|
|
160
|
+
|
|
161
|
+
// A second push lands on origin after the resolution.
|
|
162
|
+
await fs.writeFile(path.join(other, 'second.md'), 'more\n');
|
|
163
|
+
await runGit(other, ['add', '-A']);
|
|
164
|
+
await runGit(other, ['commit', '-m', 'more work']);
|
|
165
|
+
await runGit(other, ['push', 'origin', 'alice/feature']);
|
|
166
|
+
|
|
167
|
+
const atPinned = await git.changedFilesForPr(
|
|
168
|
+
workspaceId,
|
|
169
|
+
'current-company-state',
|
|
170
|
+
'alice/feature',
|
|
171
|
+
{ at: pinned },
|
|
172
|
+
);
|
|
173
|
+
expect(atPinned.map((f) => f.path)).toEqual(['added.md']);
|
|
174
|
+
|
|
175
|
+
const refreshed = await git.changedFilesForPr(
|
|
176
|
+
workspaceId,
|
|
177
|
+
'current-company-state',
|
|
178
|
+
'alice/feature',
|
|
179
|
+
);
|
|
180
|
+
expect(refreshed.map((f) => f.path).sort()).toEqual(['added.md', 'second.md']);
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
git.changedFilesForPr(workspaceId, 'current-company-state', 'alice/feature', {
|
|
184
|
+
at: { baseSha: pinned.baseSha, headSha: 'origin/alice/feature' },
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow(/invalid commit sha/);
|
|
187
|
+
});
|
|
188
|
+
|
|
133
189
|
/**
|
|
134
190
|
* roles.yaml can never change through a merge — `preserveBaseRolesYaml`
|
|
135
191
|
* restores the base copy onto the source before every merge — so the review
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
8
|
+
import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
9
|
+
import { WorkflowHooks } from '../../workflow-hooks.js';
|
|
10
|
+
import { GitService } from '../git.service.js';
|
|
11
|
+
import { WorkflowValidationError } from '../../../../shared/domain-errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `commitFile` on a path whose bytes sit BESIDE the repository.
|
|
15
|
+
*
|
|
16
|
+
* The workspace dir holds the clone as `knowledge-base/`. A caller that
|
|
17
|
+
* writes a workspace-relative path without that prefix puts the file next to
|
|
18
|
+
* the clone, where `git status` inside the clone reports it clean. Before,
|
|
19
|
+
* `commitFile` read that as "nothing to commit" and returned null, so the
|
|
20
|
+
* write was reported as landed while git never saw it. Now it throws, and the
|
|
21
|
+
* pending-commits ladder escalates the row instead of dropping it.
|
|
22
|
+
*
|
|
23
|
+
* The three honest null cases must survive unchanged: a clean committed path,
|
|
24
|
+
* a path that exists nowhere (a queued re-apply of a committed deletion), and
|
|
25
|
+
* a repo-relative path some internal callers pass directly.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
const KB = 'knowledge-base';
|
|
30
|
+
|
|
31
|
+
const USER: AuthUser = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
|
|
32
|
+
|
|
33
|
+
async function mkTmpRoot(): Promise<string> {
|
|
34
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-stray-'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GIT_ENV = {
|
|
38
|
+
...process.env,
|
|
39
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
40
|
+
GIT_AUTHOR_EMAIL: 't@x.com',
|
|
41
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
42
|
+
GIT_COMMITTER_EMAIL: 't@x.com',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function runGit(cwd: string, args: string[]): Promise<string> {
|
|
46
|
+
const { stdout } = await execFileAsync('git', args, { cwd, env: GIT_ENV });
|
|
47
|
+
return stdout.toString().trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function seedWorkspace(root: string, workspaceId: string): Promise<{ workspaceDir: string; repo: string }> {
|
|
51
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
52
|
+
const repo = path.join(workspaceDir, KB);
|
|
53
|
+
await fs.mkdir(repo, { recursive: true });
|
|
54
|
+
await runGit(repo, ['init', '-b', 'main']);
|
|
55
|
+
await runGit(repo, ['config', 'user.email', 't@x.com']);
|
|
56
|
+
await runGit(repo, ['config', 'user.name', 'Test']);
|
|
57
|
+
await runGit(repo, ['config', 'gc.auto', '0']);
|
|
58
|
+
// One committed file so HEAD exists and the "clean path" cases have something to be clean about.
|
|
59
|
+
await fs.mkdir(path.join(repo, 'Knowledge'), { recursive: true });
|
|
60
|
+
await fs.writeFile(path.join(repo, 'Knowledge/A.md'), 'a\n');
|
|
61
|
+
await runGit(repo, ['add', 'Knowledge/A.md']);
|
|
62
|
+
await runGit(repo, ['commit', '-m', 'seed']);
|
|
63
|
+
return { workspaceDir, repo };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
|
|
67
|
+
return {
|
|
68
|
+
getWorkspacePath: async (id: string) => {
|
|
69
|
+
if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
|
|
70
|
+
return workspaceDir;
|
|
71
|
+
},
|
|
72
|
+
} as unknown as WorkspaceService;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('GitService.commitFile with bytes beside the repository', () => {
|
|
76
|
+
let root: string;
|
|
77
|
+
const workspaceId = 'ws-stray';
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
root = await mkTmpRoot();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws instead of returning null when the file exists at the workspace root and not in the clone', async () => {
|
|
88
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
89
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
90
|
+
// The prefix-less write the agent made: beside the clone, not inside it.
|
|
91
|
+
await fs.mkdir(path.join(workspaceDir, 'KnowledgeBase/Reviews'), { recursive: true });
|
|
92
|
+
await fs.writeFile(path.join(workspaceDir, 'KnowledgeBase/Reviews/PR-12.html'), '<p>review</p>');
|
|
93
|
+
const headBefore = await runGit(repo, ['rev-parse', 'HEAD']);
|
|
94
|
+
|
|
95
|
+
const attempt = svc.commitFile(workspaceId, USER, 'KnowledgeBase/Reviews/PR-12.html');
|
|
96
|
+
await expect(attempt).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
97
|
+
await expect(attempt).rejects.toThrow('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
98
|
+
|
|
99
|
+
// Nothing landed, and the stray is left where it is for someone to recover.
|
|
100
|
+
expect(await runGit(repo, ['rev-parse', 'HEAD'])).toBe(headBefore);
|
|
101
|
+
expect(await fs.readFile(path.join(workspaceDir, 'KnowledgeBase/Reviews/PR-12.html'), 'utf-8')).toBe('<p>review</p>');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still returns null for a clean, committed path (the honest no-op)', async () => {
|
|
105
|
+
const { workspaceDir } = await seedWorkspace(root, workspaceId);
|
|
106
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
107
|
+
expect(await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/A.md`)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('still returns null for a path that exists nowhere (a queued re-apply of a committed deletion)', async () => {
|
|
111
|
+
const { workspaceDir } = await seedWorkspace(root, workspaceId);
|
|
112
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
113
|
+
expect(await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/Gone.md`)).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still returns null for a repo-relative path that is clean inside the clone (the form internal callers pass)', async () => {
|
|
117
|
+
const { workspaceDir } = await seedWorkspace(root, workspaceId);
|
|
118
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
119
|
+
expect(await svc.commitFile(workspaceId, USER, 'Knowledge/A.md')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('still returns null for a repo-relative path when the clone has that file too, even with a same-named file at the workspace root', async () => {
|
|
123
|
+
// Not provably stray: the clone holds `Knowledge/A.md` and it is clean, so
|
|
124
|
+
// a repo-relative caller's no-op stays honest whatever else sits beside
|
|
125
|
+
// the clone under the same name.
|
|
126
|
+
const { workspaceDir } = await seedWorkspace(root, workspaceId);
|
|
127
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
128
|
+
await fs.mkdir(path.join(workspaceDir, 'Knowledge'), { recursive: true });
|
|
129
|
+
await fs.writeFile(path.join(workspaceDir, 'Knowledge/A.md'), 'a copy beside the clone\n');
|
|
130
|
+
expect(await svc.commitFile(workspaceId, USER, 'Knowledge/A.md')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not mistake an unreadable stray for an absent one', async () => {
|
|
134
|
+
// Only ENOENT means "nothing there". Any other stat failure must surface,
|
|
135
|
+
// or a stray the process cannot read is silently reported as committed.
|
|
136
|
+
// root ignores mode bits, and Windows has none to set (`chmod` there only
|
|
137
|
+
// toggles read-only, so the stat below still succeeds): nothing to prove.
|
|
138
|
+
if (process.getuid?.() === 0 || process.platform === 'win32') return;
|
|
139
|
+
const { workspaceDir } = await seedWorkspace(root, workspaceId);
|
|
140
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
141
|
+
const strayDir = path.join(workspaceDir, 'KnowledgeBase');
|
|
142
|
+
await fs.mkdir(strayDir, { recursive: true });
|
|
143
|
+
await fs.writeFile(path.join(strayDir, 'PR-12.html'), 'x');
|
|
144
|
+
await fs.chmod(strayDir, 0o000);
|
|
145
|
+
try {
|
|
146
|
+
const attempt = svc.commitFile(workspaceId, USER, 'KnowledgeBase/PR-12.html');
|
|
147
|
+
await expect(attempt).rejects.toMatchObject({ code: 'EACCES' });
|
|
148
|
+
} finally {
|
|
149
|
+
await fs.chmod(strayDir, 0o755);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('still commits a prefixed path inside the clone', async () => {
|
|
154
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
155
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
|
|
156
|
+
await fs.writeFile(path.join(repo, 'Knowledge/B.md'), 'b\n');
|
|
157
|
+
const committed = await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/B.md`);
|
|
158
|
+
expect(committed).not.toBeNull();
|
|
159
|
+
expect(committed!.authorEmail).toBe('alice@example.com');
|
|
160
|
+
expect(await runGit(repo, ['log', '--oneline'])).toMatch(/^\S+ .*\n\S+ seed$/);
|
|
161
|
+
});
|
|
162
|
+
});
|