@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.
Files changed (27) hide show
  1. package/THIRD-PARTY-NOTICES.md +4 -4
  2. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  3. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  4. package/dist/modules/kb-fs/clone-config.js +94 -2
  5. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  6. package/dist/modules/workflow/workflow.service.d.ts +38 -0
  7. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  8. package/dist/modules/workflow/workflow.service.js +112 -6
  9. package/dist/modules/workflow/workflow.service.js.map +1 -1
  10. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  11. package/dist/modules/workspace/startup/kb-git.js +21 -4
  12. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  13. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  14. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  15. package/dist/modules/workspace/workspace.service.js +121 -23
  16. package/dist/modules/workspace/workspace.service.js.map +1 -1
  17. package/package.json +5 -5
  18. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  19. package/src/modules/kb-fs/clone-config.ts +97 -2
  20. package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
  21. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  22. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  23. package/src/modules/workflow/workflow.service.ts +118 -6
  24. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  25. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  26. package/src/modules/workspace/startup/kb-git.ts +20 -7
  27. 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 next checkpoint or final release
251
- // will retry the push.
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
- expect(emitSpy).not.toHaveBeenCalled();
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
- // The commit landed locally but we do NOT emit file-changed — other
485
- // clients would refetch a SHA that's not yet on origin. Recovery
486
- // (or the next worker pass) emits it when the push actually lands.
487
- expect(emitSpy).not.toHaveBeenCalled();
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
- expect(emitSpy).not.toHaveBeenCalled();
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
- expect(emitSpy).not.toHaveBeenCalled();
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.git.push(workspaceId, user);
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.git.push(workspaceId, user);
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.git.push(workspaceId, user);
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.git.push(ws.id, user);
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.git.push(ws.id, user);
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 normalizeCloneTracking's
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
+ });