@cat-factory/executor-harness 1.58.0 → 1.62.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.
@@ -1,9 +1,58 @@
1
- import { spawn } from 'node:child_process';
2
- import { killChildProcess, spawnDetached } from './process.js';
3
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
1
+ import { runCapturedCommand } from './captured-command.js';
2
+ /**
3
+ * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
4
+ * default it applies when the body omits one.
5
+ *
6
+ * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
7
+ * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
8
+ * cannot import them. Keep the two in step: the API validates writes against the contracts
9
+ * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
10
+ * was allowed to save, with nothing to flag the mismatch.
11
+ */
12
+ export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
13
+ export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
14
+ /**
15
+ * Parse the optional PRE-PR VALIDATION CHECKS envelope off the job body: the service's ordered
16
+ * `{ label, command }` pairs and the repair-round budget. Every entry needs a non-empty command;
17
+ * entries without one are dropped, and a spec that ends up with no usable check returns
18
+ * `undefined` — so a malformed body degrades to the exact pre-feature behaviour (no loop, PR
19
+ * opens as before) rather than failing an otherwise-good coding run. `maxAttempts` is clamped to
20
+ * a sane range so a bad body can't make a container loop forever.
21
+ *
22
+ * Lives with the feature rather than in `job.ts` so each pre-PR verification phase owns its own
23
+ * job-body parser next to the loop that consumes it (the reproduction proof's
24
+ * `parseReproductionSpec` is the sibling); `job.ts` stays the job SHAPE plus the generic
25
+ * assembly.
26
+ */
27
+ export function parseValidationChecksSpec(value) {
28
+ if (typeof value !== 'object' || value === null)
29
+ return undefined;
30
+ const o = value;
31
+ if (!Array.isArray(o.checks))
32
+ return undefined;
33
+ const checks = [];
34
+ for (const raw of o.checks) {
35
+ if (typeof raw !== 'object' || raw === null)
36
+ continue;
37
+ const c = raw;
38
+ if (typeof c.command !== 'string' || c.command.trim() === '')
39
+ continue;
40
+ const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
41
+ checks.push({ label, command: c.command });
42
+ }
43
+ if (checks.length === 0)
44
+ return undefined;
45
+ const parsed = typeof o.maxAttempts === 'number' && Number.isFinite(o.maxAttempts) && o.maxAttempts > 0
46
+ ? Math.floor(o.maxAttempts)
47
+ : undefined;
48
+ return {
49
+ checks,
50
+ maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
51
+ };
52
+ }
4
53
  /**
5
54
  * Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
6
- * `detail` blob). Deliberately smaller than {@link MAX_CAPTURED_OUTPUT_CHARS}, which is what the
55
+ * `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is what the
7
56
  * AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
8
57
  * enough to recognise it, and a chatty build must not inflate every run's stored state.
9
58
  */
@@ -75,88 +124,27 @@ export async function runValidationChecks(cwd, spec, attempt, logger, opts) {
75
124
  return { report, fullTails };
76
125
  }
77
126
  /**
78
- * Run ONE check as `sh -c <command>` in `cwd`, capturing a bounded, secret-scrubbed tail of its
79
- * combined stdout+stderr. The exit code is the verdict — computed here by the harness, never
80
- * self-reported by the model, which is the whole point of a programmatic gate. A watchdog kills
81
- * the process tree on timeout and an aborted run resolves non-zero, so the loop is never blocked.
82
- *
83
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
84
- * not a mutated global: the harness spawns this itself rather than through the agent, so without
85
- * the explicit merge a native-mode job would run its checks without the private-registry npmrc
86
- * pointer (and against a sibling job's state, had this been staged in `process.env`).
127
+ * Run ONE check through the shared {@link runCapturedCommand} seam and shape it as a check
128
+ * outcome. The exit code is the verdict — computed by the harness, never self-reported by the
129
+ * model, which is the whole point of a programmatic gate.
87
130
  */
