@redwoodjs/agent-ci 0.6.0 → 0.7.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/cli.js CHANGED
@@ -130,9 +130,11 @@ async function run() {
130
130
  pauseOnFailure,
131
131
  noMatrix,
132
132
  });
133
- printSummary(results);
133
+ if (results.length > 0) {
134
+ printSummary(results);
135
+ }
134
136
  postCommitStatus(results, sha);
135
- const anyFailed = results.some((r) => !r.succeeded);
137
+ const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
136
138
  process.exit(anyFailed ? 1 : 0);
137
139
  }
138
140
  if (!workflow) {
@@ -163,9 +165,11 @@ async function run() {
163
165
  pauseOnFailure,
164
166
  noMatrix,
165
167
  });
166
- printSummary(results);
168
+ if (results.length > 0) {
169
+ printSummary(results);
170
+ }
167
171
  postCommitStatus(results, sha);
168
- if (results.some((r) => !r.succeeded)) {
172
+ if (results.length === 0 || results.some((r) => !r.succeeded)) {
169
173
  process.exit(1);
170
174
  }
171
175
  process.exit(0);
@@ -44,10 +44,10 @@ export function buildContainerBinds(opts) {
44
44
  ...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
45
45
  `${h(diagDir)}:/home/runner/_diag`,
46
46
  `${h(toolCacheDir)}:/opt/hostedtoolcache`,
47
- // Package manager caches (persist across runs)
48
- `${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`,
49
- `${h(npmCacheDir)}:/home/runner/.npm`,
50
- `${h(bunCacheDir)}:/home/runner/.bun/install/cache`,
47
+ // Package manager caches (persist across runs, only for detected PM)
48
+ ...(pnpmStoreDir ? [`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`] : []),
49
+ ...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
50
+ ...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
51
51
  `${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
52
52
  // Warm node_modules: mounted outside the workspace so actions/checkout can
53
53
  // delete the symlink without EBUSY. A symlink in the entrypoint points
@@ -41,7 +41,7 @@ describe("buildContainerEnv", () => {
41
41
  });
42
42
  // ── buildContainerBinds ───────────────────────────────────────────────────────
43
43
  describe("buildContainerBinds", () => {
44
- it("builds standard bind mounts", async () => {
44
+ it("builds standard bind mounts with all PM caches", async () => {
45
45
  const { buildContainerBinds } = await import("./container-config.js");
46
46
  const binds = buildContainerBinds({
47
47
  hostWorkDir: "/tmp/work",
@@ -60,9 +60,46 @@ describe("buildContainerBinds", () => {
60
60
  expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
61
61
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
62
62
  expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
63
+ expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
64
+ expect(binds).toContain("/tmp/npm:/home/runner/.npm");
65
+ expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
63
66
  // Standard mode should NOT include runner home bind (but _work bind is expected)
64
67
  expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
65
68
  });
69
+ it("omits PM bind mounts when cache dirs are not provided", async () => {
70
+ const { buildContainerBinds } = await import("./container-config.js");
71
+ const binds = buildContainerBinds({
72
+ hostWorkDir: "/tmp/work",
73
+ shimsDir: "/tmp/shims",
74
+ diagDir: "/tmp/diag",
75
+ toolCacheDir: "/tmp/toolcache",
76
+ playwrightCacheDir: "/tmp/playwright",
77
+ warmModulesDir: "/tmp/warm",
78
+ hostRunnerDir: "/tmp/runner",
79
+ useDirectContainer: false,
80
+ });
81
+ expect(binds).toContain("/tmp/work:/home/runner/_work");
82
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
83
+ expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
84
+ expect(binds.some((b) => b.includes(".bun"))).toBe(false);
85
+ });
86
+ it("includes only the npm bind mount when only npmCacheDir is provided", async () => {
87
+ const { buildContainerBinds } = await import("./container-config.js");
88
+ const binds = buildContainerBinds({
89
+ hostWorkDir: "/tmp/work",
90
+ shimsDir: "/tmp/shims",
91
+ diagDir: "/tmp/diag",
92
+ toolCacheDir: "/tmp/toolcache",
93
+ npmCacheDir: "/tmp/npm",
94
+ playwrightCacheDir: "/tmp/playwright",
95
+ warmModulesDir: "/tmp/warm",
96
+ hostRunnerDir: "/tmp/runner",
97
+ useDirectContainer: false,
98
+ });
99
+ expect(binds).toContain("/tmp/npm:/home/runner/.npm");
100
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
101
+ expect(binds.some((b) => b.includes(".bun"))).toBe(false);
102
+ });
66
103
  it("includes runner bind mount for direct container", async () => {
67
104
  const { buildContainerBinds } = await import("./container-config.js");
68
105
  const binds = buildContainerBinds({
@@ -18,6 +18,7 @@ export function copyWorkspace(repoRoot, dest) {
18
18
  const files = execSync("git ls-files --cached --others --exclude-standard -z", {
19
19
  stdio: "pipe",
20
20
  cwd: repoRoot,
21
+ maxBuffer: 100 * 1024 * 1024, // 100MB — default 1MB overflows in large monorepos
21
22
  })
22
23
  .toString()
23
24
  .split("\0")
@@ -82,6 +83,25 @@ const LOCKFILE_NAMES = [
82
83
  "bun.lock",
83
84
  "bun.lockb",
84
85
  ];
86
+ const LOCKFILE_TO_PM = {
87
+ "pnpm-lock.yaml": "pnpm",
88
+ "package-lock.json": "npm",
89
+ "yarn.lock": "yarn",
90
+ "bun.lock": "bun",
91
+ "bun.lockb": "bun",
92
+ };
93
+ /**
94
+ * Detect the project's package manager by looking for lockfiles in the repo root.
95
+ * Returns the first match in priority order, or null if no lockfile is found.
96
+ */
97
+ export function detectPackageManager(repoRoot) {
98
+ for (const name of LOCKFILE_NAMES) {
99
+ if (fs.existsSync(path.join(repoRoot, name))) {
100
+ return LOCKFILE_TO_PM[name];
101
+ }
102
+ }
103
+ return null;
104
+ }
85
105
  /**
86
106
  * Compute a short SHA-256 hash of lockfiles tracked in the repo.
87
107
  * Searches for all known lockfile types (pnpm, npm, yarn, bun) and hashes
@@ -136,6 +136,51 @@ describe("computeLockfileHash", () => {
136
136
  expect(hash).toMatch(/^[a-f0-9]{16}$/);
137
137
  });
138
138
  });
139
+ // ── detectPackageManager tests ────────────────────────────────────────────────
140
+ describe("detectPackageManager", () => {
141
+ let repoDir;
142
+ beforeEach(() => {
143
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "oa-pm-detect-"));
144
+ });
145
+ afterEach(() => {
146
+ fs.rmSync(repoDir, { recursive: true, force: true });
147
+ });
148
+ it("detects pnpm from pnpm-lock.yaml", async () => {
149
+ const { detectPackageManager } = await import("./cleanup.js");
150
+ fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
151
+ expect(detectPackageManager(repoDir)).toBe("pnpm");
152
+ });
153
+ it("detects npm from package-lock.json", async () => {
154
+ const { detectPackageManager } = await import("./cleanup.js");
155
+ fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
156
+ expect(detectPackageManager(repoDir)).toBe("npm");
157
+ });
158
+ it("detects yarn from yarn.lock", async () => {
159
+ const { detectPackageManager } = await import("./cleanup.js");
160
+ fs.writeFileSync(path.join(repoDir, "yarn.lock"), "# yarn lockfile v1");
161
+ expect(detectPackageManager(repoDir)).toBe("yarn");
162
+ });
163
+ it("detects bun from bun.lock", async () => {
164
+ const { detectPackageManager } = await import("./cleanup.js");
165
+ fs.writeFileSync(path.join(repoDir, "bun.lock"), '{"lockfileVersion": 0}');
166
+ expect(detectPackageManager(repoDir)).toBe("bun");
167
+ });
168
+ it("detects bun from bun.lockb", async () => {
169
+ const { detectPackageManager } = await import("./cleanup.js");
170
+ fs.writeFileSync(path.join(repoDir, "bun.lockb"), Buffer.from([0x00]));
171
+ expect(detectPackageManager(repoDir)).toBe("bun");
172
+ });
173
+ it("returns null when no lockfile exists", async () => {
174
+ const { detectPackageManager } = await import("./cleanup.js");
175
+ expect(detectPackageManager(repoDir)).toBeNull();
176
+ });
177
+ it("prefers pnpm over npm when both lockfiles exist", async () => {
178
+ const { detectPackageManager } = await import("./cleanup.js");
179
+ fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
180
+ fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
181
+ expect(detectPackageManager(repoDir)).toBe("pnpm");
182
+ });
183
+ });
139
184
  // ── isWarmNodeModules tests ───────────────────────────────────────────────────
140
185
  describe("isWarmNodeModules", () => {
141
186
  let tmpDir;
@@ -71,3 +71,39 @@ describe("printSummary", () => {
71
71
  expect(output).not.toContain("FAILURES");
72
72
  });
73
73
  });
74
+ // ── Empty results behavior (CLI exit logic) ──────────────────────────────────
75
+ // The CLI treats empty results as failure. These tests verify the logic pattern
76
+ // used in cli.ts: `results.length === 0 || results.some(r => !r.succeeded)`
77
+ describe("empty results exit logic", () => {
78
+ function shouldFail(results) {
79
+ return results.length === 0 || results.some((r) => !r.succeeded);
80
+ }
81
+ function shouldPrintSummary(results) {
82
+ return results.length > 0;
83
+ }
84
+ it("treats empty results as failure", () => {
85
+ expect(shouldFail([])).toBe(true);
86
+ });
87
+ it("treats results with a failure as failure", () => {
88
+ expect(shouldFail([makeResult({ succeeded: false })])).toBe(true);
89
+ });
90
+ it("treats all-passing results as success", () => {
91
+ const passing = [
92
+ {
93
+ name: "c1",
94
+ workflow: "ci.yml",
95
+ taskId: "test",
96
+ succeeded: true,
97
+ durationMs: 100,
98
+ debugLogPath: "/tmp/x",
99
+ },
100
+ ];
101
+ expect(shouldFail(passing)).toBe(false);
102
+ });
103
+ it("skips summary print for empty results", () => {
104
+ expect(shouldPrintSummary([])).toBe(false);
105
+ });
106
+ it("prints summary for non-empty results", () => {
107
+ expect(shouldPrintSummary([makeResult()])).toBe(true);
108
+ });
109
+ });
@@ -17,6 +17,18 @@ function getSpinnerFrame() {
17
17
  function fmtMs(ms) {
18
18
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
19
19
  }
20
+ function fmtBytes(bytes) {
21
+ if (bytes >= 1000000000) {
22
+ return `${(bytes / 1000000000).toFixed(1)} GB`;
23
+ }
24
+ if (bytes >= 1000000) {
25
+ return `${(bytes / 1000000).toFixed(1)} MB`;
26
+ }
27
+ if (bytes >= 1000) {
28
+ return `${(bytes / 1000).toFixed(1)} KB`;
29
+ }
30
+ return `${bytes} B`;
31
+ }
20
32
  // ─── Step node builder ────────────────────────────────────────────────────────
21
33
  function buildStepNode(step, job, padW) {
22
34
  const pad = (n) => String(n).padStart(padW);
@@ -72,8 +84,21 @@ function buildJobNodes(job, singleJobMode) {
72
84
  const bootNode = {
73
85
  label: `${getSpinnerFrame()} Starting runner ${job.runnerId} (${elapsed}s)`,
74
86
  };
87
+ const children = [];
88
+ if (job.pullProgress) {
89
+ const { phase, currentBytes, totalBytes } = job.pullProgress;
90
+ const pct = totalBytes > 0 ? Math.round((currentBytes / totalBytes) * 100) : 0;
91
+ const label = phase === "extracting" ? "Extracting" : "Downloading";
92
+ children.push({
93
+ label: `${DIM}${label}: ${fmtBytes(currentBytes)} / ${fmtBytes(totalBytes)} (${pct}%)${RESET}`,
94
+ });
95
+ }
75
96
  if (job.logDir) {
76
- bootNode.children = [{ label: `${DIM}Logs: ${job.logDir}${RESET}` }];
97
+ const shortLogDir = job.logDir.replace(/^.*?(agent-ci\/)/, "$1");
98
+ children.push({ label: `${DIM}Logs: ${shortLogDir}${RESET}` });
99
+ }
100
+ if (children.length > 0) {
101
+ bootNode.children = children;
77
102
  }
78
103
  return [bootNode];
79
104
  }
@@ -1,7 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
3
  import { getWorkingDirectory } from "../output/working-directory.js";
4
- import { computeLockfileHash, repairWarmCache } from "../output/cleanup.js";
4
+ import { computeLockfileHash, detectPackageManager, repairWarmCache } from "../output/cleanup.js";
5
5
  import { config } from "../config.js";
6
6
  import { findRepoRoot } from "./metadata.js";
7
7
  import { debugRunner } from "../output/debug.js";
@@ -23,14 +23,26 @@ export function createRunDirectories(opts) {
23
23
  // Shared caches
24
24
  const repoSlug = (githubRepo || config.GITHUB_REPO).replace("/", "-");
25
25
  const toolCacheDir = path.resolve(workDir, "cache", "toolcache");
26
- const pnpmStoreDir = path.resolve(workDir, "cache", "pnpm-store", repoSlug);
27
- const npmCacheDir = path.resolve(workDir, "cache", "npm-cache", repoSlug);
28
- const bunCacheDir = path.resolve(workDir, "cache", "bun-cache", repoSlug);
29
26
  const playwrightCacheDir = path.resolve(workDir, "cache", "playwright", repoSlug);
27
+ // Detect the project's package manager so we only mount the relevant cache
28
+ let detectedPM = null;
29
+ const repoRoot = workflowPath ? findRepoRoot(workflowPath) : undefined;
30
+ if (repoRoot) {
31
+ detectedPM = detectPackageManager(repoRoot);
32
+ }
33
+ // Only create cache dirs for the detected PM (or all if unknown)
34
+ const pnpmStoreDir = !detectedPM || detectedPM === "pnpm"
35
+ ? path.resolve(workDir, "cache", "pnpm-store", repoSlug)
36
+ : undefined;
37
+ const npmCacheDir = !detectedPM || detectedPM === "npm"
38
+ ? path.resolve(workDir, "cache", "npm-cache", repoSlug)
39
+ : undefined;
40
+ const bunCacheDir = !detectedPM || detectedPM === "bun"
41
+ ? path.resolve(workDir, "cache", "bun-cache", repoSlug)
42
+ : undefined;
30
43
  // Warm node_modules: keyed by the lockfile hash (any supported PM)
31
44
  let lockfileHash = "no-lockfile";
32
45
  try {
33
- const repoRoot = workflowPath ? findRepoRoot(workflowPath) : undefined;
34
46
  if (repoRoot) {
35
47
  lockfileHash = computeLockfileHash(repoRoot);
36
48
  }
@@ -55,7 +67,7 @@ export function createRunDirectories(opts) {
55
67
  bunCacheDir,
56
68
  playwrightCacheDir,
57
69
  warmModulesDir,
58
- ];
70
+ ].filter((d) => d !== undefined);
59
71
  for (const dir of allDirs) {
60
72
  fs.mkdirSync(dir, { recursive: true, mode: 0o777 });
61
73
  }
@@ -79,6 +91,7 @@ export function createRunDirectories(opts) {
79
91
  warmModulesDir,
80
92
  workspaceDir,
81
93
  repoSlug,
94
+ detectedPM,
82
95
  };
83
96
  }
84
97
  // ─── Permissions helper ───────────────────────────────────────────────────────
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
+ import { execSync } from "node:child_process";
5
6
  // ── ensureWorldWritable ───────────────────────────────────────────────────────
6
7
  describe("ensureWorldWritable", () => {
7
8
  let tmpDir;
@@ -29,3 +30,181 @@ describe("ensureWorldWritable", () => {
29
30
  expect(() => ensureWorldWritable(["/nonexistent/path"])).not.toThrow();
30
31
  });
31
32
  });
33
+ // ── Package manager detection + conditional cache dirs ────────────────────────
34
+ describe("createRunDirectories — PM-scoped caching", () => {
35
+ let repoDir;
36
+ let runDir;
37
+ /** Scaffold a git repo with a workflow file and the given lockfile. */
38
+ function makeFixture(lockfileName, lockfileContent) {
39
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-fixture-"));
40
+ runDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-rundir-"));
41
+ execSync("git init", { cwd: repoDir, stdio: "pipe" });
42
+ execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
43
+ execSync('git config user.email "t@t.com"', { cwd: repoDir, stdio: "pipe" });
44
+ // Lockfile
45
+ fs.writeFileSync(path.join(repoDir, lockfileName), lockfileContent);
46
+ // Minimal workflow
47
+ const wfDir = path.join(repoDir, ".github", "workflows");
48
+ fs.mkdirSync(wfDir, { recursive: true });
49
+ fs.writeFileSync(path.join(wfDir, "ci.yml"), "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n");
50
+ execSync("git add -A", { cwd: repoDir, stdio: "pipe" });
51
+ execSync('git commit -m "init"', { cwd: repoDir, stdio: "pipe" });
52
+ }
53
+ afterEach(() => {
54
+ if (repoDir) {
55
+ fs.rmSync(repoDir, { recursive: true, force: true });
56
+ }
57
+ if (runDir) {
58
+ fs.rmSync(runDir, { recursive: true, force: true });
59
+ }
60
+ });
61
+ it("npm: only creates npm cache dir, not pnpm or bun", async () => {
62
+ makeFixture("package-lock.json", '{"lockfileVersion":3}');
63
+ const { createRunDirectories } = await import("./directory-setup.js");
64
+ const dirs = createRunDirectories({
65
+ runDir,
66
+ githubRepo: "test/npm-project",
67
+ workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
68
+ });
69
+ expect(dirs.detectedPM).toBe("npm");
70
+ expect(dirs.npmCacheDir).toBeDefined();
71
+ expect(dirs.pnpmStoreDir).toBeUndefined();
72
+ expect(dirs.bunCacheDir).toBeUndefined();
73
+ });
74
+ it("pnpm: only creates pnpm cache dir, not npm or bun", async () => {
75
+ makeFixture("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
76
+ const { createRunDirectories } = await import("./directory-setup.js");
77
+ const dirs = createRunDirectories({
78
+ runDir,
79
+ githubRepo: "test/pnpm-project",
80
+ workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
81
+ });
82
+ expect(dirs.detectedPM).toBe("pnpm");
83
+ expect(dirs.pnpmStoreDir).toBeDefined();
84
+ expect(dirs.npmCacheDir).toBeUndefined();
85
+ expect(dirs.bunCacheDir).toBeUndefined();
86
+ });
87
+ it("yarn: creates no PM-specific cache dirs (no dedicated mount)", async () => {
88
+ makeFixture("yarn.lock", "# yarn lockfile v1\n");
89
+ const { createRunDirectories } = await import("./directory-setup.js");
90
+ const dirs = createRunDirectories({
91
+ runDir,
92
+ githubRepo: "test/yarn-project",
93
+ workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
94
+ });
95
+ expect(dirs.detectedPM).toBe("yarn");
96
+ expect(dirs.pnpmStoreDir).toBeUndefined();
97
+ expect(dirs.npmCacheDir).toBeUndefined();
98
+ expect(dirs.bunCacheDir).toBeUndefined();
99
+ });
100
+ it("bun: only creates bun cache dir, not pnpm or npm", async () => {
101
+ makeFixture("bun.lock", '{"lockfileVersion":0}');
102
+ const { createRunDirectories } = await import("./directory-setup.js");
103
+ const dirs = createRunDirectories({
104
+ runDir,
105
+ githubRepo: "test/bun-project",
106
+ workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
107
+ });
108
+ expect(dirs.detectedPM).toBe("bun");
109
+ expect(dirs.bunCacheDir).toBeDefined();
110
+ expect(dirs.pnpmStoreDir).toBeUndefined();
111
+ expect(dirs.npmCacheDir).toBeUndefined();
112
+ });
113
+ it("no lockfile: creates all PM cache dirs (fallback)", async () => {
114
+ // Repo with no lockfile at all
115
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-fixture-"));
116
+ runDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-rundir-"));
117
+ execSync("git init", { cwd: repoDir, stdio: "pipe" });
118
+ execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
119
+ execSync('git config user.email "t@t.com"', { cwd: repoDir, stdio: "pipe" });
120
+ const wfDir = path.join(repoDir, ".github", "workflows");
121
+ fs.mkdirSync(wfDir, { recursive: true });
122
+ fs.writeFileSync(path.join(wfDir, "ci.yml"), "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n");
123
+ execSync("git add -A", { cwd: repoDir, stdio: "pipe" });
124
+ execSync('git commit -m "init"', { cwd: repoDir, stdio: "pipe" });
125
+ const { createRunDirectories } = await import("./directory-setup.js");
126
+ const dirs = createRunDirectories({
127
+ runDir,
128
+ githubRepo: "test/no-pm-project",
129
+ workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
130
+ });
131
+ expect(dirs.detectedPM).toBeNull();
132
+ expect(dirs.pnpmStoreDir).toBeDefined();
133
+ expect(dirs.npmCacheDir).toBeDefined();
134
+ expect(dirs.bunCacheDir).toBeDefined();
135
+ });
136
+ });
137
+ // ── Bind mounts respect detected PM ──────────────────────────────────────────
138
+ describe("buildContainerBinds — PM-scoped mounts", () => {
139
+ it("npm project: only mounts .npm, no .pnpm-store or .bun", async () => {
140
+ const { buildContainerBinds } = await import("../docker/container-config.js");
141
+ const binds = buildContainerBinds({
142
+ hostWorkDir: "/tmp/work",
143
+ shimsDir: "/tmp/shims",
144
+ diagDir: "/tmp/diag",
145
+ toolCacheDir: "/tmp/toolcache",
146
+ npmCacheDir: "/tmp/npm-cache",
147
+ // pnpmStoreDir and bunCacheDir intentionally omitted (npm project)
148
+ playwrightCacheDir: "/tmp/playwright",
149
+ warmModulesDir: "/tmp/warm",
150
+ hostRunnerDir: "/tmp/runner",
151
+ useDirectContainer: false,
152
+ });
153
+ expect(binds).toContain("/tmp/npm-cache:/home/runner/.npm");
154
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
155
+ expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
156
+ });
157
+ it("pnpm project: only mounts .pnpm-store, no .npm or .bun", async () => {
158
+ const { buildContainerBinds } = await import("../docker/container-config.js");
159
+ const binds = buildContainerBinds({
160
+ hostWorkDir: "/tmp/work",
161
+ shimsDir: "/tmp/shims",
162
+ diagDir: "/tmp/diag",
163
+ toolCacheDir: "/tmp/toolcache",
164
+ pnpmStoreDir: "/tmp/pnpm-store",
165
+ // npmCacheDir and bunCacheDir intentionally omitted (pnpm project)
166
+ playwrightCacheDir: "/tmp/playwright",
167
+ warmModulesDir: "/tmp/warm",
168
+ hostRunnerDir: "/tmp/runner",
169
+ useDirectContainer: false,
170
+ });
171
+ expect(binds).toContain("/tmp/pnpm-store:/home/runner/_work/.pnpm-store");
172
+ expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
173
+ expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
174
+ });
175
+ it("bun project: only mounts .bun, no .pnpm-store or .npm", async () => {
176
+ const { buildContainerBinds } = await import("../docker/container-config.js");
177
+ const binds = buildContainerBinds({
178
+ hostWorkDir: "/tmp/work",
179
+ shimsDir: "/tmp/shims",
180
+ diagDir: "/tmp/diag",
181
+ toolCacheDir: "/tmp/toolcache",
182
+ bunCacheDir: "/tmp/bun-cache",
183
+ // pnpmStoreDir and npmCacheDir intentionally omitted (bun project)
184
+ playwrightCacheDir: "/tmp/playwright",
185
+ warmModulesDir: "/tmp/warm",
186
+ hostRunnerDir: "/tmp/runner",
187
+ useDirectContainer: false,
188
+ });
189
+ expect(binds).toContain("/tmp/bun-cache:/home/runner/.bun/install/cache");
190
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
191
+ expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
192
+ });
193
+ it("yarn project: no PM-specific mounts at all", async () => {
194
+ const { buildContainerBinds } = await import("../docker/container-config.js");
195
+ const binds = buildContainerBinds({
196
+ hostWorkDir: "/tmp/work",
197
+ shimsDir: "/tmp/shims",
198
+ diagDir: "/tmp/diag",
199
+ toolCacheDir: "/tmp/toolcache",
200
+ // all PM dirs omitted (yarn has no dedicated mount)
201
+ playwrightCacheDir: "/tmp/playwright",
202
+ warmModulesDir: "/tmp/warm",
203
+ hostRunnerDir: "/tmp/runner",
204
+ useDirectContainer: false,
205
+ });
206
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
207
+ expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
208
+ expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
209
+ });
210
+ });
@@ -16,7 +16,7 @@ import { computeFakeSha, writeGitShim } from "./git-shim.js";
16
16
  import { prepareWorkspace } from "./workspace.js";
17
17
  import { createRunDirectories } from "./directory-setup.js";
18
18
  import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.js";
19
- import { buildJobResult, sanitizeStepName } from "./result-builder.js";
19
+ import { buildJobResult } from "./result-builder.js";
20
20
  import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
21
21
  import { syncWorkspaceForRetry } from "./sync.js";
22
22
  // ─── Docker setup ─────────────────────────────────────────────────────────────
@@ -127,6 +127,15 @@ export async function executeLocalJob(job, options) {
127
127
  const dtuUrl = ephemeralDtu?.url ?? config.GITHUB_API_URL;
128
128
  const dtuContainerUrl = ephemeralDtu?.containerUrl ?? dtuUrl;
129
129
  t0 = bt("dtu-start", t0);
130
+ // ── Create run directories ────────────────────────────────────────────────
131
+ // Done before DTU registration so we can use the detected package manager
132
+ // to scope virtualCachePatterns to only the relevant PM.
133
+ const dirs = createRunDirectories({
134
+ runDir,
135
+ githubRepo: job.githubRepo,
136
+ workflowPath: job.workflowPath,
137
+ });
138
+ debugRunner(`Detected package manager: ${dirs.detectedPM ?? "none (mounting all PM caches)"}`);
130
139
  await fetch(`${dtuUrl}/_dtu/start-runner`, {
131
140
  method: "POST",
132
141
  headers: { "Content-Type": "application/json" },
@@ -138,7 +147,7 @@ export async function executeLocalJob(job, options) {
138
147
  // no need for the runner to tar/gzip them. Tell the DTU to return a
139
148
  // synthetic hit for any cache key matching these patterns — skipping the
140
149
  // 60s+ tar entirely.
141
- virtualCachePatterns: ["pnpm", "npm", "yarn", "bun"],
150
+ virtualCachePatterns: dirs.detectedPM ? [dirs.detectedPM] : ["pnpm", "npm", "yarn", "bun"],
142
151
  }),
143
152
  }).catch(() => {
144
153
  /* non-fatal */
@@ -148,12 +157,6 @@ export async function executeLocalJob(job, options) {
148
157
  writeJobMetadata({ logDir, containerName, job });
149
158
  // Open debug stream to capture raw container output
150
159
  const debugStream = fs.createWriteStream(debugLogPath);
151
- // ── Create run directories ────────────────────────────────────────────────
152
- const dirs = createRunDirectories({
153
- runDir,
154
- githubRepo: job.githubRepo,
155
- workflowPath: job.workflowPath,
156
- });
157
160
  // Signal handler: ensure cleanup runs even when killed.
158
161
  const signalCleanup = () => {
159
162
  killRunnerContainers(containerName);
@@ -305,11 +308,81 @@ export async function executeLocalJob(job, options) {
305
308
  if (err) {
306
309
  return reject(err);
307
310
  }
311
+ // Track per-layer progress across download and extraction phases
312
+ const downloadProgress = new Map();
313
+ const extractProgress = new Map();
314
+ let lastProgressUpdate = 0;
315
+ let currentPhase = "downloading";
316
+ const flushProgress = (force = false) => {
317
+ const map = currentPhase === "downloading" ? downloadProgress : extractProgress;
318
+ if (map.size === 0) {
319
+ return;
320
+ }
321
+ const now = Date.now();
322
+ if (!force && now - lastProgressUpdate < 250) {
323
+ return;
324
+ }
325
+ lastProgressUpdate = now;
326
+ let totalBytes = 0;
327
+ let currentBytes = 0;
328
+ for (const layer of map.values()) {
329
+ totalBytes += layer.total;
330
+ currentBytes += layer.current;
331
+ }
332
+ store?.updateJob(containerName, {
333
+ pullProgress: { phase: currentPhase, currentBytes, totalBytes },
334
+ });
335
+ };
308
336
  docker.modem.followProgress(stream, (err) => {
309
337
  if (err) {
310
338
  return reject(err);
311
339
  }
340
+ store?.updateJob(containerName, { pullProgress: undefined });
312
341
  resolve();
342
+ }, (event) => {
343
+ if (!event.id) {
344
+ return;
345
+ }
346
+ const detail = event.progressDetail;
347
+ const hasByteCounts = detail &&
348
+ typeof detail.current === "number" &&
349
+ typeof detail.total === "number" &&
350
+ detail.total > 0;
351
+ if (event.status === "Downloading" && hasByteCounts) {
352
+ downloadProgress.set(event.id, {
353
+ current: detail.current,
354
+ total: detail.total,
355
+ });
356
+ }
357
+ else if (event.status === "Download complete") {
358
+ const existing = downloadProgress.get(event.id);
359
+ if (existing) {
360
+ existing.current = existing.total;
361
+ }
362
+ }
363
+ else if (event.status === "Extracting" && hasByteCounts) {
364
+ const phaseChanged = currentPhase !== "extracting";
365
+ currentPhase = "extracting";
366
+ extractProgress.set(event.id, {
367
+ current: detail.current,
368
+ total: detail.total,
369
+ });
370
+ // Force update on first extraction event so the phase change is visible immediately
371
+ if (phaseChanged) {
372
+ flushProgress(true);
373
+ return;
374
+ }
375
+ }
376
+ else if (event.status === "Pull complete") {
377
+ const existing = extractProgress.get(event.id);
378
+ if (existing) {
379
+ existing.current = existing.total;
380
+ }
381
+ }
382
+ else {
383
+ return;
384
+ }
385
+ flushProgress();
313
386
  });
314
387
  });
315
388
  });
@@ -422,44 +495,24 @@ export async function executeLocalJob(job, options) {
422
495
  const lines = content.split("\n");
423
496
  pausedStepName = lines[0] || null;
424
497
  const attempt = parseInt(lines[1] || "1", 10);
425
- if (attempt !== lastSeenAttempt) {
498
+ const isNewAttempt = attempt !== lastSeenAttempt;
499
+ if (isNewAttempt) {
426
500
  lastSeenAttempt = attempt;
427
501
  isPaused = true;
428
502
  pausedAtMs = Date.now();
429
503
  setupStdinRetry();
430
- // Read last output lines from the failed step's log
431
- let tailLines = [];
432
- if (pausedStepName) {
433
- const stepsDir = path.join(logDir, "steps");
434
- const sanitized = sanitizeStepName(pausedStepName);
435
- const byName = path.join(stepsDir, `${sanitized}.log`);
436
- tailLines = tailLogFile(byName, 20);
437
- if (tailLines.length === 0 && fs.existsSync(stepsDir)) {
438
- let newest = "";
439
- let newestMtime = 0;
440
- for (const f of fs.readdirSync(stepsDir)) {
441
- if (!f.endsWith(".log")) {
442
- continue;
443
- }
444
- const mt = fs.statSync(path.join(stepsDir, f)).mtimeMs;
445
- if (mt > newestMtime) {
446
- newestMtime = mt;
447
- newest = f;
448
- }
449
- }
450
- if (newest) {
451
- tailLines = tailLogFile(path.join(stepsDir, newest), 20);
452
- }
453
- }
454
- }
455
- store?.updateJob(containerName, {
456
- status: "paused",
457
- pausedAtStep: pausedStepName || undefined,
458
- pausedAtMs: new Date(pausedAtMs).toISOString(),
459
- attempt: lastSeenAttempt,
460
- lastOutputLines: tailLines,
461
- });
462
504
  }
505
+ // Read output captured by the wrapper script's tee — written directly
506
+ // to the signals dir so it's always available when paused.
507
+ const tailLines = tailLogFile(path.join(dirs.signalsDir, "step-output"), 20);
508
+ store?.updateJob(containerName, {
509
+ status: "paused",
510
+ pausedAtStep: pausedStepName || undefined,
511
+ ...(isNewAttempt && pausedAtMs !== null
512
+ ? { pausedAtMs: new Date(pausedAtMs).toISOString(), attempt: lastSeenAttempt }
513
+ : {}),
514
+ lastOutputLines: tailLines,
515
+ });
463
516
  }
464
517
  else if (isPaused && !fs.existsSync(pausedSignalPath)) {
465
518
  // Pause signal removed — job is retrying
@@ -697,7 +750,13 @@ export async function executeLocalJob(job, options) {
697
750
  }
698
751
  }
699
752
  if (jobSucceeded && fs.existsSync(dirs.containerWorkDir)) {
700
- fs.rmSync(dirs.containerWorkDir, { recursive: true, force: true });
753
+ try {
754
+ fs.rmSync(dirs.containerWorkDir, { recursive: true, force: true });
755
+ }
756
+ catch {
757
+ // Best-effort cleanup — ENOTEMPTY can occur when container
758
+ // processes haven't fully released file handles yet.
759
+ }
701
760
  }
702
761
  await ephemeralDtu?.close().catch(() => { });
703
762
  return buildJobResult({
@@ -40,7 +40,7 @@ while true; do
40
40
  set +e
41
41
  (
42
42
  ${script}
43
- )
43
+ ) > >(tee "$__SIGNALS/step-output") 2>&1
44
44
  __EC=$?
45
45
  set -e
46
46
  if [ $__EC -eq 0 ]; then exit 0; fi
@@ -33,6 +33,10 @@ describe("wrapStepScript", () => {
33
33
  const wrapped = wrapStepScript("npm test", "My Step", 1);
34
34
  expect(wrapped).toContain(`"$__FROM_STEP" != '*'`);
35
35
  });
36
+ it("captures output via tee to signals dir", () => {
37
+ const wrapped = wrapStepScript("npm test", "Run tests", 1);
38
+ expect(wrapped).toContain('> >(tee "$__SIGNALS/step-output") 2>&1');
39
+ });
36
40
  });
37
41
  // ── wrapJobSteps ──────────────────────────────────────────────────────────────
38
42
  describe("wrapJobSteps", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
5
5
  "keywords": [
6
6
  "act-alternative",
@@ -40,7 +40,7 @@
40
40
  "log-update": "^7.2.0",
41
41
  "minimatch": "^10.2.1",
42
42
  "yaml": "^2.8.2",
43
- "dtu-github-actions": "0.6.0"
43
+ "dtu-github-actions": "0.7.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",