@redwoodjs/agent-ci 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +628 -0
  4. package/dist/config.js +63 -0
  5. package/dist/docker/container-config.js +178 -0
  6. package/dist/docker/container-config.test.js +156 -0
  7. package/dist/docker/service-containers.js +205 -0
  8. package/dist/docker/service-containers.test.js +236 -0
  9. package/dist/docker/shutdown.js +120 -0
  10. package/dist/docker/shutdown.test.js +148 -0
  11. package/dist/output/agent-mode.js +7 -0
  12. package/dist/output/agent-mode.test.js +36 -0
  13. package/dist/output/cleanup.js +218 -0
  14. package/dist/output/cleanup.test.js +241 -0
  15. package/dist/output/concurrency.js +57 -0
  16. package/dist/output/concurrency.test.js +88 -0
  17. package/dist/output/debug.js +36 -0
  18. package/dist/output/logger.js +57 -0
  19. package/dist/output/logger.test.js +82 -0
  20. package/dist/output/reporter.js +67 -0
  21. package/dist/output/run-state.js +126 -0
  22. package/dist/output/run-state.test.js +169 -0
  23. package/dist/output/state-renderer.js +149 -0
  24. package/dist/output/state-renderer.test.js +488 -0
  25. package/dist/output/tree-renderer.js +52 -0
  26. package/dist/output/tree-renderer.test.js +105 -0
  27. package/dist/output/working-directory.js +20 -0
  28. package/dist/runner/directory-setup.js +98 -0
  29. package/dist/runner/directory-setup.test.js +31 -0
  30. package/dist/runner/git-shim.js +92 -0
  31. package/dist/runner/git-shim.test.js +57 -0
  32. package/dist/runner/local-job.js +691 -0
  33. package/dist/runner/metadata.js +90 -0
  34. package/dist/runner/metadata.test.js +127 -0
  35. package/dist/runner/result-builder.js +119 -0
  36. package/dist/runner/result-builder.test.js +177 -0
  37. package/dist/runner/step-wrapper.js +82 -0
  38. package/dist/runner/step-wrapper.test.js +77 -0
  39. package/dist/runner/sync.js +80 -0
  40. package/dist/runner/workspace.js +66 -0
  41. package/dist/types.js +1 -0
  42. package/dist/workflow/job-scheduler.js +62 -0
  43. package/dist/workflow/job-scheduler.test.js +130 -0
  44. package/dist/workflow/workflow-parser.js +556 -0
  45. package/dist/workflow/workflow-parser.test.js +642 -0
  46. package/package.json +39 -0
  47. package/shim.sh +11 -0
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { startServiceContainers, cleanupServiceContainers, parseHealthCheck, } from "./service-containers.js";
3
+ // ─── Mock Docker client ───────────────────────────────────────────────────────
4
+ function makeMockContainer(id, healthStatus) {
5
+ return {
6
+ id,
7
+ start: vi.fn().mockResolvedValue(undefined),
8
+ stop: vi.fn().mockResolvedValue(undefined),
9
+ remove: vi.fn().mockResolvedValue(undefined),
10
+ inspect: vi.fn().mockResolvedValue({
11
+ State: {
12
+ Running: true,
13
+ Health: healthStatus ? { Status: healthStatus } : undefined,
14
+ },
15
+ }),
16
+ };
17
+ }
18
+ function makeMockDocker(opts) {
19
+ const containers = new Map();
20
+ let containerCounter = 0;
21
+ const mockDocker = {
22
+ createNetwork: vi.fn().mockResolvedValue({ id: "net-123" }),
23
+ createContainer: vi.fn().mockImplementation((config) => {
24
+ containerCounter++;
25
+ const id = `container-${containerCounter}`;
26
+ const c = makeMockContainer(id, opts?.healthStatus);
27
+ containers.set(config.name || id, c);
28
+ return Promise.resolve(c);
29
+ }),
30
+ getContainer: vi.fn().mockImplementation((nameOrId) => {
31
+ const existing = containers.get(nameOrId);
32
+ if (existing) {
33
+ return existing;
34
+ }
35
+ return {
36
+ remove: vi.fn().mockRejectedValue(new Error("not found")),
37
+ stop: vi.fn().mockRejectedValue(new Error("not found")),
38
+ inspect: vi.fn().mockResolvedValue({
39
+ State: {
40
+ Running: true,
41
+ Health: opts?.healthStatus ? { Status: opts.healthStatus } : undefined,
42
+ },
43
+ }),
44
+ };
45
+ }),
46
+ getImage: vi.fn().mockReturnValue({
47
+ inspect: vi.fn().mockResolvedValue({}),
48
+ }),
49
+ getNetwork: vi.fn().mockReturnValue({
50
+ remove: vi.fn().mockResolvedValue(undefined),
51
+ }),
52
+ pull: vi.fn(),
53
+ modem: { followProgress: vi.fn() },
54
+ _containers: containers,
55
+ };
56
+ return mockDocker;
57
+ }
58
+ // ─── parseHealthCheck ─────────────────────────────────────────────────────────
59
+ describe("parseHealthCheck", () => {
60
+ it("parses all health-check flags from options string", () => {
61
+ const options = `--health-cmd="mysqladmin ping -h localhost -proot" --health-interval=5s --health-timeout=3s --health-retries=10`;
62
+ const result = parseHealthCheck(options);
63
+ expect(result).toBeDefined();
64
+ expect(result.Test).toEqual(["CMD-SHELL", "mysqladmin ping -h localhost -proot"]);
65
+ expect(result.Interval).toBe(5000000000);
66
+ expect(result.Timeout).toBe(3000000000);
67
+ expect(result.Retries).toBe(10);
68
+ });
69
+ it("uses defaults when interval/timeout/retries are missing", () => {
70
+ const options = `--health-cmd="curl -f http://localhost/"`;
71
+ const result = parseHealthCheck(options);
72
+ expect(result).toBeDefined();
73
+ expect(result.Interval).toBe(10000000000);
74
+ expect(result.Timeout).toBe(5000000000);
75
+ expect(result.Retries).toBe(3);
76
+ });
77
+ it("returns undefined when no --health-cmd is present", () => {
78
+ expect(parseHealthCheck("--some-other-flag")).toBeUndefined();
79
+ });
80
+ it("returns undefined for empty string", () => {
81
+ expect(parseHealthCheck("")).toBeUndefined();
82
+ });
83
+ });
84
+ // ─── startServiceContainers ───────────────────────────────────────────────────
85
+ describe("startServiceContainers", () => {
86
+ const MYSQL_SERVICE = {
87
+ name: "mysql",
88
+ image: "mysql:8.0",
89
+ env: { MYSQL_ROOT_PASSWORD: "root", MYSQL_DATABASE: "test_db" },
90
+ ports: ["3306:3306"],
91
+ options: `--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=3s --health-retries=10`,
92
+ };
93
+ const REDIS_SERVICE = {
94
+ name: "redis",
95
+ image: "redis:7",
96
+ ports: ["6379:6379"],
97
+ };
98
+ it("creates a Docker network with agent-ci-net- prefix", async () => {
99
+ const docker = makeMockDocker({ healthStatus: "healthy" });
100
+ await startServiceContainers(docker, [REDIS_SERVICE], "agent-ci-42");
101
+ expect(docker.createNetwork).toHaveBeenCalledWith({
102
+ Name: "agent-ci-net-agent-ci-42",
103
+ Driver: "bridge",
104
+ });
105
+ });
106
+ it("creates containers for each service on the shared network", async () => {
107
+ const docker = makeMockDocker({ healthStatus: "healthy" });
108
+ await startServiceContainers(docker, [MYSQL_SERVICE, REDIS_SERVICE], "agent-ci-42");
109
+ expect(docker.createContainer).toHaveBeenCalledTimes(2);
110
+ const mysqlCall = docker.createContainer.mock.calls[0][0];
111
+ expect(mysqlCall.Image).toBe("mysql:8.0");
112
+ expect(mysqlCall.name).toBe("agent-ci-42-svc-mysql");
113
+ expect(mysqlCall.Env).toContain("MYSQL_ROOT_PASSWORD=root");
114
+ expect(mysqlCall.Env).toContain("MYSQL_DATABASE=test_db");
115
+ expect(mysqlCall.HostConfig.NetworkMode).toBe("agent-ci-net-agent-ci-42");
116
+ expect(mysqlCall.HostConfig.PortBindings).toEqual({ "3306/tcp": [{ HostPort: "3306" }] });
117
+ expect(mysqlCall.NetworkingConfig.EndpointsConfig["agent-ci-net-agent-ci-42"].Aliases).toContain("mysql");
118
+ const redisCall = docker.createContainer.mock.calls[1][0];
119
+ expect(redisCall.Image).toBe("redis:7");
120
+ expect(redisCall.name).toBe("agent-ci-42-svc-redis");
121
+ expect(redisCall.NetworkingConfig.EndpointsConfig["agent-ci-net-agent-ci-42"].Aliases).toContain("redis");
122
+ });
123
+ it("starts all created containers", async () => {
124
+ const docker = makeMockDocker({ healthStatus: "healthy" });
125
+ await startServiceContainers(docker, [MYSQL_SERVICE, REDIS_SERVICE], "agent-ci-42");
126
+ for (const [, container] of docker._containers) {
127
+ expect(container.start).toHaveBeenCalledTimes(1);
128
+ }
129
+ });
130
+ it("returns the correct ServiceContext", async () => {
131
+ const docker = makeMockDocker({ healthStatus: "healthy" });
132
+ const ctx = await startServiceContainers(docker, [MYSQL_SERVICE, REDIS_SERVICE], "agent-ci-42");
133
+ expect(ctx.networkName).toBe("agent-ci-net-agent-ci-42");
134
+ expect(ctx.containerIds).toHaveLength(2);
135
+ expect(ctx.containerIds[0]).toBe("container-1");
136
+ expect(ctx.containerIds[1]).toBe("container-2");
137
+ });
138
+ it("generates port-forward commands for each port mapping", async () => {
139
+ const docker = makeMockDocker({ healthStatus: "healthy" });
140
+ const ctx = await startServiceContainers(docker, [MYSQL_SERVICE, REDIS_SERVICE], "agent-ci-42");
141
+ expect(ctx.portForwards).toHaveLength(2);
142
+ expect(ctx.portForwards[0]).toContain("agent-ci-42-svc-mysql");
143
+ expect(ctx.portForwards[0]).toContain("3306");
144
+ expect(ctx.portForwards[1]).toContain("agent-ci-42-svc-redis");
145
+ expect(ctx.portForwards[1]).toContain("6379");
146
+ });
147
+ it("applies health-check config from options string", async () => {
148
+ const docker = makeMockDocker({ healthStatus: "healthy" });
149
+ await startServiceContainers(docker, [MYSQL_SERVICE], "agent-ci-42");
150
+ const call = docker.createContainer.mock.calls[0][0];
151
+ expect(call.Healthcheck).toBeDefined();
152
+ expect(call.Healthcheck.Test).toEqual(["CMD-SHELL", "mysqladmin ping"]);
153
+ expect(call.Healthcheck.Retries).toBe(10);
154
+ });
155
+ it("does not set Healthcheck when options are absent", async () => {
156
+ const docker = makeMockDocker();
157
+ await startServiceContainers(docker, [REDIS_SERVICE], "agent-ci-42");
158
+ const call = docker.createContainer.mock.calls[0][0];
159
+ expect(call.Healthcheck).toBeUndefined();
160
+ });
161
+ it("handles service with no ports (no port forwards generated)", async () => {
162
+ const docker = makeMockDocker();
163
+ const svc = { name: "memcached", image: "memcached:latest" };
164
+ const ctx = await startServiceContainers(docker, [svc], "agent-ci-42");
165
+ expect(ctx.portForwards).toHaveLength(0);
166
+ });
167
+ it("pre-cleans stale containers with the same name", async () => {
168
+ const docker = makeMockDocker();
169
+ await startServiceContainers(docker, [REDIS_SERVICE], "agent-ci-42");
170
+ expect(docker.getContainer).toHaveBeenCalledWith("agent-ci-42-svc-redis");
171
+ });
172
+ it("calls emit with progress messages", async () => {
173
+ const docker = makeMockDocker({ healthStatus: "healthy" });
174
+ const lines = [];
175
+ await startServiceContainers(docker, [MYSQL_SERVICE], "agent-ci-42", (l) => lines.push(l));
176
+ expect(lines.some((l) => l.includes("Created network"))).toBe(true);
177
+ expect(lines.some((l) => l.includes("Starting service: mysql"))).toBe(true);
178
+ expect(lines.some((l) => l.includes("mysql started"))).toBe(true);
179
+ expect(lines.some((l) => l.includes("Waiting for mysql to become healthy"))).toBe(true);
180
+ expect(lines.some((l) => l.includes("mysql healthy in"))).toBe(true);
181
+ });
182
+ it("sets short service name as a Docker network alias so DB_HOST=mysql resolves", async () => {
183
+ // Regression: container was named agent-ci-N-svc-mysql but DB_HOST=mysql couldn't resolve.
184
+ const docker = makeMockDocker({ healthStatus: "healthy" });
185
+ await startServiceContainers(docker, [MYSQL_SERVICE], "agent-ci-99");
186
+ const call = docker.createContainer.mock.calls[0][0];
187
+ const networkName = "agent-ci-net-agent-ci-99";
188
+ expect(call.NetworkingConfig.EndpointsConfig[networkName]).toBeDefined();
189
+ const aliases = call.NetworkingConfig.EndpointsConfig[networkName].Aliases;
190
+ expect(aliases).toContain("mysql");
191
+ expect(aliases).not.toContain("agent-ci-99-svc-mysql"); // full name is NOT the alias
192
+ });
193
+ it("aliases differ per service", async () => {
194
+ const docker = makeMockDocker({ healthStatus: "healthy" });
195
+ await startServiceContainers(docker, [MYSQL_SERVICE, REDIS_SERVICE], "agent-ci-42");
196
+ const networkName = "agent-ci-net-agent-ci-42";
197
+ const mysqlAliases = docker.createContainer.mock.calls[0][0].NetworkingConfig.EndpointsConfig[networkName].Aliases;
198
+ const redisAliases = docker.createContainer.mock.calls[1][0].NetworkingConfig.EndpointsConfig[networkName].Aliases;
199
+ expect(mysqlAliases).toContain("mysql");
200
+ expect(redisAliases).toContain("redis");
201
+ expect(mysqlAliases).not.toContain("redis");
202
+ expect(redisAliases).not.toContain("mysql");
203
+ });
204
+ });
205
+ // ─── cleanupServiceContainers ─────────────────────────────────────────────────
206
+ describe("cleanupServiceContainers", () => {
207
+ it("stops and removes all containers, then removes the network", async () => {
208
+ const docker = makeMockDocker();
209
+ const ctx = {
210
+ networkName: "agent-ci-net-test",
211
+ containerIds: ["c1", "c2"],
212
+ portForwards: [],
213
+ };
214
+ await cleanupServiceContainers(docker, ctx);
215
+ expect(docker.getContainer).toHaveBeenCalledWith("c1");
216
+ expect(docker.getContainer).toHaveBeenCalledWith("c2");
217
+ expect(docker.getNetwork).toHaveBeenCalledWith("agent-ci-net-test");
218
+ });
219
+ it("doesn't throw if containers are already gone", async () => {
220
+ const docker = makeMockDocker();
221
+ const ctx = {
222
+ networkName: "agent-ci-net-gone",
223
+ containerIds: ["nonexistent"],
224
+ portForwards: [],
225
+ };
226
+ await expect(cleanupServiceContainers(docker, ctx)).resolves.toBeUndefined();
227
+ });
228
+ it("emits cleanup message", async () => {
229
+ const docker = makeMockDocker();
230
+ const ctx = { networkName: "agent-ci-net-test", containerIds: [], portForwards: [] };
231
+ const lines = [];
232
+ await cleanupServiceContainers(docker, ctx, (l) => lines.push(l));
233
+ expect(lines.some((l) => l.includes("Cleaned up"))).toBe(true);
234
+ expect(lines.some((l) => l.includes("agent-ci-net-test"))).toBe(true);
235
+ });
236
+ });
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ // ─── Docker container cleanup ─────────────────────────────────────────────────
5
+ /**
6
+ * Force-kill a specific runner and its associated service containers + network.
7
+ * Used when stopping a single workflow run.
8
+ */
9
+ export function killRunnerContainers(runnerName) {
10
+ // 1. Force-remove the runner container itself
11
+ try {
12
+ execSync(`docker rm -f ${runnerName}`, {
13
+ stdio: ["pipe", "pipe", "pipe"],
14
+ });
15
+ }
16
+ catch {
17
+ // already gone
18
+ }
19
+ // 2. Force-remove any svc-* sidecars for this runner
20
+ try {
21
+ const ids = execSync(`docker ps -aq --filter "name=${runnerName}-svc-"`, {
22
+ encoding: "utf8",
23
+ stdio: ["pipe", "pipe", "pipe"],
24
+ }).trim();
25
+ if (ids) {
26
+ execSync(`docker rm -f ${ids.split("\n").join(" ")}`, {
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ });
29
+ }
30
+ }
31
+ catch {
32
+ // no sidecars or Docker not reachable
33
+ }
34
+ // 3. Remove the shared bridge network
35
+ try {
36
+ execSync(`docker network rm agent-ci-net-${runnerName}`, {
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ });
39
+ }
40
+ catch {
41
+ // network doesn't exist or already removed
42
+ }
43
+ }
44
+ /**
45
+ * Remove orphaned Docker resources left behind by previous runs:
46
+ * 1. `agent-ci-net-*` networks with no connected containers
47
+ * 2. Dangling volumes (anonymous volumes from service containers like MySQL)
48
+ *
49
+ * Call this proactively before creating new resources to prevent Docker from
50
+ * exhausting its address pool ("all predefined address pools have been fully subnetted").
51
+ */
52
+ export function pruneOrphanedDockerResources() {
53
+ // 1. Remove orphaned agent-ci-net-* networks
54
+ try {
55
+ const nets = execSync(`docker network ls -q --filter "name=agent-ci-net-"`, {
56
+ encoding: "utf8",
57
+ stdio: ["pipe", "pipe", "pipe"],
58
+ }).trim();
59
+ if (nets) {
60
+ for (const netId of nets.split("\n")) {
61
+ try {
62
+ // docker network rm fails if containers are still attached — that's fine,
63
+ // we only want to remove truly orphaned networks.
64
+ execSync(`docker network rm ${netId}`, {
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ });
67
+ }
68
+ catch {
69
+ // Network still in use — skip
70
+ }
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // Docker not reachable — skip
76
+ }
77
+ // 2. Remove dangling volumes (anonymous volumes from service containers)
78
+ try {
79
+ execSync(`docker volume prune -f`, {
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ });
82
+ }
83
+ catch {
84
+ // Docker not reachable — skip
85
+ }
86
+ }
87
+ // ─── Workspace pruning ────────────────────────────────────────────────────────
88
+ /**
89
+ * Remove stale `agent-ci-*` run directories older than `maxAgeMs` from
90
+ * `<workDir>/runs/`. Each run dir contains logs, work, shims, and diag
91
+ * co-located, so a single rm removes everything for that run.
92
+ *
93
+ * Returns an array of directory names that were pruned.
94
+ */
95
+ export function pruneStaleWorkspaces(workDir, maxAgeMs) {
96
+ const runsPath = path.join(workDir, "runs");
97
+ if (!fs.existsSync(runsPath)) {
98
+ return [];
99
+ }
100
+ const now = Date.now();
101
+ const pruned = [];
102
+ for (const entry of fs.readdirSync(runsPath, { withFileTypes: true })) {
103
+ if (!entry.isDirectory() || !entry.name.startsWith("agent-ci-")) {
104
+ continue;
105
+ }
106
+ const dirPath = path.join(runsPath, entry.name);
107
+ try {
108
+ const stat = fs.statSync(dirPath);
109
+ const ageMs = now - stat.mtimeMs;
110
+ if (ageMs > maxAgeMs) {
111
+ fs.rmSync(dirPath, { recursive: true, force: true });
112
+ pruned.push(entry.name);
113
+ }
114
+ }
115
+ catch {
116
+ // Skip dirs we can't stat
117
+ }
118
+ }
119
+ return pruned;
120
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ // ── Signal handling cleanup ───────────────────────────────────────────────────
6
+ describe("Signal handler cleanup", () => {
7
+ let tmpDir;
8
+ beforeEach(() => {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-ci-signal-test-"));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tmpDir, { recursive: true, force: true });
13
+ });
14
+ it("cleanup function removes all temp directories", () => {
15
+ // With the new layout, work/shims/diag are co-located under runs/<runnerName>/
16
+ const runDir = path.join(tmpDir, "runs", "agent-ci-sig");
17
+ const dirs = {
18
+ containerWorkDir: path.join(runDir, "work"),
19
+ workspaceDir: path.join(runDir, "work", "workspace"),
20
+ shimsDir: path.join(runDir, "shims"),
21
+ diagDir: path.join(runDir, "diag"),
22
+ };
23
+ for (const d of Object.values(dirs)) {
24
+ fs.mkdirSync(d, { recursive: true });
25
+ fs.writeFileSync(path.join(d, "test.txt"), "data");
26
+ }
27
+ // Simulate signal handler cleanup — just remove the entire runDir
28
+ try {
29
+ fs.rmSync(runDir, { recursive: true, force: true });
30
+ }
31
+ catch { }
32
+ for (const d of Object.values(dirs)) {
33
+ expect(fs.existsSync(d)).toBe(false);
34
+ }
35
+ });
36
+ it("cleanup function is idempotent (handles missing dirs)", () => {
37
+ const dirs = [path.join(tmpDir, "nonexistent-1"), path.join(tmpDir, "nonexistent-2")];
38
+ // Should not throw
39
+ for (const d of dirs) {
40
+ try {
41
+ fs.rmSync(d, { recursive: true, force: true });
42
+ }
43
+ catch { }
44
+ }
45
+ // If we got here, idempotency works
46
+ expect(true).toBe(true);
47
+ });
48
+ });
49
+ // ── Stale workspace pruning ───────────────────────────────────────────────────
50
+ describe("Stale workspace pruning", () => {
51
+ let tmpDir;
52
+ beforeEach(() => {
53
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-ci-prune-test-"));
54
+ // pruneStaleWorkspaces scans <workDir>/runs/
55
+ fs.mkdirSync(path.join(tmpDir, "runs"), { recursive: true });
56
+ });
57
+ afterEach(() => {
58
+ fs.rmSync(tmpDir, { recursive: true, force: true });
59
+ });
60
+ it("removes agent-ci-* dirs older than maxAge", async () => {
61
+ // Create a stale run dir — the entire runDir is removed (includes logs, work, shims, diag)
62
+ const staleDir = path.join(tmpDir, "runs", "agent-ci-100");
63
+ fs.mkdirSync(path.join(staleDir, "logs"), { recursive: true });
64
+ fs.writeFileSync(path.join(staleDir, "logs", "output.log"), "stale");
65
+ // Backdate it to 48 hours ago
66
+ const oldTime = new Date(Date.now() - 48 * 60 * 60 * 1000);
67
+ fs.utimesSync(staleDir, oldTime, oldTime);
68
+ const { pruneStaleWorkspaces } = await import("./shutdown.js");
69
+ const pruned = pruneStaleWorkspaces(tmpDir, 24 * 60 * 60 * 1000);
70
+ expect(pruned).toContain("agent-ci-100");
71
+ expect(fs.existsSync(staleDir)).toBe(false);
72
+ });
73
+ it("keeps agent-ci-* dirs newer than maxAge", async () => {
74
+ // Create a fresh run dir
75
+ const freshDir = path.join(tmpDir, "runs", "agent-ci-200");
76
+ fs.mkdirSync(path.join(freshDir, "logs"), { recursive: true });
77
+ fs.writeFileSync(path.join(freshDir, "logs", "output.log"), "fresh");
78
+ const { pruneStaleWorkspaces } = await import("./shutdown.js");
79
+ const pruned = pruneStaleWorkspaces(tmpDir, 24 * 60 * 60 * 1000);
80
+ expect(pruned).toEqual([]);
81
+ expect(fs.existsSync(freshDir)).toBe(true);
82
+ });
83
+ it("ignores non-agent-ci dirs", async () => {
84
+ const otherDir = path.join(tmpDir, "runs", "workspace-12345");
85
+ fs.mkdirSync(otherDir, { recursive: true });
86
+ // Backdate it
87
+ const oldTime = new Date(Date.now() - 48 * 60 * 60 * 1000);
88
+ fs.utimesSync(otherDir, oldTime, oldTime);
89
+ const { pruneStaleWorkspaces } = await import("./shutdown.js");
90
+ const pruned = pruneStaleWorkspaces(tmpDir, 24 * 60 * 60 * 1000);
91
+ expect(pruned).toEqual([]);
92
+ expect(fs.existsSync(otherDir)).toBe(true);
93
+ });
94
+ });
95
+ describe("containerWorkDir cleanup on exit", () => {
96
+ let tmpDir;
97
+ beforeEach(() => {
98
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-ci-cleanup-test-"));
99
+ });
100
+ afterEach(() => {
101
+ fs.rmSync(tmpDir, { recursive: true, force: true });
102
+ });
103
+ it("cleans entire runDir on success", () => {
104
+ // New layout: work/shims/diag are all under runs/<runnerName>/
105
+ const runDir = path.join(tmpDir, "runs", "agent-ci-1");
106
+ const containerWorkDir = path.join(runDir, "work");
107
+ const shimsDir = path.join(runDir, "shims");
108
+ const diagDir = path.join(runDir, "diag");
109
+ const logDir = path.join(runDir, "logs");
110
+ // Create all dirs
111
+ for (const d of [containerWorkDir, shimsDir, diagDir, logDir]) {
112
+ fs.mkdirSync(d, { recursive: true });
113
+ fs.writeFileSync(path.join(d, "test.txt"), "data");
114
+ }
115
+ const jobSucceeded = true;
116
+ // On success: clean the entire runDir (logs kept via archiving externally)
117
+ if (jobSucceeded && fs.existsSync(runDir)) {
118
+ fs.rmSync(runDir, { recursive: true, force: true });
119
+ }
120
+ expect(fs.existsSync(runDir)).toBe(false);
121
+ expect(fs.existsSync(containerWorkDir)).toBe(false);
122
+ expect(fs.existsSync(shimsDir)).toBe(false);
123
+ expect(fs.existsSync(diagDir)).toBe(false);
124
+ });
125
+ it("retains runDir on failure for debugging", () => {
126
+ const runDir = path.join(tmpDir, "runs", "agent-ci-2");
127
+ const containerWorkDir = path.join(runDir, "work");
128
+ const shimsDir = path.join(runDir, "shims");
129
+ const diagDir = path.join(runDir, "diag");
130
+ const logDir = path.join(runDir, "logs");
131
+ for (const d of [containerWorkDir, shimsDir, diagDir, logDir]) {
132
+ fs.mkdirSync(d, { recursive: true });
133
+ fs.writeFileSync(path.join(d, "test.txt"), "data");
134
+ }
135
+ const jobSucceeded = false;
136
+ // On failure: keep runDir so the developer can inspect work/, shims/, diag/, logs/
137
+ if (jobSucceeded && fs.existsSync(runDir)) {
138
+ fs.rmSync(runDir, { recursive: true, force: true });
139
+ }
140
+ // runDir should be RETAINED
141
+ expect(fs.existsSync(runDir)).toBe(true);
142
+ expect(fs.readFileSync(path.join(containerWorkDir, "test.txt"), "utf-8")).toBe("data");
143
+ // All subdirs retained
144
+ expect(fs.existsSync(containerWorkDir)).toBe(true);
145
+ expect(fs.existsSync(shimsDir)).toBe(true);
146
+ expect(fs.existsSync(diagDir)).toBe(true);
147
+ });
148
+ });
@@ -0,0 +1,7 @@
1
+ let quietFlag = false;
2
+ export function setQuietMode(value) {
3
+ quietFlag = value;
4
+ }
5
+ export function isAgentMode() {
6
+ return quietFlag || process.env.AI_AGENT === "1";
7
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { isAgentMode, setQuietMode } from "./agent-mode.js";
3
+ describe("isAgentMode", () => {
4
+ const originalEnv = process.env.AI_AGENT;
5
+ afterEach(() => {
6
+ if (originalEnv === undefined) {
7
+ delete process.env.AI_AGENT;
8
+ }
9
+ else {
10
+ process.env.AI_AGENT = originalEnv;
11
+ }
12
+ setQuietMode(false);
13
+ });
14
+ it("returns true when AI_AGENT=1", () => {
15
+ process.env.AI_AGENT = "1";
16
+ expect(isAgentMode()).toBe(true);
17
+ });
18
+ it("returns false when AI_AGENT is unset", () => {
19
+ delete process.env.AI_AGENT;
20
+ expect(isAgentMode()).toBe(false);
21
+ });
22
+ it("returns false when AI_AGENT is something other than 1", () => {
23
+ process.env.AI_AGENT = "true";
24
+ expect(isAgentMode()).toBe(false);
25
+ });
26
+ it("returns true when --quiet flag is set", () => {
27
+ delete process.env.AI_AGENT;
28
+ setQuietMode(true);
29
+ expect(isAgentMode()).toBe(true);
30
+ });
31
+ it("returns true when both --quiet and AI_AGENT=1 are set", () => {
32
+ process.env.AI_AGENT = "1";
33
+ setQuietMode(true);
34
+ expect(isAgentMode()).toBe(true);
35
+ });
36
+ });