@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
@@ -25,6 +25,7 @@ import { WorkspaceMutex } from '../../kb-fs/mutex.js';
25
25
  import { isAbsence } from '../../../shared/fs.contract.js';
26
26
  import { assertInsideRepo } from '../../kb-fs/repo-path.js';
27
27
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
28
+ import { readRefSha } from '../../kb-fs/index.js';
28
29
  import {
29
30
  assertValidBranchName,
30
31
  assertValidRelativePath,
@@ -294,6 +295,25 @@ export class GitService implements IGitService {
294
295
  // counts, calm under large branch lists.
295
296
  private static readonly MAX_AHEAD_BEHIND_CONCURRENCY = 8;
296
297
 
298
+ /**
299
+ * A change request's changed entries, keyed by the two commits its diff
300
+ * was read between. Two commits never change, and neither does the
301
+ * merge-base between them, so the three-dot diff of a pair is a fact: a
302
+ * push moves a tip, the pair changes, the old entry is simply never asked
303
+ * for again. No TTL, no invalidation.
304
+ *
305
+ * This is what a change-request listing was paying git for on every read:
306
+ * one `diff` per open request, polled every minute and refetched on every
307
+ * event, where nothing had moved. Now git is asked once per pair, which is
308
+ * once per push. Keyed by commits alone, not by workspace: every clone of
309
+ * the knowledge base holds the same objects, so the answer is the same in
310
+ * all of them. Bounded FIFO; the bound only caps memory.
311
+ */
312
+ private readonly changedEntriesByCommits = new Map<string, NameStatusEntry[]>();
313
+ /** Diffs being read right now, so concurrent misses on one pair share one `git diff`. */
314
+ private readonly changedEntriesInFlight = new Map<string, Promise<NameStatusEntry[]>>();
315
+ private static readonly CHANGED_ENTRIES_MAX = 4096;
316
+
297
317
  constructor(
298
318
  private readonly workspaceService: WorkspaceService,
299
319
  /**
@@ -378,7 +398,9 @@ export class GitService implements IGitService {
378
398
  * above rather than kept separately, so it cannot disagree with it.
379
399
  */
380
400
  lastRemoteContact(): { at: number; ok: boolean } | null {
381
- let latest: { at: number; ok: boolean } | null = null;
401
+ // The repository's fetches are every worktree's; the per-workspace record
402
+ // is a full clone's.
403
+ let latest: { at: number; ok: boolean } | null = this.workspaceService.repository?.lastContact() ?? null;
382
404
  for (const [workspaceId, at] of this.lastImplicitFetchAt) {
383
405
  if (latest === null || at > latest.at) {
384
406
  latest = { at, ok: this.lastImplicitFetchOk.get(workspaceId) ?? false };
@@ -495,12 +517,27 @@ export class GitService implements IGitService {
495
517
  // Union local heads with origin's remote-tracking refs: a fresh per-user
496
518
  // clone only has one local head (HEAD's default), so without this we'd hide
497
519
  // target-company-state and every draft pushed by another user.
520
+ //
521
+ // The workspace is a worktree of the one repository, which holds every
522
+ // OTHER worktree's local branch too. Those are not this workspace's: a
523
+ // clone never had them. A local head checked out in another worktree
524
+ // is read as that branch's remote-tracking ref alone (and not at all
525
+ // once origin no longer has it), so a branch deleted on origin is not
526
+ // kept alive by its own worktree's local ref — the orphan sweep reads
527
+ // this list. A branch is checked out in at most one worktree, so "not
528
+ // this one's HEAD" is "another's".
498
529
  const { stdout } = await this.git(cwd, [
499
530
  'for-each-ref',
500
- '--format=%(refname:short)%09%(refname)',
531
+ '--format=%(refname:short)%09%(refname)%09%(worktreepath)',
501
532
  'refs/heads',
502
533
  'refs/remotes/origin',
503
534
  ]);
535
+ const ownBranch = (
536
+ await this.git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(
537
+ (r) => r.stdout,
538
+ () => '',
539
+ )
540
+ ).trim();
504
541
 
505
542
  type Row = { shortName: string; ref: string; hasLocal: boolean; hasRemote: boolean };
506
543
  const buckets = new Map<string, Row>();
@@ -510,7 +547,7 @@ export class GitService implements IGitService {
510
547
  const tab = trimmed.indexOf('\t');
511
548
  if (tab < 0) continue;
512
549
  const rawShort = trimmed.slice(0, tab);
513
- const ref = trimmed.slice(tab + 1);
550
+ const [ref = '', worktreePath = ''] = trimmed.slice(tab + 1).split('\t');
514
551
  if (!rawShort || !ref) continue;
515
552
  // Skip symbolic refs up-front. Different git versions report
516
553
  // `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
@@ -521,6 +558,7 @@ export class GitService implements IGitService {
521
558
  const isLocal = ref.startsWith('refs/heads/');
522
559
  const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
523
560
  if (!shortName) continue;
561
+ if (isLocal && worktreePath !== '' && shortName !== ownBranch) continue;
524
562
  // Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
525
563
  // if a future git version lands us here, don't bucket it as a branch.
526
564
  if (shortName === 'HEAD') continue;
@@ -614,6 +652,20 @@ export class GitService implements IGitService {
614
652
  workspaceId: string,
615
653
  force = false,
616
654
  ): Promise<boolean> {
655
+ // A worktree's remote-tracking refs are the repository's: ONE fetch for
656
+ // every workspace, throttled, joined and reserved there. The per-workspace
657
+ // record below is for a full clone from an earlier release, which fetches
658
+ // on its own until the boot phase converts it.
659
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
660
+ if (repository) {
661
+ return repository.fetch({
662
+ maxAgeMs: GitService.IMPLICIT_FETCH_TTL_MS,
663
+ // A host that is down is not asked again by every poll inside the
664
+ // window, as the per-workspace record below stamps a failure too.
665
+ failureBackoffMs: GitService.IMPLICIT_FETCH_TTL_MS,
666
+ force,
667
+ });
668
+ }
617
669
  const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
618
670
  if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
619
671
  return this.lastImplicitFetchOk.get(workspaceId) ?? true;
@@ -692,8 +744,9 @@ export class GitService implements IGitService {
692
744
  // navigation to the new draft's URL fails with
693
745
  // "Remote branch <name> not found in upstream origin". Roll the local
694
746
  // ref back on failure so a retry isn't blocked by "branch already exists".
747
+ // No `-u`: the local head is dropped right below, and its upstream with it.
695
748
  try {
696
- await this.git(cwd, ['push', '-u', 'origin', name]);
749
+ await this.git(cwd, ['push', 'origin', name]);
697
750
  } catch (err) {
698
751
  await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
699
752
  throw err;
@@ -829,7 +882,14 @@ export class GitService implements IGitService {
829
882
  let localTip: string | null = null;
830
883
  if (await this.refExists(cwd, `refs/heads/${name}`)) {
831
884
  localTip = await this.revParseOrNull(cwd, `refs/heads/${name}`);
832
- await this.git(cwd, ['branch', '-D', name]);
885
+ await this.git(cwd, ['branch', '-D', name]).catch((err: unknown) => {
886
+ // In a repository of worktrees the branch's local ref is shared, and
887
+ // git keeps it while the branch's own workspace has it checked out.
888
+ // Removing that workspace — which the caller does next — takes the
889
+ // worktree and the ref with it.
890
+ if (/checked out at|used by worktree/i.test(err instanceof Error ? err.message : String(err))) return;
891
+ throw err;
892
+ });
833
893
  }
834
894
  return { lastCommit: remoteTip ?? localTip };
835
895
  });
@@ -1409,7 +1469,14 @@ export class GitService implements IGitService {
1409
1469
  );
1410
1470
  }
1411
1471
 
1412
- await this.git(cwd, ['push', '-u', 'origin', branch]);
1472
+ // No `-u`: it writes the upstream into the config during the push, and
1473
+ // a worktree's config is the repository's, shared by every workspace —
1474
+ // git refuses a second writer of it rather than waiting. The upstream is
1475
+ // stamped after the push instead, as one short write made as the
1476
+ // repository's administrator (see `git`).
1477
+ await this.git(cwd, ['push', 'origin', branch]);
1478
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
1479
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
1413
1480
  }
1414
1481
  }
1415
1482
 
@@ -3166,9 +3233,8 @@ export class GitService implements IGitService {
3166
3233
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3167
3234
  }
3168
3235
  return this.mutex.run(workspaceId, async () => {
3169
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3170
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3171
- return this.prChangedPathsAt(cwd, baseRef, headRef, opts);
3236
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3237
+ return flattenChangedPaths(await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head), opts);
3172
3238
  });
3173
3239
  }
3174
3240
 
@@ -3243,13 +3309,51 @@ export class GitService implements IGitService {
3243
3309
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3244
3310
  }
3245
3311
  return this.mutex.run(workspaceId, async () => {
3246
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3247
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3248
- const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
3312
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3313
+ const entries = await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head);
3249
3314
  return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
3250
3315
  });
3251
3316
  }
3252
3317
 
3318
+ /**
3319
+ * {@link prChangedEntriesAt} for a pair of COMMITS, read once per pair
3320
+ * (see `changedEntriesByCommits`). The caller holds the reservation, as for
3321
+ * `prChangedEntriesAt`; a memo hit holds it for nothing, which is cheaper
3322
+ * than a second code path around the lock.
3323
+ */
3324
+ private async prChangedEntriesBetweenCommits(
3325
+ cwd: string,
3326
+ baseSha: string,
3327
+ headSha: string,
3328
+ ): Promise<NameStatusEntry[]> {
3329
+ const key = `${baseSha}\0${headSha}`;
3330
+ const known = this.changedEntriesByCommits.get(key);
3331
+ if (known) return known;
3332
+ let pending = this.changedEntriesInFlight.get(key);
3333
+ if (!pending) {
3334
+ pending = this.prChangedEntriesAt(cwd, baseSha, headSha)
3335
+ .then((entries) => {
3336
+ if (this.changedEntriesByCommits.size >= GitService.CHANGED_ENTRIES_MAX) {
3337
+ const oldest = this.changedEntriesByCommits.keys().next().value;
3338
+ if (oldest !== undefined) this.changedEntriesByCommits.delete(oldest);
3339
+ }
3340
+ this.changedEntriesByCommits.set(key, entries);
3341
+ return entries;
3342
+ })
3343
+ .finally(() => {
3344
+ if (this.changedEntriesInFlight.get(key) === pending) this.changedEntriesInFlight.delete(key);
3345
+ });
3346
+ // Same bound as the memo: evicting an in-flight entry costs at most one
3347
+ // redundant diff for that pair, never a lost answer.
3348
+ if (this.changedEntriesInFlight.size >= GitService.CHANGED_ENTRIES_MAX) {
3349
+ const oldest = this.changedEntriesInFlight.keys().next().value;
3350
+ if (oldest !== undefined) this.changedEntriesInFlight.delete(oldest);
3351
+ }
3352
+ this.changedEntriesInFlight.set(key, pending);
3353
+ }
3354
+ return pending;
3355
+ }
3356
+
3253
3357
  /**
3254
3358
  * The merge-base commit of a change request's two branches (their origin
3255
3359
  * refs), or null when they share no history. This is the "before" the CR's
@@ -3590,16 +3694,23 @@ export class GitService implements IGitService {
3590
3694
  baseBranch: string,
3591
3695
  headBranch: string,
3592
3696
  ): Promise<{ base: string; head: string } | null> {
3593
- const shas: string[] = [];
3594
- for (const branch of [baseBranch, headBranch]) {
3595
- try {
3596
- shas.push((await this.resolvePublishedBranch(cwd, branch)).sha);
3597
- } catch (err) {
3598
- if (err instanceof WorkflowValidationError) return null;
3599
- throw err;
3600
- }
3697
+ try {
3698
+ return await this.publishedPrEnds(cwd, baseBranch, headBranch);
3699
+ } catch (err) {
3700
+ if (err instanceof WorkflowValidationError) return null;
3701
+ throw err;
3601
3702
  }
3602
- return { base: shas[0], head: shas[1] };
3703
+ }
3704
+
3705
+ /** The two commits a change request is read between, each resolved as {@link resolvePublishedBranch} does. */
3706
+ private async publishedPrEnds(
3707
+ cwd: string,
3708
+ baseBranch: string,
3709
+ headBranch: string,
3710
+ ): Promise<{ base: string; head: string }> {
3711
+ const base = (await this.resolvePublishedBranch(cwd, baseBranch)).sha;
3712
+ const head = (await this.resolvePublishedBranch(cwd, headBranch)).sha;
3713
+ return { base, head };
3603
3714
  }
3604
3715
 
3605
3716
  /**
@@ -3625,6 +3736,13 @@ export class GitService implements IGitService {
3625
3736
  branch: string,
3626
3737
  ): Promise<{ ref: string; sha: string }> {
3627
3738
  for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
3739
+ // The ref file first (`readRefSha`): no process, same answer, same
3740
+ // freshness. Git is asked only for what the files cannot decide — a
3741
+ // missing ref is decided by the files and the loop moves on; an
3742
+ // unreadable one is git's to answer, as the authority.
3743
+ const fromFiles = await readRefSha(cwd, ref);
3744
+ if (fromFiles === 'missing') continue;
3745
+ if (fromFiles !== 'unreadable') return { ref, sha: fromFiles.sha };
3628
3746
  try {
3629
3747
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
3630
3748
  return { ref, sha: stdout.trim() };
@@ -4097,7 +4215,7 @@ export class GitService implements IGitService {
4097
4215
  * from a real failure are unaffected.
4098
4216
  */
4099
4217
  private git(cwd: string, args: string[], opts?: Omit<GitRunOptions, 'encoding'>): Promise<GitRunResult> {
4100
- return this.gitRunner.run(cwd, args, {
4218
+ const run = () => this.gitRunner.run(cwd, args, {
4101
4219
  ...opts,
4102
4220
  // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
4103
4221
  // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as glob
@@ -4112,6 +4230,18 @@ export class GitService implements IGitService {
4112
4230
  // a search for a file literally named that.
4113
4231
  env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
4114
4232
  });
4233
+ // A write to the git config is a write to the ONE file every worktree of
4234
+ // the repository shares, which git refuses a second writer of rather than
4235
+ // waiting: it runs as the repository's administrator. Everything else —
4236
+ // a fetch, a commit, a branch delete — git serializes with its own locks,
4237
+ // as it does for any two worktrees of one repository.
4238
+ return writesSharedConfig(args) ? this.asRepositoryAdministrator(cwd, run) : run();
4239
+ }
4240
+
4241
+ /** `run` as the repository's administrator when `cwd` is a worktree of it; as itself for a full clone. */
4242
+ private async asRepositoryAdministrator<T>(cwd: string, run: () => Promise<T>): Promise<T> {
4243
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
4244
+ return repository ? repository.exclusive(run) : run();
4115
4245
  }
4116
4246
 
4117
4247
  /**
@@ -4299,3 +4429,35 @@ export function parseNumstatZ(out: string): NumstatEntry[] {
4299
4429
  }
4300
4430
  return entries;
4301
4431
  }
4432
+
4433
+ /**
4434
+ * Whether a git command WRITES the config — the one file every worktree of
4435
+ * the knowledge base's repository shares, which git refuses a second writer
4436
+ * of rather than waiting — and so must run as the repository's administrator
4437
+ * (`IKbRepository.exclusive`): a `config` that sets or unsets, a `remote`
4438
+ * change, a branch rename, delete or upstream change (its config section is
4439
+ * written, or removed with the branch), a push that sets the upstream. Reads
4440
+ * of the config are not writes. Nothing else is: the refs, `packed-refs` and
4441
+ * the object store are locked by git itself, and the worktree list is the
4442
+ * repository's own to serialize (`KbRepository`).
4443
+ *
4444
+ * Leading `-c key=value` / `-C dir` options are skipped to find the subcommand.
4445
+ */
4446
+ function writesSharedConfig(args: readonly string[]): boolean {
4447
+ let i = 0;
4448
+ while (i < args.length && (args[i] === '-c' || args[i] === '-C')) i += 2;
4449
+ const sub = args[i];
4450
+ const rest = args.slice(i + 1);
4451
+ switch (sub) {
4452
+ case 'remote':
4453
+ return true;
4454
+ case 'config':
4455
+ return !rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l|--show-origin)$/.test(a));
4456
+ case 'branch':
4457
+ return rest.some((a) => /^(-d|-D|--delete|-m|-M|--move|-u|--set-upstream-to=.*|--unset-upstream)$/.test(a));
4458
+ case 'push':
4459
+ return rest.some((a) => a === '-u' || a === '--set-upstream');
4460
+ default:
4461
+ return false;
4462
+ }
4463
+ }
@@ -65,7 +65,7 @@ export interface AgentOperationContext {
65
65
  sessionId?: string;
66
66
  /**
67
67
  * The workspace-relative path the operation targets. Absent for a
68
- * write-capable operation with no resolvable target (`execute_command`).
68
+ * write-capable operation with no resolvable target.
69
69
  */
70
70
  wsPath?: string;
71
71
  /** The branch (draft) whose workspace the operation acts on. */
@@ -22,7 +22,6 @@ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
22
22
  import { ToolDescriptionNotes } from '../agent-access.gate.js';
23
23
  import { SpillStore } from '../spill-store.js';
24
24
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
25
- import { WorkspaceService } from '../workspace.service.js';
26
25
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
27
26
  import { AgentDownloadStore, DOWNLOAD_TOKEN_REFUSAL } from '../agent-download.store.js';
28
27
  import { attachmentDisposition, createAgentDownloadRoutes, createDownloadFetcherIdentifier } from '../agent-download.routes.js';
@@ -31,6 +30,7 @@ import jwt from 'jsonwebtoken';
31
30
  import { buildDownload, DOWNLOAD_PERMISSION_REQUIRED, NOT_FOUND } from '../agent-download.builder.js';
32
31
  import { READ_ONLY_CODE, type IWriteAccess } from '../../write-access/write-access.js';
33
32
  import { sharedFileRules } from '../../agent-instructions/shared-file-rules.js';
33
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
34
34
 
35
35
  /**
36
36
  * `request_file_download` end to end, over the REAL access resolver on a
@@ -136,7 +136,7 @@ async function start(options: StartOptions = {}): Promise<Started> {
136
136
  await mkdir(dirname(abs), { recursive: true });
137
137
  await writeFile(abs, contents);
138
138
  }
139
- const workspaceService = new WorkspaceService(
139
+ const workspaceService = newWorkspaceService(
140
140
  root,
141
141
  'https://example.invalid/kb.git',
142
142
  testKbContext({ kbDirName: KB }),
@@ -0,0 +1,213 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { WorkerPatternMatcher, type WorkerPatternMatcherOptions } from '../bounded-matcher.js';
4
+ import { PatternTooExpensiveError } from '../pattern-matcher.contract.js';
5
+
6
+ /** The QA finding's pattern, and a 40-character line it backtracks on for ever. */
7
+ const CATASTROPHIC = '^(a+)+$';
8
+ const LONG_LINE = `${'a'.repeat(39)}!`;
9
+ /** The ticket's per-search budget (Razvan, 2026-10-15). */
10
+ const FIVE_SECONDS = 5_000;
11
+
12
+ let matchers: WorkerPatternMatcher[] = [];
13
+ /** A matcher of the test's own, closed after it — the defaults unless a test says otherwise. */
14
+ function matcher(opts?: WorkerPatternMatcherOptions): WorkerPatternMatcher {
15
+ const m = new WorkerPatternMatcher(opts);
16
+ matchers.push(m);
17
+ return m;
18
+ }
19
+ afterEach(async () => {
20
+ await Promise.all(matchers.map((m) => m.close()));
21
+ matchers = [];
22
+ });
23
+
24
+ /**
25
+ * The longest the event loop went without running a timer while `work` ran.
26
+ * A pattern run on the loop would show the whole match time here.
27
+ */
28
+ async function longestStall(work: () => Promise<unknown>): Promise<number> {
29
+ let last = Date.now();
30
+ let worst = 0;
31
+ const tick = setInterval(() => {
32
+ const now = Date.now();
33
+ worst = Math.max(worst, now - last);
34
+ last = now;
35
+ }, 10);
36
+ try {
37
+ await work().catch(() => {});
38
+ } finally {
39
+ clearInterval(tick);
40
+ }
41
+ return Math.max(worst, Date.now() - last);
42
+ }
43
+
44
+ describe('WorkerPatternMatcher', () => {
45
+ it('stops a catastrophic pattern over a 40-character line with the worded 400 in its 5 s budget, and never stalls the loop', async () => {
46
+ expect(LONG_LINE).toHaveLength(40);
47
+ const started = Date.now();
48
+ let caught: unknown;
49
+ const stall = await longestStall(async () => {
50
+ try {
51
+ await matcher().search(CATASTROPHIC, '').matchingLines([LONG_LINE], 200);
52
+ } catch (err) {
53
+ caught = err;
54
+ }
55
+ });
56
+ const took = Date.now() - started;
57
+ expect(caught).toBeInstanceOf(PatternTooExpensiveError);
58
+ expect((caught as PatternTooExpensiveError).status).toBe(400);
59
+ expect((caught as Error).message).toMatch(/^Pattern too expensive: /);
60
+ expect((caught as Error).message).toContain('simpler pattern');
61
+ expect((caught as Error).message).toContain("this search's 5 s budget");
62
+ expect(took).toBeGreaterThanOrEqual(FIVE_SECONDS - 100);
63
+ expect(took).toBeLessThan(FIVE_SECONDS + 2_000);
64
+ expect(stall).toBeLessThan(500);
65
+ });
66
+
67
+ it('answers ordinary patterns with exactly the lines RegExp#test matches', async () => {
68
+ const m = matcher();
69
+ const lines = ['hello world', 'Getting started', 'aaa', 'line 42', '', 'foo|bar', 'bar baz', LONG_LINE];
70
+ for (const [source, flags] of [
71
+ ['world', ''],
72
+ ['^aaa$', ''],
73
+ ['getting started', 'i'],
74
+ ['[0-9]+', ''],
75
+ ['foo|baz', ''],
76
+ ['^$', ''],
77
+ ['a+!$', ''],
78
+ ] as const) {
79
+ const re = new RegExp(source, flags);
80
+ const expected = lines.flatMap((l, i) => (re.test(l) ? [i] : []));
81
+ expect(await m.search(source, flags).matchingLines(lines, 1000), `/${source}/${flags}`).toEqual(expected);
82
+ }
83
+ });
84
+
85
+ it('returns only the first `limit` matches', async () => {
86
+ expect(await matcher().search('x', '').matchingLines(['x', 'y', 'x', 'x'], 2)).toEqual([0, 2]);
87
+ });
88
+
89
+ it('refuses a sizing that could never serve a search when it is built, not when a search hangs on it', () => {
90
+ expect(() => new WorkerPatternMatcher({ poolMax: 0 })).toThrow(/poolMax must be an integer from 1 /);
91
+ // Past the longest delay setTimeout honours, the timer would fire after 1 ms and stop every search.
92
+ expect(() => new WorkerPatternMatcher({ budgetMs: 2_147_483_647 })).toThrow(/budgetMs must be an integer from 1 to 2147483397/);
93
+ expect(() => new WorkerPatternMatcher({ poolMax: 1.5 })).toThrow(RangeError);
94
+ expect(() => new WorkerPatternMatcher({ budgetMs: 0 })).toThrow(/budgetMs/);
95
+ expect(() => new WorkerPatternMatcher({ maxWaiting: -1 })).toThrow(/maxWaiting/);
96
+ expect(() => new WorkerPatternMatcher({ batchChars: Number.NaN })).toThrow(/batchChars/);
97
+ expect(() => new WorkerPatternMatcher({ batchLines: 0 })).toThrow(/batchLines/);
98
+ expect(() => matcher({ maxWaiting: 0 })).not.toThrow();
99
+ });
100
+
101
+ it('serves an ordinary search under the largest budget the timer can hold', async () => {
102
+ expect(await matcher({ budgetMs: 2_147_483_647 - 250 }).search('b', '').matchingLines(['a', 'b'], 10)).toEqual([1]);
103
+ });
104
+
105
+ it('refuses invalid syntax with the 400 grep always gave', () => {
106
+ expect(() => matcher().search('(', '')).toThrow(/^Invalid regex: /);
107
+ });
108
+
109
+ it('refuses a pattern too long to compile on the request thread, and accepts one at the limit', () => {
110
+ const m = matcher({ maxPatternLength: 10 });
111
+ expect(() => m.search('a'.repeat(11), '')).toThrow(/^Pattern too long: /);
112
+ expect(() => m.search('a'.repeat(10), '')).not.toThrow();
113
+ });
114
+
115
+ it('hands a large file over in batches and answers the same line numbers, up to `limit`', async () => {
116
+ const m = matcher({ batchChars: 100 });
117
+ const filler = 'x'.repeat(9);
118
+ const lines = Array.from({ length: 250 }, (_, i) => (i % 7 === 0 ? `hit ${i}` : filler));
119
+ const expected = lines.flatMap((l, i) => (l.startsWith('hit') ? [i] : []));
120
+ expect(expected.at(-1)! * filler.length).toBeGreaterThan(200);
121
+ expect(await m.search('^hit', '').matchingLines(lines, 10_000)).toEqual(expected);
122
+ expect(await m.search('^hit', '').matchingLines(lines, 3)).toEqual(expected.slice(0, 3));
123
+ });
124
+
125
+ it('caps a batch by its line count too, so a file of empty lines never goes over in one message', async () => {
126
+ const m = matcher({ batchLines: 100 });
127
+ const lines = Array.from({ length: 1_050 }, (_, i) => (i % 97 === 0 ? 'hit' : ''));
128
+ const expected = lines.flatMap((l, i) => (l === 'hit' ? [i] : []));
129
+ const posted = vi.spyOn(Worker.prototype, 'postMessage');
130
+ try {
131
+ expect(await m.search('^hit$', '').matchingLines(lines, 10_000)).toEqual(expected);
132
+ expect(await m.search('^$', '').matchingLines(lines, 10_000)).toHaveLength(lines.length - expected.length);
133
+ const postedLines = posted.mock.calls.map(([msg]) => ((msg as { lines?: string[] }).lines ?? []).length);
134
+ expect(postedLines.filter((n) => n > 0)).toHaveLength(2 * 11);
135
+ expect(Math.max(...postedLines)).toBeLessThanOrEqual(100);
136
+ } finally {
137
+ posted.mockRestore();
138
+ }
139
+ });
140
+
141
+ it('hands a line longer than one batch over in shared memory, never in a message, and matches it exactly', async () => {
142
+ const m = matcher({ batchChars: 100 });
143
+ // A lone surrogate included: the line must reach the worker code unit for code unit.
144
+ const long = `${'x'.repeat(1_000)}\uD800 tail ${'y'.repeat(500)}`;
145
+ const lines = ['short', long, 'z'.repeat(400), 'end tail'];
146
+ const posted = vi.spyOn(Worker.prototype, 'postMessage');
147
+ try {
148
+ for (const pattern of ['tail', '\\uD800 tail', '^x+\\uD800', 'y{500}$', '^z+$', 'nowhere']) {
149
+ const re = new RegExp(pattern);
150
+ const expected = lines.flatMap((l, i) => (re.test(l) ? [i] : []));
151
+ expect(await m.search(pattern, '').matchingLines(lines, 10_000)).toEqual(expected);
152
+ }
153
+ const postedChars = posted.mock.calls.map(([msg]) =>
154
+ ((msg as { lines?: string[] }).lines ?? []).reduce((n, l) => n + l.length, 0),
155
+ );
156
+ expect(postedChars.length).toBeGreaterThan(0);
157
+ expect(Math.max(...postedChars)).toBeLessThanOrEqual(100);
158
+ } finally {
159
+ posted.mockRestore();
160
+ }
161
+ });
162
+
163
+ it('refuses a search with 503 once the queue for a worker is full, rather than holding its lines', async () => {
164
+ const m = matcher({ budgetMs: 500, poolMax: 2, maxWaiting: 2 });
165
+ // Every worker held by a pattern that runs out its budget, the queue filled behind them.
166
+ const holding = Promise.allSettled(Array.from({ length: 2 }, () => m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 1)));
167
+ await new Promise((r) => setTimeout(r, 100));
168
+ const queued = Array.from({ length: 2 }, () => m.search('b', '').matchingLines(['b'], 1));
169
+ await expect(m.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({
170
+ status: 503,
171
+ details: { code: 'search_busy' },
172
+ });
173
+ // The queued searches are served once the workers come free.
174
+ expect(await Promise.all(queued)).toEqual(queued.map(() => [0]));
175
+ expect((await holding).every((r) => r.status === 'rejected')).toBe(true);
176
+ });
177
+
178
+ it('stops two expensive searches at once, and a search started meanwhile still answers', async () => {
179
+ const m = matcher({ budgetMs: 500 });
180
+ const results = await Promise.allSettled([
181
+ m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 10),
182
+ m.search('(a|aa)+$', '').matchingLines([`${'a'.repeat(60)}!`], 10),
183
+ m.search('aaa', '').matchingLines(['xaaay'], 10),
184
+ ]);
185
+ expect(results[0]).toMatchObject({ status: 'rejected', reason: expect.any(PatternTooExpensiveError) });
186
+ expect(results[1]).toMatchObject({ status: 'rejected', reason: expect.any(PatternTooExpensiveError) });
187
+ expect(results[2]).toEqual({ status: 'fulfilled', value: [0] });
188
+ // The terminated workers were replaced: the pool still serves.
189
+ expect(await m.search('b', '').matchingLines(['a', 'b'], 10)).toEqual([1]);
190
+ });
191
+
192
+ it('ends its workers on close: a search running then, and one after, is answered busy', async () => {
193
+ const m = matcher();
194
+ const running = m.search(CATASTROPHIC, '').matchingLines([LONG_LINE], 1);
195
+ await new Promise((r) => setTimeout(r, 100));
196
+ const started = Date.now();
197
+ await m.close();
198
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
199
+ expect(Date.now() - started).toBeLessThan(1_000);
200
+ await expect(m.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({ status: 503 });
201
+ });
202
+
203
+ it('answers busy at once when closed while a long line is still being written to shared memory', async () => {
204
+ // A line of a million characters written ten at a time: many turns of the loop to close in.
205
+ const m = matcher({ budgetMs: 3_000, batchChars: 10 });
206
+ const running = m.search('nowhere', '').matchingLines(['a'.repeat(1_000_000)], 1);
207
+ await new Promise((r) => setTimeout(r, 20));
208
+ const started = Date.now();
209
+ await m.close();
210
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
211
+ expect(Date.now() - started).toBeLessThan(1_000);
212
+ });
213
+ });