@bevel-software/platform-core-backend 0.12.0 → 0.12.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 +4 -4
- 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/workflow/workflow.service.d.ts +38 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +112 -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/package.json +5 -5
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
- 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/workflow.service.ts +118 -6
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- 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
|
@@ -220,7 +220,12 @@ describe('WorkflowService.commitFileWhileLocked', () => {
|
|
|
220
220
|
const change = await svc.commitFileWhileLocked('ws-1', 'feat/x', 'foo.md', USER);
|
|
221
221
|
expect(change).toEqual(CHANGE);
|
|
222
222
|
expect(git.pull).not.toHaveBeenCalled();
|
|
223
|
+
// Best-effort no longer means invisible: the sync banner goes out ahead of
|
|
224
|
+
// file-changed, because "the push catches up later" is a hope, not a fact —
|
|
225
|
+
// an auth failure never catches up, and silently hoping is how a broken
|
|
226
|
+
// deployment stacked 136 local commits before anyone found out.
|
|
223
227
|
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
228
|
+
'git-sync-failed',
|
|
224
229
|
'file-changed',
|
|
225
230
|
]);
|
|
226
231
|
});
|
|
@@ -243,20 +248,21 @@ describe('WorkflowService.commitFileWhileLocked', () => {
|
|
|
243
248
|
]);
|
|
244
249
|
});
|
|
245
250
|
|
|
246
|
-
it('best-effort: still emits file-changed when non-fast-forward recovery itself fails', async () => {
|
|
251
|
+
it('best-effort: still emits file-changed when non-fast-forward recovery itself fails — and NO banner', async () => {
|
|
247
252
|
// Pull rebase failed (conflicts) or retry-push got rejected again.
|
|
248
253
|
// Autosave must NOT throw — the user is mid-edit and would see a
|
|
249
254
|
// baffling error banner for something they didn't even trigger.
|
|
250
|
-
// The local commit landed; the
|
|
251
|
-
//
|
|
255
|
+
// The local commit landed; the release push retries, and if THAT
|
|
256
|
+
// fails the banner comes from pushWithRecovery. A background
|
|
257
|
+
// autosave losing a routine two-editors race is not an outage, so
|
|
258
|
+
// no git-sync-failed here — that stays reserved for auth/host
|
|
259
|
+
// failures, which never clear themselves (previous test).
|
|
252
260
|
const git = makeGit({ pushBehavior: 'nff', pullBehavior: 'fail' });
|
|
253
261
|
const locks = makeFileLocks(USER.id);
|
|
254
262
|
const svc = makeFacade(git, locks, events);
|
|
255
263
|
|
|
256
264
|
const change = await svc.commitFileWhileLocked('ws-1', 'feat/x', 'foo.md', USER);
|
|
257
265
|
expect(change).toEqual(CHANGE);
|
|
258
|
-
// Even though push never succeeded, file-changed still emits so the
|
|
259
|
-
// local commit is visible to other tabs of the same user.
|
|
260
266
|
expect(emitSpy.mock.calls.map((c) => (c[0] as { kind: string }).kind)).toEqual([
|
|
261
267
|
'file-changed',
|
|
262
268
|
]);
|
|
@@ -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
|
|
|
@@ -49,11 +49,12 @@ import type { GitService } from './git/git.service.js';
|
|
|
49
49
|
import type { PullRequestService } from './git/pull-request.service.js';
|
|
50
50
|
import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
|
|
51
51
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
52
|
-
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
52
|
+
import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
53
53
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
54
54
|
import { FileLockService } from './file-lock.service.js';
|
|
55
55
|
import { PendingCommitsService } from './pending-commits.service.js';
|
|
56
56
|
import type { WorkflowEventBus } from './event-bus.js';
|
|
57
|
+
import { sanitizeError } from './sanitize-error.js';
|
|
57
58
|
import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
|
|
58
59
|
import { WorkflowHooks } from './workflow-hooks.js';
|
|
59
60
|
import { WorkspaceMutex } from '../kb-fs/mutex.js';
|
|
@@ -355,7 +356,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
355
356
|
// to discard.
|
|
356
357
|
|
|
357
358
|
shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
|
|
358
|
-
return this.
|
|
359
|
+
return this.trackedPush(workspaceId, user);
|
|
359
360
|
}
|
|
360
361
|
|
|
361
362
|
refreshRemotes(workspaceId: string): Promise<void> {
|
|
@@ -680,6 +681,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
680
681
|
if (change) {
|
|
681
682
|
try {
|
|
682
683
|
await this.git.push(workspaceId, user);
|
|
684
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
683
685
|
} catch (err) {
|
|
684
686
|
// Non-fast-forward recovery on the autosave path: try the
|
|
685
687
|
// cooperative `pull --rebase` + retry once. Autosave is
|
|
@@ -695,12 +697,15 @@ export class WorkflowService implements IWorkflowService {
|
|
|
695
697
|
const detail = err instanceof Error ? err.message : String(err);
|
|
696
698
|
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(detail);
|
|
697
699
|
let recovered = false;
|
|
700
|
+
let recoveryError: unknown = null;
|
|
698
701
|
if (looksLikeNonFastForward) {
|
|
699
702
|
try {
|
|
700
703
|
await this.git.pull(workspaceId);
|
|
701
704
|
await this.git.push(workspaceId, user);
|
|
702
705
|
recovered = true;
|
|
706
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
703
707
|
} catch (recoveryErr) {
|
|
708
|
+
recoveryError = recoveryErr;
|
|
704
709
|
console.warn(
|
|
705
710
|
'[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
|
|
706
711
|
recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
|
|
@@ -708,6 +713,20 @@ export class WorkflowService implements IWorkflowService {
|
|
|
708
713
|
}
|
|
709
714
|
}
|
|
710
715
|
if (!recovered) {
|
|
716
|
+
// Still best-effort — we don't fail the autosave. What gets the
|
|
717
|
+
// BANNER is only the class that cannot clear itself: auth or host
|
|
718
|
+
// failures, which no later attempt fixes. A non-fast-forward here —
|
|
719
|
+
// recovery attempted or not — is the routine two-editors race the
|
|
720
|
+
// surrounding flow already handles: the release push retries it,
|
|
721
|
+
// and if THAT fails the user gets the typed agent hand-off plus the
|
|
722
|
+
// banner from `pushWithRecovery`. Bannering it from a background
|
|
723
|
+
// autosave would tell an author mid-keystroke that the deployment
|
|
724
|
+
// is broken over a race that usually settles on its own — while an
|
|
725
|
+
// auth failure swallowed into a log line is how a broken deployment
|
|
726
|
+
// once stacked 136 local commits before anyone found out.
|
|
727
|
+
if (!looksLikeNonFastForward) {
|
|
728
|
+
this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? err);
|
|
729
|
+
}
|
|
711
730
|
console.warn(
|
|
712
731
|
'[workflow] push after autosave commit failed (commit landed locally):',
|
|
713
732
|
detail,
|
|
@@ -896,6 +915,87 @@ export class WorkflowService implements IWorkflowService {
|
|
|
896
915
|
this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
|
|
897
916
|
}
|
|
898
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Workspaces whose last push failed. Purely so the recovery event fires on
|
|
920
|
+
* the transition back to healthy instead of on every subsequent save — a
|
|
921
|
+
* push runs on every write, and the healthy case must stay silent.
|
|
922
|
+
*
|
|
923
|
+
* Process-local and deliberately not persisted: a restart clears it, and the
|
|
924
|
+
* worst that costs is one redundant `git-sync-recovered` on a workspace that
|
|
925
|
+
* was already fine, which clients treat as a no-op.
|
|
926
|
+
*/
|
|
927
|
+
private readonly gitSyncFailing = new Set<string>();
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Announce that a push landed. Emits only when this workspace was previously
|
|
931
|
+
* failing — see `gitSyncFailing`.
|
|
932
|
+
*/
|
|
933
|
+
private noteGitSyncOk(workspaceId: string, branch: string): void {
|
|
934
|
+
// Callers hand this both spellings of a workspace id — HTTP routes pass
|
|
935
|
+
// the Express-decoded `user/feat`, internal callers (the pending-commits
|
|
936
|
+
// worker, the agent filesystem) the encoded `user%2Ffeat`. Keyed raw, a
|
|
937
|
+
// failure recorded by one caller could never be cleared by the other's
|
|
938
|
+
// success, and the banner would stick. Canonicalize the key AND the
|
|
939
|
+
// emitted id so every consumer sees one spelling.
|
|
940
|
+
const id = branchForWorkspaceId(workspaceId);
|
|
941
|
+
if (!this.gitSyncFailing.delete(id)) return;
|
|
942
|
+
console.log(`[workflow] git sync recovered for workspace=${id} branch=${branch}`);
|
|
943
|
+
this.events?.emit({ kind: 'git-sync-recovered', workspaceId: id, branch });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Announce that a commit is sitting locally because its push failed.
|
|
948
|
+
*
|
|
949
|
+
* This is the visibility half of the failure paths below; it never changes
|
|
950
|
+
* what they DO. The commit is already safe on disk and the retry / hand-off
|
|
951
|
+
* machinery is unaffected — all this adds is that someone finds out, rather
|
|
952
|
+
* than the deployment looking healthy while nothing reaches the remote.
|
|
953
|
+
*/
|
|
954
|
+
private noteGitSyncFailed(workspaceId: string, branch: string, err: unknown): void {
|
|
955
|
+
// Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
|
|
956
|
+
const id = branchForWorkspaceId(workspaceId);
|
|
957
|
+
this.gitSyncFailing.add(id);
|
|
958
|
+
this.events?.emit({
|
|
959
|
+
kind: 'git-sync-failed',
|
|
960
|
+
workspaceId: id,
|
|
961
|
+
branch,
|
|
962
|
+
// Git stderr can quote a credentialed URL; the banner is user-facing and
|
|
963
|
+
// the string also lands in client logs, so sanitize before it leaves.
|
|
964
|
+
reason: sanitizeError(err),
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* A bare push wrapped in the sync tracker: a failure raises the banner, a
|
|
970
|
+
* success clears it. For the workflow paths that push directly — sharing a
|
|
971
|
+
* branch, publishing a CR's source, update-from-base, the decline revert,
|
|
972
|
+
* the roles.yaml restore — rather than through `pushWithRecovery`'s
|
|
973
|
+
* cooperative ladder. Behaviour is otherwise unchanged: the error still
|
|
974
|
+
* propagates to the caller exactly as the bare push's did. Without this,
|
|
975
|
+
* those pushes could fail invisibly (no banner) and, worse, a successful
|
|
976
|
+
* share could not CLEAR a banner an earlier save had raised.
|
|
977
|
+
*
|
|
978
|
+
* The branch is derived from the workspace id (they are the same string,
|
|
979
|
+
* URL-encoding aside) so call sites cannot pass a mismatched pair.
|
|
980
|
+
*/
|
|
981
|
+
private async trackedPush(
|
|
982
|
+
workspaceId: string,
|
|
983
|
+
user: AuthUser,
|
|
984
|
+
opts?: { systemAuthorized?: boolean },
|
|
985
|
+
): Promise<void> {
|
|
986
|
+
const branch = branchForWorkspaceId(workspaceId);
|
|
987
|
+
try {
|
|
988
|
+
// Preserve the exact call shape of the bare pushes this replaces — an
|
|
989
|
+
// explicit `undefined` third argument is a different call signature to
|
|
990
|
+
// every spy that asserts on it.
|
|
991
|
+
await (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user));
|
|
992
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
this.noteGitSyncFailed(workspaceId, branch, err);
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
899
999
|
/**
|
|
900
1000
|
* Shared implementation of "push, and on non-fast-forward try a
|
|
901
1001
|
* cooperative pull-rebase + retry, then hand off to the agent if that
|
|
@@ -911,20 +1011,24 @@ export class WorkflowService implements IWorkflowService {
|
|
|
911
1011
|
): Promise<void> {
|
|
912
1012
|
try {
|
|
913
1013
|
await this.git.push(workspaceId, user, opts);
|
|
1014
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
914
1015
|
} catch (firstPushErr) {
|
|
915
1016
|
const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
|
|
916
1017
|
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
|
|
917
1018
|
let recovered = false;
|
|
918
1019
|
let recoveryDetail = '(cooperative path not attempted)';
|
|
1020
|
+
let recoveryError: unknown = null;
|
|
919
1021
|
if (looksLikeNonFastForward) {
|
|
920
1022
|
try {
|
|
921
1023
|
await this.git.pull(workspaceId);
|
|
922
1024
|
await this.git.push(workspaceId, user, opts);
|
|
923
1025
|
recovered = true;
|
|
1026
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
924
1027
|
console.log(
|
|
925
1028
|
`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
|
|
926
1029
|
);
|
|
927
1030
|
} catch (recoveryErr) {
|
|
1031
|
+
recoveryError = recoveryErr;
|
|
928
1032
|
recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
|
|
929
1033
|
console.warn(
|
|
930
1034
|
`[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
|
|
@@ -933,6 +1037,14 @@ export class WorkflowService implements IWorkflowService {
|
|
|
933
1037
|
}
|
|
934
1038
|
}
|
|
935
1039
|
if (!recovered) {
|
|
1040
|
+
// Banner first, then the typed throw. The throw only reaches a UI that
|
|
1041
|
+
// is still mounted and listening — the release fired as an editor
|
|
1042
|
+
// unmounts has nowhere to surface it — whereas the event reaches every
|
|
1043
|
+
// session on the branch regardless of what triggered the push.
|
|
1044
|
+
// When recovery RAN, its error is the current state of the world (the
|
|
1045
|
+
// first rejection may be a stale non-fast-forward the pull already
|
|
1046
|
+
// cured); when it was skipped, the first error is all there is.
|
|
1047
|
+
this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
|
|
936
1048
|
console.warn(
|
|
937
1049
|
`[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
|
|
938
1050
|
firstDetail,
|
|
@@ -1214,7 +1326,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1214
1326
|
// Push the source — `gh pr create` against an unpushed branch returns
|
|
1215
1327
|
// "head branch does not exist on remote". Push *after* the auto-merge
|
|
1216
1328
|
// so the remote sees the merge commit too.
|
|
1217
|
-
await this.
|
|
1329
|
+
await this.trackedPush(workspaceId, user);
|
|
1218
1330
|
|
|
1219
1331
|
const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
1220
1332
|
const cwd = path.join(workspacePath, this.kbDirName);
|
|
@@ -1367,7 +1479,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1367
1479
|
// If a new merge commit landed, push so the CR picks it up. When
|
|
1368
1480
|
// already up to date there's nothing to share — short-circuit the push.
|
|
1369
1481
|
if (!outcome.alreadyUpToDate) {
|
|
1370
|
-
await this.
|
|
1482
|
+
await this.trackedPush(workspaceId, user);
|
|
1371
1483
|
}
|
|
1372
1484
|
this.prs.invalidateDetailCache(number);
|
|
1373
1485
|
const refreshed = await this.prs.getPrDetail(number, {
|
|
@@ -1556,7 +1668,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1556
1668
|
`Revert ${repoRelPath} (declined in change request #${number})`,
|
|
1557
1669
|
true, // skipValidator — this restores an already-validated base version
|
|
1558
1670
|
);
|
|
1559
|
-
await this.
|
|
1671
|
+
await this.trackedPush(ws.id, user);
|
|
1560
1672
|
} finally {
|
|
1561
1673
|
// Committed inline — drop the lock row directly rather than enqueueing
|
|
1562
1674
|
// a duplicate commit through releaseLock.
|
|
@@ -2105,7 +2217,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2105
2217
|
'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
|
|
2106
2218
|
);
|
|
2107
2219
|
}
|
|
2108
|
-
await this.
|
|
2220
|
+
await this.trackedPush(ws.id, user);
|
|
2109
2221
|
this.accessControl.invalidate(ws.id);
|
|
2110
2222
|
return true;
|
|
2111
2223
|
} finally {
|
|
@@ -454,7 +454,7 @@ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
|
|
|
454
454
|
|
|
455
455
|
beforeEach(async () => {
|
|
456
456
|
root = await mkTmpRoot();
|
|
457
|
-
// The fake `.git` in seedBranchWorkspace makes
|
|
457
|
+
// The fake `.git` in seedBranchWorkspace makes normalizeCloneConfig's
|
|
458
458
|
// git calls fail; that path only warns, which is noise here.
|
|
459
459
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
460
460
|
});
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { KbStartupRunner } from '../kb-startup-runner.js';
|
|
8
|
+
import { WorkspaceService } from '../../workspace.service.js';
|
|
8
9
|
import { redactSecret } from '../kb-git.js';
|
|
9
10
|
import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
10
11
|
|
|
@@ -493,3 +494,143 @@ describe('redactSecret', () => {
|
|
|
493
494
|
expect(redactSecret('https://example.com/kb.git')).toBe('https://example.com/kb.git');
|
|
494
495
|
});
|
|
495
496
|
});
|
|
497
|
+
|
|
498
|
+
describe('KbStartupRunner credentials', () => {
|
|
499
|
+
// The phase clones the default branch into the very directory
|
|
500
|
+
// `WorkspaceService` adopts, and `GitService.push` later runs a bare
|
|
501
|
+
// `git push` from it. Position of the credential config is what decides
|
|
502
|
+
// whether that works: `git -c … clone` authenticates the clone and leaves
|
|
503
|
+
// the repo with nothing, so every later push prompts for a username, finds
|
|
504
|
+
// no tty, and dies — commits pile up locally and reach the remote never.
|
|
505
|
+
// Only real git tells the two spellings apart, so this drives real git.
|
|
506
|
+
const ORIGINAL = process.env.GITHUB_TOKEN;
|
|
507
|
+
afterEach(() => {
|
|
508
|
+
if (ORIGINAL === undefined) delete process.env.GITHUB_TOKEN;
|
|
509
|
+
else process.env.GITHUB_TOKEN = ORIGINAL;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
/** Run the phase far enough to put the default branch's clone on disk. */
|
|
514
|
+
async function materializeDefaultBranchClone(): Promise<string> {
|
|
515
|
+
let repoDir = '';
|
|
516
|
+
await makeRunner([
|
|
517
|
+
step('touch', async (ctx) => {
|
|
518
|
+
repoDir = await (await ctx.defaultBranch()).repoDir();
|
|
519
|
+
return { outcome: 'ok' };
|
|
520
|
+
}),
|
|
521
|
+
]).runAll();
|
|
522
|
+
expect(repoDir).toBe(path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base'));
|
|
523
|
+
return repoDir;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
it('leaves the boot clone able to authenticate a push of its own', async () => {
|
|
527
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
528
|
+
await populatedUpstream();
|
|
529
|
+
// The branch handle clones lazily, so a step has to actually reach for the
|
|
530
|
+
// repo before there is anything on disk to inspect.
|
|
531
|
+
const repo = await materializeDefaultBranchClone();
|
|
532
|
+
const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
|
|
533
|
+
expect(helper).toContain('username=x-access-token');
|
|
534
|
+
// The literal, not the value — the helper resolves it per invocation, so
|
|
535
|
+
// the secret never lands in a config file we don't control the lifetime of.
|
|
536
|
+
expect(helper).toContain('password=$GITHUB_TOKEN');
|
|
537
|
+
expect(helper).not.toContain('ghp_boot');
|
|
538
|
+
|
|
539
|
+
// Exactly one value: a re-stamp must collapse, never accumulate, or git
|
|
540
|
+
// errors with "cannot overwrite multiple values".
|
|
541
|
+
const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
542
|
+
expect(all.split('\n')).toHaveLength(1);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('adopting an existing clone collapses an accumulated helper back to one value', async () => {
|
|
546
|
+
// The boot-time assertion above sees a FRESH clone, which only ever has
|
|
547
|
+
// one value by construction. The collapse guarantee lives in the adoption
|
|
548
|
+
// path (`--replace-all`), so drift a second value in out of band and let
|
|
549
|
+
// WorkspaceService adopt the clone — that is the code the guarantee is
|
|
550
|
+
// about.
|
|
551
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
552
|
+
await populatedUpstream();
|
|
553
|
+
const repo = await materializeDefaultBranchClone();
|
|
554
|
+
// App-shaped drift: a second stamp of OURS with a stale username — the
|
|
555
|
+
// realistic accumulation (an operator's own helper is a different case,
|
|
556
|
+
// covered below, and must NOT be collapsed).
|
|
557
|
+
await git(repo, ['config', '--add', 'credential.helper',
|
|
558
|
+
'!f() { echo "username=stale-user"; echo "password=$GITHUB_TOKEN"; }; f']);
|
|
559
|
+
|
|
560
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
561
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
562
|
+
|
|
563
|
+
const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
564
|
+
expect(all.split('\n')).toHaveLength(1);
|
|
565
|
+
expect(all).toContain('username=x-access-token');
|
|
566
|
+
expect(all).not.toContain('stale-user');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('an operator-configured helper survives both the stamp and the token-removal unset', async () => {
|
|
570
|
+
// git chains every configured helper, so an operator's clone-local
|
|
571
|
+
// `cache`/`store`/custom helper coexists with ours — and losing it on a
|
|
572
|
+
// token change would break the very fallback auth they set up.
|
|
573
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
574
|
+
await populatedUpstream();
|
|
575
|
+
const repo = await materializeDefaultBranchClone();
|
|
576
|
+
await git(repo, ['config', '--add', 'credential.helper', 'cache --timeout=300']);
|
|
577
|
+
|
|
578
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
579
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // stamp with token
|
|
580
|
+
let all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
581
|
+
expect(all).toContain('cache --timeout=300');
|
|
582
|
+
expect(all).toContain('password=$GITHUB_TOKEN');
|
|
583
|
+
|
|
584
|
+
delete process.env.GITHUB_TOKEN;
|
|
585
|
+
const ws2 = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
586
|
+
await ws2.getOrCreateForBranch(DEFAULT_BRANCH); // unset OUR helper only
|
|
587
|
+
all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
588
|
+
expect(all).toContain('cache --timeout=300');
|
|
589
|
+
expect(all).not.toContain('password=$GITHUB_TOKEN');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('adopting a clone after the token was removed unsets the stale helper', async () => {
|
|
593
|
+
// A deployment that lost its token must not keep a clone-local helper
|
|
594
|
+
// answering with an empty password — it would shadow whatever fallback
|
|
595
|
+
// auth the operator switched to.
|
|
596
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
597
|
+
await populatedUpstream();
|
|
598
|
+
const repo = await materializeDefaultBranchClone(); // stamped
|
|
599
|
+
delete process.env.GITHUB_TOKEN;
|
|
600
|
+
|
|
601
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
602
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
603
|
+
|
|
604
|
+
await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
|
|
605
|
+
.rejects.toMatchObject({ code: 1 });
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('a token added after the branch is cached reaches the clone without a restart', async () => {
|
|
609
|
+
// The rotation gap: the setup screen can supply a token AFTER a branch
|
|
610
|
+
// was first opened, and the cached fast path returns before the adoption
|
|
611
|
+
// re-stamp. The fingerprint check must catch the change there.
|
|
612
|
+
delete process.env.GITHUB_TOKEN;
|
|
613
|
+
await populatedUpstream();
|
|
614
|
+
const repo = await materializeDefaultBranchClone(); // no helper
|
|
615
|
+
|
|
616
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
617
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // adopt, tokenless
|
|
618
|
+
process.env.GITHUB_TOKEN = 'ghp_late';
|
|
619
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // cached fast path
|
|
620
|
+
|
|
621
|
+
const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
|
|
622
|
+
expect(helper).toContain('password=$GITHUB_TOKEN');
|
|
623
|
+
expect(helper).not.toContain('ghp_late');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('clones without a helper when the deployment has no token — an open remote still boots', async () => {
|
|
627
|
+
delete process.env.GITHUB_TOKEN;
|
|
628
|
+
await populatedUpstream();
|
|
629
|
+
const repo = await materializeDefaultBranchClone();
|
|
630
|
+
// `--get` exits 1 when the key is unset. Assert on that exit code rather
|
|
631
|
+
// than on "it threw": a missing clone directory throws too, which would
|
|
632
|
+
// pass this test while proving nothing.
|
|
633
|
+
await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
|
|
634
|
+
.rejects.toMatchObject({ code: 1 });
|
|
635
|
+
});
|
|
636
|
+
});
|