@bevel-software/platform-core-backend 0.29.0 → 0.30.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 (181) hide show
  1. package/dist/__tests__/test-repositories.d.ts +3 -0
  2. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  3. package/dist/__tests__/test-repositories.js +18 -0
  4. package/dist/__tests__/test-repositories.js.map +1 -0
  5. package/dist/core/create-core-server.js +1 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +3 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +32 -4
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core/lifecycle.d.ts +8 -0
  12. package/dist/core/lifecycle.d.ts.map +1 -1
  13. package/dist/core/lifecycle.js +4 -0
  14. package/dist/core/lifecycle.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +20 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +32 -1
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +138 -16
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access.routes.d.ts.map +1 -1
  22. package/dist/modules/access/access.routes.js +20 -6
  23. package/dist/modules/access/access.routes.js.map +1 -1
  24. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  25. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  26. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  27. package/dist/modules/diff/diff.service.js +7 -1
  28. package/dist/modules/diff/diff.service.js.map +1 -1
  29. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  30. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  31. package/dist/modules/kb-fs/git-dir.js +58 -0
  32. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  33. package/dist/modules/kb-fs/index.d.ts +2 -0
  34. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  35. package/dist/modules/kb-fs/index.js +2 -1
  36. package/dist/modules/kb-fs/index.js.map +1 -1
  37. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  38. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  39. package/dist/modules/kb-fs/ref-files.js +249 -0
  40. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  41. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  42. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  43. package/dist/modules/plugins/teams.routes.js +9 -1
  44. package/dist/modules/plugins/teams.routes.js.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  46. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  47. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  48. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  49. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  50. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  51. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  52. package/dist/modules/workflow/git/git.service.d.ts +29 -0
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +177 -24
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  57. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  58. package/dist/modules/workspace/agent-access.gate.js +2 -2
  59. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  60. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  61. package/dist/modules/workspace/bounded-matcher.js +261 -0
  62. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  63. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  64. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  65. package/dist/modules/workspace/fetch-instant.js +13 -0
  66. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  67. package/dist/modules/workspace/index.d.ts +7 -0
  68. package/dist/modules/workspace/index.d.ts.map +1 -1
  69. package/dist/modules/workspace/index.js +6 -0
  70. package/dist/modules/workspace/index.js.map +1 -1
  71. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  72. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  73. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  74. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  75. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  76. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  77. package/dist/modules/workspace/kb-repository.js +811 -0
  78. package/dist/modules/workspace/kb-repository.js.map +1 -0
  79. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  80. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  81. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  82. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  83. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  84. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  85. package/dist/modules/workspace/routine-write-policy.js +0 -5
  86. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  87. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  88. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  89. package/dist/modules/workspace/set-aside-clone.js +139 -0
  90. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  91. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  92. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  93. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  94. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  95. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  96. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  97. package/dist/modules/workspace/workspace.service.js +240 -161
  98. package/dist/modules/workspace/workspace.service.js.map +1 -1
  99. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  100. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  101. package/dist/modules/workspace/workspace.tools.js +162 -336
  102. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  103. package/dist/test-setup.js +4 -0
  104. package/dist/test-setup.js.map +1 -1
  105. package/package.json +3 -3
  106. package/src/__tests__/test-repositories.ts +22 -0
  107. package/src/__tests__/workspace-service.ts +69 -0
  108. package/src/core/__tests__/lifecycle.test.ts +16 -0
  109. package/src/core/create-core-server.ts +1 -1
  110. package/src/core/create-core-services.ts +36 -3
  111. package/src/core/lifecycle.ts +10 -0
  112. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  113. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  114. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  115. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  116. package/src/modules/access/access-control.interface.ts +22 -0
  117. package/src/modules/access/access-control.service.ts +142 -14
  118. package/src/modules/access/access.routes.ts +22 -6
  119. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  120. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  121. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  122. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  123. package/src/modules/diff/diff.service.ts +6 -4
  124. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  125. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  126. package/src/modules/kb-fs/git-dir.ts +53 -0
  127. package/src/modules/kb-fs/index.ts +2 -0
  128. package/src/modules/kb-fs/ref-files.ts +279 -0
  129. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  130. package/src/modules/plugins/teams.routes.ts +14 -1
  131. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  132. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  133. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  134. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  135. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  136. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  137. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  138. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
  139. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  140. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  141. package/src/modules/workflow/git/git.service.ts +184 -22
  142. package/src/modules/workflow/workflow-hooks.ts +1 -1
  143. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  144. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  145. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  146. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  147. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  148. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  149. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  150. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  151. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  152. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  153. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  154. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  155. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  156. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  157. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  158. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  159. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  160. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  161. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  162. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  163. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  164. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  165. package/src/modules/workspace/agent-access.gate.ts +2 -2
  166. package/src/modules/workspace/bounded-matcher.ts +328 -0
  167. package/src/modules/workspace/fetch-instant.ts +13 -0
  168. package/src/modules/workspace/index.ts +12 -0
  169. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  170. package/src/modules/workspace/kb-repository.ts +844 -0
  171. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  172. package/src/modules/workspace/routine-write-policy.ts +0 -15
  173. package/src/modules/workspace/set-aside-clone.ts +140 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  175. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  176. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  177. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  178. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  179. package/src/modules/workspace/workspace.service.ts +247 -169
  180. package/src/modules/workspace/workspace.tools.ts +179 -365
  181. package/src/test-setup.ts +5 -0
