@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
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
2
3
  import { logger } from '../../shared/logging.js';
3
4
  const log = logger('workspace');
4
5
  import path from 'node:path';
@@ -20,8 +21,9 @@ import { BranchNotFoundError, RemoteBranchGoneError, WorkflowDomainError, isMiss
20
21
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
21
22
  import { normalizeRepositoryAddress, sameRepository } from '../kb-fs/remote-url.js';
22
23
  import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
23
- import { setAsideClone, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
24
- import { cloneCredentialArgs, cloneCredentialConfigArgs, credentialHelperValue, cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS, } from '../kb-fs/clone-config.js';
24
+ import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
25
+ import { workingCopyShape } from './kb-repository.js';
26
+ import { cloneCredentialConfigArgs, credentialHelperValue, cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS, } from '../kb-fs/clone-config.js';
25
27
  // Zip-bomb guardrails. The upload cap is 50 MB compressed; even a generous
26
28
  // 100× expansion ratio still fits inside these limits, so a well-behaved
27
29
  // archive will never trip them. They exist so a hostile or malformed zip
@@ -76,21 +78,6 @@ export class EntryExistsError extends WorkflowDomainError {
76
78
  this.name = 'EntryExistsError';
77
79
  }
78
80
  }
79
- /**
80
- * A floor under the git deadline for a clone: a first clone of a large
81
- * knowledge base over a slow link legitimately takes minutes, and the port's
82
- * default is sized for a running deployment's operations. A configured
83
- * ceiling above this applies as configured.
84
- */
85
- const CLONE_TIMEOUT_MS = 600_000;
86
- /**
87
- * Identity stamped on the per-branch clone's git config. Every workflow
88
- * commit overrides this with `--author=…` so the human shows up in
89
- * `git log`; this is just the fallback committer identity so plain
90
- * git operations have something to attribute to.
91
- */
92
- const BOT_NAME = 'Bevel Workflow';
93
- const BOT_EMAIL = 'bevel-workflow@bevel.software';
94
81
  /** Redact the token the runner authenticates with from an error message. */
95
82
  function redactError(err, token) {
96
83
  const msg = err instanceof Error ? err.message : String(err);
@@ -154,6 +141,7 @@ export class WorkspaceService {
154
141
  gitRunner;
155
142
  setAsideRoot;
156
143
  aroundSetAside;
144
+ repository;
157
145
  /** Maps branch → absolute directory path. Lazily populated. */
158
146
  branchDirs = new Map();
159
147
  /**
@@ -245,15 +233,62 @@ export class WorkspaceService {
245
233
  * where it was and the branch is refused with that reason. Absent, the
246
234
  * move runs as it is.
247
235
  */
248
- aroundSetAside = (_workspaceId, move) => move()) {
236
+ aroundSetAside = (_workspaceId, move) => move(),
237
+ /**
238
+ * The one repository every branch workspace is a worktree of — THE SAME
239
+ * INSTANCE THE KB STARTUP PHASE AND THE GIT LAYER USE, so that one fetch
240
+ * serves them all and its reservation is one. Built by the composition
241
+ * root, never here.
242
+ */
243
+ repository) {
249
244
  this.workspacesRoot = workspacesRoot;
250
245
  this.kb = kb;
251
246
  this.disk = disk;
252
247
  this.gitRunner = gitRunner;
253
248
  this.setAsideRoot = setAsideRoot;
254
249
  this.aroundSetAside = aroundSetAside;
250
+ this.repository = repository;
255
251
  this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
256
252
  }
253
+ /**
254
+ * Whether the checkout folder `repoDir` is a worktree (its `.git` a file)
255
+ * rather than a full clone. A stat, no git.
256
+ */
257
+ async isWorktree(repoDir) {
258
+ // Synchronous on purpose: it is one stat of a local file, and it sits in
259
+ // front of the fetch joins (`ensureRemotesFetched`), where an await
260
+ // between two concurrent callers' in-flight checks is what lets both
261
+ // start a fetch.
262
+ try {
263
+ return statSync(path.join(repoDir, '.git')).isFile();
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+ /**
270
+ * Whether `repoDir` holds a working copy that can be served: a worktree of
271
+ * the repository or a full clone. A worktree whose link to the repository is
272
+ * broken is NOT one — it is an unfinished checkout, recreated on the next
273
+ * open. Errors other than absence propagate.
274
+ */
275
+ async hasWorkingCopy(repoDir) {
276
+ const shape = await workingCopyShape(repoDir);
277
+ return shape === 'worktree' || shape === 'clone';
278
+ }
279
+ /**
280
+ * The repository behind a workspace when it is a worktree of it; null for a
281
+ * full clone (which fetches on its own) or a workspace with no checkout.
282
+ * For the git layer, which keys its fetches on this.
283
+ */
284
+ async sharedRepositoryOf(workspaceId) {
285
+ const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
286
+ return this.repositoryOfCheckout(path.join(workspaceDir, this.kbDirName));
287
+ }
288
+ /** {@link sharedRepositoryOf}, for a caller that already has the checkout folder. */
289
+ async repositoryOfCheckout(repoDir) {
290
+ return (await this.isWorktree(repoDir)) ? this.repository : null;
291
+ }
257
292
  /**
258
293
  * The set-asides in flight, one per branch. Two callers opening the same
259
294
  * branch both find the clone of another repository; the second waits for
@@ -389,64 +424,6 @@ export class WorkspaceService {
389
424
  return err instanceof GitRunError && err.exitCode === 2;
390
425
  }
391
426
  }
392
- /**
393
- * Build git clone args. Credentials go in as `clone --config`, which both
394
- * authenticates this clone and leaves the helper in the new repo's config for
395
- * every later push — and keeps the token itself out of the repo URL, out of
396
- * argv, and out of `.git/config` (the helper reads it from the environment).
397
- */
398
- gitCloneArgs(targetDir, branch, referenceRepo) {
399
- // core.longpaths=true: KB has paths >260 chars; without this the clone silently drops those files on Windows.
400
- const args = ['clone', '-c', 'core.longpaths=true', '-b', branch];
401
- // Borrow the object database of an existing sibling clone. Every commit
402
- // in the KB is already on disk locally, so object transfer drops to
403
- // ~zero and a cold branch switch pays only the ref-negotiation
404
- // round-trip instead of a full download. `--dissociate` copies the
405
- // borrowed objects into the new repo and drops the alternates link, so
406
- // the new clone stays independent even if the sibling is later swept
407
- // or gc'd. Still a real clone of the real remote — all `refs/remotes/
408
- // origin/*` are populated as usual.
409
- if (referenceRepo) {
410
- args.push('--reference', referenceRepo, '--dissociate');
411
- }
412
- // Persisted into the new repo's config (`clone --config`), not just applied
413
- // to this invocation — every later push from this clone depends on finding
414
- // the helper there. See `cloneCredentialArgs`.
415
- args.push(...cloneCredentialArgs(this.gitRunner.credentials));
416
- args.push(this.kbRepoUrl(), targetDir);
417
- return args;
418
- }
419
- /**
420
- * Find an existing workspace clone on disk to use as a `--reference` for
421
- * a new clone. Any clone of the KB works — they all share the same object
422
- * history. Returns the absolute path to the inner repo dir, or null when
423
- * no sibling exists (the first branch after a cold start). Scans disk
424
- * rather than the in-memory `branchDirs` map so clones that survived a
425
- * process restart are still found.
426
- */
427
- async findSiblingRepo(excludeBranch) {
428
- const excludeId = workspaceIdForBranch(excludeBranch);
429
- let entries;
430
- try {
431
- entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
432
- }
433
- catch {
434
- return null;
435
- }
436
- for (const entry of entries) {
437
- if (!entry.isDirectory() || entry.name === excludeId)
438
- continue;
439
- const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
440
- try {
441
- await fs.access(path.join(repoDir, '.git'));
442
- return repoDir;
443
- }
444
- catch {
445
- // No `.git` — half-built or unrelated dir; keep looking.
446
- }
447
- }
448
- return null;
449
- }
450
427
  /**
451
428
  * Return the id of any workspace already cloned on disk, or null if none
452
429
  * exists. Every clone tracks the same origin, so for a *repo-global* operation
@@ -482,13 +459,10 @@ export class WorkspaceService {
482
459
  if (!entry.isDirectory())
483
460
  continue;
484
461
  const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
485
- try {
486
- await fs.access(path.join(repoDir, '.git'));
487
- }
488
- catch {
489
- // No `.git` — half-built or unrelated dir; keep looking.
462
+ // No working copy — half-built, a worktree whose repository is gone, or
463
+ // an unrelated dir; keep looking.
464
+ if (!(await this.hasWorkingCopy(repoDir).catch(() => false)))
490
465
  continue;
491
- }
492
466
  await this.refreshCredentialHelperIfStale(branchForWorkspaceId(entry.name), repoDir);
493
467
  return entry.name;
494
468
  }
@@ -544,7 +518,10 @@ export class WorkspaceService {
544
518
  continue;
545
519
  }
546
520
  try {
547
- await fs.access(path.join(this.workspacesRoot, entry.name, this.kbDirName, '.git'));
521
+ // A worktree whose repository is gone is not a working copy to pull:
522
+ // it is recreated when the branch is next opened.
523
+ if (!(await this.hasWorkingCopy(path.join(this.workspacesRoot, entry.name, this.kbDirName))))
524
+ continue;
548
525
  }
549
526
  catch (err) {
550
527
  if (isAbsence(err))
@@ -566,32 +543,6 @@ export class WorkspaceService {
566
543
  }
567
544
  return cloned;
568
545
  }
569
- /**
570
- * Run the clone. When a `referenceRepo` is supplied and the referenced
571
- * clone fails (e.g. the sibling's object store is corrupt or mid-write),
572
- * fall back once to a plain network clone so a bad sibling can never
573
- * block a new branch from bootstrapping.
574
- */
575
- async runClone(targetDir, branch, referenceRepo) {
576
- // Long enough for a first clone, whatever the configured ceiling says.
577
- const timeoutMs = Math.max(this.gitRunner.defaultTimeoutMs, CLONE_TIMEOUT_MS);
578
- if (referenceRepo) {
579
- try {
580
- await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch, referenceRepo), {
581
- timeoutMs,
582
- });
583
- return;
584
- }
585
- catch (err) {
586
- log.warn(`referenced clone for "${branch}" failed, retrying without reference:`, {
587
- detail: redactError(err, this.gitRunner.credentials.token()),
588
- });
589
- // Clear any partial output so the retry clones into a clean dir.
590
- await fs.rm(targetDir, { recursive: true, force: true }).catch(() => { });
591
- }
592
- }
593
- await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch), { timeoutMs });
594
- }
595
546
  /**
596
547
  * Get-or-create the per-branch workspace. Idempotent — repeat calls for
597
548
  * the same branch reuse the existing clone. Concurrent first-callers
@@ -627,9 +578,11 @@ export class WorkspaceService {
627
578
  await this.refreshCredentialHelperIfStale(branch, repoDir);
628
579
  return this.buildWorkspaceInfo(branch, workspaceDir);
629
580
  }
630
- // Check disk — the clone may have been bootstrapped by a previous
631
- // process and survived a restart.
632
- const onDisk = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
581
+ // Check disk — the worktree (or a clone from an earlier release) may have
582
+ // been made by a previous process and survived a restart. A worktree whose
583
+ // repository is gone is not on disk in any useful sense: bootstrap below
584
+ // recreates it.
585
+ const onDisk = await this.hasWorkingCopy(repoDir).catch(() => false);
633
586
  // A clone fetches and pushes through the address stored in its OWN
634
587
  // `remote.origin.url`, which nothing rewrites when the configured
635
588
  // repository changes. Adopting one of the repository that was replaced is
@@ -662,7 +615,12 @@ export class WorkspaceService {
662
615
  // refuses this caller with the same reason.
663
616
  await setAsideInFlight;
664
617
  }
665
- else if (onDisk && (await this.isCloneOfAnotherRepository(repoDir))) {
618
+ else if (onDisk
619
+ ? await this.isCloneOfAnotherRepository(repoDir)
620
+ : // Nothing of this branch on disk yet, but the worktree it is about to
621
+ // get would be of the one repository — whose address may be the
622
+ // replaced one, until the startup phase has been through.
623
+ await this.isRepositoryOfAnother()) {
666
624
  // Asked again: another caller may have started the move while the
667
625
  // address was being read.
668
626
  const startedMeanwhile = this.inFlightSetAsides.get(branch);
@@ -679,13 +637,54 @@ export class WorkspaceService {
679
637
  // the middle of making. Nothing is left to move; the bootstrap below
680
638
  // waits for that clone.
681
639
  }
640
+ else if (!onDisk || (await this.isWorktree(repoDir))) {
641
+ // A worktree of a repository of another address: the address is the
642
+ // REPOSITORY's, and its commits are the repository's, so the repository
643
+ // and every worktree of it are set aside together, into one folder.
644
+ const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp());
645
+ const ids = await this.worktreeIdsOnDisk();
646
+ if (onDisk && !ids.includes(id))
647
+ ids.push(id);
648
+ let movedIds = null;
649
+ const move = async () => {
650
+ if (this.discardsOf(id) !== discardsAtAdoption)
651
+ return;
652
+ // Asked again under the reservation: two branches opened at once
653
+ // both read the old address, and the first one's set-aside and fresh
654
+ // repository may be done by the time this one holds it. Moving then
655
+ // would set aside the replacement and the first branch's worktree.
656
+ let superseded = false;
657
+ const ids = await setAsideRepository(this.repository, this.workspacesRoot, this.kbDirName, kept, async () => {
658
+ superseded = !(await this.isRepositoryOfAnother());
659
+ return !superseded;
660
+ });
661
+ if (superseded)
662
+ return;
663
+ movedIds = ids;
664
+ for (const movedId of movedIds)
665
+ this.forgetClone(movedId);
666
+ };
667
+ // What surrounds a set-aside is asked for every workspace that goes,
668
+ // nested, so the move runs once with all of them held.
669
+ const wrapped = ids.reduce((inner, wid) => () => this.aroundSetAside(wid, inner), move);
670
+ const moving = this.settleSetAside(wrapped(), () => movedIds !== null, branch).finally(() => {
671
+ this.inFlightSetAsides.delete(branch);
672
+ });
673
+ this.inFlightSetAsides.set(branch, moving);
674
+ await moving;
675
+ if (movedIds) {
676
+ log.warn(`the knowledge-base repository and its ${movedIds.length} worktree(s) were of another ` +
677
+ `repository. Set aside together at ${kept}; nothing was deleted, and "${branch}" is being opened ` +
678
+ 'fresh from the configured one. Work that was never pushed is in that folder.');
679
+ }
680
+ }
682
681
  else {
683
682
  const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp(), id);
684
683
  // What surrounds the move may wait, and the startup phase may set
685
684
  // this very copy aside meanwhile. Then there is nothing left to move:
686
685
  // the phase did it, and said so.
687
686
  let moved = false;
688
- const moving = this.aroundSetAside(id, async () => {
687
+ const move = async () => {
689
688
  if (this.discardsOf(id) !== discardsAtAdoption)
690
689
  return;
691
690
  await setAsideClone(repoDir, kept);
@@ -695,7 +694,8 @@ export class WorkspaceService {
695
694
  // that looked at the old copy before this line knows its answer is
696
695
  // stale, however late it comes back.
697
696
  this.forgetClone(id);
698
- }).finally(() => {
697
+ };
698
+ const moving = this.settleSetAside(this.aroundSetAside(id, move), () => moved, branch).finally(() => {
699
699
  this.inFlightSetAsides.delete(branch);
700
700
  });
701
701
  this.inFlightSetAsides.set(branch, moving);
@@ -864,7 +864,37 @@ export class WorkspaceService {
864
864
  * process that does not know where the repository is has no business
865
865
  * declaring a clone stale.
866
866
  */
