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