@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
@@ -0,0 +1,69 @@
1
+ import { ToolError } from '../tool-helpers/tool.contract.js';
2
+
3
+ /**
4
+ * The contract for running a CALLER'S regular expression: what `grep` (REST,
5
+ * MCP and `call_tool_chain` alike, one handler) depends on, so it never holds
6
+ * a concrete engine. The one implementation is `WorkerPatternMatcher`
7
+ * (`bounded-matcher.ts`), owned by the composition root and closed with the
8
+ * graph.
9
+ *
10
+ * JavaScript's engine backtracks, so a pattern like `^(a+)+$` over a line of
11
+ * forty `a`s and a `!` never finishes. An implementation must therefore never
12
+ * run the pattern on the request thread, must stop a search whose matching
13
+ * outlives its budget with {@link PatternTooExpensiveError}, and must refuse
14
+ * work it cannot take on with {@link SearchBusyError} rather than queue it
15
+ * without bound. Within budget, a pattern matches exactly the lines
16
+ * `RegExp#test` does.
17
+ */
18
+ export interface IPatternMatcher {
19
+ /**
20
+ * One search's matcher for `source`/`flags`: its budget is spent across
21
+ * every call it makes. Throws the 400 `Invalid regex` the caller's syntax
22
+ * earns, or the 400 `pattern_too_long` an over-long pattern earns.
23
+ */
24
+ search(source: string, flags: string): IPatternSearch;
25
+ /** Ends the workers; a search after this is refused. */
26
+ close(): Promise<void>;
27
+ }
28
+
29
+ /** One search, under one budget. */
30
+ export interface IPatternSearch {
31
+ /**
32
+ * The indices of the lines the pattern matches, in order, at most `limit` of
33
+ * them (the first `limit`, exactly as a loop that stops at the cap finds).
34
+ * Throws {@link PatternTooExpensiveError} once the search's budget is spent.
35
+ */
36
+ matchingLines(lines: string[], limit: number): Promise<number[]>;
37
+ }
38
+
39
+ /** A pattern stopped for taking too long — a 400, worded for the caller. */
40
+ export class PatternTooExpensiveError extends ToolError {
41
+ constructor(pattern: string, budgetMs: number) {
42
+ super(
43
+ `Pattern too expensive: /${pattern.length > 80 ? `${pattern.slice(0, 80)}…` : pattern}/ did not finish matching within this search's ${budgetMs / 1000} s budget, ` +
44
+ 'so it was stopped and nothing was returned. Patterns with nested or overlapping repetition such as ' +
45
+ '`(a+)+` or `(a|aa)*` can take exponential time on some lines. Use a simpler pattern — a literal, ' +
46
+ 'an anchor, a character class, a single repetition — or narrow `path`.',
47
+ 400,
48
+ { code: 'pattern_too_expensive' },
49
+ );
50
+ this.name = 'PatternTooExpensiveError';
51
+ }
52
+ }
53
+
54
+ /** Every worker busy and the queue for one full — a 503, so the caller retries rather than reads a partial answer. */
55
+ export class SearchBusyError extends ToolError {
56
+ constructor() {
57
+ super('Too many searches are running at once; try again in a few seconds.', 503, { code: 'search_busy' });
58
+ this.name = 'SearchBusyError';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Whether `err` ends a whole search rather than one file's part of it: a
64
+ * directory walk that skipped a file for either would answer an incomplete
65
+ * result as if it were the whole one.
66
+ */
67
+ export function endsTheSearch(err: unknown): boolean {
68
+ return err instanceof PatternTooExpensiveError || err instanceof SearchBusyError;
69
+ }
@@ -33,12 +33,6 @@ export interface IRoutineWritePolicy {
33
33
  * always passes — so this is a no-op for every non-restricted caller.
34
34
  */
35
35
  assertPathWritable(sessionId: string | undefined, wsPath: string): void;
36
- /**
37
- * Throw a 403 `ToolError` when the session is restricted at all — for write
38
- * tools with no single target path to check (arbitrary shell), which a
39
- * restricted run must not be able to use as a write bypass.
40
- */
41
- assertUnrestricted(sessionId: string | undefined): void;
42
36
  }
43
37
 
44
38
  export class RoutineWritePolicyService implements IRoutineWritePolicy {
@@ -66,13 +60,4 @@ export class RoutineWritePolicyService implements IRoutineWritePolicy {
66
60
  403,
67
61
  );
68
62
  }
69
-
70
- assertUnrestricted(sessionId: string | undefined): void {
71
- if (sessionId && this.restrictions.has(sessionId)) {
72
- throw new ToolError(
73
- 'This run is restricted to writing dashboard views only, so shell commands (which could write any file) are disabled.',
74
- 403,
75
- );
76
- }
77
- }
78
63
  }
@@ -1,5 +1,40 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { logger } from '../../shared/logging.js';
4
+ import { isAbsence } from '../../shared/fs.contract.js';
5
+ import { readGitfile } from '../kb-fs/index.js';
6
+ import type { IKbRepository } from './kb-repository.contract.js';
7
+
8
+ const log = logger('set-aside');
9
+
10
+ /**
11
+ * `p` as the file system names it, for comparing two paths that may be
12
+ * spelled differently: git writes a worktree's link with forward slashes and
13
+ * the long folder names (`C:/Users/runneradmin/…`) where node may hold the
14
+ * same folder by its 8.3 short name (`C:\Users\RUNNER~1\…`). The nearest
15
+ * part that exists is resolved by the OS, the rest appended; on Windows the
16
+ * result is case-folded, as its file system is. Only an absent part is
17
+ * stepped over: a part that cannot be read (a permission or I/O error) throws,
18
+ * since "could not tell" is no reason to move someone's worktree.
19
+ */
20
+ async function comparablePath(p: string): Promise<string> {
21
+ let existing = path.resolve(p);
22
+ const rest: string[] = [];
23
+ for (;;) {
24
+ try {
25
+ existing = await fs.realpath(existing);
26
+ break;
27
+ } catch (err) {
28
+ if (!isAbsence(err)) throw err;
29
+ const parent = path.dirname(existing);
30
+ if (parent === existing) break;
31
+ rest.unshift(path.basename(existing));
32
+ existing = parent;
33
+ }
34
+ }
35
+ const joined = path.join(existing, ...rest);
36
+ return process.platform === 'win32' ? joined.toLowerCase() : joined;
37
+ }
3
38
 
4
39
  /**
5
40
  * Moving a working copy of a repository that is no longer the configured one
@@ -94,3 +129,108 @@ export async function setAsideClone(repoDir: string, dest: string): Promise<void
94
129
  }
95
130
  await fs.rm(repoDir, { recursive: true, force: true });
96
131
  }
132
+
133
+ /**
134
+ * Set aside the knowledge base's repository AND EVERY WORKTREE OF IT, together,
135
+ * into `dest`: each worktree's folder as `dest/<workspace id>` (where a clone
136
+ * lands), the repository beside them under its own name.
137
+ *
138
+ * Together, because neither is any use without the other: a worktree's
139
+ * commits live in the repository, and a repository of another address is
140
+ * exactly as unusable as a clone of one. Nothing is deleted. The moved
141
+ * worktrees are re-linked to the moved repository, so `git log` in
142
+ * `dest/<workspace id>` shows the work that was never pushed.
143
+ *
144
+ * All or nothing: when a move fails part-way (the repository will not move
145
+ * after its worktrees did), the worktrees already moved are put back where
146
+ * they were, so a retry finds them with their repository and moves them
147
+ * together. A repository whose presence cannot be read (a permission or I/O
148
+ * error, not an absence) stops the call before anything moves.
149
+ *
150
+ * A worktree is found by its `.git` FILE naming a folder inside the
151
+ * repository — whether or not that folder is still there — so a worktree
152
+ * git had already lost track of goes with the rest. As the repository's one
153
+ * administrator (`exclusive`): no creation of a fresh repository and no
154
+ * config write runs while the folders move. Git operations in the worktrees
155
+ * are the caller's to hold off: the startup phase runs before anything is
156
+ * served, and the workspace service wraps the move in what surrounds a
157
+ * set-aside. Answers the workspace ids moved.
158
+ *
159
+ * `stillApplies`, when given, is asked again as the administrator, before
160
+ * anything moves: a caller decided to set the repository aside on what it
161
+ * read before taking the reservation, and another caller may have set it
162
+ * aside and made a fresh one of the configured address meanwhile. When the
163
+ * answer is no, nothing moves and the call answers no ids.
164
+ *
165
+ * A workspaces root, or a working copy's `.git`, that cannot be read (a
166
+ * permission or I/O error, not an absence) stops the call before anything
167
+ * moves, as an unreadable repository does: moving the repository without
168
+ * worktrees it could not see would leave every one of them pointing at
169
+ * nothing.
170
+ */
171
+ export async function setAsideRepository(
172
+ repository: IKbRepository,
173
+ workspacesRoot: string,
174
+ kbDirName: string,
175
+ dest: string,
176
+ stillApplies?: () => Promise<boolean>,
177
+ ): Promise<string[]> {
178
+ return repository.exclusive(async () => {
179
+ if (stillApplies && !(await stillApplies())) return [];
180
+ const repositoryPath = await comparablePath(repository.dir);
181
+ const inside = async (p: string): Promise<boolean> => {
182
+ const at = await comparablePath(p);
183
+ return at === repositoryPath || at.startsWith(repositoryPath + path.sep);
184
+ };
185
+ const worktrees: Array<{ id: string; dir: string }> = [];
186
+ let entries: import('node:fs').Dirent[] = [];
187
+ try {
188
+ entries = await fs.readdir(workspacesRoot, { withFileTypes: true });
189
+ } catch (err) {
190
+ const code = (err as NodeJS.ErrnoException).code;
191
+ // No workspaces root: only the repository to move.
192
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err;
193
+ }
194
+ for (const entry of entries) {
195
+ if (!entry.isDirectory()) continue;
196
+ const dir = path.join(workspacesRoot, entry.name, kbDirName);
197
+ // Only absence shows a folder is no worktree: a `.git` that could not be
198
+ // read (EACCES, EIO) stops the call, as an unreadable root does.
199
+ const gitdir = await readGitfile(path.join(dir, '.git'));
200
+ if (gitdir !== null && (await inside(gitdir))) worktrees.push({ id: entry.name, dir });
201
+ }
202
+ const repositoryThere = await fs.access(repository.dir).then(
203
+ () => true,
204
+ (err: NodeJS.ErrnoException) => {
205
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
206
+ throw err;
207
+ },
208
+ );
209
+ if (!repositoryThere && worktrees.length === 0) return [];
210
+ const moved: Array<{ dir: string; to: string }> = [];
211
+ const movedRepository = path.join(dest, path.basename(repository.dir));
212
+ try {
213
+ for (const { id, dir } of worktrees) {
214
+ const to = path.join(dest, id);
215
+ await setAsideClone(dir, to);
216
+ moved.push({ dir, to });
217
+ }
218
+ if (repositoryThere) await setAsideClone(repository.dir, movedRepository);
219
+ } catch (err) {
220
+ // Back where they were, beside the repository that did not move.
221
+ for (const { dir, to } of moved.reverse()) {
222
+ await setAsideClone(to, dir).catch((backErr: unknown) => {
223
+ log.error(`the set-aside worktree ${to} could not be put back at ${dir}; it is kept there:`, {
224
+ detail: backErr instanceof Error ? backErr.message : String(backErr),
225
+ });
226
+ });
227
+ }
228
+ throw err;
229
+ }
230
+ if (repositoryThere) {
231
+ await repository.relinkMovedCopy(movedRepository, moved.map(({ to }) => to));
232
+ }
233
+ repository.forget();
234
+ return worktrees.map(({ id }) => id);
235
+ });
236
+ }
@@ -4,10 +4,11 @@ import { promisify } from 'node:util';
4
4
  import fs from 'node:fs/promises';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { KbRemoteUnreachableError, KbStartupRunner } from '../kb-startup-runner.js';
7
+ import { KbRemoteUnreachableError } from '../kb-startup-runner.js';
8
8
  import { NodeGitRunner } from '../../../workflow/git/node-git-runner.js';
9
9
  import { GitRunError, NO_GIT_CREDENTIALS, type IGitRunner } from '../../../../shared/git.contract.js';
10
10
  import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
11
+ import { newKbStartupRunner } from '../../../../__tests__/workspace-service.js';
11
12
 
12
13
  /**
13
14
  * What the runner does when the remote is not there — the one failure a boot
@@ -66,7 +67,7 @@ async function waitFor(condition: () => boolean, timeoutMs = 20_000): Promise<vo
66
67
  }
67
68
 
68
69
  function makeRunner(steps: OnServerStart[], url: () => string, gitRunner: IGitRunner = new NodeGitRunner()) {
69
- return new KbStartupRunner({
70
+ return newKbStartupRunner({
70
71
  gitRunner,
71
72
  kbRepoUrl: url,
72
73
  workspacesRoot,
@@ -6,12 +6,11 @@ import { promisify } from 'node:util';
6
6
  import fs from 'node:fs/promises';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
- import { KbStartupRunner } from '../kb-startup-runner.js';
10
- import { WorkspaceService } from '../../workspace.service.js';
11
9
  import { NodeGitRunner } from '../../../workflow/git/node-git-runner.js';
12
10
  import { ClassifiedFailure, classifyGitFailure, failureOf } from '../../../../shared/git-failure.js';
13
11
  import { GitRunError, gitCredentials } from '../../../../shared/git.contract.js';
14
12
  import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
13
+ import { newKbStartupRunner, newWorkspaceService } from '../../../../__tests__/workspace-service.js';
15
14
 
16
15
  /**
17
16
  * The token in effect for the runners these suites build, changed by the
@@ -71,7 +70,7 @@ async function populatedUpstream(): Promise<void> {
71
70
  }
72
71
 
73
72
  function makeRunner(steps: OnServerStart[], overrides: Partial<Parameters<typeof runnerOpts>[1]> = {}) {
74
- return new KbStartupRunner(runnerOpts(steps, overrides));
73
+ return newKbStartupRunner(runnerOpts(steps, overrides));
75
74
  }
76
75
 
77
76
  function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> = {}) {
@@ -996,8 +995,11 @@ describe('KbStartupRunner — a working copy whose clone was never finished', ()
996
995
  // The clone is sound and has a commit, but git cannot read the branch it
997
996
  // is to follow: the ref the fetch updates is damaged. Not a state this
998
997
  // phase repairs, so the start stops, and says which working copy.
999
- const refs = path.join(local(), '.git', 'refs', 'remotes', 'origin');
1000
- await fs.rm(path.join(local(), '.git', 'packed-refs'), { force: true });
998
+ // The refs are where git says they are: a worktree's live in the
999
+ // repository it shares with every other branch.
1000
+ const gitDir = (await git(local(), ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
1001
+ const refs = path.join(gitDir, 'refs', 'remotes', 'origin');
1002
+ await fs.rm(path.join(gitDir, 'packed-refs'), { force: true });
1001
1003
  await fs.mkdir(refs, { recursive: true });
1002
1004
  await fs.writeFile(path.join(refs, DEFAULT_BRANCH), 'not a commit id\n', 'utf8');
1003
1005
 
@@ -1129,7 +1131,7 @@ describe('KbStartupRunner credentials', () => {
1129
1131
  await git(repo, ['config', '--add', 'credential.helper',
1130
1132
  '!f() { echo "username=stale-user"; echo "password=$GITHUB_TOKEN"; }; f']);
1131
1133
 
1132
- const ws = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1134
+ const ws = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1133
1135
  await ws.getOrCreateForBranch(DEFAULT_BRANCH);
1134
1136
 
1135
1137
  const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
@@ -1147,14 +1149,14 @@ describe('KbStartupRunner credentials', () => {
1147
1149
  const repo = await materializeDefaultBranchClone();
1148
1150
  await git(repo, ['config', '--add', 'credential.helper', 'cache --timeout=300']);
1149
1151
 
1150
- const ws = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1152
+ const ws = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1151
1153
  await ws.getOrCreateForBranch(DEFAULT_BRANCH); // stamp with token
1152
1154
  let all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
1153
1155
  expect(all).toContain('cache --timeout=300');
1154
1156
  expect(all).toContain('password=$GITHUB_TOKEN');
1155
1157
 
1156
1158
  token = null;
1157
- const ws2 =new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1159
+ const ws2 =newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1158
1160
  await ws2.getOrCreateForBranch(DEFAULT_BRANCH); // unset OUR helper only
1159
1161
  all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
1160
1162
  expect(all).toContain('cache --timeout=300');
@@ -1170,7 +1172,7 @@ describe('KbStartupRunner credentials', () => {
1170
1172
  const repo = await materializeDefaultBranchClone(); // stamped
1171
1173
  token = null;
1172
1174
 
1173
- const ws = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1175
+ const ws = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1174
1176
  await ws.getOrCreateForBranch(DEFAULT_BRANCH);
1175
1177
 
1176
1178
  await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
@@ -1185,7 +1187,7 @@ describe('KbStartupRunner credentials', () => {
1185
1187
  await populatedUpstream();
1186
1188
  const repo = await materializeDefaultBranchClone(); // no helper
1187
1189
 
1188
- const ws = new WorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1190
+ const ws = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs(), new NodeGitRunner(undefined, credentials));
1189
1191
  await ws.getOrCreateForBranch(DEFAULT_BRANCH); // adopt, tokenless
1190
1192
  token = 'ghp_late';
1191
1193
  await ws.getOrCreateForBranch(DEFAULT_BRANCH); // cached fast path