@redwoodjs/agent-ci 0.5.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/README.md CHANGED
@@ -42,6 +42,24 @@ Agent CI connects to Docker via the `DOCKER_HOST` environment variable. By defau
42
42
  DOCKER_HOST=ssh://user@remote-server npx agent-ci run --workflow .github/workflows/ci.yml
43
43
  ```
44
44
 
45
+ ### Docker host resolution for job containers
46
+
47
+ By default, Agent CI uses `host.docker.internal` for container-to-host DTU traffic and adds a default Docker host mapping:
48
+
49
+ - `host.docker.internal:host-gateway`
50
+
51
+ This keeps behavior OS-agnostic and works on Docker Desktop and modern native Docker.
52
+
53
+ If your setup is custom, use environment overrides:
54
+
55
+ - `AGENT_CI_DTU_HOST` - override the hostname/IP used by runner containers to reach DTU
56
+ - `AGENT_CI_DOCKER_EXTRA_HOSTS` - comma-separated `host:ip` entries passed to Docker `ExtraHosts` (full replacement for defaults)
57
+ - `AGENT_CI_DOCKER_HOST_GATEWAY` - override the default `host-gateway` token/IP for automatic mapping
58
+ - `AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS=1` - disable the default `host.docker.internal` mapping
59
+ - `AGENT_CI_DOCKER_BRIDGE_GATEWAY` - fallback gateway IP used when Agent CI runs inside Docker and cannot detect its container IP, and as an explicit DTU host override outside Docker when `AGENT_CI_DTU_HOST` is not set
60
+
61
+ When using a remote daemon (`DOCKER_HOST=ssh://...`), `host-gateway` resolves relative to the remote Docker host. If DTU is not reachable from that host, set `AGENT_CI_DTU_HOST` and `AGENT_CI_DOCKER_EXTRA_HOSTS` explicitly for your network.
62
+
45
63
  ### `agent-ci run`
46
64
 
47
65
  Run GitHub Actions workflow jobs locally.
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { renderRunState } from "./output/state-renderer.js";
21
21
  import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
22
22
  import logUpdate from "log-update";
23
23
  import { createFailedJobResult, wrapJobError, isJobError } from "./runner/job-result.js";
24
+ import { postCommitStatus } from "./commit-status.js";
24
25
  function findSignalsDir(runnerName) {
25
26
  const workDir = getWorkingDirectory();
26
27
  const runsDir = path.resolve(workDir, "runs");
@@ -129,8 +130,11 @@ async function run() {
129
130
  pauseOnFailure,
130
131
  noMatrix,
131
132
  });
132
- printSummary(results);
133
- const anyFailed = results.some((r) => !r.succeeded);
133
+ if (results.length > 0) {
134
+ printSummary(results);
135
+ }
136
+ postCommitStatus(results, sha);
137
+ const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
134
138
  process.exit(anyFailed ? 1 : 0);
135
139
  }
136
140
  if (!workflow) {
@@ -161,8 +165,11 @@ async function run() {
161
165
  pauseOnFailure,
162
166
  noMatrix,
163
167
  });
