@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.
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +32 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +138 -16
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +2 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts +29 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +177 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +7 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +6 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +36 -3
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +142 -14
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +184 -22
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +12 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- 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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
|
3170
|
-
|
|
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
|
|
3247
|
-
const
|
|
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
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
});
|