@redwoodjs/agent-ci 0.3.0 → 0.3.2
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 +28 -3
- package/dist/runner/job-result.js +37 -0
- package/dist/runner/job-result.test.js +100 -0
- package/package.json +5 -2
package/dist/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import { execSync } from "child_process";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import fs from "fs";
|
|
@@ -19,7 +20,7 @@ import { RunStateStore } from "./output/run-state.js";
|
|
|
19
20
|
import { renderRunState } from "./output/state-renderer.js";
|
|
20
21
|
import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
|
|
21
22
|
import logUpdate from "log-update";
|
|
22
|
-
|
|
23
|
+
import { createFailedJobResult, wrapJobError, isJobError } from "./runner/job-result.js";
|
|
23
24
|
function findSignalsDir(runnerName) {
|
|
24
25
|
const workDir = getWorkingDirectory();
|
|
25
26
|
const runsDir = path.resolve(workDir, "runs");
|
|
@@ -341,6 +342,9 @@ async function runWorkflows(options) {
|
|
|
341
342
|
if (s.status === "fulfilled") {
|
|
342
343
|
allResults.push(...s.value);
|
|
343
344
|
}
|
|
345
|
+
else {
|
|
346
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
347
|
+
}
|
|
344
348
|
}
|
|
345
349
|
}
|
|
346
350
|
else {
|
|
@@ -349,6 +353,9 @@ async function runWorkflows(options) {
|
|
|
349
353
|
if (s.status === "fulfilled") {
|
|
350
354
|
allResults.push(...s.value);
|
|
351
355
|
}
|
|
356
|
+
else {
|
|
357
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
358
|
+
}
|
|
352
359
|
}
|
|
353
360
|
}
|
|
354
361
|
}
|
|
@@ -616,20 +623,38 @@ async function handleWorkflow(options) {
|
|
|
616
623
|
debugCli("Cold cache — running first job to populate warm modules...");
|
|
617
624
|
const firstResult = await runOrSkipJob(waveJobs[0]);
|
|
618
625
|
allResults.push(firstResult);
|
|
619
|
-
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej))
|
|
626
|
+
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
627
|
+
throw wrapJobError(ej.taskName, error);
|
|
628
|
+
}))));
|
|
620
629
|
for (const r of results) {
|
|
621
630
|
if (r.status === "fulfilled") {
|
|
622
631
|
allResults.push(r.value);
|
|
623
632
|
}
|
|
633
|
+
else {
|
|
634
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
635
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
636
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
637
|
+
console.error(` Error: ${errorMessage}`);
|
|
638
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
639
|
+
}
|
|
624
640
|
}
|
|
625
641
|
warm = true;
|
|
626
642
|
}
|
|
627
643
|
else {
|
|
628
|
-
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej))
|
|
644
|
+
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
645
|
+
throw wrapJobError(ej.taskName, error);
|
|
646
|
+
}))));
|
|
629
647
|
for (const r of results) {
|
|
630
648
|
if (r.status === "fulfilled") {
|
|
631
649
|
allResults.push(r.value);
|
|
632
650
|
}
|
|
651
|
+
else {
|
|
652
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
653
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
654
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
655
|
+
console.error(` Error: ${errorMessage}`);
|
|
656
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
657
|
+
}
|
|
633
658
|
}
|
|
634
659
|
}
|
|
635
660
|
// Check whether to abort remaining waves on failure
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
export function isJobError(error) {
|
|
3
|
+
if (typeof error !== "object" || error === null) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const err = error;
|
|
7
|
+
return typeof err.taskName === "string";
|
|
8
|
+
}
|
|
9
|
+
export function getErrorMessage(error) {
|
|
10
|
+
if (error instanceof Error) {
|
|
11
|
+
return error.message;
|
|
12
|
+
}
|
|
13
|
+
if (typeof error === "string") {
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
return String(error);
|
|
17
|
+
}
|
|
18
|
+
export function createFailedJobResult(taskName, workflowPath, error) {
|
|
19
|
+
const errorMessage = isJobError(error) ? error.message : getErrorMessage(error);
|
|
20
|
+
return {
|
|
21
|
+
name: `agent-ci-error-${taskName}`,
|
|
22
|
+
workflow: path.basename(workflowPath),
|
|
23
|
+
taskId: taskName,
|
|
24
|
+
succeeded: false,
|
|
25
|
+
durationMs: 0,
|
|
26
|
+
debugLogPath: "",
|
|
27
|
+
failedStep: "[Job startup failed]",
|
|
28
|
+
lastOutputLines: [errorMessage],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function wrapJobError(taskName, error) {
|
|
32
|
+
return {
|
|
33
|
+
taskName,
|
|
34
|
+
message: getErrorMessage(error),
|
|
35
|
+
originalError: error,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createFailedJobResult, wrapJobError, isJobError, getErrorMessage, } from "./job-result.js";
|
|
3
|
+
describe("getErrorMessage", () => {
|
|
4
|
+
it("extracts message from Error object", () => {
|
|
5
|
+
const result = getErrorMessage(new Error("test message"));
|
|
6
|
+
expect(result).toBe("test message");
|
|
7
|
+
});
|
|
8
|
+
it("returns string as-is", () => {
|
|
9
|
+
const result = getErrorMessage("string error");
|
|
10
|
+
expect(result).toBe("string error");
|
|
11
|
+
});
|
|
12
|
+
it("converts other types to string", () => {
|
|
13
|
+
expect(getErrorMessage(123)).toBe("123");
|
|
14
|
+
expect(getErrorMessage(null)).toBe("null");
|
|
15
|
+
expect(getErrorMessage(undefined)).toBe("undefined");
|
|
16
|
+
expect(getErrorMessage({ code: "ENOENT" })).toBe("[object Object]");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("isJobError", () => {
|
|
20
|
+
it("returns true for valid JobError", () => {
|
|
21
|
+
const error = {
|
|
22
|
+
taskName: "test-job",
|
|
23
|
+
message: "something failed",
|
|
24
|
+
originalError: new Error("original"),
|
|
25
|
+
};
|
|
26
|
+
expect(isJobError(error)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("returns false for null", () => {
|
|
29
|
+
expect(isJobError(null)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
it("returns false for undefined", () => {
|
|
32
|
+
expect(isJobError(undefined)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it("returns false for plain Error", () => {
|
|
35
|
+
expect(isJobError(new Error("test"))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("returns false for object without taskName", () => {
|
|
38
|
+
expect(isJobError({ message: "test" })).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("wrapJobError", () => {
|
|
42
|
+
it("wraps Error with taskName", () => {
|
|
43
|
+
const original = new Error("original error");
|
|
44
|
+
const wrapped = wrapJobError("my-job", original);
|
|
45
|
+
expect(wrapped.taskName).toBe("my-job");
|
|
46
|
+
expect(wrapped.message).toBe("original error");
|
|
47
|
+
expect(wrapped.originalError).toBe(original);
|
|
48
|
+
});
|
|
49
|
+
it("wraps string error with taskName", () => {
|
|
50
|
+
const wrapped = wrapJobError("my-job", "string error");
|
|
51
|
+
expect(wrapped.taskName).toBe("my-job");
|
|
52
|
+
expect(wrapped.message).toBe("string error");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("createFailedJobResult", () => {
|
|
56
|
+
it("creates a failed result with error message from Error object", () => {
|
|
57
|
+
const result = createFailedJobResult("setup_job", "/path/to/workflow.yml", new Error("Missing required secret: API_KEY"));
|
|
58
|
+
expect(result.succeeded).toBe(false);
|
|
59
|
+
expect(result.taskId).toBe("setup_job");
|
|
60
|
+
expect(result.workflow).toBe("workflow.yml");
|
|
61
|
+
expect(result.name).toBe("agent-ci-error-setup_job");
|
|
62
|
+
expect(result.failedStep).toBe("[Job startup failed]");
|
|
63
|
+
expect(result.durationMs).toBe(0);
|
|
64
|
+
expect(result.debugLogPath).toBe("");
|
|
65
|
+
expect(result.lastOutputLines).toEqual(["Missing required secret: API_KEY"]);
|
|
66
|
+
});
|
|
67
|
+
it("extracts message from JobError", () => {
|
|
68
|
+
const jobError = {
|
|
69
|
+
taskName: "wrapped-job",
|
|
70
|
+
message: "wrapped message",
|
|
71
|
+
originalError: new Error("original"),
|
|
72
|
+
};
|
|
73
|
+
const result = createFailedJobResult("test", "workflow.yml", jobError);
|
|
74
|
+
expect(result.lastOutputLines).toEqual(["wrapped message"]);
|
|
75
|
+
});
|
|
76
|
+
it("handles string errors", () => {
|
|
77
|
+
const result = createFailedJobResult("build_job", "/home/user/project/.github/workflows/ci.yaml", "Container failed to start");
|
|
78
|
+
expect(result.succeeded).toBe(false);
|
|
79
|
+
expect(result.taskId).toBe("build_job");
|
|
80
|
+
expect(result.workflow).toBe("ci.yaml");
|
|
81
|
+
expect(result.lastOutputLines).toEqual(["Container failed to start"]);
|
|
82
|
+
});
|
|
83
|
+
it("handles errors without message property", () => {
|
|
84
|
+
const result = createFailedJobResult("test_job", "workflow.yml", {
|
|
85
|
+
code: "ENOENT",
|
|
86
|
+
path: "/missing/file",
|
|
87
|
+
});
|
|
88
|
+
expect(result.succeeded).toBe(false);
|
|
89
|
+
expect(result.lastOutputLines).toEqual(["[object Object]"]);
|
|
90
|
+
});
|
|
91
|
+
it("handles null/undefined errors", () => {
|
|
92
|
+
const result = createFailedJobResult("job1", "workflow.yml", null);
|
|
93
|
+
expect(result.succeeded).toBe(false);
|
|
94
|
+
expect(result.lastOutputLines).toEqual(["null"]);
|
|
95
|
+
});
|
|
96
|
+
it("extracts basename from full workflow path", () => {
|
|
97
|
+
const result = createFailedJobResult("deploy", "/very/long/path/to/.github/workflows/production.yml", new Error("Deploy failed"));
|
|
98
|
+
expect(result.workflow).toBe("production.yml");
|
|
99
|
+
});
|
|
100
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Local GitHub Actions runner",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"license": "FSL-1.1-MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"log-update": "^7.2.0",
|
|
28
28
|
"minimatch": "^10.2.1",
|
|
29
29
|
"yaml": "^2.8.2",
|
|
30
|
-
"dtu-github-actions": "0.3.
|
|
30
|
+
"dtu-github-actions": "0.3.2"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/dockerode": "^3.3.34",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"tsx": "^4.21.0",
|
|
36
36
|
"vitest": "^4.0.18"
|
|
37
37
|
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
38
41
|
"scripts": {
|
|
39
42
|
"build": "tsgo",
|
|
40
43
|
"typecheck": "tsgo",
|