agent-dealer 1.2.8 → 1.2.9
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 +80 -4
- package/bundle/server/dist/adapters/github.test.js +83 -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 +194 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +145 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -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/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 +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- 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/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
|
@@ -386,6 +386,14 @@ export function createGithubAdapter(exec = defaultExec) {
|
|
|
386
386
|
return null;
|
|
387
387
|
}
|
|
388
388
|
},
|
|
389
|
+
async hasPullRequestWorkflow({ cwd, baseRef }) {
|
|
390
|
+
try {
|
|
391
|
+
return await baseRefHasPullRequestWorkflow(cwd, baseRef);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
389
397
|
async publishReview({ cwd, number, event, bodyFilePath }) {
|
|
390
398
|
const result = await runReview(exec, cwd, number, event, bodyFilePath);
|
|
391
399
|
if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
|
|
@@ -491,6 +499,64 @@ export async function fetchChecksFailureEvidence(exec, opts) {
|
|
|
491
499
|
details,
|
|
492
500
|
};
|
|
493
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* NOT-311: does `origin/<baseRef>` contain a workflow triggered by `pull_request`?
|
|
504
|
+
* False when the tree has no workflow files at all; null when the tree itself cannot
|
|
505
|
+
* be read (unknown base ref, unparsable git output) so the caller waits fail-closed.
|
|
506
|
+
*/
|
|
507
|
+
export async function baseRefHasPullRequestWorkflow(cwd, baseRef) {
|
|
508
|
+
let listing;
|
|
509
|
+
try {
|
|
510
|
+
// `-r`: without it ls-tree prints the workflows *directory* entry itself rather
|
|
511
|
+
// than the files under it, so the scan would never see a workflow (NOT-311).
|
|
512
|
+
listing = (await run("git", ["ls-tree", "-r", `origin/${baseRef}`, "--name-only", ".github/workflows"], { cwd })).stdout;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const files = listing
|
|
518
|
+
.split("\n")
|
|
519
|
+
.map((s) => s.trim())
|
|
520
|
+
.filter((f) => f && /\.(ya?ml)$/i.test(f));
|
|
521
|
+
if (files.length === 0)
|
|
522
|
+
return false;
|
|
523
|
+
for (const file of files) {
|
|
524
|
+
let content;
|
|
525
|
+
try {
|
|
526
|
+
content = (await run("git", ["show", `origin/${baseRef}:${file}`], { cwd })).stdout;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
if (workflowHasPullRequestTrigger(content))
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Heuristic `on:` trigger scan: matches inline (`on: pull_request`,
|
|
538
|
+
* `on: [push, pull_request]`) and block forms. Stops at the next top-level key so a
|
|
539
|
+
* later `pull_request` mention outside `on:` (a job name, a comment) cannot qualify.
|
|
540
|
+
* Leans toward waiting — a false positive only costs CI-wait backoff, while a false
|
|
541
|
+
* negative would send an unverified PR to review.
|
|
542
|
+
*/
|
|
543
|
+
export function workflowHasPullRequestTrigger(content) {
|
|
544
|
+
const lines = content.split("\n");
|
|
545
|
+
const onIdx = lines.findIndex((l) => /^\s*on\s*:/.test(l));
|
|
546
|
+
if (onIdx < 0)
|
|
547
|
+
return false;
|
|
548
|
+
const onLine = lines[onIdx];
|
|
549
|
+
if (/\bpull_request\b/.test(onLine.slice(onLine.indexOf("on") + 2)))
|
|
550
|
+
return true;
|
|
551
|
+
for (let i = onIdx + 1; i < lines.length; i++) {
|
|
552
|
+
const line = lines[i];
|
|
553
|
+
if (/^\S/.test(line))
|
|
554
|
+
break;
|
|
555
|
+
if (/\bpull_request\b/.test(line.replace(/#.*$/, "")))
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
494
560
|
export const realGithubAdapter = createGithubAdapter();
|
|
495
561
|
const REVIEW_FLAG = {
|
|
496
562
|
APPROVE: "--approve",
|
|
@@ -531,24 +597,34 @@ export async function pollPrChecks(
|
|
|
531
597
|
// NOT-310: narrowed to what the poll actually calls — the merge-conflict sync
|
|
532
598
|
// passes only a checks reader, and full adapters still satisfy this shape.
|
|
533
599
|
adapter, opts) {
|
|
600
|
+
return (await pollPrChecksDetailed(adapter, opts)).result;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* NOT-311: same bounded poll as {@link pollPrChecks}, but reporting what was last
|
|
604
|
+
* seen and whether an abort cut the wait short. `pollPrChecks` keeps the collapsed
|
|
605
|
+
* contract for callers that only branch on the result.
|
|
606
|
+
*/
|
|
607
|
+
export async function pollPrChecksDetailed(adapter, opts) {
|
|
534
608
|
const deadline = Date.now() + opts.timeoutMs;
|
|
535
609
|
let noneStreak = 0;
|
|
610
|
+
let lastSnapshot = null;
|
|
536
611
|
for (;;) {
|
|
537
612
|
if (opts.signal?.aborted)
|
|
538
|
-
return "timeout";
|
|
613
|
+
return { result: "timeout", lastSnapshot, aborted: true };
|
|
539
614
|
const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
|
|
615
|
+
lastSnapshot = snapshot;
|
|
540
616
|
if (snapshot === "failure" || snapshot === "success")
|
|
541
|
-
return snapshot;
|
|
617
|
+
return { result: snapshot, lastSnapshot, aborted: false };
|
|
542
618
|
if (snapshot === "none") {
|
|
543
619
|
noneStreak++;
|
|
544
620
|
if (noneStreak >= NONE_STREAK_REQUIRED)
|
|
545
|
-
return "none";
|
|
621
|
+
return { result: "none", lastSnapshot, aborted: false };
|
|
546
622
|
}
|
|
547
623
|
else {
|
|
548
624
|
noneStreak = 0;
|
|
549
625
|
}
|
|
550
626
|
if (Date.now() >= deadline)
|
|
551
|
-
return "timeout";
|
|
627
|
+
return { result: "timeout", lastSnapshot, aborted: false };
|
|
552
628
|
await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
|
|
553
629
|
}
|
|
554
630
|
}
|
|
@@ -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,
|
|
@@ -205,6 +205,88 @@ test("pollPrChecks bails out as timeout immediately when the lease signal is alr
|
|
|
205
205
|
});
|
|
206
206
|
assert.equal(result, "timeout");
|
|
207
207
|
});
|
|
208
|
+
test("NOT-311: pollPrChecksDetailed reports the last snapshot so a timeout can tell pending from abort", async () => {
|
|
209
|
+
const pending = await pollPrChecksDetailed(fakeAdapter(["pending", "pending", "pending", "pending"]), {
|
|
210
|
+
cwd: "/x",
|
|
211
|
+
timeoutMs: 20,
|
|
212
|
+
intervalMs: 10,
|
|
213
|
+
});
|
|
214
|
+
assert.deepEqual(pending, { result: "timeout", lastSnapshot: "pending", aborted: false });
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
controller.abort();
|
|
217
|
+
const aborted = await pollPrChecksDetailed(fakeAdapter(["pending"]), {
|
|
218
|
+
cwd: "/x",
|
|
219
|
+
timeoutMs: 1000,
|
|
220
|
+
intervalMs: 5,
|
|
221
|
+
signal: controller.signal,
|
|
222
|
+
});
|
|
223
|
+
assert.deepEqual(aborted, { result: "timeout", lastSnapshot: null, aborted: true });
|
|
224
|
+
const none = await pollPrChecksDetailed(fakeAdapter(["none", "none"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 });
|
|
225
|
+
assert.deepEqual(none, { result: "none", lastSnapshot: "none", aborted: false });
|
|
226
|
+
const incompleteNoneStreak = await pollPrChecksDetailed(fakeAdapter(["none"]), {
|
|
227
|
+
cwd: "/x",
|
|
228
|
+
timeoutMs: 0,
|
|
229
|
+
intervalMs: 10,
|
|
230
|
+
});
|
|
231
|
+
assert.deepEqual(incompleteNoneStreak, { result: "timeout", lastSnapshot: "none", aborted: false });
|
|
232
|
+
});
|
|
233
|
+
test("NOT-311: workflowHasPullRequestTrigger matches inline, list, and block on: forms", () => {
|
|
234
|
+
assert.equal(workflowHasPullRequestTrigger("on: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n"), true);
|
|
235
|
+
assert.equal(workflowHasPullRequestTrigger("on: [push, pull_request]\n"), true);
|
|
236
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n"), true);
|
|
237
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n"), false);
|
|
238
|
+
assert.equal(workflowHasPullRequestTrigger("on: push\n"), false);
|
|
239
|
+
assert.equal(workflowHasPullRequestTrigger("on:\n schedule:\n - cron: '0 0 * * *'\n"), false);
|
|
240
|
+
assert.equal(workflowHasPullRequestTrigger("jobs:\n x:\n runs-on: ubuntu-latest\n"), false);
|
|
241
|
+
// A pull_request mention outside `on:` (job name) must not qualify.
|
|
242
|
+
assert.equal(workflowHasPullRequestTrigger("on: push\njobs:\n pull_request_preview:\n runs-on: ubuntu-latest\n"), false);
|
|
243
|
+
});
|
|
244
|
+
test("NOT-311: baseRefHasPullRequestWorkflow reads the base ref tree, false without workflows, null on unknown ref", async () => {
|
|
245
|
+
const { execFileSync } = await import("node:child_process");
|
|
246
|
+
const fs = await import("node:fs");
|
|
247
|
+
const os = await import("node:os");
|
|
248
|
+
const path = await import("node:path");
|
|
249
|
+
const setup = (files) => {
|
|
250
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-repo-"));
|
|
251
|
+
const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
|
|
252
|
+
git("init", "-q", "-b", "main");
|
|
253
|
+
git("config", "user.email", "test@example.com");
|
|
254
|
+
git("config", "user.name", "Test");
|
|
255
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
256
|
+
fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
|
|
257
|
+
fs.writeFileSync(path.join(repo, rel), content);
|
|
258
|
+
}
|
|
259
|
+
git("add", ".");
|
|
260
|
+
git("commit", "-q", "-m", "init");
|
|
261
|
+
const remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-remote-"));
|
|
262
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
|
|
263
|
+
git("remote", "add", "origin", remote);
|
|
264
|
+
git("push", "-q", "origin", "main");
|
|
265
|
+
git("fetch", "-q", "origin");
|
|
266
|
+
return { repo, remote };
|
|
267
|
+
};
|
|
268
|
+
const withCi = setup({
|
|
269
|
+
"README.md": "hi\n",
|
|
270
|
+
".github/workflows/ci.yml": "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
|
271
|
+
});
|
|
272
|
+
const pushOnly = setup({
|
|
273
|
+
"README.md": "hi\n",
|
|
274
|
+
".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 0 * * *'\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
|
275
|
+
});
|
|
276
|
+
const bare = setup({ "README.md": "hi\n" });
|
|
277
|
+
try {
|
|
278
|
+
assert.equal(await baseRefHasPullRequestWorkflow(withCi.repo, "main"), true);
|
|
279
|
+
assert.equal(await baseRefHasPullRequestWorkflow(pushOnly.repo, "main"), false);
|
|
280
|
+
assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "main"), false);
|
|
281
|
+
assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "no-such-branch"), null);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
for (const { repo, remote } of [withCi, pushOnly, bare]) {
|
|
285
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
286
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
208
290
|
// --- NOT-252: terminal checks_failed enrichment ---
|
|
209
291
|
const HEAD_SHA = "abc123def456abc123def456abc123def456abcd";
|
|
210
292
|
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
|
+
});
|