@bevel-software/platform-core-backend 0.11.2 → 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 (166) hide show
  1. package/THIRD-PARTY-NOTICES.md +1165 -427
  2. package/dist/core/create-core-server.js +1 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +2 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +5 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts +7 -0
  9. package/dist/core-config.d.ts.map +1 -1
  10. package/dist/core-config.js +9 -0
  11. package/dist/core-config.js.map +1 -1
  12. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  13. package/dist/modules/code-mode/code-mode.tool.js +7 -1
  14. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  15. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  16. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  17. package/dist/modules/kb-fs/clone-config.js +94 -2
  18. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  19. package/dist/modules/workflow/workflow.service.d.ts +38 -0
  20. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  21. package/dist/modules/workflow/workflow.service.js +112 -6
  22. package/dist/modules/workflow/workflow.service.js.map +1 -1
  23. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +71 -0
  24. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -0
  25. package/dist/modules/workspace/file-readers/doc-extract.service.js +90 -0
  26. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -0
  27. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +55 -0
  28. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -0
  29. package/dist/modules/workspace/file-readers/doc-extract.types.js +34 -0
  30. package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -0
  31. package/dist/modules/workspace/file-readers/document-reader.d.ts +32 -0
  32. package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -0
  33. package/dist/modules/workspace/file-readers/document-reader.js +59 -0
  34. package/dist/modules/workspace/file-readers/document-reader.js.map +1 -0
  35. package/dist/modules/workspace/file-readers/email-reader.d.ts +15 -0
  36. package/dist/modules/workspace/file-readers/email-reader.d.ts.map +1 -0
  37. package/dist/modules/workspace/file-readers/email-reader.js +19 -0
  38. package/dist/modules/workspace/file-readers/email-reader.js.map +1 -0
  39. package/dist/modules/workspace/file-readers/email-text.d.ts +51 -0
  40. package/dist/modules/workspace/file-readers/email-text.d.ts.map +1 -0
  41. package/dist/modules/workspace/file-readers/email-text.js +151 -0
  42. package/dist/modules/workspace/file-readers/email-text.js.map +1 -0
  43. package/dist/modules/workspace/file-readers/extract-docx.d.ts +13 -0
  44. package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -0
  45. package/dist/modules/workspace/file-readers/extract-docx.js +67 -0
  46. package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -0
  47. package/dist/modules/workspace/file-readers/extract-eml.d.ts +18 -0
  48. package/dist/modules/workspace/file-readers/extract-eml.d.ts.map +1 -0
  49. package/dist/modules/workspace/file-readers/extract-eml.js +87 -0
  50. package/dist/modules/workspace/file-readers/extract-eml.js.map +1 -0
  51. package/dist/modules/workspace/file-readers/extract-msg.d.ts +17 -0
  52. package/dist/modules/workspace/file-readers/extract-msg.d.ts.map +1 -0
  53. package/dist/modules/workspace/file-readers/extract-msg.js +121 -0
  54. package/dist/modules/workspace/file-readers/extract-msg.js.map +1 -0
  55. package/dist/modules/workspace/file-readers/extract-odp.d.ts +13 -0
  56. package/dist/modules/workspace/file-readers/extract-odp.d.ts.map +1 -0
  57. package/dist/modules/workspace/file-readers/extract-odp.js +60 -0
  58. package/dist/modules/workspace/file-readers/extract-odp.js.map +1 -0
  59. package/dist/modules/workspace/file-readers/extract-ods.d.ts +10 -0
  60. package/dist/modules/workspace/file-readers/extract-ods.d.ts.map +1 -0
  61. package/dist/modules/workspace/file-readers/extract-ods.js +173 -0
  62. package/dist/modules/workspace/file-readers/extract-ods.js.map +1 -0
  63. package/dist/modules/workspace/file-readers/extract-odt.d.ts +17 -0
  64. package/dist/modules/workspace/file-readers/extract-odt.d.ts.map +1 -0
  65. package/dist/modules/workspace/file-readers/extract-odt.js +45 -0
  66. package/dist/modules/workspace/file-readers/extract-odt.js.map +1 -0
  67. package/dist/modules/workspace/file-readers/extract-pdf.d.ts +3 -0
  68. package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -0
  69. package/dist/modules/workspace/file-readers/extract-pdf.js +176 -0
  70. package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -0
  71. package/dist/modules/workspace/file-readers/extract-pptx.d.ts +37 -0
  72. package/dist/modules/workspace/file-readers/extract-pptx.d.ts.map +1 -0
  73. package/dist/modules/workspace/file-readers/extract-pptx.js +288 -0
  74. package/dist/modules/workspace/file-readers/extract-pptx.js.map +1 -0
  75. package/dist/modules/workspace/file-readers/extract-xlsx.d.ts +10 -0
  76. package/dist/modules/workspace/file-readers/extract-xlsx.d.ts.map +1 -0
  77. package/dist/modules/workspace/file-readers/extract-xlsx.js +98 -0
  78. package/dist/modules/workspace/file-readers/extract-xlsx.js.map +1 -0
  79. package/dist/modules/workspace/file-readers/extraction-cache.d.ts +61 -0
  80. package/dist/modules/workspace/file-readers/extraction-cache.d.ts.map +1 -0
  81. package/dist/modules/workspace/file-readers/extraction-cache.js +135 -0
  82. package/dist/modules/workspace/file-readers/extraction-cache.js.map +1 -0
  83. package/dist/modules/workspace/file-readers/file-reader.d.ts +76 -0
  84. package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -0
  85. package/dist/modules/workspace/file-readers/file-reader.js +55 -0
  86. package/dist/modules/workspace/file-readers/file-reader.js.map +1 -0
  87. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts +13 -0
  88. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -0
  89. package/dist/modules/workspace/file-readers/file-reader.registry.js +41 -0
  90. package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -0
  91. package/dist/modules/workspace/file-readers/image-read.d.ts +35 -0
  92. package/dist/modules/workspace/file-readers/image-read.d.ts.map +1 -0
  93. package/dist/modules/workspace/file-readers/image-read.js +108 -0
  94. package/dist/modules/workspace/file-readers/image-read.js.map +1 -0
  95. package/dist/modules/workspace/file-readers/image-reader.d.ts +19 -0
  96. package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -0
  97. package/dist/modules/workspace/file-readers/image-reader.js +30 -0
  98. package/dist/modules/workspace/file-readers/image-reader.js.map +1 -0
  99. package/dist/modules/workspace/file-readers/odf-text.d.ts +26 -0
  100. package/dist/modules/workspace/file-readers/odf-text.d.ts.map +1 -0
  101. package/dist/modules/workspace/file-readers/odf-text.js +116 -0
  102. package/dist/modules/workspace/file-readers/odf-text.js.map +1 -0
  103. package/dist/modules/workspace/file-readers/ooxml-text.d.ts +172 -0
  104. package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -0
  105. package/dist/modules/workspace/file-readers/ooxml-text.js +439 -0
  106. package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -0
  107. package/dist/modules/workspace/file-readers/text-reader.d.ts +47 -0
  108. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -0
  109. package/dist/modules/workspace/file-readers/text-reader.js +117 -0
  110. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -0
  111. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  112. package/dist/modules/workspace/startup/kb-git.js +21 -4
  113. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  114. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  115. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  116. package/dist/modules/workspace/workspace.service.js +121 -23
  117. package/dist/modules/workspace/workspace.service.js.map +1 -1
  118. package/dist/modules/workspace/workspace.tools.d.ts +2 -1
  119. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  120. package/dist/modules/workspace/workspace.tools.js +158 -15
  121. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  122. package/package.json +11 -6
  123. package/src/core/create-core-server.ts +1 -1
  124. package/src/core/create-core-services.ts +6 -0
  125. package/src/core-config.ts +9 -0
  126. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +30 -0
  127. package/src/modules/code-mode/code-mode.tool.ts +7 -1
  128. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  129. package/src/modules/kb-fs/clone-config.ts +97 -2
  130. package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
  131. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +2 -1
  132. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  133. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  134. package/src/modules/workflow/workflow.service.ts +118 -6
  135. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  136. package/src/modules/workspace/__tests__/workspace.tools.test.ts +500 -2
  137. package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +1658 -0
  138. package/src/modules/workspace/file-readers/__tests__/email-extract.test.ts +485 -0
  139. package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +97 -0
  140. package/src/modules/workspace/file-readers/__tests__/image-read.test.ts +100 -0
  141. package/src/modules/workspace/file-readers/doc-extract.service.ts +104 -0
  142. package/src/modules/workspace/file-readers/doc-extract.types.ts +63 -0
  143. package/src/modules/workspace/file-readers/document-reader.ts +64 -0
  144. package/src/modules/workspace/file-readers/email-reader.ts +21 -0
  145. package/src/modules/workspace/file-readers/email-text.ts +193 -0
  146. package/src/modules/workspace/file-readers/extract-docx.ts +67 -0
  147. package/src/modules/workspace/file-readers/extract-eml.ts +92 -0
  148. package/src/modules/workspace/file-readers/extract-msg.ts +134 -0
  149. package/src/modules/workspace/file-readers/extract-odp.ts +63 -0
  150. package/src/modules/workspace/file-readers/extract-ods.ts +182 -0
  151. package/src/modules/workspace/file-readers/extract-odt.ts +48 -0
  152. package/src/modules/workspace/file-readers/extract-pdf.ts +178 -0
  153. package/src/modules/workspace/file-readers/extract-pptx.ts +302 -0
  154. package/src/modules/workspace/file-readers/extract-xlsx.ts +96 -0
  155. package/src/modules/workspace/file-readers/extraction-cache.ts +142 -0
  156. package/src/modules/workspace/file-readers/file-reader.registry.ts +45 -0
  157. package/src/modules/workspace/file-readers/file-reader.ts +104 -0
  158. package/src/modules/workspace/file-readers/image-read.ts +122 -0
  159. package/src/modules/workspace/file-readers/image-reader.ts +39 -0
  160. package/src/modules/workspace/file-readers/odf-text.ts +123 -0
  161. package/src/modules/workspace/file-readers/ooxml-text.ts +477 -0
  162. package/src/modules/workspace/file-readers/text-reader.ts +131 -0
  163. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  164. package/src/modules/workspace/startup/kb-git.ts +20 -7
  165. package/src/modules/workspace/workspace.service.ts +132 -25
  166. package/src/modules/workspace/workspace.tools.ts +174 -12
@@ -11,6 +11,7 @@ import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
11
11
  import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
12
12
  import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
13
13
  import { SpillStore } from '../../workspace/spill-store.js';
14
+ import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
14
15
 
15
16
  const WS = 'target-company-state';
16
17
  let recorded: unknown[][] = [];
@@ -48,7 +49,7 @@ async function start(): Promise<string> {
48
49
  // kb (citation) tools alongside the workspace tools. Those registrations —
49
50
  // and their web_search / submit_feedback / cite_kb_node cases — moved to
50
51
  // the enterprise repo with their modules; core registers only core tools.
51
- registerWorkspaceTools(registry, router, toolAuth, th, new SpillStore('/tmp/bevel-test-spills'), accessControl, 'knowledge-base', {
52
+ registerWorkspaceTools(registry, router, toolAuth, th, new SpillStore('/tmp/bevel-test-spills'), new DocExtractService('/tmp/bevel-test-doc-extract'), accessControl, 'knowledge-base', {
52
53
  service: {} as never,
53
54
  enabled: false, // ontology boundary not under test here
54
55
  kbDirName: 'knowledge-base',
@@ -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
  });