@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,90 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ // ─── Repo root detection ──────────────────────────────────────────────────────
4
+ /**
5
+ * Walk up from `startPath` looking for a `.git` directory.
6
+ * Returns the repo root, or `undefined` if none found.
7
+ */
8
+ export function findRepoRoot(startPath) {
9
+ let dir = path.dirname(startPath);
10
+ while (dir !== "/" && !fs.existsSync(path.join(dir, ".git"))) {
11
+ dir = path.dirname(dir);
12
+ }
13
+ return dir !== "/" ? dir : undefined;
14
+ }
15
+ // ─── Workflow run ID derivation ───────────────────────────────────────────────
16
+ /**
17
+ * Derive workflowRunId (group key) by stripping job/matrix/retry suffixes.
18
+ * e.g. agent-ci-redwoodjssdk-14-j1-m2-r2 → agent-ci-redwoodjssdk-14
19
+ */
20
+ export function deriveWorkflowRunId(containerName) {
21
+ return containerName.replace(/(-j\d+)?(-m\d+)?(-r\d+)?$/, "");
22
+ }
23
+ /**
24
+ * Write (or merge into) `metadata.json` in the log directory.
25
+ *
26
+ * Preserves orchestrator-written fields like matrixContext, warmCache, etc.
27
+ * while adding/updating fields derived from the job and container name.
28
+ */
29
+ export function writeJobMetadata(opts) {
30
+ const { logDir, containerName, job } = opts;
31
+ if (!job.workflowPath) {
32
+ return;
33
+ }
34
+ const metadataPath = path.join(logDir, "metadata.json");
35
+ // Derive repoPath from the workflow file (walk up to find .git)
36
+ const repoPath = findRepoRoot(job.workflowPath) ?? "";
37
+ // If the orchestrator (or retryRun) already wrote a metadata.json with the
38
+ // correct workflowRunId, honour it. This is critical for retries of multi-job
39
+ // runs (e.g. agent-ci-runner-125-001-001) where a naive regex would strip only a
40
+ // single suffix and produce the wrong group key.
41
+ let workflowRunId;
42
+ let attempt;
43
+ // Preserve the jobName written by the orchestrator (e.g. "Shard (1/3)") so
44
+ // human-readable labels aren't overwritten with the raw taskId on process start.
45
+ let existingJobName = null;
46
+ if (fs.existsSync(metadataPath)) {
47
+ try {
48
+ const existing = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
49
+ workflowRunId = existing.workflowRunId;
50
+ attempt = existing.attempt;
51
+ if (existing.jobName !== undefined) {
52
+ existingJobName = existing.jobName;
53
+ }
54
+ }
55
+ catch {
56
+ // Fall through to derivation
57
+ }
58
+ }
59
+ if (!workflowRunId) {
60
+ workflowRunId = deriveWorkflowRunId(containerName);
61
+ }
62
+ // Build our fields; we'll merge them ON TOP of whatever the orchestrator wrote
63
+ // so that matrixContext, warmCache, repoPath, etc. are preserved.
64
+ const freshFields = {
65
+ workflowPath: job.workflowPath,
66
+ workflowName: path.basename(job.workflowPath, path.extname(job.workflowPath)),
67
+ // Prefer the orchestrator-written label; fall back to raw taskId
68
+ jobName: existingJobName !== null ? existingJobName : (job.taskId ?? null),
69
+ workflowRunId,
70
+ commitId: job.headSha || "WORKING_TREE",
71
+ date: Date.now(),
72
+ taskId: job.taskId,
73
+ attempt: attempt ?? 1,
74
+ };
75
+ // Only overwrite repoPath if we actually found a .git root; otherwise keep
76
+ // the orchestrator's value (which is always correct for temp-dir tests too).
77
+ if (repoPath) {
78
+ freshFields.repoPath = repoPath;
79
+ }
80
+ // Read back existing metadata to preserve orchestrator-written fields
81
+ // like matrixContext, warmCache, etc.
82
+ let existingMeta = {};
83
+ if (fs.existsSync(metadataPath)) {
84
+ try {
85
+ existingMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
86
+ }
87
+ catch { }
88
+ }
89
+ fs.writeFileSync(metadataPath, JSON.stringify({ ...existingMeta, ...freshFields }, null, 2), "utf-8");
90
+ }
@@ -0,0 +1,127 @@
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
+ // ── findRepoRoot ──────────────────────────────────────────────────────────────
6
+ describe("findRepoRoot", () => {
7
+ let tmpDir;
8
+ beforeEach(() => {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-root-test-"));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tmpDir, { recursive: true, force: true });
13
+ });
14
+ it("finds the .git root from a deeply nested path", async () => {
15
+ const { findRepoRoot } = await import("./metadata.js");
16
+ // Create .git at root level
17
+ fs.mkdirSync(path.join(tmpDir, ".git"));
18
+ // Create a deeply nested file
19
+ const nested = path.join(tmpDir, "a", "b", "c", "file.txt");
20
+ fs.mkdirSync(path.dirname(nested), { recursive: true });
21
+ fs.writeFileSync(nested, "test");
22
+ expect(findRepoRoot(nested)).toBe(tmpDir);
23
+ });
24
+ it("returns undefined when no .git exists", async () => {
25
+ const { findRepoRoot } = await import("./metadata.js");
26
+ const file = path.join(tmpDir, "file.txt");
27
+ fs.writeFileSync(file, "test");
28
+ expect(findRepoRoot(file)).toBeUndefined();
29
+ });
30
+ });
31
+ // ── deriveWorkflowRunId ───────────────────────────────────────────────────────
32
+ describe("deriveWorkflowRunId", () => {
33
+ it("strips job/matrix/retry suffixes", async () => {
34
+ const { deriveWorkflowRunId } = await import("./metadata.js");
35
+ expect(deriveWorkflowRunId("agent-ci-redwoodjssdk-14-j1-m2-r2")).toBe("agent-ci-redwoodjssdk-14");
36
+ expect(deriveWorkflowRunId("agent-ci-redwoodjssdk-14-j1")).toBe("agent-ci-redwoodjssdk-14");
37
+ expect(deriveWorkflowRunId("agent-ci-redwoodjssdk-14")).toBe("agent-ci-redwoodjssdk-14");
38
+ });
39
+ it("handles names without suffixes", async () => {
40
+ const { deriveWorkflowRunId } = await import("./metadata.js");
41
+ expect(deriveWorkflowRunId("simple-runner")).toBe("simple-runner");
42
+ });
43
+ });
44
+ // ── writeJobMetadata ──────────────────────────────────────────────────────────
45
+ describe("writeJobMetadata", () => {
46
+ let tmpDir;
47
+ let repoDir;
48
+ beforeEach(() => {
49
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-write-test-"));
50
+ // Create a fake repo root so findRepoRoot works
51
+ repoDir = path.join(tmpDir, "repo");
52
+ fs.mkdirSync(path.join(repoDir, ".git"), { recursive: true });
53
+ fs.mkdirSync(path.join(repoDir, ".github", "workflows"), { recursive: true });
54
+ });
55
+ afterEach(() => {
56
+ fs.rmSync(tmpDir, { recursive: true, force: true });
57
+ });
58
+ it("writes metadata.json with expected fields", async () => {
59
+ const { writeJobMetadata } = await import("./metadata.js");
60
+ const logDir = path.join(tmpDir, "logs");
61
+ fs.mkdirSync(logDir, { recursive: true });
62
+ const workflowPath = path.join(repoDir, ".github", "workflows", "ci.yml");
63
+ fs.writeFileSync(workflowPath, "name: CI");
64
+ writeJobMetadata({
65
+ logDir,
66
+ containerName: "agent-ci-test-1",
67
+ job: {
68
+ deliveryId: "d1",
69
+ eventType: "push",
70
+ login: "test",
71
+ workflowPath,
72
+ taskId: "build",
73
+ headSha: "abc123",
74
+ },
75
+ });
76
+ const meta = JSON.parse(fs.readFileSync(path.join(logDir, "metadata.json"), "utf-8"));
77
+ expect(meta.workflowPath).toBe(workflowPath);
78
+ expect(meta.workflowName).toBe("ci");
79
+ expect(meta.workflowRunId).toBe("agent-ci-test-1");
80
+ expect(meta.commitId).toBe("abc123");
81
+ expect(meta.taskId).toBe("build");
82
+ expect(meta.attempt).toBe(1);
83
+ expect(meta.repoPath).toBe(repoDir);
84
+ });
85
+ it("preserves orchestrator-written fields on merge", async () => {
86
+ const { writeJobMetadata } = await import("./metadata.js");
87
+ const logDir = path.join(tmpDir, "logs");
88
+ fs.mkdirSync(logDir, { recursive: true });
89
+ const workflowPath = path.join(repoDir, ".github", "workflows", "ci.yml");
90
+ fs.writeFileSync(workflowPath, "name: CI");
91
+ // Pre-write orchestrator metadata
92
+ fs.writeFileSync(path.join(logDir, "metadata.json"), JSON.stringify({
93
+ workflowRunId: "custom-run-id",
94
+ matrixContext: { shard: 1 },
95
+ jobName: "Shard (1/3)",
96
+ attempt: 2,
97
+ }));
98
+ writeJobMetadata({
99
+ logDir,
100
+ containerName: "agent-ci-test-1-j1-m1",
101
+ job: {
102
+ deliveryId: "d1",
103
+ eventType: "push",
104
+ login: "test",
105
+ workflowPath,
106
+ taskId: "build",
107
+ },
108
+ });
109
+ const meta = JSON.parse(fs.readFileSync(path.join(logDir, "metadata.json"), "utf-8"));
110
+ // Orchestrator fields preserved
111
+ expect(meta.workflowRunId).toBe("custom-run-id");
112
+ expect(meta.matrixContext).toEqual({ shard: 1 });
113
+ expect(meta.jobName).toBe("Shard (1/3)");
114
+ expect(meta.attempt).toBe(2);
115
+ });
116
+ it("does nothing when workflowPath is not set", async () => {
117
+ const { writeJobMetadata } = await import("./metadata.js");
118
+ const logDir = path.join(tmpDir, "logs");
119
+ fs.mkdirSync(logDir, { recursive: true });
120
+ writeJobMetadata({
121
+ logDir,
122
+ containerName: "test",
123
+ job: { deliveryId: "d1", eventType: "push", login: "test" },
124
+ });
125
+ expect(fs.existsSync(path.join(logDir, "metadata.json"))).toBe(false);
126
+ });
127
+ });
@@ -0,0 +1,119 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { tailLogFile } from "../output/reporter.js";
4
+ // ─── Timeline parsing ─────────────────────────────────────────────────────────
5
+ /**
6
+ * Read `timeline.json` and map task records into `StepResult[]`.
7
+ */
8
+ export function parseTimelineSteps(timelinePath) {
9
+ try {
10
+ if (!fs.existsSync(timelinePath)) {
11
+ return [];
12
+ }
13
+ const records = JSON.parse(fs.readFileSync(timelinePath, "utf-8"));
14
+ return records
15
+ .filter((r) => r.type === "Task" && r.name)
16
+ .map((r) => ({
17
+ name: r.name,
18
+ status: r.result === "Succeeded" || r.result === "succeeded"
19
+ ? "passed"
20
+ : r.result === "Failed" || r.result === "failed"
21
+ ? "failed"
22
+ : r.result === "Skipped" || r.result === "skipped"
23
+ ? "skipped"
24
+ : r.state === "completed"
25
+ ? "passed"
26
+ : "skipped",
27
+ }));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ // ─── Step name sanitization ───────────────────────────────────────────────────
34
+ /**
35
+ * Reproduce the DTU sanitization logic for step log filenames.
36
+ */
37
+ export function sanitizeStepName(name) {
38
+ return name
39
+ .replace(/[^a-zA-Z0-9_.-]/g, "-")
40
+ .replace(/-+/g, "-")
41
+ .replace(/^-|-$/g, "")
42
+ .substring(0, 80);
43
+ }
44
+ /**
45
+ * Given a failed step name and the timeline, extract:
46
+ * - The actual exit code (from the issues array)
47
+ * - The path to the step's log file
48
+ * - The last N lines of that log
49
+ */
50
+ export function extractFailureDetails(timelinePath, failedStepName, logDir) {
51
+ const result = {};
52
+ try {
53
+ const timeline = JSON.parse(fs.readFileSync(timelinePath, "utf-8"));
54
+ const failedRecord = timeline.find((r) => r.name === failedStepName && r.type === "Task");
55
+ if (!failedRecord) {
56
+ return result;
57
+ }
58
+ // Attempt to parse the actual step exit code from the issues array
59
+ const issueMsg = failedRecord.issues?.find((i) => i.type === "error")?.message;
60
+ if (issueMsg) {
61
+ const m = issueMsg.match(/exit code (\d+)/i);
62
+ if (m) {
63
+ result.exitCode = parseInt(m[1], 10);
64
+ }
65
+ }
66
+ const stepsDir = path.join(logDir, "steps");
67
+ const sanitized = sanitizeStepName(failedStepName);
68
+ // Try sanitized name first, then record.id (feed handler), then log.id (POST/PUT handlers)
69
+ for (const id of [sanitized, failedRecord.id, failedRecord.log?.id]) {
70
+ if (!id) {
71
+ continue;
72
+ }
73
+ const stepLogPath = path.join(stepsDir, `${id}.log`);
74
+ if (fs.existsSync(stepLogPath)) {
75
+ result.stepLogPath = stepLogPath;
76
+ result.tailLines = tailLogFile(stepLogPath);
77
+ break;
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ /* best-effort */
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Build the structured `JobResult` from container exit state and timeline data.
88
+ */
89
+ export function buildJobResult(opts) {
90
+ const { containerName, job, startTime, jobSucceeded, lastFailedStep, containerExitCode, timelinePath, logDir, debugLogPath, } = opts;
91
+ const steps = parseTimelineSteps(timelinePath);
92
+ const result = {
93
+ name: containerName,
94
+ workflow: job.workflowPath ? path.basename(job.workflowPath) : "unknown",
95
+ taskId: job.taskId ?? "unknown",
96
+ succeeded: jobSucceeded,
97
+ durationMs: Date.now() - startTime,
98
+ debugLogPath,
99
+ steps,
100
+ };
101
+ if (!jobSucceeded) {
102
+ result.failedStep = lastFailedStep ?? undefined;
103
+ // The container exits with 0 if it successfully reported the job failure,
104
+ // so only use the container exit code if it actually indicates a crash (non-zero).
105
+ result.failedExitCode = containerExitCode !== 0 ? containerExitCode : undefined;
106
+ if (lastFailedStep) {
107
+ const failure = extractFailureDetails(timelinePath, lastFailedStep, logDir);
108
+ if (failure.exitCode !== undefined) {
109
+ result.failedExitCode = failure.exitCode;
110
+ }
111
+ result.failedStepLogPath = failure.stepLogPath;
112
+ result.lastOutputLines = failure.tailLines ?? [];
113
+ }
114
+ else {
115
+ result.lastOutputLines = [];
116
+ }
117
+ }
118
+ return result;
119
+ }
@@ -0,0 +1,177 @@
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
+ // ── parseTimelineSteps ────────────────────────────────────────────────────────
6
+ describe("parseTimelineSteps", () => {
7
+ let tmpDir;
8
+ beforeEach(() => {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "result-builder-test-"));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tmpDir, { recursive: true, force: true });
13
+ });
14
+ it("parses succeeded, failed, and skipped steps", async () => {
15
+ const { parseTimelineSteps } = await import("./result-builder.js");
16
+ const timelinePath = path.join(tmpDir, "timeline.json");
17
+ fs.writeFileSync(timelinePath, JSON.stringify([
18
+ { type: "Task", name: "Setup", result: "Succeeded" },
19
+ { type: "Task", name: "Build", result: "Failed" },
20
+ { type: "Task", name: "Deploy", result: "Skipped" },
21
+ { type: "Task", name: "Cleanup", state: "completed" },
22
+ ]));
23
+ const steps = parseTimelineSteps(timelinePath);
24
+ expect(steps).toEqual([
25
+ { name: "Setup", status: "passed" },
26
+ { name: "Build", status: "failed" },
27
+ { name: "Deploy", status: "skipped" },
28
+ { name: "Cleanup", status: "passed" },
29
+ ]);
30
+ });
31
+ it("returns empty array when file does not exist", async () => {
32
+ const { parseTimelineSteps } = await import("./result-builder.js");
33
+ expect(parseTimelineSteps(path.join(tmpDir, "nope.json"))).toEqual([]);
34
+ });
35
+ it("filters out non-Task records", async () => {
36
+ const { parseTimelineSteps } = await import("./result-builder.js");
37
+ const timelinePath = path.join(tmpDir, "timeline.json");
38
+ fs.writeFileSync(timelinePath, JSON.stringify([
39
+ { type: "Job", name: "Root" },
40
+ { type: "Task", name: "Build", result: "succeeded" },
41
+ ]));
42
+ const steps = parseTimelineSteps(timelinePath);
43
+ expect(steps).toHaveLength(1);
44
+ expect(steps[0].name).toBe("Build");
45
+ });
46
+ });
47
+ // ── sanitizeStepName ──────────────────────────────────────────────────────────
48
+ describe("sanitizeStepName", () => {
49
+ it("replaces special characters with hyphens", async () => {
50
+ const { sanitizeStepName } = await import("./result-builder.js");
51
+ expect(sanitizeStepName("Run npm test (shard 1/3)")).toBe("Run-npm-test-shard-1-3");
52
+ });
53
+ it("collapses multiple hyphens", async () => {
54
+ const { sanitizeStepName } = await import("./result-builder.js");
55
+ expect(sanitizeStepName("a b---c")).toBe("a-b-c");
56
+ });
57
+ it("strips leading and trailing hyphens", async () => {
58
+ const { sanitizeStepName } = await import("./result-builder.js");
59
+ expect(sanitizeStepName("--test--")).toBe("test");
60
+ });
61
+ it("truncates to 80 characters", async () => {
62
+ const { sanitizeStepName } = await import("./result-builder.js");
63
+ const long = "a".repeat(100);
64
+ expect(sanitizeStepName(long).length).toBe(80);
65
+ });
66
+ });
67
+ // ── extractFailureDetails ─────────────────────────────────────────────────────
68
+ describe("extractFailureDetails", () => {
69
+ let tmpDir;
70
+ beforeEach(() => {
71
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "failure-test-"));
72
+ });
73
+ afterEach(() => {
74
+ fs.rmSync(tmpDir, { recursive: true, force: true });
75
+ });
76
+ it("extracts exit code from the issues array", async () => {
77
+ const { extractFailureDetails } = await import("./result-builder.js");
78
+ const timelinePath = path.join(tmpDir, "timeline.json");
79
+ fs.writeFileSync(timelinePath, JSON.stringify([
80
+ {
81
+ type: "Task",
82
+ name: "Build",
83
+ result: "Failed",
84
+ issues: [{ type: "error", message: "Process completed with exit code 2" }],
85
+ },
86
+ ]));
87
+ const details = extractFailureDetails(timelinePath, "Build", tmpDir);
88
+ expect(details.exitCode).toBe(2);
89
+ });
90
+ it("finds the step log file via sanitized name", async () => {
91
+ const { extractFailureDetails } = await import("./result-builder.js");
92
+ const stepsDir = path.join(tmpDir, "steps");
93
+ fs.mkdirSync(stepsDir, { recursive: true });
94
+ fs.writeFileSync(path.join(stepsDir, "Run-tests.log"), "error line 1\nerror line 2\n");
95
+ const timelinePath = path.join(tmpDir, "timeline.json");
96
+ fs.writeFileSync(timelinePath, JSON.stringify([
97
+ {
98
+ type: "Task",
99
+ name: "Run tests",
100
+ result: "Failed",
101
+ id: "uuid-123",
102
+ },
103
+ ]));
104
+ const details = extractFailureDetails(timelinePath, "Run tests", tmpDir);
105
+ expect(details.stepLogPath).toBe(path.join(stepsDir, "Run-tests.log"));
106
+ expect(details.tailLines).toContain("error line 1");
107
+ });
108
+ it("returns empty object when no matching record exists", async () => {
109
+ const { extractFailureDetails } = await import("./result-builder.js");
110
+ const timelinePath = path.join(tmpDir, "timeline.json");
111
+ fs.writeFileSync(timelinePath, JSON.stringify([]));
112
+ const details = extractFailureDetails(timelinePath, "NonExistent", tmpDir);
113
+ expect(details).toEqual({});
114
+ });
115
+ });
116
+ // ── buildJobResult ────────────────────────────────────────────────────────────
117
+ describe("buildJobResult", () => {
118
+ let tmpDir;
119
+ beforeEach(() => {
120
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "result-test-"));
121
+ });
122
+ afterEach(() => {
123
+ fs.rmSync(tmpDir, { recursive: true, force: true });
124
+ });
125
+ it("builds a successful result", async () => {
126
+ const { buildJobResult } = await import("./result-builder.js");
127
+ const timelinePath = path.join(tmpDir, "timeline.json");
128
+ fs.writeFileSync(timelinePath, JSON.stringify([{ type: "Task", name: "Build", result: "Succeeded" }]));
129
+ const result = buildJobResult({
130
+ containerName: "test-runner",
131
+ job: { workflowPath: "/tmp/ci.yml", taskId: "build" },
132
+ startTime: Date.now() - 5000,
133
+ jobSucceeded: true,
134
+ lastFailedStep: null,
135
+ containerExitCode: 0,
136
+ timelinePath,
137
+ logDir: tmpDir,
138
+ debugLogPath: path.join(tmpDir, "debug.log"),
139
+ });
140
+ expect(result.succeeded).toBe(true);
141
+ expect(result.name).toBe("test-runner");
142
+ expect(result.workflow).toBe("ci.yml");
143
+ expect(result.steps).toHaveLength(1);
144
+ expect(result.failedStep).toBeUndefined();
145
+ });
146
+ it("builds a failed result with failure details", async () => {
147
+ const { buildJobResult } = await import("./result-builder.js");
148
+ const timelinePath = path.join(tmpDir, "timeline.json");
149
+ const stepsDir = path.join(tmpDir, "steps");
150
+ fs.mkdirSync(stepsDir, { recursive: true });
151
+ fs.writeFileSync(path.join(stepsDir, "Build.log"), "compile error\nfailed\n");
152
+ fs.writeFileSync(timelinePath, JSON.stringify([
153
+ {
154
+ type: "Task",
155
+ name: "Build",
156
+ result: "Failed",
157
+ issues: [{ type: "error", message: "Process completed with exit code 1" }],
158
+ },
159
+ ]));
160
+ const result = buildJobResult({
161
+ containerName: "test-runner",
162
+ job: { workflowPath: "/tmp/ci.yml", taskId: "build" },
163
+ startTime: Date.now() - 5000,
164
+ jobSucceeded: false,
165
+ lastFailedStep: "Build",
166
+ containerExitCode: 0,
167
+ timelinePath,
168
+ logDir: tmpDir,
169
+ debugLogPath: path.join(tmpDir, "debug.log"),
170
+ });
171
+ expect(result.succeeded).toBe(false);
172
+ expect(result.failedStep).toBe("Build");
173
+ expect(result.failedExitCode).toBe(1);
174
+ expect(result.failedStepLogPath).toBe(path.join(stepsDir, "Build.log"));
175
+ expect(result.lastOutputLines).toContain("compile error");
176
+ });
177
+ });
@@ -0,0 +1,82 @@
1
+ // ─── Pause-on-failure step wrapping ───────────────────────────────────────────
2
+ //
3
+ // Wraps `run:` step scripts in a retry loop so the runner pauses on failure
4
+ // and waits for an external signal (retry / abort) before continuing.
5
+ /**
6
+ * Wrap a bash script in the pause-on-failure retry loop.
7
+ *
8
+ * The wrapper:
9
+ * 1. Checks for a `from-step` signal file — if present and this step's index
10
+ * is below the target, the step is skipped (exit 0). When the target is
11
+ * reached the signal file is removed so subsequent steps run normally.
12
+ * 2. Runs the original script
13
+ * 3. On success → exits 0
14
+ * 4. On failure → writes a `paused` signal file, emits a `::error::` annotation,
15
+ * and polls until a `retry` or `abort` signal file appears.
16
+ *
17
+ * @param stepIndex 1-based index of this step across ALL steps (matches the UI numbering)
18
+ */
19
+ export function wrapStepScript(script, stepName, stepIndex) {
20
+ // Escape single-quotes in the step name so it's safe inside the echo
21
+ const safeName = stepName.replace(/'/g, "'\\''");
22
+ // The original script runs in a subshell `( ... )` so that:
23
+ // 1. `exit N` inside the script terminates the subshell, not the retry loop
24
+ // 2. The runner's `set -e` (bash -e {0}) doesn't bypass the wrapper
25
+ return `__SIGNALS="/tmp/agent-ci-signals"
26
+ __STEP_INDEX=${stepIndex}
27
+ # ── from-step skip logic ──
28
+ if [ -f "$__SIGNALS/from-step" ]; then
29
+ __FROM_STEP=$(cat "$__SIGNALS/from-step")
30
+ if [ "$__FROM_STEP" != '*' ] && [ "$__STEP_INDEX" -lt "$__FROM_STEP" ] 2>/dev/null; then
31
+ echo "Skipping step $__STEP_INDEX (rewind target: step $__FROM_STEP)"
32
+ exit 0
33
+ fi
34
+ rm -f "$__SIGNALS/from-step"
35
+ echo "Resuming from step $__STEP_INDEX."
36
+ fi
37
+ __ATTEMPT=0
38
+ while true; do
39
+ __ATTEMPT=$((__ATTEMPT + 1))
40
+ set +e
41
+ (
42
+ ${script}
43
+ )
44
+ __EC=$?
45
+ set -e
46
+ if [ $__EC -eq 0 ]; then exit 0; fi
47
+ printf '%s\\n%s\\n%s' '${safeName}' "$__ATTEMPT" "$__STEP_INDEX" > "$__SIGNALS/paused"
48
+ echo "::error::Step failed (exit $__EC). Paused — waiting for retry signal."
49
+ while [ ! -f "$__SIGNALS/retry" ] && [ ! -f "$__SIGNALS/abort" ]; do sleep 1; done
50
+ if [ -f "$__SIGNALS/abort" ]; then rm -f "$__SIGNALS/abort" "$__SIGNALS/paused"; exit $__EC; fi
51
+ rm -f "$__SIGNALS/retry" "$__SIGNALS/paused"
52
+ echo "Retrying step..."
53
+ done`;
54
+ }
55
+ /**
56
+ * Clone a steps array, wrapping `run:` steps when `pauseOnFailure` is enabled.
57
+ *
58
+ * Only steps with `Reference.Type === "Script"` (i.e. `run:` steps) are wrapped.
59
+ * `uses:` steps are left untouched because the runner's action dispatcher handles
60
+ * them internally and can't be wrapped at the shell level.
61
+ *
62
+ * Step indices are 1-based across ALL steps (matching the tree UI numbering),
63
+ * not just the `run:` steps.
64
+ */
65
+ export function wrapJobSteps(steps, pauseOnFailure) {
66
+ if (!pauseOnFailure || !steps) {
67
+ return steps;
68
+ }
69
+ return steps.map((step, idx) => {
70
+ if (step?.Reference?.Type !== "Script" || !step?.Inputs?.script) {
71
+ return step;
72
+ }
73
+ const stepIndex = idx + 1; // 1-based to match UI
74
+ return {
75
+ ...step,
76
+ Inputs: {
77
+ ...step.Inputs,
78
+ script: wrapStepScript(step.Inputs.script, step.Name || step.DisplayName || "step", stepIndex),
79
+ },
80
+ };
81
+ });
82
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { wrapStepScript, wrapJobSteps } from "./step-wrapper.js";
3
+ // ── wrapStepScript ────────────────────────────────────────────────────────────
4
+ describe("wrapStepScript", () => {
5
+ it("wraps the original script in a retry loop", () => {
6
+ const wrapped = wrapStepScript("npm test", "Run tests", 1);
7
+ expect(wrapped).toContain("npm test");
8
+ expect(wrapped).toContain('__SIGNALS="/tmp/agent-ci-signals"');
9
+ expect(wrapped).toContain("while true; do");
10
+ expect(wrapped).toContain("Retrying step...");
11
+ });
12
+ it("includes the step name in the paused signal", () => {
13
+ const wrapped = wrapStepScript("echo hi", "My Step", 2);
14
+ expect(wrapped).toContain('printf \'%s\\n%s\\n%s\' \'My Step\' "$__ATTEMPT" "$__STEP_INDEX" > "$__SIGNALS/paused"');
15
+ });
16
+ it("escapes single quotes in step names", () => {
17
+ const wrapped = wrapStepScript("echo hi", "it's a test", 1);
18
+ // Should not contain an unescaped single quote that breaks the shell
19
+ expect(wrapped).toContain("it'\\''s a test");
20
+ });
21
+ it("embeds step index for from-step comparison", () => {
22
+ const wrapped = wrapStepScript("npm test", "Run tests", 3);
23
+ expect(wrapped).toContain("__STEP_INDEX=3");
24
+ });
25
+ it("includes from-step skip logic with numeric comparison", () => {
26
+ const wrapped = wrapStepScript("npm test", "Run tests", 2);
27
+ expect(wrapped).toContain('if [ -f "$__SIGNALS/from-step" ]');
28
+ expect(wrapped).toContain('"$__STEP_INDEX" -lt "$__FROM_STEP"');
29
+ expect(wrapped).toContain("Skipping step $__STEP_INDEX");
30
+ expect(wrapped).toContain("Resuming from step $__STEP_INDEX.");
31
+ });
32
+ it("supports wildcard * for --from-start", () => {
33
+ const wrapped = wrapStepScript("npm test", "My Step", 1);
34
+ expect(wrapped).toContain(`"$__FROM_STEP" != '*'`);
35
+ });
36
+ });
37
+ // ── wrapJobSteps ──────────────────────────────────────────────────────────────
38
+ describe("wrapJobSteps", () => {
39
+ const scriptStep = {
40
+ Name: "Run tests",
41
+ Reference: { Type: "Script" },
42
+ Inputs: { script: "npm test" },
43
+ };
44
+ const usesStep = {
45
+ Name: "Checkout",
46
+ Reference: { Type: "Repository", Name: "actions/checkout" },
47
+ Inputs: {},
48
+ };
49
+ it("returns steps unchanged when pauseOnFailure is false", () => {
50
+ const result = wrapJobSteps([scriptStep, usesStep], false);
51
+ expect(result).toEqual([scriptStep, usesStep]);
52
+ });
53
+ it("wraps run: steps when pauseOnFailure is true", () => {
54
+ const result = wrapJobSteps([scriptStep, usesStep], true);
55
+ expect(result[0].Inputs.script).toContain("__SIGNALS");
56
+ expect(result[0].Inputs.script).toContain("npm test");
57
+ });
58
+ it("leaves uses: steps untouched", () => {
59
+ const result = wrapJobSteps([scriptStep, usesStep], true);
60
+ expect(result[1]).toEqual(usesStep);
61
+ });
62
+ it("handles undefined/empty steps gracefully", () => {
63
+ expect(wrapJobSteps([], true)).toEqual([]);
64
+ expect(wrapJobSteps(undefined, false)).toBeUndefined();
65
+ });
66
+ it("assigns correct 1-based step indices", () => {
67
+ // uses step at index 0 (step 1), script at index 1 (step 2)
68
+ const result = wrapJobSteps([usesStep, scriptStep], true);
69
+ expect(result[1].Inputs.script).toContain("__STEP_INDEX=2");
70
+ });
71
+ it("assigns sequential indices across multiple script steps", () => {
72
+ const step2 = { ...scriptStep, Name: "Build" };
73
+ const result = wrapJobSteps([scriptStep, step2], true);
74
+ expect(result[0].Inputs.script).toContain("__STEP_INDEX=1");
75
+ expect(result[1].Inputs.script).toContain("__STEP_INDEX=2");
76
+ });
77
+ });