867
- async isCloneOfAnotherRepository(repoDir) {
867
+ /**
868
+ * Wait for a set-aside. Its failure refuses the branch only while nothing
869
+ * has moved: once the move happened, what failed is what runs AFTER it
870
+ * around the move (`aroundSetAside` looking for the copy's work again,
871
+ * which already retries itself), and refusing then would report a move that
872
+ * did happen as one that did not. That failure is logged, and the branch is
873
+ * opened fresh.
874
+ */
875
+ async settleSetAside(moving, didMove, branch) {
876
+ try {
877
+ await moving;
878
+ }
879
+ catch (err) {
880
+ if (!didMove())
881
+ throw err;
882
+ log.error(`the working copy of "${branch}" was set aside, but what surrounds the move failed after it; the branch ` +
883
+ 'is opened fresh:', { detail: redactError(err, this.gitRunner.credentials.token()) });
884
+ }
885
+ }
886
+ /**
887
+ * Whether the one repository is on disk, finished, and of an address that
888
+ * is not the configured one. For a branch opened with nothing on disk: its
889
+ * worktree would be added to that repository and fetch from the old
890
+ * address.
891
+ */
892
+ async isRepositoryOfAnother() {
893
+ if (!(await this.repository.exists()))
894
+ return false;
895
+ return this.isCloneOfAnotherRepository(this.repository.dir, 'knowledge-base repository');
896
+ }
897
+ async isCloneOfAnotherRepository(repoDir, label) {
868
898
  const configured = this.kbRepoUrl().trim();
869
899
  if (configured === '')
870
900
  return false;
@@ -882,7 +912,7 @@ export class WorkspaceService {
882
912
  // knowledge-base address is one that may carry its credential there (a
883
913
  // signed URL — see `urlQuerySecrets`). So the address is scrubbed on the
884
914
  // way into the log, exactly as the startup phase scrubs its own.
885
- log.warn(`the "${path.basename(path.dirname(repoDir))}" working copy is a clone of another repository ` +
915
+ log.warn(`the ${label ?? `"${path.basename(path.dirname(repoDir))}" working copy`} is a clone of another repository ` +
886
916
  `(${redactSecret(normalizeRepositoryAddress(origin), urlQuerySecrets(origin))}).`);
887
917
  return true;
888
918
  }
@@ -961,6 +991,21 @@ export class WorkspaceService {
961
991
  * tracking keys before it pulls.
962
992
  */
963
993
  async normalizeCloneConfig(repoDir, branch) {
994
+ if (await this.isWorktree(repoDir)) {
995
+ // A worktree reads the repository's one config: the branch's keys are
996
+ // written there, one writer at a time, and the credential helper is the
997
+ // repository's, stamped once for every worktree.
998
+ try {
999
+ await this.repository.writeConfig(repoDir, cloneTrackingConfigArgs(branch));
1000
+ }
1001
+ catch (err) {
1002
+ log.warn(`could not normalize the config of the "${branch}" worktree:`, {
1003
+ detail: redactError(err, this.gitRunner.credentials.token()),
1004
+ });
1005
+ }
1006
+ await this.repository.refreshCredentials();
1007
+ return;
1008
+ }
964
1009
  try {
965
1010
  for (const args of cloneTrackingConfigArgs(branch)) {
966
1011
  await this.gitRunner.run(repoDir, args);
@@ -1054,8 +1099,32 @@ export class WorkspaceService {
1054
1099
  async refreshCredentialHelperIfStale(branch, repoDir) {
1055
1100
  if (this.stampedCredentialFingerprint.get(branch) === this.credentialFingerprint())
1056
1101
  return;
1102
+ if (await this.isWorktree(repoDir)) {
1103
+ // The repository's helper serves every worktree; it keeps its own
1104
+ // fingerprint, and a no-op costs no git.
1105
+ await this.repository.refreshCredentials();
1106
+ return;
1107
+ }
1057
1108
  await this.stampCredentialHelper(repoDir, branch);
1058
1109
  }
1110
+ /** The workspace ids whose checkout folder is a worktree (sound or broken) — a disk scan. */
1111
+ async worktreeIdsOnDisk() {
1112
+ let entries;
1113
+ try {
1114
+ entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
1115
+ }
1116
+ catch {
1117
+ return [];
1118
+ }
1119
+ const ids = [];
1120
+ for (const entry of entries) {
1121
+ if (!entry.isDirectory())
1122
+ continue;
1123
+ if (await this.isWorktree(path.join(this.workspacesRoot, entry.name, this.kbDirName)))
1124
+ ids.push(entry.name);
1125
+ }
1126
+ return ids;
1127
+ }
1059
1128
  async cloneProcessMapForBranch(workspaceDir, branch) {
1060
1129
  const targetDir = path.join(workspaceDir, this.kbDirName);
1061
1130
  // Probe whether `targetDir` exists. We split the existence check from
@@ -1081,16 +1150,14 @@ export class WorkspaceService {
1081
1150
  }
1082
1151
  if (target !== null) {
1083
1152
  // The directory exists, but a previous bootstrap may have crashed
1084
- // mid-clone — leaving a directory shell without `.git`. Treat
1085
- // anything missing `.git` as not-cloned and re-clone. (Can't be
1086
- // an active workspace either: every cloned repo has `.git` from
1087
- // step one.)
1088
- let alreadyCloned = false;
1153
+ // midway — leaving a directory shell without `.git` — or it is a
1154
+ // worktree whose link to the repository is broken (the repository was
1155
+ // lost, or the worktree pruned from under it). Either is an unfinished
1156
+ // checkout: wipe it and add the worktree again. A working worktree, or a
1157
+ // full clone from an earlier release, is adopted as it is.
1158
+ let shape;
1089
1159
  try {
1090
- const dotGit = await fs.stat(path.join(targetDir, '.git'));
1091
- if (dotGit.isDirectory() || dotGit.isFile()) {
1092
- alreadyCloned = true;
1093
- }
1160
+ shape = await workingCopyShape(targetDir);
1094
1161
  }
1095
1162
  catch (err) {
1096
1163
  // Only treat "does-not-exist" as "half-built dir, wipe and
@@ -1101,8 +1168,9 @@ export class WorkspaceService {
1101
1168
  // mean `.git` itself vanished under us — an absence either way.)
1102
1169
  if (!isAbsence(err))
1103
1170
  throw err;
1171
+ shape = 'none';
1104
1172
  }
1105
- if (alreadyCloned) {
1173
+ if (shape === 'worktree' || shape === 'clone') {
1106
1174
  // We don't auto-pull because that could clobber another user's
1107
1175
  // in-progress lock-held edits. The CONFIG is re-stamped though — it
1108
1176
  // never touches the working tree, and a clone with drifted tracking
@@ -1110,30 +1178,24 @@ export class WorkspaceService {
1110
1178
  await this.normalizeCloneConfig(targetDir, branch);
1111
1179
  return;
1112
1180
  }
1113
- // Half-built dir — wipe and re-clone.
1181
+ if (shape === 'broken') {
1182
+ log.warn(`the "${branch}" worktree had lost its link to the repository; it is added again.`);
1183
+ }
1184
+ // Half-built dir — wipe and add the worktree again.
1114
1185
  await fs.rm(targetDir, { recursive: true, force: true });
1115
1186
  }
1116
- // Borrow an existing sibling clone's objects to skip the bulk download.
1117
- // Resolved before the clone so a failure mid-clone still rolls back cleanly.
1118
- const reference = await this.findSiblingRepo(branch);
1119
1187
  try {
1120
- await this.runClone(targetDir, branch, reference);
1121
- // Persist longpaths in the cloned repo's config so subsequent
1122
- // checkouts also honor it.
1123
- await this.gitRunner.run(targetDir, ['config', 'core.longpaths', 'true']);
1124
- // Generic bot identity for the clone — every workflow commit
1125
- // overrides via `--author=…` so the real human shows up in
1126
- // `git log`. This is purely the fallback committer.
1127
- await this.gitRunner.run(targetDir, ['config', 'user.name', BOT_NAME]);
1128
- await this.gitRunner.run(targetDir, ['config', 'user.email', BOT_EMAIL]);
1129
- // Pin the clone to exactly one fetch refspec and one upstream ref for
1130
- // this branch. `git clone -b` already produces that shape; stamping it
1131
- // explicitly means the shape is asserted rather than assumed, and the
1132
- // same call is what repairs an existing clone that drifted.
1133
- await this.normalizeCloneConfig(targetDir, branch);
1134
- log.info(`Cloned ${this.kbDirName} for branch "${branch}"` + (reference ? ' (referenced a sibling clone)' : ''));
1135
- // A fresh clone has already downloaded every ref — tell the git layer
1136
- // so the first `listBranches` skips the redundant implicit `git fetch`.
1188
+ // A worktree of the one repository: no history is copied. The
1189
+ // repository is created and fetched first if this is the first branch
1190
+ // opened on the deployment, and the identity, the long-paths setting,
1191
+ // the credential helper and the branch's tracking config are the
1192
+ // repository's (see `KbRepository`). The add asks the remote for the
1193
+ // branch, so the worktree starts at its tip as a fresh clone would.
1194
+ await this.repository.addWorktree(branch, targetDir);
1195
+ log.info(`Added the ${this.kbDirName} worktree for branch "${branch}"`);
1196
+ // A working copy was just made — tell the git layer, so a fetch record
1197
+ // it kept for this workspace id (a clone it replaced) is not read as
1198
+ // this one's, and the first `listBranches` skips a redundant fetch.
1137
1199
  // Isolated from the clone-rollback `catch` below: a misbehaving listener
1138
1200
  // must not cause a successful clone to be wiped.
1139
1201
  try {
@@ -1149,6 +1211,7 @@ export class WorkspaceService {
1149
1211
  const redacted = redactError(err, this.gitRunner.credentials.token());
1150
1212
  // Roll back partial state so the next bootstrap retries cleanly.
1151
1213
  await fs.rm(targetDir, { recursive: true, force: true }).catch(() => { });
1214
+ await this.repository.prune().catch(() => { });
1152
1215
  // A branch origin does not have is a fact about the branch, not a
1153
1216
  // failure of ours — but WHICH fact depends on whether the platform ever
1154
1217
  // knew the name. A branch it cloned or listed and origin no longer has
@@ -1196,8 +1259,14 @@ export class WorkspaceService {
1196
1259
  */
1197
1260
  async deleteWorkspace(workspaceId) {
1198
1261
  const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
1199
- await fs.rm(workspaceDir, { recursive: true, force: true });
1200
1262
  const branch = branchForWorkspaceId(workspaceId);
1263
+ const repoDir = path.join(workspaceDir, this.kbDirName);
1264
+ // A worktree is removed through git and pruned from the repository, with
1265
+ // its branch's local ref; the repository's objects and every other
1266
+ // worktree are untouched.
1267
+ if (await this.isWorktree(repoDir))
1268
+ await this.repository.removeWorktree(repoDir, branch);
1269
+ await fs.rm(workspaceDir, { recursive: true, force: true });
1201
1270
  this.branchDirs.delete(branch);
1202
1271
  }
1203
1272
  async listFiles(workspaceId, readFilter) {
@@ -1540,6 +1609,16 @@ export class WorkspaceService {
1540
1609
  async ensureRemotesFetched(workspaceId, opts = {}) {
1541
1610
  const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
1542
1611
  const repoDir = path.join(workspaceDir, this.kbDirName);
1612
+ if (await this.isWorktree(repoDir)) {
1613
+ // ONE fetch for every workspace: the repository's, throttled and joined
1614
+ // there.
1615
+ const ok = await this.repository.fetch({ maxAgeMs: FETCH_CACHE_TTL_MS, force: opts.force });
1616
+ if (opts.strict && !ok)
1617
+ throw new Error('git fetch origin failed — remote refs could not be refreshed');
1618
+ return;
1619
+ }
1620
+ // A full clone from an earlier release fetches on its own until the boot
1621
+ // phase converts it.
1543
1622
  const now = Date.now();
1544
1623
  // The TTL is stamped only by a SUCCESSFUL fetch, so a fresh window is
1545
1624
  // itself the proof a strict caller wants.
@@ -2197,13 +2276,7 @@ export class WorkspaceService {
2197
2276
  catch {
2198
2277
  return false;
2199
2278
  }
2200
- try {
2201
- await fs.access(path.join(workspaceDir, this.kbDirName, '.git'));
2202
- return true;
2203
- }
2204
- catch {
2205
- return false;
2206
- }
2279
+ return this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false);
2207
2280
  }
2208
2281
  /**
2209
2282
  * Enumerate every branch this process has bootstrapped a workspace for.
@@ -2332,6 +2405,14 @@ export class WorkspaceService {
2332
2405
  log.warn(`sweep failed for ${entry.name}:`, { err });
2333
2406
  }
2334
2407
  }
2408
+ // The repository is not under the root, so the sweep never touched it.
2409
+ // What it forgets is the worktrees whose folders went, and their
2410
+ // branches' local refs, as removing a clone took its branch with it.
2411
+ if (removed.length > 0) {
2412
+ await this.repository
2413
+ .forgetWorktrees(removed.map((id) => branchForWorkspaceId(id)))
2414
+ .catch((err) => log.warn('could not prune the swept worktrees from the repository:', { err }));
2415
+ }
2335
2416
  return { removed };
2336
2417
  }
2337
2418
  /**
@@ -2392,14 +2473,12 @@ export class WorkspaceService {
2392
2473
  return this.branchDirs.get(branch);
2393
2474
  }
2394
2475
  const workspaceDir = this.workspaceDirWithinRoot(workspaceId);
2395
- try {
2396
- await fs.access(path.join(workspaceDir, this.kbDirName, '.git'));
2476
+ // A worktree whose link to the repository is broken falls through too:
2477
+ // the bootstrap recreates it.
2478
+ if (await this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false)) {
2397
2479
  this.registerBranchDir(branch, workspaceDir);
2398
2480
  return workspaceDir;
2399
2481
  }
2400
- catch {
2401
- // Fall through to lazy bootstrap.
2402
- }
2403
2482
  try {
2404
2483
  await this.getOrCreateForBranch(branch);
2405
2484
  const dir = this.branchDirs.get(branch);