@bevel-software/platform-core-backend 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/core/create-core-services.d.ts.map +1 -1
  2. package/dist/core/create-core-services.js +5 -2
  3. package/dist/core/create-core-services.js.map +1 -1
  4. package/dist/modules/database/connection.d.ts.map +1 -1
  5. package/dist/modules/database/connection.js +11 -0
  6. package/dist/modules/database/connection.js.map +1 -1
  7. package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
  8. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  9. package/dist/modules/kb-fs/file-change-notifier.js +6 -8
  10. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
  12. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  13. package/dist/modules/kb-fs/locking-filesystem.js +4 -12
  14. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  15. package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
  16. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
  17. package/dist/modules/onboarding/starter-pack.service.js +1 -1
  18. package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
  19. package/dist/modules/version-control/git/git.service.d.ts +18 -0
  20. package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
  21. package/dist/modules/version-control/git/git.service.js +53 -2
  22. package/dist/modules/version-control/git/git.service.js.map +1 -1
  23. package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
  24. package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
  25. package/dist/modules/version-control/version-control-hooks.js +17 -0
  26. package/dist/modules/version-control/version-control-hooks.js.map +1 -1
  27. package/dist/modules/version-control/version-control.service.d.ts +0 -15
  28. package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
  29. package/dist/modules/version-control/version-control.service.js +4 -13
  30. package/dist/modules/version-control/version-control.service.js.map +1 -1
  31. package/dist/shared/fire-and-forget.d.ts +14 -0
  32. package/dist/shared/fire-and-forget.d.ts.map +1 -0
  33. package/dist/shared/fire-and-forget.js +23 -0
  34. package/dist/shared/fire-and-forget.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/__tests__/capturing-logger.ts +36 -0
  37. package/src/__tests__/real-access.ts +16 -0
  38. package/src/__tests__/real-auth.ts +13 -0
  39. package/src/__tests__/real-readers.ts +27 -0
  40. package/src/__tests__/real-version-control.ts +22 -0
  41. package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
  42. package/src/core/create-core-services.ts +4 -2
  43. package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
  44. package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
  45. package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
  46. package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
  47. package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
  48. package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
  49. package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
  50. package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
  51. package/src/modules/database/__tests__/connection.test.ts +30 -0
  52. package/src/modules/database/connection.ts +12 -0
  53. package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
  54. package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
  55. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
  56. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
  57. package/src/modules/kb-fs/file-change-notifier.ts +55 -32
  58. package/src/modules/kb-fs/locking-filesystem.ts +4 -18
  59. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
  60. package/src/modules/onboarding/starter-pack.service.ts +2 -4
  61. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
  62. package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
  63. package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
  64. package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
  65. package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
  66. package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
  67. package/src/modules/version-control/git/git.service.ts +55 -2
  68. package/src/modules/version-control/version-control-hooks.ts +41 -2
  69. package/src/modules/version-control/version-control.service.ts +4 -12
  70. package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
  71. package/src/shared/fire-and-forget.ts +26 -0
@@ -49,7 +49,6 @@ import {
49
49
  } from '@mastra/core/workspace';
50
50
  import type { AuthUser, Change, IVersionControlService } from '@bevel-software/platform-shared';
51
51
  import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
52
- import type { FileChangeNotifier } from './file-change-notifier.js';
53
52
  import { isAbsence } from '../../shared/fs.contract.js';
54
53
  import { DestinationTakenError, lstatOrNull, renameNoReplace, takenError } from '../../shared/rename-no-replace.js';
55
54
  import { linkedAncestor } from '../../shared/no-link-below.js';
