@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 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
- // ─── Signal helpers for retry / abort commands ────────────────────────────────
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.0",
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.0"
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",