164
- printSummary(results);
165
- if (results.some((r) => !r.succeeded)) {
168
+ if (results.length > 0) {
169
+ printSummary(results);
170
+ }
171
+ postCommitStatus(results, sha);
172
+ if (results.length === 0 || results.some((r) => !r.succeeded)) {
166
173
  process.exit(1);
167
174
  }
168
175
  process.exit(0);
@@ -0,0 +1,48 @@
1
+ import { execSync } from "child_process";
2
+ import { config } from "./config.js";
3
+ /**
4
+ * Post a GitHub commit status via the `gh` CLI.
5
+ * Silently skips if `gh` is not available on PATH.
6
+ */
7
+ export function postCommitStatus(results, sha) {
8
+ // Check if gh CLI is available
9
+ try {
10
+ execSync("which gh", { stdio: "ignore" });
11
+ }
12
+ catch {
13
+ return;
14
+ }
15
+ const resolvedSha = sha ||
16
+ (() => {
17
+ try {
18
+ return execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ })();
24
+ if (!resolvedSha) {
25
+ return;
26
+ }
27
+ const repo = config.GITHUB_REPO;
28
+ if (repo === "unknown/unknown") {
29
+ return;
30
+ }
31
+ const passed = results.filter((r) => r.succeeded).length;
32
+ const total = results.length;
33
+ const allPassed = passed === total;
34
+ const state = allPassed ? "success" : "failure";
35
+ const description = allPassed
36
+ ? `"It works on my machine!"`
37
+ : `${passed}/${total} jobs passed, ${total - passed} failed`;
38
+ try {
39
+ execSync(`gh api repos/${repo}/statuses/${resolvedSha} ` +
40
+ `-f state=${state} ` +
41
+ `-f context=agent-ci ` +
42
+ `-f description=${JSON.stringify(description)} ` +
43
+ `-f target_url=https://agent-ci.dev`, { stdio: "ignore" });
44
+ }
45
+ catch {
46
+ // gh command failed (e.g. no auth, no network) — skip silently
47
+ }
48
+ }
@@ -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
@@ -97,32 +97,74 @@ export function buildContainerCmd(opts) {
97
97
  // ─── DTU host resolution ──────────────────────────────────────────────────────
98
98
  import fs from "fs";
99
99
  import { execSync } from "child_process";
100
- /**
101
- * Resolve the DTU host address that nested Docker containers can reach.
102
- * Inside Docker: use the container's own bridge IP.
103
- * On host: use `host.docker.internal`.
104
- */
105
- export function resolveDtuHost() {
106
- const isInsideDocker = fs.existsSync("/.dockerenv");
107
- if (!isInsideDocker) {
108
- return "host.docker.internal";
100
+ import { debugRunner } from "../output/debug.js";
101
+ const DEFAULT_DTU_HOST_ALIAS = "host.docker.internal";
102
+ const DEFAULT_DOCKER_BRIDGE_GATEWAY = "172.17.0.1";
103
+ const DEFAULT_DOCKER_HOST_GATEWAY = "host-gateway";
104
+ function isLoopbackHostname(hostname) {
105
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
106
+ }
107
+ function parseCsvEnv(value) {
108
+ return value
109
+ .split(",")
110
+ .map((entry) => entry.trim())
111
+ .filter(Boolean);
112
+ }
113
+ export async function resolveDtuHost() {
114
+ const configuredHost = process.env.AGENT_CI_DTU_HOST?.trim();
115
+ if (configuredHost) {
116
+ return configuredHost;
109
117
  }
110
- try {
111
- const ip = execSync("hostname -I 2>/dev/null | awk '{print $1}'", {
112
- encoding: "utf8",
113
- }).trim();
114
- if (ip) {
115
- return ip;
118
+ const isInsideDocker = fs.existsSync("/.dockerenv");
119
+ if (isInsideDocker) {
120
+ try {
121
+ const ip = execSync("hostname -I 2>/dev/null | awk '{print $1}'", {
122
+ encoding: "utf8",
123
+ }).trim();
124
+ if (ip) {
125
+ return ip;
126
+ }
116
127
  }
128
+ catch (error) {
129
+ debugRunner(`Failed to resolve Docker bridge IP via hostname -I: ${String(error)}`);
130
+ }
131
+ return process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY?.trim() || DEFAULT_DOCKER_BRIDGE_GATEWAY;
132
+ }
133
+ const configuredGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY?.trim();
134
+ if (configuredGateway) {
135
+ debugRunner(`Using configured bridge gateway '${configuredGateway}' for DTU host outside Docker`);
136
+ return configuredGateway;
137
+ }
138
+ return DEFAULT_DTU_HOST_ALIAS;
139
+ }
140
+ export function resolveDockerExtraHosts(dtuHost) {
141
+ const configuredExtraHosts = process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
142
+ if (configuredExtraHosts !== undefined) {
143
+ const parsed = parseCsvEnv(configuredExtraHosts);
144
+ return parsed.length > 0 ? parsed : undefined;
117
145
  }
118
- catch { }
119
- return "172.17.0.1"; // fallback to bridge gateway
146
+ if (process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS === "1") {
147
+ return undefined;
148
+ }
149
+ if (dtuHost !== DEFAULT_DTU_HOST_ALIAS) {
150
+ return undefined;
151
+ }
152
+ const gateway = process.env.AGENT_CI_DOCKER_HOST_GATEWAY?.trim() || DEFAULT_DOCKER_HOST_GATEWAY;
153
+ return [`${DEFAULT_DTU_HOST_ALIAS}:${gateway}`];
120
154
  }
121
155
  /**
122
156
  * Rewrite a DTU URL to be reachable from inside Docker containers.
123
157
  */
124
158
  export function resolveDockerApiUrl(dtuUrl, dtuHost) {
125
- return dtuUrl.replace("localhost", dtuHost).replace("127.0.0.1", dtuHost);
159
+ const parsed = new URL(dtuUrl);
160
+ if (isLoopbackHostname(parsed.hostname)) {
161
+ parsed.hostname = dtuHost;
162
+ }
163
+ const serialized = parsed.toString();
164
+ if (parsed.pathname === "/" && !parsed.search && !parsed.hash && serialized.endsWith("/")) {
165
+ return serialized.slice(0, -1);
166
+ }
167
+ return serialized;
126
168
  }
127
169
  let _mountMappings = null;
128
170
  /**
@@ -1,4 +1,8 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ afterEach(() => {
4
+ vi.restoreAllMocks();
5
+ });
2
6
  // ── buildContainerEnv ─────────────────────────────────────────────────────────
3
7
  describe("buildContainerEnv", () => {
4
8
  it("builds the standard env array", async () => {
@@ -37,7 +41,7 @@ describe("buildContainerEnv", () => {
37
41
  });
38
42
  // ── buildContainerBinds ───────────────────────────────────────────────────────
39
43
  describe("buildContainerBinds", () => {
40
- it("builds standard bind mounts", async () => {
44
+ it("builds standard bind mounts with all PM caches", async () => {
41
45
  const { buildContainerBinds } = await import("./container-config.js");
42
46
  const binds = buildContainerBinds({
43
47
  hostWorkDir: "/tmp/work",
@@ -56,9 +60,46 @@ describe("buildContainerBinds", () => {
56
60
  expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
57
61
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
58
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");
59
66
  // Standard mode should NOT include runner home bind (but _work bind is expected)
60
67
  expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
61
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
+ });
62
103
  it("includes runner bind mount for direct container", async () => {
63
104
  const { buildContainerBinds } = await import("./container-config.js");
64
105
  const binds = buildContainerBinds({
@@ -127,6 +168,118 @@ describe("resolveDockerApiUrl", () => {
127
168
  const { resolveDockerApiUrl } = await import("./container-config.js");
128
169
  expect(resolveDockerApiUrl("http://127.0.0.1:3000", "host.docker.internal")).toBe("http://host.docker.internal:3000");
129
170
  });
171
+ it("preserves path and query components", async () => {
172
+ const { resolveDockerApiUrl } = await import("./container-config.js");
173
+ expect(resolveDockerApiUrl("http://localhost:8910/api/v1?foo=bar", "10.0.0.8")).toBe("http://10.0.0.8:8910/api/v1?foo=bar");
174
+ });
175
+ it("keeps implicit https default port behavior", async () => {
176
+ const { resolveDockerApiUrl } = await import("./container-config.js");
177
+ expect(resolveDockerApiUrl("https://localhost", "host.docker.internal")).toBe("https://host.docker.internal");
178
+ });
179
+ it("does not rewrite non-loopback hosts", async () => {
180
+ const { resolveDockerApiUrl } = await import("./container-config.js");
181
+ expect(resolveDockerApiUrl("https://dtu.internal.example.com:8910", "host.docker.internal")).toBe("https://dtu.internal.example.com:8910");
182
+ });
183
+ });
184
+ describe("resolveDtuHost", () => {
185
+ const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
186
+ afterEach(() => {
187
+ if (originalBridgeGateway === undefined) {
188
+ delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
189
+ }
190
+ else {
191
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
192
+ }
193
+ });
194
+ it("uses host alias when available outside Docker", async () => {
195
+ const { resolveDtuHost } = await import("./container-config.js");
196
+ const originalExistsSync = fs.existsSync;
197
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
198
+ if (filePath === "/.dockerenv") {
199
+ return false;
200
+ }
201
+ return originalExistsSync(filePath);
202
+ });
203
+ await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
204
+ });
205
+ it("uses configured bridge gateway outside Docker when provided", async () => {
206
+ const { resolveDtuHost } = await import("./container-config.js");
207
+ const originalExistsSync = fs.existsSync;
208
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
209
+ if (filePath === "/.dockerenv") {
210
+ return false;
211
+ }
212
+ return originalExistsSync(filePath);
213
+ });
214
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = "10.10.0.1";
215
+ await expect(resolveDtuHost()).resolves.toBe("10.10.0.1");
216
+ });
217
+ it("uses host alias outside Docker when no gateway override is configured", async () => {
218
+ const { resolveDtuHost } = await import("./container-config.js");
219
+ const originalExistsSync = fs.existsSync;
220
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
221
+ if (filePath === "/.dockerenv") {
222
+ return false;
223
+ }
224
+ return originalExistsSync(filePath);
225
+ });
226
+ await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
227
+ });
228
+ });
229
+ describe("resolveDockerExtraHosts", () => {
230
+ const originalExtraHosts = process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
231
+ const originalDisable = process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
232
+ const originalGateway = process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
233
+ afterEach(() => {
234
+ if (originalExtraHosts === undefined) {
235
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
236
+ }
237
+ else {
238
+ process.env.AGENT_CI_DOCKER_EXTRA_HOSTS = originalExtraHosts;
239
+ }
240
+ if (originalDisable === undefined) {
241
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
242
+ }
243
+ else {
244
+ process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS = originalDisable;
245
+ }
246
+ if (originalGateway === undefined) {
247
+ delete process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
248
+ }
249
+ else {
250
+ process.env.AGENT_CI_DOCKER_HOST_GATEWAY = originalGateway;
251
+ }
252
+ });
253
+ it("maps host.docker.internal to host-gateway by default", async () => {
254
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
255
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
256
+ delete process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
257
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
258
+ expect(resolveDockerExtraHosts("host.docker.internal")).toEqual([
259
+ "host.docker.internal:host-gateway",
260
+ ]);
261
+ });
262
+ it("uses AGENT_CI_DOCKER_EXTRA_HOSTS when provided", async () => {
263
+ process.env.AGENT_CI_DOCKER_EXTRA_HOSTS = "host.docker.internal:172.17.0.1,api.local:10.0.0.2";
264
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
265
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
266
+ expect(resolveDockerExtraHosts("host.docker.internal")).toEqual([
267
+ "host.docker.internal:172.17.0.1",
268
+ "api.local:10.0.0.2",
269
+ ]);
270
+ });
271
+ it("returns undefined when defaults are disabled", async () => {
272
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
273
+ process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS = "1";
274
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
275
+ expect(resolveDockerExtraHosts("host.docker.internal")).toBeUndefined();
276
+ });
277
+ it("does not add default mapping for non-host.docker.internal hosts", async () => {
278
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
279
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
280
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
281
+ expect(resolveDockerExtraHosts("10.10.10.10")).toBeUndefined();
282
+ });
130
283
  });
131
284
  // ── signalsDir bind-mount ─────────────────────────────────────────────────────
132
285
  describe("buildContainerBinds with signalsDir", () => {
@@ -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;
@@ -23,14 +23,12 @@ export function printSummary(results, runDir) {
23
23
  else {
24
24
  process.stdout.write(` ✗ ${f.workflow} > ${f.taskId}\n`);
25
25
  }
26
- if (f.failedExitCode !== undefined) {
27
- process.stdout.write(` Exit code: ${f.failedExitCode}\n`);
26
+ if (f.failedStepLogPath && fs.existsSync(f.failedStepLogPath)) {
27
+ const content = fs.readFileSync(f.failedStepLogPath, "utf-8");
28
+ process.stdout.write("\n" + content);
28
29
  }
29
- if (f.lastOutputLines && f.lastOutputLines.length > 0) {
30
- process.stdout.write(` Last output:\n`);
31
- for (const line of f.lastOutputLines) {
32
- process.stdout.write(` ${line}\n`);
33
- }
30
+ else if (f.lastOutputLines && f.lastOutputLines.length > 0) {
31
+ process.stdout.write("\n" + f.lastOutputLines.join("\n") + "\n");
34
32
  }
35
33
  process.stdout.write("\n");
36
34
  }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { printSummary } from "./reporter.js";
6
+ function makeResult(overrides = {}) {
7
+ return {
8
+ name: "container-1",
9
+ workflow: "retry-proof.yml",
10
+ taskId: "test",
11
+ succeeded: false,
12
+ durationMs: 1000,
13
+ debugLogPath: "/tmp/debug.log",
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("printSummary", () => {
18
+ let tmpDir;
19
+ let output;
20
+ beforeEach(() => {
21
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "reporter-test-"));
22
+ output = "";
23
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
24
+ output += chunk;
25
+ return true;
26
+ });
27
+ });
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ fs.rmSync(tmpDir, { recursive: true, force: true });
31
+ });
32
+ it("outputs full step log content when failedStepLogPath exists", () => {
33
+ const logPath = path.join(tmpDir, "Run-assertion-test.log");
34
+ fs.writeFileSync(logPath, "line 1\nline 2\nline 3\n");
35
+ printSummary([
36
+ makeResult({
37
+ failedStep: "Run assertion test",
38
+ failedStepLogPath: logPath,
39
+ lastOutputLines: ["last line only"],
40
+ }),
41
+ ]);
42
+ expect(output).toContain("line 1\nline 2\nline 3\n");
43
+ expect(output).not.toContain("last line only");
44
+ expect(output).not.toContain("Last output:");
45
+ expect(output).not.toContain("Exit code:");
46
+ });
47
+ it("falls back to lastOutputLines when failedStepLogPath is absent", () => {
48
+ printSummary([
49
+ makeResult({
50
+ failedStep: "Run assertion test",
51
+ lastOutputLines: ["fallback line 1", "fallback line 2"],
52
+ }),
53
+ ]);
54
+ expect(output).toContain("fallback line 1\nfallback line 2");
55
+ expect(output).not.toContain("Last output:");
56
+ });
57
+ it("shows the failed step name in the FAILURES section", () => {
58
+ const logPath = path.join(tmpDir, "step.log");
59
+ fs.writeFileSync(logPath, "error output\n");
60
+ printSummary([
61
+ makeResult({
62
+ failedStep: "Run assertion test",
63
+ failedStepLogPath: logPath,
64
+ }),
65
+ ]);
66
+ expect(output).toContain('✗ retry-proof.yml > test > "Run assertion test"');
67
+ });
68
+ it("shows pass count in summary for a successful run", () => {
69
+ printSummary([makeResult({ succeeded: true })]);
70
+ expect(output).toContain("✓ 1 passed");
71
+ expect(output).not.toContain("FAILURES");
72
+ });
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
+ });
@@ -15,8 +15,8 @@ import { writeJobMetadata } from "./metadata.js";
15
15
  import { computeFakeSha, writeGitShim } from "./git-shim.js";
16
16
  import { prepareWorkspace } from "./workspace.js";
17
17
  import { createRunDirectories } from "./directory-setup.js";
18
- import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, } from "../docker/container-config.js";
19
- import { buildJobResult, sanitizeStepName } from "./result-builder.js";
18
+ import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.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 ─────────────────────────────────────────────────────────────
@@ -115,9 +115,27 @@ export async function executeLocalJob(job, options) {
115
115
  // own isolated DTU instance on a random port — eliminating port conflicts.
116
116
  let t0 = Date.now();
117
117
  const dtuCacheDir = path.resolve(getWorkingDirectory(), "cache", "dtu");
118
- const ephemeralDtu = await startEphemeralDtu(dtuCacheDir).catch(() => null);
118
+ let ephemeralDtu = null;
119
+ try {
120
+ ephemeralDtu = await startEphemeralDtu(dtuCacheDir);
121
+ debugRunner(`DTU server started - CLI URL: ${ephemeralDtu.url}, Container URL: ${ephemeralDtu.containerUrl}`);
122
+ }
123
+ catch (e) {
124
+ debugRunner(`Failed to start ephemeral DTU: ${e}`);
125
+ }
126
+ // CLI uses url (127.0.0.1), containers use containerUrl (host IP)
119
127
  const dtuUrl = ephemeralDtu?.url ?? config.GITHUB_API_URL;
128
+ const dtuContainerUrl = ephemeralDtu?.containerUrl ?? dtuUrl;
120
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)"}`);
121
139
  await fetch(`${dtuUrl}/_dtu/start-runner`, {
122
140
  method: "POST",
123
141
  headers: { "Content-Type": "application/json" },
@@ -129,7 +147,7 @@ export async function executeLocalJob(job, options) {
129
147
  // no need for the runner to tar/gzip them. Tell the DTU to return a
130
148
  // synthetic hit for any cache key matching these patterns — skipping the
131
149
  // 60s+ tar entirely.
132
- virtualCachePatterns: ["pnpm", "npm", "yarn", "bun"],
150
+ virtualCachePatterns: dirs.detectedPM ? [dirs.detectedPM] : ["pnpm", "npm", "yarn", "bun"],
133
151
  }),
134
152
  }).catch(() => {
135
153
  /* non-fatal */
@@ -139,12 +157,6 @@ export async function executeLocalJob(job, options) {
139
157
  writeJobMetadata({ logDir, containerName, job });
140
158
  // Open debug stream to capture raw container output
141
159
  const debugStream = fs.createWriteStream(debugLogPath);
142
- // ── Create run directories ────────────────────────────────────────────────
143
- const dirs = createRunDirectories({
144
- runDir,
145
- githubRepo: job.githubRepo,
146
- workflowPath: job.workflowPath,
147
- });
148
160
  // Signal handler: ensure cleanup runs even when killed.
149
161
  const signalCleanup = () => {
150
162
  killRunnerContainers(containerName);
@@ -219,12 +231,15 @@ export async function executeLocalJob(job, options) {
219
231
  bt("workspace-prep", workspacePrepStart);
220
232
  })();
221
233
  // 6. Spawn container
222
- const dtuPort = new URL(dtuUrl).port || "80";
223
- const dtuHost = resolveDtuHost();
224
- const dockerApiUrl = resolveDockerApiUrl(dtuUrl, dtuHost);
234
+ const dtuHost = await resolveDtuHost();
235
+ const dockerApiUrl = resolveDockerApiUrl(dtuContainerUrl, dtuHost);
236
+ const parsedDockerApiUrl = new URL(dockerApiUrl);
237
+ const dtuPort = parsedDockerApiUrl.port || (parsedDockerApiUrl.protocol === "https:" ? "443" : "80");
225
238
  const githubRepo = job.githubRepo || config.GITHUB_REPO;
226
239
  const repoUrl = `${dockerApiUrl}/${githubRepo}`;
227
240
  debugRunner(`Spawning container ${containerName}...`);
241
+ debugRunner(`DTU config - Port: ${dtuPort}, Host: ${dtuHost}, Docker API: ${dockerApiUrl}`);
242
+ debugRunner(`Runner will connect to: ${repoUrl}`);
228
243
  // Pre-cleanup: remove any stale container with the same name
229
244
  try {
230
245
  const stale = docker.getContainer(containerName);
@@ -293,11 +308,81 @@ export async function executeLocalJob(job, options) {
293
308
  if (err) {
294
309
  return reject(err);
295
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
+ };
296
336
  docker.modem.followProgress(stream, (err) => {
297
337
  if (err) {
298
338
  return reject(err);
299
339
  }
340
+ store?.updateJob(containerName, { pullProgress: undefined });
300
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();
301
386
  });
302
387
  });
303
388
  });
@@ -333,6 +418,7 @@ export async function executeLocalJob(job, options) {
333
418
  useDirectContainer,
334
419
  containerName,
335
420
  });
421
+ const extraHosts = resolveDockerExtraHosts(dtuHost);
336
422
  t0 = Date.now();
337
423
  const container = await docker.createContainer({
338
424
  Image: containerImage,
@@ -345,6 +431,7 @@ export async function executeLocalJob(job, options) {
345
431
  AutoRemove: false,
346
432
  Ulimits: [{ Name: "nofile", Soft: 65536, Hard: 65536 }],
347
433
  ...(serviceCtx ? { NetworkMode: serviceCtx.networkName } : {}),
434
+ ...(extraHosts ? { ExtraHosts: extraHosts } : {}),
348
435
  },
349
436
  Tty: true,
350
437
  });
@@ -408,44 +495,24 @@ export async function executeLocalJob(job, options) {
408
495
  const lines = content.split("\n");
409
496
  pausedStepName = lines[0] || null;
410
497
  const attempt = parseInt(lines[1] || "1", 10);
411
- if (attempt !== lastSeenAttempt) {
498
+ const isNewAttempt = attempt !== lastSeenAttempt;
499
+ if (isNewAttempt) {
412
500
  lastSeenAttempt = attempt;
413
501
  isPaused = true;
414
502
  pausedAtMs = Date.now();
415
503
  setupStdinRetry();
416
- // Read last output lines from the failed step's log
417
- let tailLines = [];
418
- if (pausedStepName) {
419
- const stepsDir = path.join(logDir, "steps");
420
- const sanitized = sanitizeStepName(pausedStepName);
421
- const byName = path.join(stepsDir, `${sanitized}.log`);
422
- tailLines = tailLogFile(byName, 20);
423
- if (tailLines.length === 0 && fs.existsSync(stepsDir)) {
424
- let newest = "";
425
- let newestMtime = 0;
426
- for (const f of fs.readdirSync(stepsDir)) {
427
- if (!f.endsWith(".log")) {
428
- continue;
429
- }
430
- const mt = fs.statSync(path.join(stepsDir, f)).mtimeMs;
431
- if (mt > newestMtime) {
432
- newestMtime = mt;
433
- newest = f;
434
- }
435
- }
436
- if (newest) {
437
- tailLines = tailLogFile(path.join(stepsDir, newest), 20);
438
- }
439
- }
440
- }
441
- store?.updateJob(containerName, {
442
- status: "paused",
443
- pausedAtStep: pausedStepName || undefined,
444
- pausedAtMs: new Date(pausedAtMs).toISOString(),
445
- attempt: lastSeenAttempt,
446
- lastOutputLines: tailLines,
447
- });
448
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
+ });
449
516
  }
450
517
  else if (isPaused && !fs.existsSync(pausedSignalPath)) {
451
518
  // Pause signal removed — job is retrying
@@ -683,7 +750,13 @@ export async function executeLocalJob(job, options) {
683
750
  }
684
751
  }
685
752
  if (jobSucceeded && fs.existsSync(dirs.containerWorkDir)) {
686
- 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
+ }
687
760
  }
688
761
  await ephemeralDtu?.close().catch(() => { });
689
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.5.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.5.0"
43
+ "dtu-github-actions": "0.7.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",