@@ -167,12 +166,6 @@ export interface LockingFilesystemContext {
167
166
  * when the destination is a path the validator's `appliesTo` claims.
168
167
  */
169
168
  validateWrite?: WriteValidator;
170
- /**
171
- * Post-commit hook for the BATCH `writeFiles` path (which commits via
172
- * `commitChanges` and so skips `runPendingCommit`'s emit). Single-file ops
173
- * emit through the queue instead, so they don't need this. Optional.
174
- */
175
- fileChanges?: FileChangeNotifier;
176
169
  /**
177
170
  * Optional creator read-grant planner (see `modules/access/creator-access`).
178
171
  * When present, a write/mkdir that starts a NEW FOLDER directly under one
@@ -601,7 +594,6 @@ export class LockingFilesystem extends GitGuardedFilesystem {
601
594
  ].sort();
602
595
  const change = await versionControl.commitChanges(workspaceId, user, summary, scope);
603
596
  await release('untouched');
604
- if (change) this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: scope, byUser: user });
605
597
  return change;
606
598
  } catch (err) {
607
599
  if (err instanceof PushNeedsAgentResolutionError) {
@@ -1027,16 +1019,10 @@ export class LockingFilesystem extends GitGuardedFilesystem {
1027
1019
  await releaseAll((p) =>
1028
1020
  committed.has(p) || failedSeedDiscards.has(p) ? 'discard' : 'untouched',
1029
1021
  );
1030
- // This batch path commits via `commitChanges`, NOT the per-file queue, so it
1031
- // skips `runPendingCommit`'s emit — fire the post-commit hook here with the
1032
- // WHOLE batch as one event, so an expensive subscriber (id-repair rebuilds the
1033
- // id index) reacts once per batch, not once per file. A no-op commit (clean
1034
- // tree) changed nothing, so it emits nothing.
1035
- if (change) {
1036
- // `touched` = caller paths + any creator-grant seeds that landed in the
1037
- // same commit — the exact set this batch may have touched.
1038
- this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: touched, byUser: user });
1039
- }
1022
+ // The commit, if one landed, was reported by the git layer as it landed
1023
+ // (`VersionControlHooks.onCommitLanded`) with the paths it committed —
1024
+ // `touched` plus any creator-grant seed in the same commit. Nothing to
1025
+ // announce here.
1040
1026
  return change;
1041
1027
  }
1042
1028
 
@@ -4,8 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { DEFAULT_KB_LAYOUT, type AuthUser, type IVersionControlService, type KbLayout } from '@bevel-software/platform-shared';
6
6
  import { testKbContext } from '../../../__tests__/kb-context.js';
7
- import { NodeFs } from '../../kb-fs/node-fs.js';
8
- import { WorkspaceMutex } from '../../kb-fs/mutex.js';
7
+ import { realDisk } from '../../../__tests__/real-disk.js';
8
+ import { WorkspaceMutex } from '../../kb-fs/index.js';
9
9
  import { KbPluginSource } from '../../plugins/index.js';
10
10
  import { CLAIM_TTL_MS, StarterPackError, StarterPackService, commitSubjectOf, summaryOf } from '../starter-pack.service.js';
11
11
 
@@ -92,8 +92,8 @@ function harness({
92
92
  const svc = new StarterPackService({
93
93
  packsDir,
94
94
  kb,
95
- disk: new NodeFs(),
96
- pluginSource: new KbPluginSource(new NodeFs(), kb),
95
+ disk: realDisk(),
96
+ pluginSource: new KbPluginSource(realDisk(), kb),
97
97
  pluginLocks,
98
98
  workspaceService: {
99
99
  getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-main' })),
@@ -66,7 +66,7 @@ import { PushNeedsAgentResolutionError, VersionControlDomainError } from '../../
66
66
  import type { KbContext } from '../../shared/kb-context.js';
67
67
  import type { IAdminAccessService } from '../admin/index.js';
68
68
  import type { IAccessControl } from '../access/index.js';
69
- import type { FileChangeNotifier, WorkspaceMutex } from '../kb-fs/index.js';
69
+ import type { WorkspaceMutex } from '../kb-fs/index.js';
70
70
  import { LockingFilesystem } from '../kb-fs/index.js';
71
71
  import type { IFsProbe, ITreeWalker } from '../../shared/fs.contract.js';
72
72
  import {
@@ -146,8 +146,6 @@ export interface StarterPackServiceDeps {
146
146
  pluginLocks: PluginIdentityLocks;
147
147
  /** The SSE bus: `fs-tree-changed` sends every open tree on the branch to fetch again. */
148
148
  events?: { emit(event: { kind: 'fs-tree-changed'; workspaceId: string; branch: string }): void };
149
- /** The post-commit hook catalogs refresh on — the plugin's skills appear without a restart. */
150
- fileChanges?: FileChangeNotifier;
151
149
  /**
152
150
  * One choice at a time on this replica: the second of two quick clicks
153
151
  * finds the first one's answer recorded. Across replicas the recorded
@@ -434,7 +432,7 @@ export class StarterPackService implements IStarterPackService, FirstRunStarterS
434
432
  const writes = await this.plan(user, pack, files, path.join(basePath, kb.kbDirName));
435
433
  const fsys = new LockingFilesystem(
436
434
  { basePath, contained: true },
437
- { versionControl, workspaceId: ws.id, branch, user, kbDirName: kb.kbDirName, fileChanges: this.deps.fileChanges },
435
+ { versionControl, workspaceId: ws.id, branch, user, kbDirName: kb.kbDirName },
438
436
  );
439
437
  let written: string[] = [];
440
438
  try {
@@ -1,13 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
3
- import type { TreeWalkOptions, WalkListener, WalkResult } from '../../../shared/fs.contract.js';
2
+ import { realDisk } from '../../../__tests__/real-disk.js';
4
3
  import fs from 'node:fs/promises';
5
4
  import path from 'node:path';
6
5
  import os from 'node:os';
7
6
  import { DEFAULT_BRANCH, pluginDisplayNameOf, type AuthUser } from '@bevel-software/platform-shared';
8
7
 
9
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
10
- import { AccessControlService } from '../../access/access-control.service.js';
8
+ import type { WorkspaceService } from '../../workspace/index.js';
9
+ import { realAccessControl } from '../../../__tests__/real-access.js';
11
10
  import { PushNeedsAgentResolutionError } from '../../../shared/domain-errors.js';
12
11
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
13
12
  import { KbPluginSource } from '../discovery/kb-plugin-source.js';
@@ -16,22 +15,22 @@ import { testKbContext } from '../../../__tests__/kb-context.js';
16
15
 
17
16
  // The one walk of the checkout, with a switch that makes it report a hole —
18
17
  // the way a folder it cannot list would — to every listener on it. The
19
- // walk is injected, so the switch is a disk that lies, not a module mock.
18
+ // walk is injected, so the switch is a disk that lies, not a module mock:
19
+ // the real disk the shared fixture hands out, its walk wrapped.
20
20
  const walkMock = { holeInTheWalk: false };
21
- class HoledFs extends NodeFs {
22
- override async walkKb(
23
- root: string,
24
- listeners: readonly WalkListener[],
25
- options?: Omit<TreeWalkOptions, 'skip'>,
26
- ): Promise<WalkResult> {
27
- const result = await super.walkKb(root, listeners, options);
21
+ function holedDisk(): ReturnType<typeof realDisk> {
22
+ const disk = realDisk();
23
+ const walkKb = disk.walkKb.bind(disk);
24
+ disk.walkKb = async (root, listeners, options) => {
25
+ const result = await walkKb(root, listeners, options);
28
26
  if (walkMock.holeInTheWalk) {
29
27
  const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
30
28
  for (const l of listeners) await l.onHole?.('KnowledgeBase/Notes', err);
31
29
  result.holes.push('KnowledgeBase/Notes');
32
30
  }
33
31
  return result;
34
- }
32
+ };
33
+ return disk;
35
34
  }
36
35
 
37
36
  /**
@@ -58,15 +57,19 @@ describe('PluginRenameService', () => {
58
57
  let repo: string;
59
58
  let commits: { summary: string; paths: string[] }[];
60
59
  let failNextCommit: Error | null;
60
+ /** What each workspace-relative path last read as when it was committed — the driver's picture of the tree. */
61
+ let committed: Map<string, string>;
61
62
  let failWriteMatching: string | null;
62
63
  let invalidated: number;
63
- let access: AccessControlService;
64
+ let access: ReturnType<typeof realAccessControl>;
64
65
  let svc: PluginRenameService;
65
66
 
67
+ // A seed is a committed file: what origin has before the rename runs.
66
68
  const write = async (rel: string, text: string) => {
67
69
  const abs = path.join(repo, rel);
68
70
  await fs.mkdir(path.dirname(abs), { recursive: true });
69
71
  await fs.writeFile(abs, text);
72
+ committed.set(`${KB_DIR}/${rel}`, text);
70
73
  };
71
74
  const read = (rel: string) => fs.readFile(path.join(repo, rel), 'utf-8');
72
75
  const manifest = async () => JSON.parse(await read('Plugins/GTM/plugin.json'));
@@ -77,6 +80,7 @@ describe('PluginRenameService', () => {
77
80
  commits = [];
78
81
  failNextCommit = null;
79
82
  failWriteMatching = null;
83
+ committed = new Map();
80
84
  walkMock.holeInTheWalk = false;
81
85
  invalidated = 0;
82
86
  const workspaceService = {
@@ -101,7 +105,19 @@ describe('PluginRenameService', () => {
101
105
  failNextCommit = null;
102
106
  throw err;
103
107
  }
108
+ // Git's rule: a tree whose files all read as they were last committed
109
+ // has nothing to commit, and the service is answered null.
110
+ let changed = false;
111
+ for (const p of paths) {
112
+ const now = await fs.readFile(path.join(root, wsId, p), 'utf-8');
113
+ if (now !== committed.get(p)) {
114
+ changed = true;
115
+ committed.set(p, now);
116
+ }
117
+ }
118
+ if (!changed) return null;
104
119
  commits.push({ summary, paths });
120
+ return { sha: 'c0ffee' } as never;
105
121
  },
106
122
  };
107
123
 
@@ -114,8 +130,8 @@ describe('PluginRenameService', () => {
114
130
  await write('Skills/Eng/deploy/access.md', DEPLOY_RULES);
115
131
  await write('Skills/Eng/deploy/SKILL.md', '---\ndescription: Ship it.\n---\n');
116
132
 
117
- access = new AccessControlService(workspaceService, KB_DIR, new NodeFs());
118
- const disk = new HoledFs();
133
+ access = realAccessControl(workspaceService, KB_DIR, realDisk());
134
+ const disk = holedDisk();
119
135
  svc = new PluginRenameService(workspaceService, driver, access, new KbPluginSource(disk, testKbContext({ kbDirName: KB_DIR })), disk, testKbContext({ kbDirName: KB_DIR }), undefined, () => {
120
136
  invalidated += 1;
121
137
  });
@@ -125,7 +141,7 @@ describe('PluginRenameService', () => {
125
141
  it('refuses to claim a name against a listing with a hole in it — an unreadable folder may hold that very plugin', async () => {
126
142
  // Discovery as the walker reports it when a folder exists but could not
127
143
  // be read: the plugins it did see, plus the hole.
128
- const real = new KbPluginSource(new NodeFs(), testKbContext({ kbDirName: KB_DIR }));
144
+ const real = new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR }));
129
145
  const holed = {
130
146
  dialect: 'kb',
131
147
  discover: async (root: string) => ({ ...(await real.discover(root)), unreadable: ['Plugins/Hidden'] }),
@@ -135,7 +151,7 @@ describe('PluginRenameService', () => {
135
151
  { commitChanges: async () => { throw new Error('must not commit'); } },
136
152
  access,
137
153
  holed,
138
- new NodeFs(),
154
+ realDisk(),
139
155
  testKbContext({ kbDirName: KB_DIR }),
140
156
  );
141
157
  await expect(svcOverHole.rename(manager, 'gtm', { name: 'go-to-market' })).rejects.toMatchObject({
@@ -156,7 +172,7 @@ describe('PluginRenameService', () => {
156
172
  });
157
173
 
158
174
  it('tells a NON-manager nothing about the tree — a holed discovery is still just "unknown plugin" to them', async () => {
159
- const real = new KbPluginSource(new NodeFs(), testKbContext({ kbDirName: KB_DIR }));
175
+ const real = new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR }));
160
176
  const holed = {
161
177
  dialect: 'kb',
162
178
  discover: async (root: string) => ({ ...(await real.discover(root)), unreadable: ['Plugins/Hidden'] }),
@@ -166,7 +182,7 @@ describe('PluginRenameService', () => {
166
182
  { commitChanges: async () => { throw new Error('must not commit'); } },
167
183
  access,
168
184
  holed,
169
- new NodeFs(),
185
+ realDisk(),
170
186
  testKbContext({ kbDirName: KB_DIR }),
171
187
  );
172
188
  const refusal = await svcOverHole.rename(member, 'gtm', { name: 'go-to-market' }).catch((e: unknown) => e);
@@ -270,7 +286,7 @@ describe('PluginRenameService', () => {
270
286
  expect(await read('Skills/Eng/deploy/access.md')).toBe(DEPLOY_RULES);
271
287
  // The three answers that must never diverge: the rename's, the
272
288
  // discovery source's, and the shared reader's over the file itself.
273
- const { plugins } = await new KbPluginSource(new NodeFs(), testKbContext({ kbDirName: KB_DIR })).discover(repo);
289
+ const { plugins } = await new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR })).discover(repo);
274
290
  const found = plugins.find((p) => p.folder === 'Plugins/GTM')!;
275
291
  expect([found.name, found.displayName]).toEqual([result.name, result.displayName]);
276
292
  expect(pluginDisplayNameOf(JSON.parse(await read('Plugins/GTM/plugin.json')))).toBe(result.displayName);
@@ -413,6 +429,15 @@ describe('PluginRenameService', () => {
413
429
  expect(await access.canRead(wsId, member.email, 'Skills/Eng/deploy/SKILL.md')).toBe(true);
414
430
  });
415
431
 
432
+ it('changes no bytes when the manifest already reads as the rename writes it — a clean tree, nothing to commit', async () => {
433
+ // The manifest already reads exactly as the rename will write it.
434
+ const asWritten = `${JSON.stringify({ name: 'gtm', version: '1.0.0', displayName: 'GTM' }, null, 2)}\n`;
435
+ await write('Plugins/GTM/plugin.json', asWritten);
436
+ await svc.rename(manager, 'gtm', { displayName: 'GTM' });
437
+ expect(await read('Plugins/GTM/plugin.json')).toBe(asWritten);
438
+ expect(commits).toEqual([]);
439
+ });
440
+
416
441
  it('will not rename a plugin read from an external format — that repository owns its name', async () => {
417
442
  await write('Plugins/Ext/plugin.bundle.json', '{"name":"ext"}');
418
443
  await write('Plugins/Ext/access.md', GTM_RULES);
@@ -5,16 +5,16 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import express from 'express';
7
7
  import { describe, it, expect, afterEach, vi } from 'vitest';
8
- import { NodeFs } from '../../kb-fs/node-fs.js';
8
+ import { realDisk } from '../../../__tests__/real-disk.js';
9
9
  import { KbPluginSource } from '../discovery/kb-plugin-source.js';
10
10
 
11
11
  import { DEFAULT_BRANCH, joinBranchFor } from '@bevel-software/platform-shared';
12
12
  import type { ChangeRequest, IVersionControlService } from '@bevel-software/platform-shared';
13
13
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
14
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
15
- import type { IAccessControl } from '../../access/access-control.interface.js';
16
- import type { ISkillService, SkillSummary } from '../../skills/skills.contract.js';
17
- import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
14
+ import type { WorkspaceService } from '../../workspace/index.js';
15
+ import type { IAccessControl } from '../../access/index.js';
16
+ import type { ISkillService, SkillSummary } from '../../skills/index.js';
17
+ import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/index.js';
18
18
  import { PluginIndexService } from '../plugins.service.js';
19
19
  import { createPluginCreationRoutes, createPluginsRoutes } from '../plugins.routes.js';
20
20
  import type { JoinRequestsService } from '../join-requests.service.js';
@@ -162,7 +162,7 @@ async function makeHarness(opts: HarnessOpts = {}) {
162
162
  skillService,
163
163
  toolService,
164
164
  testKbContext({ kbDirName: KB }),
165
- new KbPluginSource(new NodeFs(), testKbContext({ kbDirName: KB })),
165
+ new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB })),
166
166
  );
167
167
 
168
168
  const email = opts.email === undefined ? ALI : opts.email;
@@ -11,8 +11,8 @@ import type {
11
11
  import type { GitService } from '../git/git.service.js';
12
12
  import type { PullRequestService } from '../git/pull-request.service.js';
13
13
  import type { IChangeRequestReviewService } from '../change-request-review/change-request-review.interface.js';
14
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
15
- import type { IAccessControl } from '../../access/access-control.interface.js';
14
+ import type { WorkspaceService } from '../../workspace/index.js';
15
+ import type { IAccessControl } from '../../access/index.js';
16
16
  import { FileLockService } from '../file-lock.service.js';
17
17
  import { PendingCommitsService, type PendingCommit } from '../pending-commits.service.js';
18
18
  import { PendingCommitsWorker } from '../pending-commits.worker.js';
@@ -28,7 +28,7 @@ import {
28
28
  } from '../../../shared/domain-errors.js';
29
29
  import type { VersionControlEventBus } from '../event-bus.js';
30
30
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
31
- import type { Database } from '../../database/connection.js';
31
+ import type { Database } from '../../database/index.js';
32
32
  import { openChangeGate } from '../../../__tests__/open-change-gate.js';
33
33
  import { ReadOnBranchDeniedError } from '../read-on-branch.js';
34
34
  import { hashEmail } from '../../../shared/email-identity.js';
@@ -1801,7 +1801,7 @@ describe('VersionControlService.openChangeRequest — tells the change-request l
1801
1801
  const svc = new VersionControlService(
1802
1802
  db, git, prs, makeChangeRequestReview(), workspaces, access,
1803
1803
  makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate(),
1804
- { emit: vi.fn() } as unknown as VersionControlEventBus, undefined, undefined, hooks, accounts,
1804
+ { emit: vi.fn() } as unknown as VersionControlEventBus, undefined, hooks, accounts,
1805
1805
  );
1806
1806
  return { svc, run, prs, access, accounts, values };
1807
1807
  }
@@ -4,14 +4,14 @@ import type { AuthUser, Change } from '@bevel-software/platform-shared';
4
4
  import type { GitService } from '../git/git.service.js';
5
5
  import type { PullRequestService } from '../git/pull-request.service.js';
6
6
  import type { IChangeRequestReviewService } from '../change-request-review/change-request-review.interface.js';
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
- import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import type { IAccessControl } from '../../access/index.js';
9
9
  import { FileLockService } from '../file-lock.service.js';
10
10
  import { PendingCommitsService } from '../pending-commits.service.js';
11
11
  import { VersionControlEventBus } from '../event-bus.js';
12
12
  import { VersionControlService, syncConflictMessage } from '../version-control.service.js';
13
13
  import { openChangeGate } from '../../../__tests__/open-change-gate.js';
14
- import type { Database } from '../../database/connection.js';
14
+ import type { Database } from '../../database/index.js';
15
15
  import {
16
16
  PullRebaseConflictError,
17
17
  PullRebaseConflictError,
@@ -87,24 +87,39 @@ function makePending(): PendingCommitsService {
87
87
  } as unknown as PendingCommitsService;
88
88
  }
89
89
 
90
- function makeGit(overrides: Partial<{
90
+ interface GitBehavior {
91
91
  commitFile: Change | null;
92
92
  pushBehavior: 'ok' | 'nff' | 'auth-fail';
93
93
  pullBehavior: 'ok' | 'fail' | 'conflict';
94
94
  pushAfterPullBehavior: 'ok' | 'fail';
95
95
  hasUnpushedCommits: boolean;
96
- }> = {}): GitService {
97
- const commitFileResult = overrides.commitFile === undefined ? CHANGE : overrides.commitFile;
98
- const pushBehavior = overrides.pushBehavior ?? 'ok';
99
- const pullBehavior = overrides.pullBehavior ?? 'ok';
100
- const pushAfterPullBehavior = overrides.pushAfterPullBehavior ?? 'ok';
96
+ }
97
+
98
+ /** The git stub, whose behaviour a test changes in place (`behave`) — the service keeps the one instance it was given. */
99
+ type GitStub = GitService & { behave(next: Partial<GitBehavior>): void };
100
+
101
+ function makeGit(overrides: Partial<GitBehavior> = {}): GitStub {
102
+ const b: GitBehavior = {
103
+ commitFile: CHANGE,
104
+ pushBehavior: 'ok',
105
+ pullBehavior: 'ok',
106
+ pushAfterPullBehavior: 'ok',
107
+ hasUnpushedCommits: false,
108
+ ...overrides,
109
+ };
101
110
  let pushCalls = 0;
111
+ const behave = (next: Partial<GitBehavior>): void => {
112
+ Object.assign(b, next);
113
+ pushCalls = 0;
114
+ };
102
115
  return {
103
- commitFile: vi.fn().mockResolvedValue(commitFileResult),
116
+ behave,
117
+ commitFile: vi.fn().mockImplementation(async () => b.commitFile),
104
118
  discardPath: vi.fn().mockResolvedValue(undefined),
105
- hasUnpushedCommits: vi.fn().mockResolvedValue(overrides.hasUnpushedCommits ?? false),
119
+ hasUnpushedCommits: vi.fn().mockImplementation(async () => b.hasUnpushedCommits),
106
120
  push: vi.fn().mockImplementation(async () => {
107
121
  pushCalls++;
122
+ const { pushBehavior, pushAfterPullBehavior } = b;
108
123
  const phase = pushCalls === 1 ? pushBehavior : pushAfterPullBehavior;
109
124
  if (phase === 'nff') {
110
125
  throw new Error(
@@ -122,11 +137,11 @@ function makeGit(overrides: Partial<{
122
137
  }
123
138
  }),
124
139
  pull: vi.fn().mockImplementation(async () => {
125
- if (pullBehavior === 'fail') throw new Error('git pull failed: merge conflict');
126
- if (pullBehavior === 'conflict') throw new PullRebaseConflictError('feat/x', ['foo.md'], 'CONFLICT (content)');
140
+ if (b.pullBehavior === 'fail') throw new Error('git pull failed: merge conflict');
141
+ if (b.pullBehavior === 'conflict') throw new PullRebaseConflictError('feat/x', ['foo.md'], 'CONFLICT (content)');
127
142
  return { treeChanged: true };
128
143
  }),
129
- } as unknown as GitService;
144
+ } as unknown as GitStub;
130
145
  }
131
146
 
132
147
  function makeFacade(
@@ -642,18 +657,15 @@ describe('VersionControlService — git-sync visibility events', () => {
642
657
  });
643
658
 
644
659
  it('emits git-sync-recovered once when a push finally lands, then goes quiet again', async () => {
645
- const failing = makeGit({ pushBehavior: 'auth-fail' });
646
- const svc = makeFacade(failing, makeFileLocks(USER.id), makePending(), events);
660
+ const git = makeGit({ pushBehavior: 'auth-fail' });
661
+ const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
647
662
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
648
663
  expect(kinds()).toEqual(['git-sync-failed']);
649
664
 
650
665
  // Same service instance — the failing/healthy state is per-workspace and
651
- // lives on it, so recovery has to be observed through the same object.
652
- const healthy = makeGit();
653
- const recovered = makeFacade(healthy, makeFileLocks(USER.id), makePending(), events);
654
- Object.assign(svc as unknown as Record<string, unknown>, {
655
- git: (recovered as unknown as { git: GitService }).git,
656
- });
666
+ // lives on it, so recovery has to be observed through the same object;
667
+ // the remote comes back by the stub's behaviour changing.
668
+ git.behave({ pushBehavior: 'ok' });
657
669
 
658
670
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
659
671
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
@@ -672,15 +684,11 @@ describe('VersionControlService — git-sync visibility events', () => {
672
684
  // callers hand the encoded form (user%2Ffeat). The tracker must treat
673
685
  // them as one workspace, or a failure recorded by one caller can never
674
686
  // be cleared by the other's success and the banner sticks forever.
675
- const svc = makeFacade(
676
- makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' }),
677
- makeFileLocks(USER.id),
678
- makePending(),
679
- events,
680
- );
687
+ const git = makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' });
688
+ const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
681
689
  await svc.runPendingCommit('user%2Ffeat', 'user/feat', 'foo.md', USER).catch(() => undefined);
682
690
 
683
- Object.assign(svc as unknown as Record<string, unknown>, { git: makeGit() });
691
+ git.behave({ pushBehavior: 'ok', pushAfterPullBehavior: 'ok' });
684
692
  await svc.runPendingCommit('user/feat', 'user/feat', 'foo.md', USER);
685
693
 
686
694
  const sync = emitSpy.mock.calls
@@ -694,18 +702,20 @@ describe('VersionControlService — git-sync visibility events', () => {
694
702
  });
695
703
 
696
704
  it('tracks workspaces independently — one recovering neither clears nor suppresses another', async () => {
697
- const failingGit = () => makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' });
698
- const svc = makeFacade(failingGit(), makeFileLocks(USER.id), makePending(), events);
705
+ const failing = { pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' } as const;
706
+ const git = makeGit(failing);
707
+ const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
699
708
 
700
709
  // Both workspaces broken.
701
710
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
711
+ git.behave(failing);
702
712
  await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
703
713
 
704
714
  // ws-1 recovers; ws-2 is still broken and must (a) not be cleared by
705
715
  // ws-1's recovery and (b) still announce its own next failure.
706
- Object.assign(svc as unknown as Record<string, unknown>, { git: makeGit() });
716
+ git.behave({ pushBehavior: 'ok', pushAfterPullBehavior: 'ok' });
707
717
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
708
- Object.assign(svc as unknown as Record<string, unknown>, { git: failingGit() });
718
+ git.behave(failing);
709
719
  await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
710
720
 
711
721
  expect(