@redwoodjs/agent-ci 0.3.0 → 0.3.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 +27 -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
|
@@ -19,7 +19,7 @@ import { RunStateStore } from "./output/run-state.js";
|
|
|
19
19
|
import { renderRunState } from "./output/state-renderer.js";
|
|
20
20
|
import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
|
|
21
21
|
import logUpdate from "log-update";
|
|
22
|
-
|
|
22
|
+
import { createFailedJobResult, wrapJobError, isJobError } from "./runner/job-result.js";
|
|
23
23
|
function findSignalsDir(runnerName) {
|
|
24
24
|
const workDir = getWorkingDirectory();
|
|
25
25
|
const runsDir = path.resolve(workDir, "runs");
|
|
@@ -341,6 +341,9 @@ async function runWorkflows(options) {
|
|
|
341
341
|
if (s.status === "fulfilled") {
|
|
342
342
|
allResults.push(...s.value);
|
|
343
343
|
}
|
|
344
|
+
else {
|
|
345
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
346
|
+
}
|
|
344
347
|
}
|
|
345
348
|
}
|
|
346
349
|
else {
|
|
@@ -349,6 +352,9 @@ async function runWorkflows(options) {
|
|
|
349
352
|
if (s.status === "fulfilled") {
|
|
350
353
|
allResults.push(...s.value);
|
|
351
354
|
}
|
|
355
|
+
else {
|
|
356
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
357
|
+
}
|
|
352
358
|
}
|
|
353
359
|
}
|
|
354
360
|
}
|
|
@@ -616,20 +622,38 @@ async function handleWorkflow(options) {
|
|
|
616
622
|
debugCli("Cold cache — running first job to populate warm modules...");
|
|
617
623
|
const firstResult = await runOrSkipJob(waveJobs[0]);
|
|
618
624
|
allResults.push(firstResult);
|
|
619
|
-
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej))
|
|
625
|
+
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
626
|
+
throw wrapJobError(ej.taskName, error);
|
|
627
|
+
}))));
|
|
620
628
|
for (const r of results) {
|
|
621
629
|
if (r.status === "fulfilled") {
|
|
622
630
|
allResults.push(r.value);
|
|
623
631
|
}
|
|
632
|
+
else {
|
|
633
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
634
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
635
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
636
|
+
console.error(` Error: ${errorMessage}`);
|
|
637
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
638
|
+
}
|
|
624
639
|
}
|
|
625
640
|
warm = true;
|
|
626
641
|
}
|
|
627
642
|
else {
|
|
628
|
-
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej))
|
|
643
|
+
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
644
|
+
throw wrapJobError(ej.taskName, error);
|
|
645
|
+
}))));
|
|
629
646
|
for (const r of results) {
|
|
630
647
|
if (r.status === "fulfilled") {
|
|
631
648
|
allResults.push(r.value);
|
|
632
649
|
}
|
|
650
|
+
else {
|
|
651
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
652
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
653
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
654
|
+
console.error(` Error: ${errorMessage}`);
|
|
655
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
656
|
+
}
|
|
633
657
|
}
|
|
634
658
|
}
|
|
635
659
|
// 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.1",
|
|
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.1"
|
|
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",
|