agent-dealer 1.2.8 → 1.2.10
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/bundle/server/dist/adapters/github.js +93 -4
- package/bundle/server/dist/adapters/github.test.js +96 -1
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/coordinator/auto-merge.js +205 -1
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +341 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +237 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +460 -3
- package/bundle/server/dist/coordinator/human-resolution.js +19 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +100 -12
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
- package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts.js +134 -1
- package/bundle/server/dist/coordinator/prompts.test.js +148 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +55 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +13 -0
- package/bundle/server/dist/db/schema.sql +2 -0
- package/bundle/server/dist/repository/issues.js +39 -3
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
- package/bundle/server/dist/runners/muse-code.test.js +13 -1
- package/bundle/server/dist/runners/spawn-cli.js +93 -20
- package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +8 -0
- package/bundle/shared/dist/issues.d.ts +30 -0
- package/bundle/shared/dist/issues.js +14 -0
- package/bundle/shared/dist/issues.test.js +2 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +6 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
|
@@ -25,6 +25,10 @@ export function parsePrView(json) {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
export const PR_VIEW_FIELDS = "number,url,baseRefName,headRefName,headRefOid,isDraft";
|
|
28
|
+
/** Normalizes `gh pr view --json mergeable` output; anything unrecognized is UNKNOWN. */
|
|
29
|
+
export function parsePrMergeableState(raw) {
|
|
30
|
+
return raw === "CONFLICTING" || raw === "MERGEABLE" ? raw : "UNKNOWN";
|
|
31
|
+
}
|
|
28
32
|
const FAILURE_STATES = new Set([
|
|
29
33
|
"failure",
|
|
30
34
|
"error",
|
|
@@ -386,6 +390,23 @@ export function createGithubAdapter(exec = defaultExec) {
|
|
|
386
390
|
return null;
|
|
387
391
|
}
|
|
388
392
|
},
|
|
393
|
+
async hasPullRequestWorkflow({ cwd, baseRef }) {
|
|
394
|
+
try {
|
|
395
|
+
return await baseRefHasPullRequestWorkflow(cwd, baseRef);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
async prMergeableState({ cwd, number }) {
|
|
402
|
+
try {
|
|
403
|
+
const raw = await ghPrView(exec, cwd, "mergeable", String(number));
|
|
404
|
+
return parsePrMergeableState(raw?.mergeable);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return "UNKNOWN";
|
|
408
|
+
}
|
|
409
|
+
},
|
|
389
410
|
async publishReview({ cwd, number, event, bodyFilePath }) {
|
|
390
411
|
const result = await runReview(exec, cwd, number, event, bodyFilePath);
|
|
391
412
|
if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
|
|
@@ -491,6 +512,64 @@ export async function fetchChecksFailureEvidence(exec, opts) {
|
|
|
491
512
|
details,
|
|
492
513
|
};
|
|
493
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* NOT-311: does `origin/<baseRef>` contain a workflow triggered by `pull_request`?
|
|
517
|
+
* False when the tree has no workflow files at all; null when the tree itself cannot
|
|
518
|
+
* be read (unknown base ref, unparsable git output) so the caller waits fail-closed.
|
|
519
|
+
*/
|
|
520
|
+
export async function baseRefHasPullRequestWorkflow(cwd, baseRef) {
|
|
521
|
+
let listing;
|
|
522
|
+
try {
|
|
523
|
+
// `-r`: without it ls-tree prints the workflows *directory* entry itself rather
|
|
524
|
+
// than the files under it, so the scan would never see a workflow (NOT-311).
|
|
525
|
+
listing = (await run("git", ["ls-tree", "-r", `origin/${baseRef}`, "--name-only", ".github/workflows"], { cwd })).stdout;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
const files = listing
|
|
531
|
+
.split("\n")
|
|
532
|
+
.map((s) => s.trim())
|
|
533
|
+
.filter((f) => f && /\.(ya?ml)$/i.test(f));
|
|
534
|
+
if (files.length === 0)
|
|
535
|
+
return false;
|
|
536
|
+
for (const file of files) {
|
|
537
|
+
let content;
|
|
538
|
+
try {
|
|
539
|
+
content = (await run("git", ["show", `origin/${baseRef}:${file}`], { cwd })).stdout;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
if (workflowHasPullRequestTrigger(content))
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Heuristic `on:` trigger scan: matches inline (`on: pull_request`,
|
|
551
|
+
* `on: [push, pull_request]`) and block forms. Stops at the next top-level key so a
|
|
552
|
+
* later `pull_request` mention outside `on:` (a job name, a comment) cannot qualify.
|
|
553
|
+
* Leans toward waiting — a false positive only costs CI-wait backoff, while a false
|
|
554
|
+
* negative would send an unverified PR to review.
|
|
555
|
+
*/
|
|
556
|
+
export function workflowHasPullRequestTrigger(content) {
|
|
557
|
+
const lines = content.split("\n");
|
|
558
|
+
const onIdx = lines.findIndex((l) => /^\s*on\s*:/.test(l));
|
|
559
|
+
if (onIdx < 0)
|
|
560
|
+
return false;
|
|
561
|
+
const onLine = lines[onIdx];
|
|
562
|
+
if (/\bpull_request\b/.test(onLine.slice(onLine.indexOf("on") + 2)))
|
|
563
|
+
return true;
|
|
564
|
+
for (let i = onIdx + 1; i < lines.length; i++) {
|
|
565
|
+
const line = lines[i];
|
|
566
|
+
if (/^\S/.test(line))
|
|
567
|
+
break;
|
|
568
|
+
if (/\bpull_request\b/.test(line.replace(/#.*$/, "")))
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
494
573
|
export const realGithubAdapter = createGithubAdapter();
|
|
495
574
|
const REVIEW_FLAG = {
|
|
496
575
|
APPROVE: "--approve",
|
|
@@ -531,24 +610,34 @@ export async function pollPrChecks(
|
|
|
531
610
|
// NOT-310: narrowed to what the poll actually calls — the merge-conflict sync
|
|
532
611
|
// passes only a checks reader, and full adapters still satisfy this shape.
|
|
533
612
|
adapter, opts) {
|
|
613
|
+
return (await pollPrChecksDetailed(adapter, opts)).result;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* NOT-311: same bounded poll as {@link pollPrChecks}, but reporting what was last
|
|
617
|
+
* seen and whether an abort cut the wait short. `pollPrChecks` keeps the collapsed
|
|
618
|
+
* contract for callers that only branch on the result.
|
|
619
|
+
*/
|
|
620
|
+
export async function pollPrChecksDetailed(adapter, opts) {
|
|
534
621
|
const deadline = Date.now() + opts.timeoutMs;
|
|
535
622
|
let noneStreak = 0;
|
|
623
|
+
let lastSnapshot = null;
|
|
536
624
|
for (;;) {
|
|
537
625
|
if (opts.signal?.aborted)
|
|
538
|
-
return "timeout";
|
|
626
|
+
return { result: "timeout", lastSnapshot, aborted: true };
|
|
539
627
|
const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
|
|
628
|
+
lastSnapshot = snapshot;
|
|
540
629
|
if (snapshot === "failure" || snapshot === "success")
|
|
541
|
-
return snapshot;
|
|
630
|
+
return { result: snapshot, lastSnapshot, aborted: false };
|
|
542
631
|
if (snapshot === "none") {
|
|
543
632
|
noneStreak++;
|
|
544
633
|
if (noneStreak >= NONE_STREAK_REQUIRED)
|
|
545
|
-
return "none";
|
|
634
|
+
return { result: "none", lastSnapshot, aborted: false };
|
|
546
635
|
}
|
|
547
636
|
else {
|
|
548
637
|
noneStreak = 0;
|
|
549
638
|
}
|
|
550
639
|
if (Date.now() >= deadline)
|
|
551
|
-
return "timeout";
|
|
640
|
+
return { result: "timeout", lastSnapshot, aborted: false };
|
|
552
641
|
await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
|
|
553
642
|
}
|
|
554
643
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/server/src/adapters/github.test.ts
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import { parsePrView, summarizeChecks, pollPrChecks, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
|
|
4
|
+
import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
|
|
5
5
|
test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
|
|
6
6
|
const view = parsePrView(JSON.stringify({
|
|
7
7
|
number: 7,
|
|
@@ -114,6 +114,19 @@ test("viewPr: a retry can find the already-created PR by explicit branch even wi
|
|
|
114
114
|
const view = await createGithubAdapter(exec).viewPr({ cwd: "/repo", branch: "issue-4e5eb611" });
|
|
115
115
|
assert.equal(view?.number, 42);
|
|
116
116
|
});
|
|
117
|
+
test("NOT-354: prMergeableState reads `mergeable` by PR number; unknown values and failures are UNKNOWN", async () => {
|
|
118
|
+
const conflicting = queuedExec([{ stdout: JSON.stringify({ mergeable: "CONFLICTING" }) }]);
|
|
119
|
+
assert.equal(await createGithubAdapter(conflicting.exec).prMergeableState({ cwd: "/repo", number: 42 }), "CONFLICTING");
|
|
120
|
+
assert.deepEqual(conflicting.calls[0], ["pr", "view", "42", "--json", "mergeable"]);
|
|
121
|
+
const mergeable = queuedExec([{ stdout: JSON.stringify({ mergeable: "MERGEABLE" }) }]);
|
|
122
|
+
assert.equal(await createGithubAdapter(mergeable.exec).prMergeableState({ cwd: "/repo", number: 42 }), "MERGEABLE");
|
|
123
|
+
const unknown = queuedExec([{ stdout: JSON.stringify({ mergeable: "UNKNOWN" }) }]);
|
|
124
|
+
assert.equal(await createGithubAdapter(unknown.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
125
|
+
const odd = queuedExec([{ stdout: JSON.stringify({ mergeable: "something-new" }) }]);
|
|
126
|
+
assert.equal(await createGithubAdapter(odd.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
127
|
+
const failing = queuedExec([{ error: "HTTP 502" }]);
|
|
128
|
+
assert.equal(await createGithubAdapter(failing.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
129
|
+
});
|
|
117
130
|
test("checksSnapshot looks up the PR's check rollup by explicit selector (number preferred), never a bare `gh pr view`", async () => {
|
|
118
131
|
const success = { stdout: JSON.stringify({ statusCheckRollup: [{ conclusion: "success" }] }) };
|
|
119
132
|
const byNumber = queuedExec([success]);
|
|
@@ -205,6 +218,88 @@ test("pollPrChecks bails out as timeout immediately when the lease signal is alr
|
|
|
205
218
|
});
|
|
206
219
|
assert.equal(result, "timeout");
|
|
207
220
|
});
|
|
221
|
+
test("NOT-311: pollPrChecksDetailed reports the last snapshot so a timeout can tell pending from abort", async () => {
|
|
222
|
+
const pending = await pollPrChecksDetailed(fakeAdapter(["pending", "pending", "pending", "pending"]), {
|
|
223
|
+
cwd: "/x",
|
|
224
|
+
timeoutMs: 20,
|
|
225
|
+
intervalMs: 10,
|
|
226
|
+
});
|
|
227
|
+
assert.deepEqual(pending, { result: "timeout", lastSnapshot: "pending", aborted: false });
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
controller.abort();
|
|
230
|
+
const aborted = await pollPrChecksDetailed(fakeAdapter(["pending"]), {
|
|
231
|
+
cwd: "/x",
|
|
232
|
+
timeoutMs: 1000,
|
|
233
|
+
intervalMs: 5,
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
});
|
|
236
|
+
assert.deepEqual(aborted, { result: "timeout", lastSnapshot: null, aborted: true });
|
|
237
|
+
const none = await pollPrChecksDetailed(fakeAdapter(["none", "none"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 });
|
|
238
|
+
assert.deepEqual(none, { result: "none", lastSnapshot: "none", aborted: false });
|
|
239
|
+
const incompleteNoneStreak = await pollPrChecksDetailed(fakeAdapter(["none"]), {
|
|
240
|
+
cwd: "/x",
|
|
241
|
+
timeoutMs: 0,
|
|
242
|
+
intervalMs: 10,
|
|
243
|
+
});
|
|
244
|
+
assert.deepEqual(incompleteNoneStreak, { result: "timeout", lastSnapshot: "none", aborted: false });
|
|
245
|
+
});
|
|
246
|
+
test("NOT-311: workflowHasPullRequestTrigger matches inline, list, and block on: forms", () => {
|
|
247
|
+
assert.equal(workflowHasPullRequestTrigger("on: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n"), true);
|
|
248
|
+
assert.equal(workflowHasPullRequestTrigger("on: [push, pull_request]\n"), true);
|
|
249
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n"), true);
|
|
250
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n"), false);
|
|
251
|
+
assert.equal(workflowHasPullRequestTrigger("on: push\n"), false);
|
|
252
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n schedule:\n - cron: '0 0 * * *'\n"), false);
|
|
253
|
+
assert.equal(workflowHasPullRequestTrigger("jobs:\n x:\n runs-on: ubuntu-latest\n"), false);
|
|
254
|
+
// A pull_request mention outside `on:` (job name) must not qualify.
|
|
255
|
+
assert.equal(workflowHasPullRequestTrigger("on: push\njobs:\n pull_request_preview:\n runs-on: ubuntu-latest\n"), false);
|
|
256
|
+
});
|
|
257
|
+
test("NOT-311: baseRefHasPullRequestWorkflow reads the base ref tree, false without workflows, null on unknown ref", async () => {
|
|
258
|
+
const { execFileSync } = await import("node:child_process");
|
|
259
|
+
const fs = await import("node:fs");
|
|
260
|
+
const os = await import("node:os");
|
|
261
|
+
const path = await import("node:path");
|
|
262
|
+
const setup = (files) => {
|
|
263
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-repo-"));
|
|
264
|
+
const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
|
|
265
|
+
git("init", "-q", "-b", "main");
|
|
266
|
+
git("config", "user.email", "test@example.com");
|
|
267
|
+
git("config", "user.name", "Test");
|
|
268
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
269
|
+
fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
|
|
270
|
+
fs.writeFileSync(path.join(repo, rel), content);
|
|
271
|
+
}
|
|
272
|
+
git("add", ".");
|
|
273
|
+
git("commit", "-q", "-m", "init");
|
|
274
|
+
const remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-remote-"));
|
|
275
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
|
|
276
|
+
git("remote", "add", "origin", remote);
|
|
277
|
+
git("push", "-q", "origin", "main");
|
|
278
|
+
git("fetch", "-q", "origin");
|
|
279
|
+
return { repo, remote };
|
|
280
|
+
};
|
|
281
|
+
const withCi = setup({
|
|
282
|
+
"README.md": "hi\n",
|
|
283
|
+
".github/workflows/ci.yml": "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
|
284
|
+
});
|
|
285
|
+
const pushOnly = setup({
|
|
286
|
+
"README.md": "hi\n",
|
|
287
|
+
".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 0 * * *'\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
|
288
|
+
});
|
|
289
|
+
const bare = setup({ "README.md": "hi\n" });
|
|
290
|
+
try {
|
|
291
|
+
assert.equal(await baseRefHasPullRequestWorkflow(withCi.repo, "main"), true);
|
|
292
|
+
assert.equal(await baseRefHasPullRequestWorkflow(pushOnly.repo, "main"), false);
|
|
293
|
+
assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "main"), false);
|
|
294
|
+
assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "no-such-branch"), null);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
for (const { repo, remote } of [withCi, pushOnly, bare]) {
|
|
298
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
299
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
208
303
|
// --- NOT-252: terminal checks_failed enrichment ---
|
|
209
304
|
const HEAD_SHA = "abc123def456abc123def456abc123def456abcd";
|
|
210
305
|
function prViewWithRollup(rollup, headSha = HEAD_SHA) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// packages/server/src/adapters/worktree-deps.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-315: provision dependencies in a fresh developer worktree before the
|
|
4
|
+
// session spawns. The Muse builder sandbox has no network (restricted mode:
|
|
5
|
+
// DNS and loopback listen() both fail), so an in-session `npm install` can
|
|
6
|
+
// never work — the coordinator runs it here instead, outside the sandbox,
|
|
7
|
+
// with network.
|
|
8
|
+
//
|
|
9
|
+
// Only npm is in scope: a lockfile for another package manager (or no
|
|
10
|
+
// lockfile at all) skips the step with a logged reason, never a failure.
|
|
11
|
+
// A failed or timed-out install throws — the caller turns that into a
|
|
12
|
+
// visible `adapter_failure` worktree-setup failure (retryable on the existing
|
|
13
|
+
// infra-retry path), never a silent skip.
|
|
14
|
+
//
|
|
15
|
+
// The command runner is an injectable seam so tests prove the exact argv/cwd
|
|
16
|
+
// without spending network or touching a registry.
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
const run = promisify(execFile);
|
|
22
|
+
/** Default bound for `npm ci` in a fresh worktree (NOT-315). */
|
|
23
|
+
export const DEFAULT_WORKTREE_DEPS_TIMEOUT_MS = 5 * 60_000;
|
|
24
|
+
/** Bounded timeout for the install step; env override for tests/operators. */
|
|
25
|
+
export function worktreeDepsTimeoutMs() {
|
|
26
|
+
const raw = Number(process.env.WORKTREE_DEPS_TIMEOUT_MS);
|
|
27
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WORKTREE_DEPS_TIMEOUT_MS;
|
|
28
|
+
}
|
|
29
|
+
/** Exact install invocation — asserted by tests, never drifted silently. */
|
|
30
|
+
export const NPM_CI_ARGS = ["ci", "--prefer-offline", "--no-audit", "--no-fund"];
|
|
31
|
+
/** Lockfiles that mean "another package manager owns this worktree" — npm is out of scope. */
|
|
32
|
+
const NON_NPM_LOCKFILES = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"];
|
|
33
|
+
/** Last bytes of install output kept in the failure reason (a full log would drown routing). */
|
|
34
|
+
const STDERR_TAIL_CHARS = 2000;
|
|
35
|
+
const defaultRunner = async (cmd, args, opts) => run(cmd, [...args], { cwd: opts.cwd, timeout: opts.timeoutMs });
|
|
36
|
+
/** Keep the failure reason actionable: the tail of what npm actually printed. */
|
|
37
|
+
export function stderrTail(err) {
|
|
38
|
+
const e = err;
|
|
39
|
+
const raw = (typeof e?.stderr === "string" && e.stderr.trim() ? e.stderr : null) ??
|
|
40
|
+
(typeof e?.stdout === "string" && e.stdout.trim() ? e.stdout : null) ??
|
|
41
|
+
(typeof e?.message === "string" && e.message ? e.message : String(err));
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
return trimmed.length > STDERR_TAIL_CHARS ? trimmed.slice(-STDERR_TAIL_CHARS) : trimmed;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Run `npm ci` in `worktreePath` when the checkout declares npm dependencies
|
|
47
|
+
* that are not installed yet. Returns whether the install ran (with its
|
|
48
|
+
* duration) or why it was skipped. Throws
|
|
49
|
+
* `dependency install failed: <stderr tail>` when the install itself fails or
|
|
50
|
+
* times out — the caller surfaces that as a worktree-setup `adapter_failure`.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureWorktreeDeps(worktreePath, opts = {}) {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const skipped = (reason) => ({
|
|
55
|
+
ran: false,
|
|
56
|
+
reason,
|
|
57
|
+
durationMs: Date.now() - startedAt,
|
|
58
|
+
});
|
|
59
|
+
if (fs.existsSync(path.join(worktreePath, "node_modules"))) {
|
|
60
|
+
return skipped("node_modules present");
|
|
61
|
+
}
|
|
62
|
+
if (!fs.existsSync(path.join(worktreePath, "package.json"))) {
|
|
63
|
+
return skipped("no package.json");
|
|
64
|
+
}
|
|
65
|
+
for (const lock of NON_NPM_LOCKFILES) {
|
|
66
|
+
if (fs.existsSync(path.join(worktreePath, lock))) {
|
|
67
|
+
return skipped(`non-npm lockfile ${lock} present; only npm is in scope`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!fs.existsSync(path.join(worktreePath, "package-lock.json"))) {
|
|
71
|
+
return skipped("no lockfile");
|
|
72
|
+
}
|
|
73
|
+
const timeoutMs = opts.timeoutMs ?? worktreeDepsTimeoutMs();
|
|
74
|
+
const runner = opts.runner ?? defaultRunner;
|
|
75
|
+
try {
|
|
76
|
+
await runner("npm", NPM_CI_ARGS, { cwd: worktreePath, timeoutMs });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// A failed `npm ci` (or a timeout kill mid-reify) can leave a
|
|
80
|
+
// partial/empty node_modules behind. The setup failure retries on the
|
|
81
|
+
// infra path, which reuses the leftover worktree — and a leftover
|
|
82
|
+
// node_modules would make the retry skip the install and hand the builder
|
|
83
|
+
// broken deps. Remove it so the retry reinstalls from scratch; the removal
|
|
84
|
+
// itself is best-effort and must never mask the install error.
|
|
85
|
+
try {
|
|
86
|
+
fs.rmSync(path.join(worktreePath, "node_modules"), { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// keep the original install error below
|
|
90
|
+
}
|
|
91
|
+
const killed = err?.killed === true;
|
|
92
|
+
const prefix = killed ? `timed out after ${timeoutMs}ms: ` : "";
|
|
93
|
+
throw new Error(`dependency install failed: ${prefix}${stderrTail(err)}`);
|
|
94
|
+
}
|
|
95
|
+
return { ran: true, durationMs: Date.now() - startedAt };
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// packages/server/src/adapters/worktree-deps.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-315: the pre-spawn dependency step runs `npm ci` exactly once when the
|
|
4
|
+
// worktree declares npm dependencies, skips otherwise, and surfaces install
|
|
5
|
+
// failures with the stderr tail — all against a fake runner, never a registry.
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { ensureWorktreeDeps, NPM_CI_ARGS } from "./worktree-deps.js";
|
|
12
|
+
let dirs = [];
|
|
13
|
+
function makeWorktree(files) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-deps-"));
|
|
15
|
+
dirs.push(dir);
|
|
16
|
+
for (const [name, content] of Object.entries(files)) {
|
|
17
|
+
fs.writeFileSync(path.join(dir, name), content);
|
|
18
|
+
}
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
after(() => {
|
|
22
|
+
for (const dir of dirs)
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
dirs = [];
|
|
25
|
+
});
|
|
26
|
+
const PKG = JSON.stringify({ name: "wt", version: "1.0.0" });
|
|
27
|
+
const LOCK = JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} });
|
|
28
|
+
function recordingRunner(calls) {
|
|
29
|
+
return async (cmd, args, opts) => {
|
|
30
|
+
calls.push({ cmd, args: [...args], cwd: opts.cwd, timeoutMs: opts.timeoutMs });
|
|
31
|
+
return { stdout: "", stderr: "" };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
test("lockfile present and node_modules absent -> install runs once with the expected args and cwd", async () => {
|
|
35
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
36
|
+
const calls = [];
|
|
37
|
+
const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls), timeoutMs: 1234 });
|
|
38
|
+
assert.equal(calls.length, 1);
|
|
39
|
+
assert.equal(calls[0].cmd, "npm");
|
|
40
|
+
assert.deepEqual(calls[0].args, [...NPM_CI_ARGS]);
|
|
41
|
+
assert.deepEqual(calls[0].args, ["ci", "--prefer-offline", "--no-audit", "--no-fund"]);
|
|
42
|
+
assert.equal(calls[0].cwd, dir);
|
|
43
|
+
assert.equal(calls[0].timeoutMs, 1234);
|
|
44
|
+
assert.equal(result.ran, true);
|
|
45
|
+
if (result.ran)
|
|
46
|
+
assert.ok(typeof result.durationMs === "number" && result.durationMs >= 0);
|
|
47
|
+
});
|
|
48
|
+
test("node_modules present -> skipped without running anything", async () => {
|
|
49
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
50
|
+
fs.mkdirSync(path.join(dir, "node_modules"));
|
|
51
|
+
const calls = [];
|
|
52
|
+
const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
|
|
53
|
+
assert.equal(calls.length, 0);
|
|
54
|
+
assert.equal(result.ran, false);
|
|
55
|
+
if (!result.ran)
|
|
56
|
+
assert.match(result.reason, /node_modules/);
|
|
57
|
+
assert.ok(typeof result.durationMs === "number" && result.durationMs >= 0);
|
|
58
|
+
});
|
|
59
|
+
test("no lockfile -> skipped without running anything", async () => {
|
|
60
|
+
const dir = makeWorktree({ "package.json": PKG });
|
|
61
|
+
const calls = [];
|
|
62
|
+
const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
|
|
63
|
+
assert.equal(calls.length, 0);
|
|
64
|
+
assert.equal(result.ran, false);
|
|
65
|
+
if (!result.ran)
|
|
66
|
+
assert.match(result.reason, /no lockfile/);
|
|
67
|
+
});
|
|
68
|
+
test("no package.json -> skipped without running anything", async () => {
|
|
69
|
+
const dir = makeWorktree({ "README.md": "hello\n" });
|
|
70
|
+
const calls = [];
|
|
71
|
+
const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
|
|
72
|
+
assert.equal(calls.length, 0);
|
|
73
|
+
assert.equal(result.ran, false);
|
|
74
|
+
});
|
|
75
|
+
test("another package manager's lockfile -> skipped without running anything", async () => {
|
|
76
|
+
for (const lock of ["pnpm-lock.yaml", "yarn.lock", "bun.lockb"]) {
|
|
77
|
+
const dir = makeWorktree({ "package.json": PKG, [lock]: "lock\n" });
|
|
78
|
+
const calls = [];
|
|
79
|
+
const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
|
|
80
|
+
assert.equal(calls.length, 0, `${lock} must not trigger npm`);
|
|
81
|
+
assert.equal(result.ran, false);
|
|
82
|
+
if (!result.ran)
|
|
83
|
+
assert.match(result.reason, /non-npm lockfile/);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
test("a failing install throws with the stderr tail", async () => {
|
|
87
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
88
|
+
const failing = async () => {
|
|
89
|
+
const err = new Error("npm ci exited");
|
|
90
|
+
err.stderr = "npm error code E500\nnpm error registry exploded on line 42\n";
|
|
91
|
+
throw err;
|
|
92
|
+
};
|
|
93
|
+
await assert.rejects(() => ensureWorktreeDeps(dir, { runner: failing }), (err) => {
|
|
94
|
+
assert.match(err.message, /^dependency install failed: /);
|
|
95
|
+
assert.match(err.message, /registry exploded on line 42/);
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
test("a timed-out install throws with the same dependency-install prefix", async () => {
|
|
100
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
101
|
+
const timingOut = async () => {
|
|
102
|
+
throw new Error("npm ci timed out after 300000ms — network or registry may be down");
|
|
103
|
+
};
|
|
104
|
+
await assert.rejects(() => ensureWorktreeDeps(dir, { runner: timingOut }), (err) => {
|
|
105
|
+
assert.match(err.message, /^dependency install failed: /);
|
|
106
|
+
assert.match(err.message, /timed out/);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
test("a failing install removes a leftover node_modules so an infra retry reinstalls", async () => {
|
|
111
|
+
// npm ci that dies mid-reify (or is SIGTERM-killed by the execFile timeout)
|
|
112
|
+
// can leave a partial node_modules behind; the retry reuses the leftover
|
|
113
|
+
// worktree, so the husk must be gone or the retry would skip the install.
|
|
114
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
115
|
+
const failing = async (_cmd, _args, opts) => {
|
|
116
|
+
fs.mkdirSync(path.join(opts.cwd, "node_modules", "half-written-pkg"), { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(opts.cwd, "node_modules", "half-written-pkg", "index.js"), "partial\n");
|
|
118
|
+
const err = new Error("npm ci exited");
|
|
119
|
+
err.stderr = "npm error code E500\nnpm error registry exploded on line 42\n";
|
|
120
|
+
throw err;
|
|
121
|
+
};
|
|
122
|
+
await assert.rejects(() => ensureWorktreeDeps(dir, { runner: failing }), (err) => {
|
|
123
|
+
assert.match(err.message, /^dependency install failed: /);
|
|
124
|
+
assert.match(err.message, /registry exploded on line 42/);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
assert.equal(fs.existsSync(path.join(dir, "node_modules")), false, "partial node_modules must be removed");
|
|
128
|
+
});
|
|
129
|
+
test("a killed (timed-out) install names the timeout in the reason", async () => {
|
|
130
|
+
const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
|
|
131
|
+
const killed = async () => {
|
|
132
|
+
// Shape of a real execFile timeout: killed by SIGTERM with little stderr.
|
|
133
|
+
const err = new Error("Command failed: npm ci --prefer-offline --no-audit --no-fund");
|
|
134
|
+
err.killed = true;
|
|
135
|
+
err.signal = "SIGTERM";
|
|
136
|
+
throw err;
|
|
137
|
+
};
|
|
138
|
+
await assert.rejects(() => ensureWorktreeDeps(dir, { runner: killed, timeoutMs: 4321 }), (err) => {
|
|
139
|
+
assert.match(err.message, /^dependency install failed: /);
|
|
140
|
+
assert.match(err.message, /timed out after 4321ms/);
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
assert.equal(fs.existsSync(path.join(dir, "node_modules")), false);
|
|
144
|
+
});
|