@redwoodjs/agent-ci 0.6.0 → 0.7.1
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 +47 -11
- package/dist/docker/container-config.js +5 -4
- package/dist/docker/container-config.test.js +53 -2
- package/dist/docker/repro-126.test.js +72 -0
- package/dist/docker/shutdown.js +21 -4
- package/dist/output/cleanup.js +20 -0
- package/dist/output/cleanup.test.js +45 -0
- package/dist/output/logger.js +35 -6
- package/dist/output/logger.test.js +40 -0
- package/dist/output/reporter.test.js +36 -0
- package/dist/output/state-renderer.js +26 -1
- package/dist/runner/directory-setup.js +19 -6
- package/dist/runner/directory-setup.test.js +179 -0
- package/dist/runner/local-job.js +139 -72
- package/dist/runner/result-builder.js +12 -0
- package/dist/runner/result-builder.test.js +21 -0
- package/dist/runner/step-wrapper.js +1 -1
- package/dist/runner/step-wrapper.test.js +4 -0
- package/dist/workflow/workflow-parser.js +18 -1
- package/dist/workflow/workflow-parser.test.js +21 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -130,9 +130,11 @@ async function run() {
|
|
|
130
130
|
pauseOnFailure,
|
|
131
131
|
noMatrix,
|
|
132
132
|
});
|
|
133
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -241,6 +245,9 @@ async function run() {
|
|
|
241
245
|
// One workflow = --all with a single entry.
|
|
242
246
|
async function runWorkflows(options) {
|
|
243
247
|
const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
|
|
248
|
+
// Suppress EventEmitter MaxListenersExceeded warnings when running many
|
|
249
|
+
// parallel jobs (each job adds SIGINT/SIGTERM listeners).
|
|
250
|
+
process.setMaxListeners(0);
|
|
244
251
|
// Create the run state store — single source of truth for all progress
|
|
245
252
|
const runId = `run-${Date.now()}`;
|
|
246
253
|
const storeFilePath = path.join(getWorkingDirectory(), "runs", runId, "run-state.json");
|
|
@@ -318,6 +325,15 @@ async function runWorkflows(options) {
|
|
|
318
325
|
}
|
|
319
326
|
}, 80);
|
|
320
327
|
}
|
|
328
|
+
// Top-level signal handler: exit the process after per-job handlers have
|
|
329
|
+
// cleaned up their containers. All listeners fire synchronously in
|
|
330
|
+
// registration order, so we defer the exit to let per-job handlers
|
|
331
|
+
// (registered later in local-job.ts) run first.
|
|
332
|
+
const exitOnSignal = () => {
|
|
333
|
+
setTimeout(() => process.exit(1), 0);
|
|
334
|
+
};
|
|
335
|
+
process.on("SIGINT", exitOnSignal);
|
|
336
|
+
process.on("SIGTERM", exitOnSignal);
|
|
321
337
|
try {
|
|
322
338
|
const allResults = [];
|
|
323
339
|
if (workflowPaths.length === 1) {
|
|
@@ -343,6 +359,11 @@ async function runWorkflows(options) {
|
|
|
343
359
|
catch { }
|
|
344
360
|
const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
|
|
345
361
|
const warm = isWarmNodeModules(warmModulesDir);
|
|
362
|
+
// Pre-allocate unique run numbers so parallel workflows don't collide.
|
|
363
|
+
// Each workflow gets its own baseRunNum (e.g. 306, 307, 308) so their
|
|
364
|
+
// job suffixes (-j1, -j2, -j3) never produce duplicate container names.
|
|
365
|
+
const baseRunNum = getNextLogNum("agent-ci");
|
|
366
|
+
const runNums = workflowPaths.map((_, i) => baseRunNum + i);
|
|
346
367
|
if (!warm && workflowPaths.length > 1) {
|
|
347
368
|
// Cold cache — run first workflow serially to populate warm modules,
|
|
348
369
|
// then launch the rest in parallel.
|
|
@@ -352,11 +373,17 @@ async function runWorkflows(options) {
|
|
|
352
373
|
pauseOnFailure,
|
|
353
374
|
noMatrix,
|
|
354
375
|
store,
|
|
376
|
+
baseRunNum: runNums[0],
|
|
355
377
|
});
|
|
356
378
|
allResults.push(...firstResults);
|
|
357
|
-
const settled = await Promise.allSettled(workflowPaths
|
|
358
|
-
|
|
359
|
-
|
|
379
|
+
const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
|
|
380
|
+
workflowPath: wf,
|
|
381
|
+
sha,
|
|
382
|
+
pauseOnFailure,
|
|
383
|
+
noMatrix,
|
|
384
|
+
store,
|
|
385
|
+
baseRunNum: runNums[i + 1],
|
|
386
|
+
})));
|
|
360
387
|
for (const s of settled) {
|
|
361
388
|
if (s.status === "fulfilled") {
|
|
362
389
|
allResults.push(...s.value);
|
|
@@ -367,7 +394,14 @@ async function runWorkflows(options) {
|
|
|
367
394
|
}
|
|
368
395
|
}
|
|
369
396
|
else {
|
|
370
|
-
const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({
|
|
397
|
+
const settled = await Promise.allSettled(workflowPaths.map((wf, i) => handleWorkflow({
|
|
398
|
+
workflowPath: wf,
|
|
399
|
+
sha,
|
|
400
|
+
pauseOnFailure,
|
|
401
|
+
noMatrix,
|
|
402
|
+
store,
|
|
403
|
+
baseRunNum: runNums[i],
|
|
404
|
+
})));
|
|
371
405
|
for (const s of settled) {
|
|
372
406
|
if (s.status === "fulfilled") {
|
|
373
407
|
allResults.push(...s.value);
|
|
@@ -382,6 +416,8 @@ async function runWorkflows(options) {
|
|
|
382
416
|
return allResults;
|
|
383
417
|
}
|
|
384
418
|
finally {
|
|
419
|
+
process.removeListener("SIGINT", exitOnSignal);
|
|
420
|
+
process.removeListener("SIGTERM", exitOnSignal);
|
|
385
421
|
if (renderInterval) {
|
|
386
422
|
clearInterval(renderInterval);
|
|
387
423
|
}
|
|
@@ -492,7 +528,7 @@ async function handleWorkflow(options) {
|
|
|
492
528
|
const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
|
|
493
529
|
let warm = isWarmNodeModules(warmModulesDir);
|
|
494
530
|
// Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
|
|
495
|
-
const baseRunNum = getNextLogNum("agent-ci");
|
|
531
|
+
const baseRunNum = options.baseRunNum ?? getNextLogNum("agent-ci");
|
|
496
532
|
let globalIdx = 0;
|
|
497
533
|
const buildJob = (ej) => {
|
|
498
534
|
const secrets = loadMachineSecrets(repoRoot);
|
|
@@ -699,8 +735,8 @@ async function handleWorkflow(options) {
|
|
|
699
735
|
return allResults;
|
|
700
736
|
}
|
|
701
737
|
catch (error) {
|
|
702
|
-
console.error(`[Agent CI] Failed to
|
|
703
|
-
|
|
738
|
+
console.error(`[Agent CI] Failed to run workflow ${path.basename(workflowPath)}: ${error.message}`);
|
|
739
|
+
throw error;
|
|
704
740
|
}
|
|
705
741
|
}
|
|
706
742
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
@@ -11,6 +11,7 @@ export function buildContainerEnv(opts) {
|
|
|
11
11
|
`GITHUB_API_URL=${dockerApiUrl}`,
|
|
12
12
|
`GITHUB_SERVER_URL=${repoUrl}`,
|
|
13
13
|
`GITHUB_REPOSITORY=${githubRepo}`,
|
|
14
|
+
`AGENT_CI_LOCAL=true`,
|
|
14
15
|
`AGENT_CI_LOCAL_SYNC=true`,
|
|
15
16
|
`AGENT_CI_HEAD_SHA=${headSha || "HEAD"}`,
|
|
16
17
|
`AGENT_CI_DTU_HOST=${dtuHost}`,
|
|
@@ -44,10 +45,10 @@ export function buildContainerBinds(opts) {
|
|
|
44
45
|
...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
|
|
45
46
|
`${h(diagDir)}:/home/runner/_diag`,
|
|
46
47
|
`${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
|
|
48
|
+
// Package manager caches (persist across runs, only for detected PM)
|
|
49
|
+
...(pnpmStoreDir ? [`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`] : []),
|
|
50
|
+
...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
|
|
51
|
+
...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
|
|
51
52
|
`${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
|
|
52
53
|
// Warm node_modules: mounted outside the workspace so actions/checkout can
|
|
53
54
|
// delete the symlink without EBUSY. A symlink in the entrypoint points
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
afterEach(() => {
|
|
4
4
|
vi.restoreAllMocks();
|
|
@@ -19,6 +19,7 @@ describe("buildContainerEnv", () => {
|
|
|
19
19
|
});
|
|
20
20
|
expect(env).toContain("RUNNER_NAME=runner-1");
|
|
21
21
|
expect(env).toContain("GITHUB_REPOSITORY=org/repo");
|
|
22
|
+
expect(env).toContain("AGENT_CI_LOCAL=true");
|
|
22
23
|
expect(env).toContain("AGENT_CI_HEAD_SHA=abc123");
|
|
23
24
|
expect(env).toContain("FORCE_COLOR=1");
|
|
24
25
|
// Should NOT include root-mode vars for standard container
|
|
@@ -41,7 +42,7 @@ describe("buildContainerEnv", () => {
|
|
|
41
42
|
});
|
|
42
43
|
// ── buildContainerBinds ───────────────────────────────────────────────────────
|
|
43
44
|
describe("buildContainerBinds", () => {
|
|
44
|
-
it("builds standard bind mounts", async () => {
|
|
45
|
+
it("builds standard bind mounts with all PM caches", async () => {
|
|
45
46
|
const { buildContainerBinds } = await import("./container-config.js");
|
|
46
47
|
const binds = buildContainerBinds({
|
|
47
48
|
hostWorkDir: "/tmp/work",
|
|
@@ -60,9 +61,46 @@ describe("buildContainerBinds", () => {
|
|
|
60
61
|
expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
|
|
61
62
|
expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
|
|
62
63
|
expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
|
|
64
|
+
expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
|
|
65
|
+
expect(binds).toContain("/tmp/npm:/home/runner/.npm");
|
|
66
|
+
expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
|
|
63
67
|
// Standard mode should NOT include runner home bind (but _work bind is expected)
|
|
64
68
|
expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
|
|
65
69
|
});
|
|
70
|
+
it("omits PM bind mounts when cache dirs are not provided", async () => {
|
|
71
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
72
|
+
const binds = buildContainerBinds({
|
|
73
|
+
hostWorkDir: "/tmp/work",
|
|
74
|
+
shimsDir: "/tmp/shims",
|
|
75
|
+
diagDir: "/tmp/diag",
|
|
76
|
+
toolCacheDir: "/tmp/toolcache",
|
|
77
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
78
|
+
warmModulesDir: "/tmp/warm",
|
|
79
|
+
hostRunnerDir: "/tmp/runner",
|
|
80
|
+
useDirectContainer: false,
|
|
81
|
+
});
|
|
82
|
+
expect(binds).toContain("/tmp/work:/home/runner/_work");
|
|
83
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
84
|
+
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
85
|
+
expect(binds.some((b) => b.includes(".bun"))).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it("includes only the npm bind mount when only npmCacheDir is provided", async () => {
|
|
88
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
89
|
+
const binds = buildContainerBinds({
|
|
90
|
+
hostWorkDir: "/tmp/work",
|
|
91
|
+
shimsDir: "/tmp/shims",
|
|
92
|
+
diagDir: "/tmp/diag",
|
|
93
|
+
toolCacheDir: "/tmp/toolcache",
|
|
94
|
+
npmCacheDir: "/tmp/npm",
|
|
95
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
96
|
+
warmModulesDir: "/tmp/warm",
|
|
97
|
+
hostRunnerDir: "/tmp/runner",
|
|
98
|
+
useDirectContainer: false,
|
|
99
|
+
});
|
|
100
|
+
expect(binds).toContain("/tmp/npm:/home/runner/.npm");
|
|
101
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
102
|
+
expect(binds.some((b) => b.includes(".bun"))).toBe(false);
|
|
103
|
+
});
|
|
66
104
|
it("includes runner bind mount for direct container", async () => {
|
|
67
105
|
const { buildContainerBinds } = await import("./container-config.js");
|
|
68
106
|
const binds = buildContainerBinds({
|
|
@@ -146,6 +184,10 @@ describe("resolveDockerApiUrl", () => {
|
|
|
146
184
|
});
|
|
147
185
|
describe("resolveDtuHost", () => {
|
|
148
186
|
const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
|
|
187
|
+
const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
190
|
+
});
|
|
149
191
|
afterEach(() => {
|
|
150
192
|
if (originalBridgeGateway === undefined) {
|
|
151
193
|
delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
|
|
@@ -153,8 +195,15 @@ describe("resolveDtuHost", () => {
|
|
|
153
195
|
else {
|
|
154
196
|
process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
|
|
155
197
|
}
|
|
198
|
+
if (originalDtuHost === undefined) {
|
|
199
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
process.env.AGENT_CI_DTU_HOST = originalDtuHost;
|
|
203
|
+
}
|
|
156
204
|
});
|
|
157
205
|
it("uses host alias when available outside Docker", async () => {
|
|
206
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
158
207
|
const { resolveDtuHost } = await import("./container-config.js");
|
|
159
208
|
const originalExistsSync = fs.existsSync;
|
|
160
209
|
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
|
|
@@ -166,6 +215,7 @@ describe("resolveDtuHost", () => {
|
|
|
166
215
|
await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
|
|
167
216
|
});
|
|
168
217
|
it("uses configured bridge gateway outside Docker when provided", async () => {
|
|
218
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
169
219
|
const { resolveDtuHost } = await import("./container-config.js");
|
|
170
220
|
const originalExistsSync = fs.existsSync;
|
|
171
221
|
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
|
|
@@ -178,6 +228,7 @@ describe("resolveDtuHost", () => {
|
|
|
178
228
|
await expect(resolveDtuHost()).resolves.toBe("10.10.0.1");
|
|
179
229
|
});
|
|
180
230
|
it("uses host alias outside Docker when no gateway override is configured", async () => {
|
|
231
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
181
232
|
const { resolveDtuHost } = await import("./container-config.js");
|
|
182
233
|
const originalExistsSync = fs.existsSync;
|
|
183
234
|
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproduction for https://github.com/redwoodjs/agent-ci/issues/126
|
|
3
|
+
*
|
|
4
|
+
* When running inside Docker (via agent-ci), AGENT_CI_DTU_HOST is set in the
|
|
5
|
+
* container environment. The resolveDtuHost tests delete it, but this test
|
|
6
|
+
* verifies the mock and env var interaction actually works in that scenario.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
describe("issue-126 reproduction: resolveDtuHost inside Docker", () => {
|
|
14
|
+
const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
|
|
15
|
+
const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (originalBridgeGateway === undefined) {
|
|
21
|
+
delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
|
|
25
|
+
}
|
|
26
|
+
if (originalDtuHost === undefined) {
|
|
27
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
process.env.AGENT_CI_DTU_HOST = originalDtuHost;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
it("reports environment state", async () => {
|
|
34
|
+
console.log("--- issue-126 repro diagnostics ---");
|
|
35
|
+
console.log("/.dockerenv exists:", fs.existsSync("/.dockerenv"));
|
|
36
|
+
console.log("AGENT_CI_DTU_HOST:", process.env.AGENT_CI_DTU_HOST ?? "(unset)");
|
|
37
|
+
console.log("AGENT_CI_DOCKER_BRIDGE_GATEWAY:", process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY ?? "(unset)");
|
|
38
|
+
expect(true).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it("mock fs.existsSync intercepts /.dockerenv check", async () => {
|
|
41
|
+
const realResult = fs.existsSync("/.dockerenv");
|
|
42
|
+
console.log("Real fs.existsSync('/.dockerenv'):", realResult);
|
|
43
|
+
const originalExistsSync = fs.existsSync;
|
|
44
|
+
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
|
|
45
|
+
if (filePath === "/.dockerenv") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return originalExistsSync(filePath);
|
|
49
|
+
});
|
|
50
|
+
const mockedResult = fs.existsSync("/.dockerenv");
|
|
51
|
+
console.log("Mocked fs.existsSync('/.dockerenv'):", mockedResult);
|
|
52
|
+
expect(mockedResult).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it("resolveDtuHost uses bridge gateway when mock is active (the failing test)", async () => {
|
|
55
|
+
// Simulate agent-ci Docker env: AGENT_CI_DTU_HOST was set but we deleted it
|
|
56
|
+
delete process.env.AGENT_CI_DTU_HOST;
|
|
57
|
+
const { resolveDtuHost } = await import("./container-config.js");
|
|
58
|
+
const originalExistsSync = fs.existsSync;
|
|
59
|
+
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
|
|
60
|
+
if (filePath === "/.dockerenv") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return originalExistsSync(filePath);
|
|
64
|
+
});
|
|
65
|
+
process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = "10.10.0.1";
|
|
66
|
+
// This is the assertion that was failing: expected '10.10.0.1' but got 'host.docker.internal'
|
|
67
|
+
const result = await resolveDtuHost();
|
|
68
|
+
console.log("resolveDtuHost() returned:", result);
|
|
69
|
+
console.log("Expected: 10.10.0.1");
|
|
70
|
+
expect(result).toBe("10.10.0.1");
|
|
71
|
+
});
|
|
72
|
+
});
|
package/dist/docker/shutdown.js
CHANGED
|
@@ -43,14 +43,31 @@ export function killRunnerContainers(runnerName) {
|
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* Remove orphaned Docker resources left behind by previous runs:
|
|
46
|
-
* 1. `agent-ci
|
|
47
|
-
* 2.
|
|
46
|
+
* 1. Stopped `agent-ci-*` containers (runners + sidecars)
|
|
47
|
+
* 2. `agent-ci-net-*` networks with no connected containers
|
|
48
|
+
* 3. Dangling volumes (anonymous volumes from service containers like MySQL)
|
|
49
|
+
*
|
|
50
|
+
* Stopped containers must be removed first so their network references are
|
|
51
|
+
* released, allowing the network prune in step 2 to reclaim address pool capacity.
|
|
48
52
|
*
|
|
49
53
|
* Call this proactively before creating new resources to prevent Docker from
|
|
50
54
|
* exhausting its address pool ("all predefined address pools have been fully subnetted").
|
|
51
55
|
*/
|
|
52
56
|
export function pruneOrphanedDockerResources() {
|
|
53
|
-
// 1. Remove
|
|
57
|
+
// 1. Remove stopped agent-ci-* containers (runners + sidecars) so their
|
|
58
|
+
// network references are released before we try to prune networks.
|
|
59
|
+
try {
|
|
60
|
+
const stoppedIds = execSync(`docker ps -aq --filter "name=agent-ci-" --filter "status=exited"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
61
|
+
if (stoppedIds) {
|
|
62
|
+
execSync(`docker rm -f ${stoppedIds.split("\n").join(" ")}`, {
|
|
63
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Docker not reachable or no stopped containers — skip
|
|
69
|
+
}
|
|
70
|
+
// 2. Remove orphaned agent-ci-net-* networks
|
|
54
71
|
try {
|
|
55
72
|
const nets = execSync(`docker network ls -q --filter "name=agent-ci-net-"`, {
|
|
56
73
|
encoding: "utf8",
|
|
@@ -74,7 +91,7 @@ export function pruneOrphanedDockerResources() {
|
|
|
74
91
|
catch {
|
|
75
92
|
// Docker not reachable — skip
|
|
76
93
|
}
|
|
77
|
-
//
|
|
94
|
+
// 3. Remove dangling volumes (anonymous volumes from service containers)
|
|
78
95
|
try {
|
|
79
96
|
execSync(`docker volume prune -f`, {
|
|
80
97
|
stdio: ["pipe", "pipe", "pipe"],
|
package/dist/output/cleanup.js
CHANGED
|
@@ -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;
|
package/dist/output/logger.js
CHANGED
|
@@ -31,15 +31,44 @@ export function getNextLogNum(prefix) {
|
|
|
31
31
|
});
|
|
32
32
|
return nums.length > 0 ? Math.max(...nums) + 1 : 1;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Atomically allocate a numbered run directory under `runs/`.
|
|
36
|
+
* Uses mkdirSync without `recursive` so that EEXIST signals a
|
|
37
|
+
* collision — the caller just increments and retries.
|
|
38
|
+
*/
|
|
39
|
+
function allocateRunDir(prefix) {
|
|
40
|
+
const runsDir = getRunsDir();
|
|
41
|
+
let num = getNextLogNum(prefix);
|
|
42
|
+
for (;;) {
|
|
43
|
+
const name = `${prefix}-${num}`;
|
|
44
|
+
const runDir = path.join(runsDir, name);
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(runDir); // atomic — fails with EEXIST on collision
|
|
47
|
+
return { num, name, runDir };
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "EEXIST") {
|
|
51
|
+
num++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
34
58
|
export function createLogContext(prefix, preferredName) {
|
|
35
59
|
ensureLogDirs();
|
|
36
|
-
let num
|
|
37
|
-
let name
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
let num;
|
|
61
|
+
let name;
|
|
62
|
+
let runDir;
|
|
63
|
+
if (preferredName) {
|
|
64
|
+
num = 0;
|
|
65
|
+
name = preferredName;
|
|
66
|
+
runDir = path.join(getRunsDir(), name);
|
|
67
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
({ num, name, runDir } = allocateRunDir(prefix));
|
|
41
71
|
}
|
|
42
|
-
const runDir = path.join(getRunsDir(), name);
|
|
43
72
|
const logDir = path.join(runDir, "logs");
|
|
44
73
|
fs.mkdirSync(logDir, { recursive: true });
|
|
45
74
|
return {
|
|
@@ -78,5 +78,45 @@ describe("Logger utilities", () => {
|
|
|
78
78
|
const second = createLogContext("agent-ci");
|
|
79
79
|
expect(second.num).toBe(first.num + 1);
|
|
80
80
|
});
|
|
81
|
+
it("skips over a directory that already exists (race condition)", async () => {
|
|
82
|
+
const { setWorkingDirectory } = await import("./working-directory.js");
|
|
83
|
+
const { createLogContext } = await import("./logger.js");
|
|
84
|
+
setWorkingDirectory(tmpDir);
|
|
85
|
+
// Pre-create runs/agent-ci-1 to simulate another process winning the race
|
|
86
|
+
fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
|
|
87
|
+
const ctx = createLogContext("agent-ci");
|
|
88
|
+
// Should have skipped 1 and landed on 2
|
|
89
|
+
expect(ctx.num).toBe(2);
|
|
90
|
+
expect(ctx.name).toBe("agent-ci-2");
|
|
91
|
+
expect(fs.existsSync(ctx.runDir)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
it("handles multiple consecutive collisions", async () => {
|
|
94
|
+
const { setWorkingDirectory } = await import("./working-directory.js");
|
|
95
|
+
const { createLogContext } = await import("./logger.js");
|
|
96
|
+
setWorkingDirectory(tmpDir);
|
|
97
|
+
// Pre-create 1, 2, and 3 to simulate several concurrent processes
|
|
98
|
+
fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
|
|
99
|
+
fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-2"), { recursive: true });
|
|
100
|
+
fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-3"), { recursive: true });
|
|
101
|
+
const ctx = createLogContext("agent-ci");
|
|
102
|
+
expect(ctx.num).toBe(4);
|
|
103
|
+
expect(ctx.name).toBe("agent-ci-4");
|
|
104
|
+
expect(fs.existsSync(ctx.runDir)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it("concurrent calls each get a unique directory", async () => {
|
|
107
|
+
const { setWorkingDirectory } = await import("./working-directory.js");
|
|
108
|
+
const { createLogContext } = await import("./logger.js");
|
|
109
|
+
setWorkingDirectory(tmpDir);
|
|
110
|
+
// Call createLogContext many times synchronously — each should get a unique dir
|
|
111
|
+
const results = Array.from({ length: 10 }, () => createLogContext("agent-ci"));
|
|
112
|
+
const names = results.map((r) => r.name);
|
|
113
|
+
const uniqueNames = new Set(names);
|
|
114
|
+
expect(uniqueNames.size).toBe(10);
|
|
115
|
+
// All directories should exist
|
|
116
|
+
for (const r of results) {
|
|
117
|
+
expect(fs.existsSync(r.runDir)).toBe(true);
|
|
118
|
+
expect(fs.existsSync(r.logDir)).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
81
121
|
});
|
|
82
122
|
});
|
|
@@ -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
|
-
|
|
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
|
}
|