@cat-factory/executor-harness 1.98.0 → 1.102.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/agent-capabilities.js +25 -11
- package/dist/agent.js +2 -1
- package/dist/checkout-dir.d.ts +33 -0
- package/dist/checkout-dir.js +56 -0
- package/dist/coding-agent.d.ts +0 -13
- package/dist/coding-agent.js +4 -19
- package/package.json +4 -4
- package/src/agent-capabilities.ts +22 -13
- package/src/agent.ts +2 -6
- package/src/checkout-dir.ts +62 -0
- package/src/coding-agent.ts +4 -21
|
@@ -277,19 +277,33 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
|
277
277
|
* reached the container by any other route is held to the rule too.
|
|
278
278
|
*/
|
|
279
279
|
export function isAllowedMcpHttpUrl(raw) {
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
// ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
|
|
281
|
+
// WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
|
|
282
|
+
// a url carrying one parses to something other than what it reads as, and this url is written
|
|
283
|
+
// VERBATIM into the CLI's MCP config below.
|
|
284
|
+
for (let i = 0; i < raw.length; i += 1)
|
|
285
|
+
if (raw.charCodeAt(i) <= 0x20)
|
|
286
|
+
return false;
|
|
287
|
+
let parsed;
|
|
288
|
+
try {
|
|
289
|
+
parsed = new URL(raw);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// silent-catch-ok: an unparseable url is exactly what this predicate refuses.
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
282
296
|
return false;
|
|
283
|
-
if (
|
|
297
|
+
if (parsed.protocol === 'https:')
|
|
284
298
|
return true;
|
|
285
|
-
// Plain http from here: the host must be loopback.
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
// Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
|
|
300
|
+
// because it is the parser the CLI resolves the request with, so the host ruled on is the host
|
|
301
|
+
// the credential header travels to. It strips userinfo from the LAST `@` (or
|
|
302
|
+
// `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
|
|
303
|
+
// it terminates the authority at a backslash as well as at `/?#` (or
|
|
304
|
+
// `http://evil.example\@127.0.0.1` reads as loopback the same way).
|
|
305
|
+
const hostname = parsed.hostname;
|
|
306
|
+
const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
|
293
307
|
return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
294
308
|
}
|
|
295
309
|
/** A string→string record, dropping any non-string entry. Undefined when nothing survives. */
|
package/dist/agent.js
CHANGED
|
@@ -9,7 +9,8 @@ import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './re
|
|
|
9
9
|
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
|
|
10
10
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js';
|
|
11
11
|
import { applyPrDescription } from './pr-description.js';
|
|
12
|
-
import { makeDirClaimer
|
|
12
|
+
import { makeDirClaimer } from './checkout-dir.js';
|
|
13
|
+
import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js';
|
|
13
14
|
import { validationFailureMessage } from './validation-checks.js';
|
|
14
15
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
|
|
15
16
|
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RepoSpec } from './job.js';
|
|
2
|
+
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
3
|
+
export declare function safeDirSegment(value: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
|
|
6
|
+
* over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
|
|
7
|
+
* concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
|
|
8
|
+
* taken from `node:crypto` because the backend needs the identical function and runs it in
|
|
9
|
+
* workerd as well as on Node.
|
|
10
|
+
*
|
|
11
|
+
* MUST stay byte-identical to the backend's `checkoutDirDigest`
|
|
12
|
+
* (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkoutDirDigest(owner: string, name: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
17
|
+
* repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
|
|
18
|
+
* what lets this and the backend compute it independently with no shared ordering or state. Kept
|
|
19
|
+
* as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
|
|
20
|
+
* byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
|
|
21
|
+
* `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
|
|
22
|
+
* computed independently, so a divergent rule would point the agent at a directory that does not
|
|
23
|
+
* exist.
|
|
24
|
+
*
|
|
25
|
+
* The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
|
|
26
|
+
* is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
|
|
27
|
+
* namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
|
|
28
|
+
* join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
|
|
29
|
+
* namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
|
|
30
|
+
* collision puts two legs on one directory, and the second one's clone then fails against a
|
|
31
|
+
* directory the first already filled, killing the run in the clone phase naming neither repo.
|
|
32
|
+
*/
|
|
33
|
+
export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The HARNESS half of the sibling-checkout-directory contract.
|
|
3
|
+
//
|
|
4
|
+
// The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
|
|
5
|
+
// NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
|
|
6
|
+
// depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
|
|
7
|
+
// each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
|
|
8
|
+
// the pairing sits in one small module per side rather than buried in the agent runner: the whole
|
|
9
|
+
// point of the pairing is that a reader can see both halves at once.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
12
|
+
export function safeDirSegment(value) {
|
|
13
|
+
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
|
|
17
|
+
* over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
|
|
18
|
+
* concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
|
|
19
|
+
* taken from `node:crypto` because the backend needs the identical function and runs it in
|
|
20
|
+
* workerd as well as on Node.
|
|
21
|
+
*
|
|
22
|
+
* MUST stay byte-identical to the backend's `checkoutDirDigest`
|
|
23
|
+
* (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
|
|
24
|
+
*/
|
|
25
|
+
export function checkoutDirDigest(owner, name) {
|
|
26
|
+
const input = `${owner}\u0000${name}`;
|
|
27
|
+
let hash = 0x811c9dc5;
|
|
28
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
29
|
+
hash ^= input.charCodeAt(i);
|
|
30
|
+
// The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
|
|
31
|
+
// step so the arithmetic never drifts into float range and diverges between engines.
|
|
32
|
+
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
return hash.toString(36).padStart(7, '0');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
38
|
+
* repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
|
|
39
|
+
* what lets this and the backend compute it independently with no shared ordering or state. Kept
|
|
40
|
+
* as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
|
|
41
|
+
* byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
|
|
42
|
+
* `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
|
|
43
|
+
* computed independently, so a divergent rule would point the agent at a directory that does not
|
|
44
|
+
* exist.
|
|
45
|
+
*
|
|
46
|
+
* The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
|
|
47
|
+
* is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
|
|
48
|
+
* namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
|
|
49
|
+
* join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
|
|
50
|
+
* namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
|
|
51
|
+
* collision puts two legs on one directory, and the second one's clone then fails against a
|
|
52
|
+
* directory the first already filled, killing the run in the clone phase naming neither repo.
|
|
53
|
+
*/
|
|
54
|
+
export function makeDirClaimer() {
|
|
55
|
+
return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`;
|
|
56
|
+
}
|
package/dist/coding-agent.d.ts
CHANGED
|
@@ -219,19 +219,6 @@ export declare function runRalphValidation(repoDir: string, cwd: string, validat
|
|
|
219
219
|
iteration?: number;
|
|
220
220
|
headSha?: string;
|
|
221
221
|
}>;
|
|
222
|
-
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
223
|
-
export declare function safeDirSegment(value: string): string;
|
|
224
|
-
/**
|
|
225
|
-
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
226
|
-
* repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
|
|
227
|
-
* — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
|
|
228
|
-
* `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
|
|
229
|
-
* the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
|
|
230
|
-
* backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
|
|
231
|
-
* (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
|
|
232
|
-
* independently, so a divergent rule would point the agent at a directory that does not exist.
|
|
233
|
-
*/
|
|
234
|
-
export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
|
|
235
222
|
/**
|
|
236
223
|
* Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
|
|
237
224
|
* peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
|
package/dist/coding-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { runCapturedCommand } from './captured-command.js';
|
|
4
|
+
import { makeDirClaimer } from './checkout-dir.js';
|
|
4
5
|
import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
5
6
|
import { openPullRequest } from './vcs-api.js';
|
|
6
7
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
@@ -676,23 +677,6 @@ export async function runRalphValidation(repoDir, cwd, validation, logger, opts)
|
|
|
676
677
|
...(headSha ? { headSha } : {}),
|
|
677
678
|
};
|
|
678
679
|
}
|
|
679
|
-
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
680
|
-
export function safeDirSegment(value) {
|
|
681
|
-
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
|
|
682
|
-
}
|
|
683
|
-
/**
|
|
684
|
-
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
685
|
-
* repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
|
|
686
|
-
* — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
|
|
687
|
-
* `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
|
|
688
|
-
* the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
|
|
689
|
-
* backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
|
|
690
|
-
* (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
|
|
691
|
-
* independently, so a divergent rule would point the agent at a directory that does not exist.
|
|
692
|
-
*/
|
|
693
|
-
export function makeDirClaimer() {
|
|
694
|
-
return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`;
|
|
695
|
-
}
|
|
696
680
|
/**
|
|
697
681
|
* Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
|
|
698
682
|
* peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
|
|
@@ -711,8 +695,9 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
711
695
|
const peers = job.peerRepos ?? [];
|
|
712
696
|
const references = job.referenceRepos ?? [];
|
|
713
697
|
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
714
|
-
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
715
|
-
// matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
698
|
+
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
699
|
+
// (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
700
|
+
// read-only explore fan-out.
|
|
716
701
|
const claimDir = makeDirClaimer();
|
|
717
702
|
const legs = [
|
|
718
703
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.102.0",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"hono": "^4.13.0",
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
32
|
"vitest": "^4.1.10",
|
|
33
|
-
"@cat-factory/kernel": "0.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.15.
|
|
33
|
+
"@cat-factory/kernel": "0.273.0",
|
|
34
|
+
"@cat-factory/server": "0.253.0",
|
|
35
|
+
"@cat-factory/spend": "0.15.41"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
@@ -382,19 +382,28 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
|
382
382
|
* reached the container by any other route is held to the rule too.
|
|
383
383
|
*/
|
|
384
384
|
export function isAllowedMcpHttpUrl(raw: string): boolean {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
385
|
+
// ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
|
|
386
|
+
// WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
|
|
387
|
+
// a url carrying one parses to something other than what it reads as, and this url is written
|
|
388
|
+
// VERBATIM into the CLI's MCP config below.
|
|
389
|
+
for (let i = 0; i < raw.length; i += 1) if (raw.charCodeAt(i) <= 0x20) return false
|
|
390
|
+
let parsed: URL
|
|
391
|
+
try {
|
|
392
|
+
parsed = new URL(raw)
|
|
393
|
+
} catch {
|
|
394
|
+
// silent-catch-ok: an unparseable url is exactly what this predicate refuses.
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
398
|
+
if (parsed.protocol === 'https:') return true
|
|
399
|
+
// Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
|
|
400
|
+
// because it is the parser the CLI resolves the request with, so the host ruled on is the host
|
|
401
|
+
// the credential header travels to. It strips userinfo from the LAST `@` (or
|
|
402
|
+
// `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
|
|
403
|
+
// it terminates the authority at a backslash as well as at `/?#` (or
|
|
404
|
+
// `http://evil.example\@127.0.0.1` reads as loopback the same way).
|
|
405
|
+
const hostname = parsed.hostname
|
|
406
|
+
const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
|
|
398
407
|
return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host)
|
|
399
408
|
}
|
|
400
409
|
|
package/src/agent.ts
CHANGED
|
@@ -29,12 +29,8 @@ import {
|
|
|
29
29
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
30
30
|
import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
|
|
31
31
|
import { applyPrDescription } from './pr-description.js'
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
noChangesReason,
|
|
35
|
-
runCodingAgent,
|
|
36
|
-
runMultiRepoCoding,
|
|
37
|
-
} from './coding-agent.js'
|
|
32
|
+
import { makeDirClaimer } from './checkout-dir.js'
|
|
33
|
+
import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js'
|
|
38
34
|
import { validationFailureMessage } from './validation-checks.js'
|
|
39
35
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
|
|
40
36
|
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { RepoSpec } from './job.js'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The HARNESS half of the sibling-checkout-directory contract.
|
|
5
|
+
//
|
|
6
|
+
// The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
|
|
7
|
+
// NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
|
|
8
|
+
// depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
|
|
9
|
+
// each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
|
|
10
|
+
// the pairing sits in one small module per side rather than buried in the agent runner: the whole
|
|
11
|
+
// point of the pairing is that a reader can see both halves at once.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
15
|
+
export function safeDirSegment(value: string): string {
|
|
16
|
+
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
|
|
21
|
+
* over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
|
|
22
|
+
* concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
|
|
23
|
+
* taken from `node:crypto` because the backend needs the identical function and runs it in
|
|
24
|
+
* workerd as well as on Node.
|
|
25
|
+
*
|
|
26
|
+
* MUST stay byte-identical to the backend's `checkoutDirDigest`
|
|
27
|
+
* (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
|
|
28
|
+
*/
|
|
29
|
+
export function checkoutDirDigest(owner: string, name: string): string {
|
|
30
|
+
const input = `${owner}\u0000${name}`
|
|
31
|
+
let hash = 0x811c9dc5
|
|
32
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
33
|
+
hash ^= input.charCodeAt(i)
|
|
34
|
+
// The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
|
|
35
|
+
// step so the arithmetic never drifts into float range and diverges between engines.
|
|
36
|
+
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0
|
|
37
|
+
}
|
|
38
|
+
return hash.toString(36).padStart(7, '0')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
43
|
+
* repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
|
|
44
|
+
* what lets this and the backend compute it independently with no shared ordering or state. Kept
|
|
45
|
+
* as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
|
|
46
|
+
* byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
|
|
47
|
+
* `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
|
|
48
|
+
* computed independently, so a divergent rule would point the agent at a directory that does not
|
|
49
|
+
* exist.
|
|
50
|
+
*
|
|
51
|
+
* The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
|
|
52
|
+
* is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
|
|
53
|
+
* namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
|
|
54
|
+
* join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
|
|
55
|
+
* namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
|
|
56
|
+
* collision puts two legs on one directory, and the second one's clone then fails against a
|
|
57
|
+
* directory the first already filled, killing the run in the clone phase naming neither repo.
|
|
58
|
+
*/
|
|
59
|
+
export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
|
|
60
|
+
return (repo) =>
|
|
61
|
+
`${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`
|
|
62
|
+
}
|
package/src/coding-agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { runCapturedCommand } from './captured-command.js'
|
|
4
|
+
import { makeDirClaimer } from './checkout-dir.js'
|
|
4
5
|
import type {
|
|
5
6
|
AgentJob,
|
|
6
7
|
AgentResult,
|
|
@@ -1018,25 +1019,6 @@ export async function runRalphValidation(
|
|
|
1018
1019
|
}
|
|
1019
1020
|
}
|
|
1020
1021
|
|
|
1021
|
-
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
1022
|
-
export function safeDirSegment(value: string): string {
|
|
1023
|
-
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
1028
|
-
* repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
|
|
1029
|
-
* — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
|
|
1030
|
-
* `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
|
|
1031
|
-
* the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
|
|
1032
|
-
* backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
|
|
1033
|
-
* (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
|
|
1034
|
-
* independently, so a divergent rule would point the agent at a directory that does not exist.
|
|
1035
|
-
*/
|
|
1036
|
-
export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
|
|
1037
|
-
return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
1022
|
/** One repository participating in a multi-repo run: where to clone it + what to do after. */
|
|
1041
1023
|
interface RepoLeg {
|
|
1042
1024
|
repo: RepoSpec
|
|
@@ -1086,8 +1068,9 @@ export async function runMultiRepoCoding(
|
|
|
1086
1068
|
const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
|
|
1087
1069
|
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
1088
1070
|
|
|
1089
|
-
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
1090
|
-
// matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
1071
|
+
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
1072
|
+
// (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
1073
|
+
// read-only explore fan-out.
|
|
1091
1074
|
const claimDir = makeDirClaimer()
|
|
1092
1075
|
const legs: RepoLeg[] = [
|
|
1093
1076
|
{
|