88
131
  async function runOneCheck(cwd, check, logger, opts) {
89
- const timeoutMs = validationCommandTimeoutMs();
90
- const startedAt = Date.now();
91
132
  logger.info('validation: running check', { label: check.label });
92
- return new Promise((resolve) => {
93
- let out = '';
94
- let settled = false;
95
- let timedOut = false;
96
- const child = spawn('sh', ['-c', check.command], {
97
- cwd,
98
- detached: spawnDetached,
99
- stdio: ['ignore', 'pipe', 'pipe'],
100
- env: { ...process.env, ...opts.agentEnv },
101
- });
102
- // Keep only the tail; guard against unbounded buffering on a chatty command.
103
- const capture = (chunk) => {
104
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
105
- };
106
- child.stdout?.on('data', capture);
107
- child.stderr?.on('data', capture);
108
- const finish = (exitCode) => {
109
- if (settled)
110
- return;
111
- settled = true;
112
- clearTimeout(timer);
113
- opts.signal?.removeEventListener('abort', onAbort);
114
- const trimmed = out.trim();
115
- // Scrub BEFORE truncating: a token straddling the cut would otherwise survive as a
116
- // partial, and the pattern rules need the whole assignment to match.
117
- const scrubbed = trimmed ? redactSecrets(trimmed) : '';
118
- logger.info('validation: check finished', { label: check.label, exitCode });
119
- resolve({
120
- outcome: {
121
- label: check.label,
122
- command: check.command,
123
- exitCode,
124
- passed: exitCode === 0,
125
- ...(scrubbed ? { outputTail: tailFor(scrubbed) } : {}),
126
- durationMs: Date.now() - startedAt,
127
- ...(timedOut ? { timedOut: true } : {}),
128
- },
129
- ...(scrubbed ? { fullTail: scrubbed } : {}),
130
- });
131
- };
132
- const timer = setTimeout(() => {
133
- logger.warn('validation: check timed out', { label: check.label, timeoutMs });
134
- timedOut = true;
135
- killChildProcess(child, undefined, logger);
136
- finish(124); // conventional timeout exit code (a non-zero fail)
137
- }, timeoutMs);
138
- timer.unref?.();
139
- const onAbort = () => {
140
- killChildProcess(child, undefined, logger);
141
- finish(130); // aborted (a non-zero fail)
142
- };
143
- opts.signal?.addEventListener('abort', onAbort, { once: true });
144
- child.on('error', (err) => {
145
- logger.warn('validation: check failed to spawn', {
146
- label: check.label,
147
- error: err instanceof Error ? err.message : String(err),
148
- });
149
- finish(127); // spawn error / command not found (a non-zero fail)
150
- });
151
- child.on('close', (code) => finish(code ?? 1));
133
+ const { fullTail, ...run } = await runCapturedCommand({
134
+ cwd,
135
+ command: check.command,
136
+ timeoutMs: validationCommandTimeoutMs(),
137
+ reportTailChars: VALIDATION_REPORT_TAIL_CHARS,
138
+ logLabel: 'validation',
139
+ logFields: { label: check.label },
140
+ logger,
141
+ opts,
152
142
  });
153
- }
154
- /** Bound an already-scrubbed output tail to what the REPORT carries. */
155
- function tailFor(scrubbed) {
156
- if (scrubbed.length <= VALIDATION_REPORT_TAIL_CHARS)
157
- return scrubbed;
158
- const trimmed = scrubbed.length - VALIDATION_REPORT_TAIL_CHARS;
159
- return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-VALIDATION_REPORT_TAIL_CHARS)}`;
143
+ logger.info('validation: check finished', { label: check.label, exitCode: run.exitCode });
144
+ return {
145
+ outcome: { label: check.label, command: check.command, ...run },
146
+ ...(fullTail ? { fullTail } : {}),
147
+ };
160
148
  }
161
149
  /**
162
150
  * The repair instruction handed to the agent after a failed attempt: the failing commands and
@@ -0,0 +1,402 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The VCS HOST REST surface: opening a pull request / merge request, finding the one that is
3
+ // already open, and refreshing its title + description.
4
+ //
5
+ // Split out of `git.ts`, which is otherwise entirely the git CLI. The seam is real rather than
6
+ // arithmetic: nothing here shells out to git, nothing in `git.ts` speaks HTTP, and the two
7
+ // halves fail in completely different ways (a rejected credential and a 403 from an App
8
+ // permission want different remedies). `test/git-pr.test.ts` already covered exactly this
9
+ // surface before it had a file of its own.
10
+ //
11
+ // Provider-agnostic by construction: GitHub and GitLab each get their own request shapes behind
12
+ // one `openPullRequest` entry point, and every capability added to one is added to the other.
13
+ // ---------------------------------------------------------------------------
14
+ import { HarnessFailure } from './failure.js';
15
+ import { preserveManagedSection } from './pr-description.js';
16
+ import { redactSecrets } from './redact.js';
17
+ /**
18
+ * Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
19
+ * undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
20
+ * {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
21
+ * load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
22
+ * Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
23
+ * request vs merge request). Pure, so it is unit-tested per status.
24
+ */
25
+ export function describePrOpenFailure(status, provider) {
26
+ const noun = provider === 'gitlab' ? 'merge request' : 'pull request';
27
+ if (status === 401) {
28
+ return (`The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
29
+ '(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
30
+ 'the GitHub App for the workspace (or regenerate the PAT), then retry.');
31
+ }
32
+ if (status === 403) {
33
+ const scope = provider === 'gitlab'
34
+ ? 'the GitLab token needs the `api` scope and Developer+ access to the project'
35
+ : 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository';
36
+ return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`;
37
+ }
38
+ if (status === 404) {
39
+ return (`The repository could not be found while opening the ${noun} — it may have been deleted, ` +
40
+ 'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
41
+ "credential's access to it, then retry.");
42
+ }
43
+ if (status === 422 || status === 400) {
44
+ return (`GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
45
+ 'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
46
+ 'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
47
+ 'then retry.');
48
+ }
49
+ return undefined;
50
+ }
51
+ /**
52
+ * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
53
+ * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
54
+ * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
55
+ * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
56
+ * self-managed instances named that way) is treated as GitLab.
57
+ */
58
+ export function inferVcsProvider(cloneUrl) {
59
+ let host = '';
60
+ try {
61
+ host = new URL(cloneUrl).host.toLowerCase();
62
+ }
63
+ catch {
64
+ return 'github';
65
+ }
66
+ if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
67
+ return 'gitlab';
68
+ }
69
+ return 'github';
70
+ }
71
+ /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
72
+ export function gitlabApiBaseFromCloneUrl(cloneUrl) {
73
+ const u = new URL(cloneUrl);
74
+ return `${u.protocol}//${u.host}/api/v4`;
75
+ }
76
+ /**
77
+ * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
78
+ * survive), with the trailing `.git` stripped, e.g.
79
+ * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
80
+ */
81
+ export function gitlabProjectPath(cloneUrl) {
82
+ const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '');
83
+ return encodeURIComponent(path);
84
+ }
85
+ /** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
86
+ function abortError(signal) {
87
+ return signal.reason instanceof Error ? signal.reason : new Error('aborted');
88
+ }
89
+ /** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
90
+ function isAbortError(err) {
91
+ return err instanceof Error && err.name === 'AbortError';
92
+ }
93
+ /**
94
+ * Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
95
+ * forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
96
+ * 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
97
+ * yields undefined so the caller falls back to exponential backoff.
98
+ */
99
+ function retryAfterMs(res) {
100
+ const raw = res.headers.get('retry-after');
101
+ if (!raw)
102
+ return undefined;
103
+ const secs = Number(raw);
104
+ if (Number.isFinite(secs)) {
105
+ return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined;
106
+ }
107
+ const at = Date.parse(raw);
108
+ if (Number.isNaN(at))
109
+ return undefined;
110
+ const ms = at - Date.now();
111
+ return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
112
+ }
113
+ /** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
114
+ function abortableDelay(ms, signal) {
115
+ return new Promise((resolve, reject) => {
116
+ if (signal?.aborted)
117
+ return reject(abortError(signal));
118
+ const onAbort = () => {
119
+ clearTimeout(timer);
120
+ reject(abortError(signal));
121
+ };
122
+ const timer = setTimeout(() => {
123
+ signal?.removeEventListener('abort', onAbort);
124
+ resolve();
125
+ }, ms);
126
+ signal?.addEventListener('abort', onAbort, { once: true });
127
+ });
128
+ }
129
+ const MAX_RETRY_AFTER_MS = 8_000;
130
+ const RETRY_BASE_MS = 500;
131
+ const RETRY_MAX_DELAY_MS = 4_000;
132
+ /**
133
+ * Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
134
+ * upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
135
+ * otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
136
+ * (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
137
+ * every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
138
+ *
139
+ * ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
140
+ * rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
141
+ * returned to the caller unretried, and a caller abort is rethrown at once. The response
142
+ * body is never read here, so the caller's existing status handling is unchanged.
143
+ */
144
+ async function withApiRetry(fn, opts = {}) {
145
+ const maxAttempts = opts.attempts ?? 3;
146
+ let lastError;
147
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
148
+ if (opts.signal?.aborted)
149
+ throw abortError(opts.signal);
150
+ let res;
151
+ try {
152
+ res = await fn();
153
+ }
154
+ catch (err) {
155
+ // A caller/watchdog abort is terminal; a network error is transient → retry.
156
+ if (isAbortError(err) || opts.signal?.aborted)
157
+ throw err;
158
+ lastError = err;
159
+ }
160
+ if (res) {
161
+ const transient = res.status >= 500 || res.status === 429;
162
+ if (!transient || attempt >= maxAttempts)
163
+ return res;
164
+ const after = retryAfterMs(res);
165
+ // Discard the unread body before retrying so the connection can be reused.
166
+ await res.body?.cancel().catch(() => { });
167
+ await abortableDelay(after ?? backoffMs(attempt), opts.signal);
168
+ continue;
169
+ }
170
+ if (attempt >= maxAttempts)
171
+ break;
172
+ await abortableDelay(backoffMs(attempt), opts.signal);
173
+ }
174
+ // Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
175
+ const message = lastError instanceof Error ? lastError.message : 'API request failed after retries';
176
+ throw new HarnessFailure('api', redactSecrets(message));
177
+ }
178
+ /** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
179
+ function backoffMs(attempt) {
180
+ const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
181
+ return base + Math.floor(base * 0.25 * Math.random());
182
+ }
183
+ /**
184
+ * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
185
+ * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
186
+ * falling back to host inference from the clone URL only when it didn't — so a self-managed
187
+ * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
188
+ * GitHub's API. The GitHub path is unchanged.
189
+ */
190
+ export async function openPullRequest(opts) {
191
+ const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github');
192
+ if (provider === 'gitlab') {
193
+ if (!opts.cloneUrl) {
194
+ throw new Error('Cannot open a GitLab merge request without the repo clone URL');
195
+ }
196
+ return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl });
197
+ }
198
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
199
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
200
+ const res = await withApiRetry(() => fetch(`${apiBase}/repos/${path}/pulls`, {
201
+ method: 'POST',
202
+ headers: {
203
+ authorization: `Bearer ${opts.ghToken}`,
204
+ accept: 'application/vnd.github+json',
205
+ 'user-agent': 'cat-factory-executor',
206
+ 'x-github-api-version': '2022-11-28',
207
+ 'content-type': 'application/json',
208
+ },
209
+ body: JSON.stringify({
210
+ title: opts.pr.title,
211
+ head: opts.head,
212
+ base: opts.base,
213
+ body: opts.pr.body,
214
+ }),
215
+ // Bound on the watchdog so a hung GitHub call can't stall the job.
216
+ ...(opts.signal ? { signal: opts.signal } : {}),
217
+ }), { signal: opts.signal });
218
+ if (!res.ok) {
219
+ const detail = await res.text().catch(() => '');
220
+ // A resumed run pushes to a branch that already has an open PR; GitHub answers
221
+ // 422 "A pull request already exists". That's success for us — return the
222
+ // existing PR's url rather than failing the resumed run.
223
+ if (res.status === 422 && /pull request already exists/i.test(detail)) {
224
+ const existing = await findOpenPullRequest(opts);
225
+ if (existing) {
226
+ if (opts.refreshExisting)
227
+ await refreshPullRequest(opts, existing);
228
+ return existing.url;
229
+ }
230
+ }
231
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
232
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
233
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
234
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
235
+ // the run with GitHub's opaque 422.
236
+ if (res.status === 422 && /no commits between/i.test(detail))
237
+ return null;
238
+ const remedy = describePrOpenFailure(res.status, 'github');
239
+ const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`);
240
+ throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
241
+ }
242
+ const body = (await res.json());
243
+ if (!body.html_url)
244
+ throw new HarnessFailure('api', 'GitHub did not return a PR url');
245
+ return body.html_url;
246
+ }
247
+ /** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
248
+ function gitlabHeaders(token) {
249
+ return {
250
+ 'private-token': token,
251
+ accept: 'application/json',
252
+ 'user-agent': 'cat-factory-executor',
253
+ 'content-type': 'application/json',
254
+ };
255
+ }
256
+ /**
257
+ * Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
258
+ * base + project path are derived from the clone URL's host, so it works for gitlab.com and a
259
+ * self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
260
+ * (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
261
+ * web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
262
+ */
263
+ async function openGitLabMergeRequest(opts) {
264
+ const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl);
265
+ const project = gitlabProjectPath(opts.cloneUrl);
266
+ const res = await withApiRetry(() => fetch(`${apiBase}/projects/${project}/merge_requests`, {
267
+ method: 'POST',
268
+ headers: gitlabHeaders(opts.ghToken),
269
+ body: JSON.stringify({
270
+ source_branch: opts.head,
271
+ target_branch: opts.base,
272
+ title: opts.pr.title,
273
+ description: opts.pr.body,
274
+ }),
275
+ ...(opts.signal ? { signal: opts.signal } : {}),
276
+ }), { signal: opts.signal });
277
+ if (!res.ok) {
278
+ const detail = await res.text().catch(() => '');
279
+ // GitLab returns 409 (sometimes 400) when an open MR already exists for this source
280
+ // branch; that is success for a resumed run — return the existing MR's url.
281
+ if ((res.status === 409 || res.status === 400) &&
282
+ /already exists|open merge request/i.test(detail)) {
283
+ const existing = await findOpenMergeRequest(apiBase, project, opts);
284
+ if (existing) {
285
+ if (opts.refreshExisting)
286
+ await refreshMergeRequest(apiBase, project, opts, existing);
287
+ return existing.url;
288
+ }
289
+ }
290
+ const remedy = describePrOpenFailure(res.status, 'gitlab');
291
+ const base = redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`);
292
+ throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
293
+ }
294
+ const body = (await res.json());
295
+ if (!body.web_url)
296
+ throw new HarnessFailure('api', 'GitLab did not return a merge request url');
297
+ return body.web_url;
298
+ }
299
+ /**
300
+ * Rewrite an already-open MR's title and description — the GitLab half of
301
+ * {@link refreshPullRequest}, so a resumed run's agent briefing reaches both hosts alike.
302
+ * Best-effort for the same reason.
303
+ */
304
+ async function refreshMergeRequest(apiBase, project, opts, existing) {
305
+ if (existing.number === undefined)
306
+ return;
307
+ await fetch(`${apiBase}/projects/${project}/merge_requests/${existing.number}`, {
308
+ method: 'PUT',
309
+ headers: gitlabHeaders(opts.ghToken),
310
+ body: JSON.stringify({
311
+ title: opts.pr.title,
312
+ description: preserveManagedSection(existing.body, opts.pr.body),
313
+ }),
314
+ ...(opts.signal ? { signal: opts.signal } : {}),
315
+ }).catch(() => undefined);
316
+ }
317
+ /** Find the open GitLab MR for `opts.head`→`opts.base`, or undefined when there is none. */
318
+ async function findOpenMergeRequest(apiBase, project, opts) {
319
+ // Filter by BOTH branches: a source branch can have open MRs to several targets, so the
320
+ // source alone could match an MR against a different base than the one we just tried to open.
321
+ const query = new URLSearchParams({
322
+ source_branch: opts.head,
323
+ target_branch: opts.base,
324
+ state: 'opened',
325
+ });
326
+ const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
327
+ headers: gitlabHeaders(opts.ghToken),
328
+ ...(opts.signal ? { signal: opts.signal } : {}),
329
+ });
330
+ if (!res.ok)
331
+ return undefined;
332
+ const list = (await res.json().catch(() => []));
333
+ const found = Array.isArray(list) ? list[0] : undefined;
334
+ if (!found?.web_url)
335
+ return undefined;
336
+ return {
337
+ url: found.web_url,
338
+ // `iid` (project-scoped) is what the update endpoint addresses, not the global `id`.
339
+ ...(typeof found.iid === 'number' ? { number: found.iid } : {}),
340
+ body: found.description ?? undefined,
341
+ };
342
+ }
343
+ /**
344
+ * Rewrite an already-open PR's title and description from `opts.pr` (a resumed run whose agent
345
+ * wrote a fresh reviewer briefing — see {@link OpenPullRequestOptions.refreshExisting}).
346
+ *
347
+ * Best-effort by construction: the work is already pushed and the PR already exists, so a failed
348
+ * refresh must degrade to the stale description rather than fail the run.
349
+ */
350
+ async function refreshPullRequest(opts, existing) {
351
+ if (existing.number === undefined)
352
+ return;
353
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
354
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
355
+ await fetch(`${apiBase}/repos/${path}/pulls/${existing.number}`, {
356
+ method: 'PATCH',
357
+ headers: {
358
+ authorization: `Bearer ${opts.ghToken}`,
359
+ accept: 'application/vnd.github+json',
360
+ 'user-agent': 'cat-factory-executor',
361
+ 'x-github-api-version': '2022-11-28',
362
+ 'content-type': 'application/json',
363
+ },
364
+ body: JSON.stringify({
365
+ title: opts.pr.title,
366
+ body: preserveManagedSection(existing.body, opts.pr.body),
367
+ }),
368
+ ...(opts.signal ? { signal: opts.signal } : {}),
369
+ }).catch(() => undefined);
370
+ }
371
+ /** Find the open PR for `opts.head` on `opts.base`, or undefined when there is none. */
372
+ async function findOpenPullRequest(opts) {
373
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
374
+ // Encode the ref-derived query params: a branch/owner containing `&` or `#` would
375
+ // otherwise split the query string or inject an unintended parameter.
376
+ const query = new URLSearchParams({
377
+ head: `${opts.owner}:${opts.head}`,
378
+ base: opts.base,
379
+ state: 'open',
380
+ });
381
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
382
+ const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
383
+ headers: {
384
+ authorization: `Bearer ${opts.ghToken}`,
385
+ accept: 'application/vnd.github+json',
386
+ 'user-agent': 'cat-factory-executor',
387
+ 'x-github-api-version': '2022-11-28',
388
+ },
389
+ ...(opts.signal ? { signal: opts.signal } : {}),
390
+ });
391
+ if (!res.ok)
392
+ return undefined;
393
+ const list = (await res.json().catch(() => []));
394
+ const found = Array.isArray(list) ? list[0] : undefined;
395
+ if (!found?.html_url)
396
+ return undefined;
397
+ return {
398
+ url: found.html_url,
399
+ ...(typeof found.number === 'number' ? { number: found.number } : {}),
400
+ body: found.body ?? undefined,
401
+ };
402
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.58.0",
3
+ "version": "1.62.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",
@@ -26,8 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.153.1",
30
- "@cat-factory/spend": "0.12.89"
29
+ "@cat-factory/kernel": "0.168.0",
30
+ "@cat-factory/spend": "0.12.97",
31
+ "@cat-factory/server": "0.158.0"
31
32
  },
32
33
  "scripts": {
33
34
  "build": "tsc -p tsconfig.json",