@@ -16,6 +16,7 @@ import type { ICreatorAccess } from '../../access-model/creator.js';
16
16
  import { createWorkspaceRoutes } from '../workspace.routes.js';
17
17
  import { WorkspaceService } from '../workspace.service.js';
18
18
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
19
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
19
20
 
20
21
  /**
21
22
  * A folder exists until someone deletes it, and its placeholder is never shown
@@ -56,7 +57,7 @@ async function makeHarness(): Promise<Harness> {
56
57
  const workspaceDir = path.join(root, workspaceId);
57
58
  // The inner `.git` lets the service accept the workspace without cloning.
58
59
  await fs.mkdir(path.join(workspaceDir, KB, '.git'), { recursive: true });
59
- const workspaceService = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
60
+ const workspaceService = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
60
61
  await workspaceService.getWorkspacePath(workspaceId);
61
62
 
62
63
  const releaseLock = vi.fn<(...args: unknown[]) => Promise<never>>(async () => undefined as never);
@@ -335,7 +336,7 @@ describe('WorkspaceService.writeFolderPlaceholder', () => {
335
336
  const workspaceId = workspaceIdForBranch('placeholders');
336
337
  const kbDir = path.join(root, workspaceId, KB);
337
338
  await fs.mkdir(path.join(kbDir, '.git'), { recursive: true });
338
- return { svc: new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs()), workspaceId, kbDir };
339
+ return { svc: newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs()), workspaceId, kbDir };
339
340
  }
340
341
 
341
342
  it('writes into an empty folder, and leaves a full or a vanished one alone', async () => {
@@ -378,7 +379,7 @@ describe('WorkspaceService.withFolderTurn', () => {
378
379
  root = await fs.mkdtemp(path.join(os.tmpdir(), 'folder-turn-'));
379
380
  const workspaceId = workspaceIdForBranch('turns');
380
381
  await fs.mkdir(path.join(root, workspaceId, KB, '.git'), { recursive: true });
381
- const svc = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
382
+ const svc = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
382
383
 
383
384
  const events: string[] = [];
384
385
  let open!: () => void;
@@ -6,9 +6,9 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { testKbContext } from '../../../__tests__/kb-context.js';
8
8
  import { NodeFs } from '../../kb-fs/node-fs.js';
9
- import { WorkspaceService } from '../workspace.service.js';
10
9
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
11
10
  import { gitCredentials } from '../../../shared/git.contract.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
14
 
@@ -87,7 +87,7 @@ async function anOldCloneWithNoHelper(branch: string): Promise<string> {
87
87
  }
88
88
 
89
89
  function service(token: string | null) {
90
- return new WorkspaceService(
90
+ return newWorkspaceService(
91
91
  workspacesRoot,
92
92
  () => upstream,
93
93
  testKbContext({ kbDirName: KB_DIR, branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
@@ -4,8 +4,8 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { NodeFs } from '../../kb-fs/node-fs.js';
7
- import { WorkspaceService } from '../workspace.service.js';
8
7
  import type { IGitRunner } from '../../../shared/git.contract.js';
8
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
9
9
 
10
10
  /**
11
11
  * `ensureRemotesFetched` is one `git fetch --prune origin` for a whole clone,
@@ -42,7 +42,7 @@ describe('WorkspaceService.ensureRemotesFetched', () => {
42
42
  });
43
43
 
44
44
  const service = () =>
45
- new WorkspaceService(
45
+ newWorkspaceService(
46
46
  root,
47
47
  'https://example.test/kb.git',
48
48
  testKbContext(),
@@ -4,8 +4,8 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { testKbContext } from '../../../__tests__/kb-context.js';
6
6
  import { NodeFs } from '../../kb-fs/node-fs.js';
7
- import { WorkspaceService } from '../workspace.service.js';
8
7
  import type { IGitRunner } from '../../../shared/git.contract.js';
8
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
9
9
 
10
10
  /**
11
11
  * What happens to work ALREADY IN FLIGHT when the knowledge-base repository is
@@ -56,7 +56,7 @@ function heldRunner() {
56
56
  }
57
57
 
58
58
  function service(runner: IGitRunner) {
59
- return new WorkspaceService(
59
+ return newWorkspaceService(
60
60
  workspacesRoot,
61
61
  () => `${root}/configured.git`,
62
62
  testKbContext({ branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
@@ -138,12 +138,19 @@ describe('a bootstrap in flight when the working copy is taken away', () => {
138
138
 
139
139
  // The replacement lands while the clone runs.
140
140
  svc.forgetClone(WORKSPACE_ID);
141
- // Let the clone — and every config call after it — finish.
142
- for (let i = 0; i < 40 && calls.length > 0; i++) {
141
+ // Let the checkout — and every git call after it — finish. Until the open
142
+ // has settled, not until no call is pending for a moment: making the
143
+ // repository does filesystem work between its git calls.
144
+ let settled = false;
145
+ void opening.then(
146
+ () => (settled = true),
147
+ () => (settled = true),
148
+ );
149
+ for (let i = 0; i < 400 && !settled; i++) {
143
150
  const pending = calls.splice(0, calls.length);
144
151
  for (const call of pending) call.settle();
145
152
  await Promise.resolve();
146
- await new Promise((r) => setTimeout(r, 0));
153
+ await new Promise((r) => setTimeout(r, 5));
147
154
  }
148
155
 
149
156
  await refused;
@@ -4,7 +4,7 @@ import { NodeFs } from '../../kb-fs/node-fs.js';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
- import { WorkspaceService } from '../workspace.service.js';
7
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
8
8
 
9
9
  /**
10
10
  * `listClonedWorkspaces` is what the remote sync enumerates. It must read the
@@ -37,7 +37,7 @@ describe('WorkspaceService.listClonedWorkspaces', () => {
37
37
  // A stray file at the root is ignored.
38
38
  await fs.writeFile(path.join(root, 'notes.txt'), 'x');
39
39
 
40
- const svc = new WorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
40
+ const svc = newWorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
41
41
  const found = await svc.listClonedWorkspaces();
42
42
  expect(found.sort((a, b) => a.branch.localeCompare(b.branch))).toEqual([
43
43
  { id: 'ali%2Fnew-skill', branch: 'ali/new-skill' },
@@ -46,7 +46,7 @@ describe('WorkspaceService.listClonedWorkspaces', () => {
46
46
  });
47
47
 
48
48
  it('is empty when the workspaces root does not exist yet', async () => {
49
- const svc = new WorkspaceService(path.join(root, 'missing'), 'https://example.test/kb.git', testKbContext(), new NodeFs());
49
+ const svc = newWorkspaceService(path.join(root, 'missing'), 'https://example.test/kb.git', testKbContext(), new NodeFs());
50
50
  expect(await svc.listClonedWorkspaces()).toEqual([]);
51
51
  });
52
52
  });
@@ -67,14 +67,14 @@ describe('WorkspaceService.listClonedWorkspaces — what is not a clone', () =>
67
67
  for (const name of ['main', 'foo!', '-leading-dash', 'a..b', 'has space']) {
68
68
  await fs.mkdir(path.join(root, name, 'knowledge-base', '.git'), { recursive: true });
69
69
  }
70
- const svc = new WorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
70
+ const svc = newWorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
71
71
  expect(await svc.listClonedWorkspaces()).toEqual([{ id: 'main', branch: 'main' }]);
72
72
  });
73
73
 
74
74
  it('a branch whose bootstrap is in flight right now', async () => {
75
75
  await fs.mkdir(path.join(root, 'main', 'knowledge-base', '.git'), { recursive: true });
76
76
  await fs.mkdir(path.join(root, 'ali%2Fx', 'knowledge-base', '.git'), { recursive: true });
77
- const svc = new WorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
77
+ const svc = newWorkspaceService(root, 'https://example.test/kb.git', testKbContext(), new NodeFs());
78
78
  // While a clone runs, `.git` exists but the tree is not checked out yet.
79
79
  // The listing asks the public predicate, which is stubbed here rather than
80
80
  // the tracker behind it — no public seam can hold a real clone open.
@@ -89,7 +89,7 @@ describe('WorkspaceService.listClonedWorkspaces — what is not a clone', () =>
89
89
  // to stage one in a temp dir; the code path is the same `throw err`.
90
90
  const file = path.join(root, 'not-a-dir');
91
91
  await fs.writeFile(file, 'x');
92
- const svc = new WorkspaceService(file, 'https://example.test/kb.git', testKbContext(), new NodeFs());
92
+ const svc = newWorkspaceService(file, 'https://example.test/kb.git', testKbContext(), new NodeFs());
93
93
  expect(await svc.listClonedWorkspaces()).toEqual([]);
94
94
  });
95
95
  });
@@ -6,6 +6,7 @@ import os from 'node:os';
6
6
  import { NodeFs } from '../../kb-fs/node-fs.js';
7
7
  import { WorkspaceService } from '../workspace.service.js';
8
8
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
9
10
 
10
11
  /**
11
12
  * A rename never overwrites.
@@ -55,7 +56,7 @@ describe('WorkspaceService.moveEntry — a name that is taken is refused, never
55
56
  // The inner `.git` is what lets `resolveWorkspaceDir` accept the workspace
56
57
  // without trying to clone.
57
58
  await fs.mkdir(path.join(workspaceDir, KB, '.git'), { recursive: true });
58
- svc = new WorkspaceService(
59
+ svc = newWorkspaceService(
59
60
  root,
60
61
  'https://github.com/Bevel-Software/knowledge-base.git',
61
62
  testKbContext({ kbDirName: KB }),
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { WorkspaceService, type ReadTreeFilter } from '../workspace.service.js';
8
8
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
9
10
 
10
11
  const KB = 'knowledge-base';
11
12
 
@@ -48,7 +49,7 @@ describe('WorkspaceService.listFiles — read filter', () => {
48
49
  // readers, say), and its files are readable.
49
50
  await mkFile(workspaceDir, 'Scopes/Deploy/SKILL.md');
50
51
  await mkFile(workspaceDir, 'Scopes/other.md');
51
- svc = new WorkspaceService(root, 'https://example.invalid/repo.git', testKbContext({ kbDirName: KB }), new NodeFs());
52
+ svc = newWorkspaceService(root, 'https://example.invalid/repo.git', testKbContext({ kbDirName: KB }), new NodeFs());
52
53
  await svc.getWorkspacePath(workspaceId);
53
54
  });
54
55
 
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { WorkspaceService } from '../workspace.service.js';
10
10
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
@@ -77,7 +78,7 @@ function service(
77
78
  aroundSetAside?: (workspaceId: string, move: () => Promise<void>) => Promise<void>,
78
79
  gitRunner: NodeGitRunner = new NodeGitRunner(),
79
80
  ) {
80
- return new WorkspaceService(
81
+ return newWorkspaceService(
81
82
  workspacesRoot,
82
83
  kbRepoUrl,
83
84
  testKbContext({ branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { WorkspaceService } from '../workspace.service.js';
10
10
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
@@ -66,7 +67,7 @@ describe('WorkspaceService — branch-keyed identity', () => {
66
67
  });
67
68
 
68
69
  it('a malformed workspace id (bad percent-escape) rejects as a 400 BranchNameError, not a 500', async () => {
69
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
70
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
70
71
  // `%zz` fails decodeURIComponent, falls back to itself, and `%` never
71
72
  // passes the branch-name rules — the cold-path bootstrap must surface
72
73
  // that as the status-carrying domain error, not a wrapped plain Error.
@@ -78,7 +79,7 @@ describe('WorkspaceService — branch-keyed identity', () => {
78
79
 
79
80
  it('returns workspace info derived from the branch — no .workspace.json on disk', async () => {
80
81
  const { workspaceId, workspaceDir } = await seedBranchWorkspace(root, 'target-company-state');
81
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
82
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
82
83
  const info = await svc.getOrCreateForBranch('target-company-state');
83
84
 
84
85
  expect(info.id).toBe(workspaceId);
@@ -91,7 +92,7 @@ describe('WorkspaceService — branch-keyed identity', () => {
91
92
  it('two branches map to distinct directories', async () => {
92
93
  const a = await seedBranchWorkspace(root, 'target-company-state');
93
94
  const b = await seedBranchWorkspace(root, 'alice/feature');
94
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
95
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
95
96
 
96
97
  const infoA = await svc.getOrCreateForBranch('target-company-state');
97
98
  const infoB = await svc.getOrCreateForBranch('alice/feature');
@@ -114,7 +115,7 @@ describe('WorkspaceService — branch-keyed identity', () => {
114
115
  const squatter = path.join(workspaceDir, 'knowledge-base');
115
116
  await fs.writeFile(squatter, 'not a clone', 'utf8');
116
117
 
117
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
118
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
118
119
  await expect(svc.getOrCreateForBranch('target-company-state')).rejects.toThrow(
119
120
  /"knowledge-base" in this workspace exists but is not a directory/,
120
121
  );
@@ -122,21 +123,21 @@ describe('WorkspaceService — branch-keyed identity', () => {
122
123
  });
123
124
 
124
125
  it('rejects invalid branch names before touching disk', async () => {
125
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
126
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
126
127
  await expect(svc.getOrCreateForBranch('')).rejects.toThrow();
127
128
  await expect(svc.getOrCreateForBranch('-bad-leading-dash')).rejects.toThrow();
128
129
  });
129
130
 
130
131
  it('getOrCreateForUser falls back to target-company-state when no branch is supplied', async () => {
131
132
  await seedBranchWorkspace(root, 'target-company-state');
132
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
133
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
133
134
  const info = await svc.getOrCreateForUser({ id: 'u', email: 'a@b.c', name: 'A' });
134
135
  expect(info.id).toBe(workspaceIdForBranch('target-company-state'));
135
136
  });
136
137
 
137
138
  it('getOrCreateForUser respects an explicit branch override', async () => {
138
139
  await seedBranchWorkspace(root, 'alice/feature');
139
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
140
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
140
141
  const info = await svc.getOrCreateForUser({ id: 'u', email: 'a@b.c', name: 'A' }, 'alice/feature');
141
142
  expect(info.id).toBe(workspaceIdForBranch('alice/feature'));
142
143
  });
@@ -156,7 +157,7 @@ describe('WorkspaceService.createDirectory', () => {
156
157
  workspaceDir = seeded.workspaceDir;
157
158
  repoDir = path.join(workspaceDir, 'knowledge-base');
158
159
  workspaceId = seeded.workspaceId;
159
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
160
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
160
161
  // Hydrate the in-memory map so subsequent ops resolve fast.
161
162
  await svc.getWorkspacePath(workspaceId);
162
163
  });
@@ -267,7 +268,7 @@ describe('WorkspaceService.writeFile — expectedContent', () => {
267
268
  const seeded = await seedBranchWorkspace(root, 'target-company-state');
268
269
  workspaceDir = seeded.workspaceDir;
269
270
  workspaceId = seeded.workspaceId;
270
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
271
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
271
272
  });
272
273
 
273
274
  afterEach(async () => {
@@ -408,7 +409,7 @@ describe('WorkspaceService.withPathTurn', () => {
408
409
  const seeded = await seedBranchWorkspace(root, 'target-company-state');
409
410
  workspaceDir = seeded.workspaceDir;
410
411
  workspaceId = seeded.workspaceId;
411
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
412
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
412
413
  });
413
414
 
414
415
  afterEach(async () => {
@@ -636,7 +637,7 @@ describe('WorkspaceService.assertContentMatches', () => {
636
637
  const seeded = await seedBranchWorkspace(root, 'target-company-state');
637
638
  workspaceDir = seeded.workspaceDir;
638
639
  workspaceId = seeded.workspaceId;
639
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
640
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
640
641
  });
641
642
 
642
643
  afterEach(async () => {
@@ -681,7 +682,7 @@ describe('WorkspaceService.createFolderZip', () => {
681
682
  workspaceDir = seeded.workspaceDir;
682
683
  repoDir = path.join(workspaceDir, 'knowledge-base');
683
684
  workspaceId = seeded.workspaceId;
684
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
685
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
685
686
  await svc.getWorkspacePath(workspaceId);
686
687
  });
687
688
 
@@ -879,7 +880,7 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
879
880
  });
880
881
 
881
882
  it('clones a branch on first bootstrap and checks out the right ref', async () => {
882
- const svc = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
883
+ const svc = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
883
884
  const info = await svc.getOrCreateForBranch('target-company-state');
884
885
 
885
886
  const repo = path.join(info.absolutePath, 'knowledge-base');
@@ -888,7 +889,7 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
888
889
  });
889
890
 
890
891
  it('uses an existing sibling clone as a --reference and stays dissociated', async () => {
891
- const svc = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
892
+ const svc = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
892
893
  // First branch: plain clone — becomes the sibling for the next bootstrap.
893
894
  await svc.getOrCreateForBranch('target-company-state');
894
895
  // Second branch: should borrow the first clone's objects, then dissociate.
@@ -916,7 +917,7 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
916
917
  // post-merge pull of a target branch. Every clone this service hands out must
917
918
  // therefore track exactly one upstream ref through exactly one refspec.
918
919
  it('stamps a single fetch refspec and upstream ref on a fresh clone', async () => {
919
- const svc = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
920
+ const svc = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
920
921
  const info = await svc.getOrCreateForBranch('target-company-state');
921
922
  const repo = path.join(info.absolutePath, 'knowledge-base');
922
923
 
@@ -940,7 +941,7 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
940
941
  await runGit(repo, ['config', '--add', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*']);
941
942
  await runGit(repo, ['config', '--add', 'branch.alice/draft.merge', 'refs/heads/target-company-state']);
942
943
 
943
- const svc = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
944
+ const svc = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
944
945
  await svc.getOrCreateForBranch('alice/draft');
945
946
 
946
947
  expect(await gitOut(repo, ['config', '--get-all', 'remote.origin.fetch']))
@@ -952,7 +953,7 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
952
953
  });
953
954
 
954
955
  it('notifies the cloned-workspace listener with the workspace id after a clone', async () => {
955
- const svc = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
956
+ const svc = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
956
957
  const cloned: string[] = [];
957
958
  svc.setWorkspaceClonedListener((id) => cloned.push(id));
958
959
 
@@ -980,7 +981,7 @@ describe('WorkspaceService — symbolic links', () => {
980
981
  const seeded = await seedBranchWorkspace(root, 'main');
981
982
  workspaceDir = seeded.workspaceDir;
982
983
  workspaceId = seeded.workspaceId;
983
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
984
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
984
985
  });
985
986
 
986
987
  afterEach(async () => {
@@ -1065,7 +1066,7 @@ describe('WorkspaceService — symbolic links', () => {
1065
1066
  // A mounted-volume shape: the whole workspaces root reached through a link.
1066
1067
  const mount = path.join(root, 'mount');
1067
1068
  await fs.symlink(root, mount, linkType);
1068
- const viaMount = new WorkspaceService(mount, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1069
+ const viaMount = newWorkspaceService(mount, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1069
1070
  expect(await viaMount.readFile(workspaceId, rel)).toBe('Hello.');
1070
1071
  await viaMount.writeFile(workspaceId, rel, 'Hello again.');
1071
1072
  expect(await fs.readFile(path.join(workspaceDir, rel), 'utf-8')).toBe('Hello again.');
@@ -1098,7 +1099,7 @@ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
1098
1099
 
1099
1100
  it('removes the clone of a branch that is not in the known set', async () => {
1100
1101
  const { workspaceId, workspaceDir } = await seedBranchWorkspace(root, 'target-company-state');
1101
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1102
+ const svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1102
1103
  await svc.getOrCreateForBranch('target-company-state');
1103
1104
 
1104
1105
  // The branch vanishes from the known set; the sweep reclaims its clone.
@@ -1119,7 +1120,7 @@ describe('WorkspaceService.readAllKbFiles', () => {
1119
1120
  const seeded = await seedBranchWorkspace(root, 'target-company-state');
1120
1121
  repoRoot = path.join(seeded.workspaceDir, 'knowledge-base');
1121
1122
  workspaceId = seeded.workspaceId;
1122
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1123
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1123
1124
  await svc.getWorkspacePath(workspaceId);
1124
1125
  });
1125
1126
 
@@ -1178,7 +1179,7 @@ describe('WorkspaceService.unzipFile — per-entry write guard', () => {
1178
1179
  workspaceDir = seeded.workspaceDir;
1179
1180
  repoDir = path.join(workspaceDir, 'knowledge-base');
1180
1181
  workspaceId = seeded.workspaceId;
1181
- svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1182
+ svc = newWorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', testKbContext(), new NodeFs());
1182
1183
  await svc.getWorkspacePath(workspaceId);
1183
1184
  });
1184
1185
 
@@ -1389,7 +1390,7 @@ describe('WorkspaceService — every operation resolves inside the repository',
1389
1390
  workspaceDir = seeded.workspaceDir;
1390
1391
  repoDir = path.join(workspaceDir, 'knowledge-base');
1391
1392
  workspaceId = seeded.workspaceId;
1392
- svc = new WorkspaceService(root, 'https://example.invalid/kb.git', testKbContext(), new NodeFs());
1393
+ svc = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext(), new NodeFs());
1393
1394
  await svc.getWorkspacePath(workspaceId);
1394
1395
  });
1395
1396
 
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import { NodeFs } from '../../kb-fs/node-fs.js';
9
9
  import { WorkspaceService } from '../workspace.service.js';
10
10
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
@@ -64,7 +65,7 @@ describe('WorkspaceService — a branch origin does not have', () => {
64
65
  const deleteOnHost = (branch: string): Promise<void> => runGit(upstream, ['branch', '-D', branch]);
65
66
 
66
67
  const service = (repoUrl = upstream): WorkspaceService =>
67
- new WorkspaceService(workspacesRoot, repoUrl, testKbContext(), new NodeFs());
68
+ newWorkspaceService(workspacesRoot, repoUrl, testKbContext(), new NodeFs());
68
69
 
69
70
  it('a name nothing has ever cloned or listed is a 404 that names only the branch', async () => {
70
71
  const svc = service();