@redwoodjs/agent-ci 0.1.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/README.md +34 -14
- package/dist/cli.js +126 -12
- package/dist/output/working-directory.js +1 -1
- package/dist/runner/job-result.js +37 -0
- package/dist/runner/job-result.test.js +100 -0
- package/dist/runner/local-job.js +17 -2
- package/dist/runner/result-builder.js +111 -1
- package/dist/runner/result-builder.test.js +138 -0
- package/dist/runner/step-wrapper.js +69 -0
- package/dist/workflow/workflow-parser.js +246 -11
- package/dist/workflow/workflow-parser.test.js +340 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -12,35 +12,34 @@ Agent CI runs on any machine that can run a container. When a step fails the run
|
|
|
12
12
|
|
|
13
13
|
<!-- TODO: Add demo video/screen recording -->
|
|
14
14
|
|
|
15
|
-
##
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm install -g agent-ci
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### Prerequisites
|
|
15
|
+
## Prerequisites
|
|
22
16
|
|
|
23
17
|
- **Docker** — A running Docker provider:
|
|
24
18
|
- **macOS:** [OrbStack](https://orbstack.dev/) (recommended) or Docker Desktop
|
|
25
19
|
- **Linux:** Native Docker Engine
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
Agent CI connects to Docker via the `DOCKER_HOST` environment variable. By default it uses the local socket (`unix:///var/run/docker.sock`), but you can point it at any remote Docker daemon:
|
|
21
|
+
## Installation
|
|
30
22
|
|
|
31
23
|
```bash
|
|
32
|
-
|
|
33
|
-
DOCKER_HOST=ssh://user@remote-server agent-ci run --workflow .github/workflows/ci.yml
|
|
24
|
+
npm install -D @redwoodjs/agent-ci
|
|
34
25
|
```
|
|
35
26
|
|
|
36
27
|
## Usage
|
|
37
28
|
|
|
38
29
|
```bash
|
|
39
30
|
# Run a specific workflow
|
|
40
|
-
agent-ci run --workflow .github/workflows/ci.yml
|
|
31
|
+
npx agent-ci run --workflow .github/workflows/ci.yml
|
|
41
32
|
|
|
42
33
|
# Run all relevant workflows for the current branch
|
|
43
|
-
agent-ci run --all
|
|
34
|
+
npx agent-ci run --all
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Remote Docker
|
|
38
|
+
|
|
39
|
+
Agent CI connects to Docker via the `DOCKER_HOST` environment variable. By default it uses the local socket (`unix:///var/run/docker.sock`), but you can point it at any remote Docker daemon:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
DOCKER_HOST=ssh://user@remote-server npx agent-ci run --workflow .github/workflows/ci.yml
|
|
44
43
|
```
|
|
45
44
|
|
|
46
45
|
### `agent-ci run`
|
|
@@ -77,3 +76,24 @@ Abort a paused runner and tear down its container.
|
|
|
77
76
|
## YAML Compatibility
|
|
78
77
|
|
|
79
78
|
See [compatibility.md](./compatibility.md) for detailed GitHub Actions workflow syntax support.
|
|
79
|
+
|
|
80
|
+
## Debugging
|
|
81
|
+
|
|
82
|
+
Set the `DEBUG` environment variable to enable verbose debug logging. It accepts a comma-separated list of glob patterns matching the namespaces you want to see:
|
|
83
|
+
|
|
84
|
+
| Value | What it shows |
|
|
85
|
+
| --------------------------------- | ----------------------------- |
|
|
86
|
+
| `DEBUG=agent-ci:*` | All debug output |
|
|
87
|
+
| `DEBUG=agent-ci:cli` | CLI-level logs only |
|
|
88
|
+
| `DEBUG=agent-ci:runner` | Runner/container logs only |
|
|
89
|
+
| `DEBUG=agent-ci:dtu` | DTU mock-server logs only |
|
|
90
|
+
| `DEBUG=agent-ci:boot` | Boot/startup timing logs only |
|
|
91
|
+
| `DEBUG=agent-ci:cli,agent-ci:dtu` | Multiple namespaces |
|
|
92
|
+
|
|
93
|
+
- Output goes to **stderr** so stdout stays clean for piping.
|
|
94
|
+
- If `DEBUG` is unset or empty, all debug loggers become **no-ops** (zero overhead).
|
|
95
|
+
- Pattern matching uses [minimatch](https://github.com/isaacs/minimatch) globs, so `agent-ci:*` matches all four namespaces.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
DEBUG=agent-ci:* npx agent-ci run
|
|
99
|
+
```
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,8 @@ import { getNextLogNum } from "./output/logger.js";
|
|
|
6
6
|
import { setWorkingDirectory, DEFAULT_WORKING_DIR, PROJECT_ROOT, } from "./output/working-directory.js";
|
|
7
7
|
import { debugCli } from "./output/debug.js";
|
|
8
8
|
import { executeLocalJob } from "./runner/local-job.js";
|
|
9
|
-
import { getWorkflowTemplate, parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, isWorkflowRelevant, getChangedFiles, } from "./workflow/workflow-parser.js";
|
|
9
|
+
import { getWorkflowTemplate, parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, } from "./workflow/workflow-parser.js";
|
|
10
|
+
import { resolveJobOutputs } from "./runner/result-builder.js";
|
|
10
11
|
import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
|
|
11
12
|
import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
|
|
12
13
|
import { getWorkingDirectory } from "./output/working-directory.js";
|
|
@@ -18,7 +19,7 @@ import { RunStateStore } from "./output/run-state.js";
|
|
|
18
19
|
import { renderRunState } from "./output/state-renderer.js";
|
|
19
20
|
import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
|
|
20
21
|
import logUpdate from "log-update";
|
|
21
|
-
|
|
22
|
+
import { createFailedJobResult, wrapJobError, isJobError } from "./runner/job-result.js";
|
|
22
23
|
function findSignalsDir(runnerName) {
|
|
23
24
|
const workDir = getWorkingDirectory();
|
|
24
25
|
const runsDir = path.resolve(workDir, "runs");
|
|
@@ -340,6 +341,9 @@ async function runWorkflows(options) {
|
|
|
340
341
|
if (s.status === "fulfilled") {
|
|
341
342
|
allResults.push(...s.value);
|
|
342
343
|
}
|
|
344
|
+
else {
|
|
345
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
346
|
+
}
|
|
343
347
|
}
|
|
344
348
|
}
|
|
345
349
|
else {
|
|
@@ -348,6 +352,9 @@ async function runWorkflows(options) {
|
|
|
348
352
|
if (s.status === "fulfilled") {
|
|
349
353
|
allResults.push(...s.value);
|
|
350
354
|
}
|
|
355
|
+
else {
|
|
356
|
+
console.error(`\n[Agent CI] Workflow failed: ${s.reason?.message || String(s.reason)}`);
|
|
357
|
+
}
|
|
351
358
|
}
|
|
352
359
|
}
|
|
353
360
|
}
|
|
@@ -497,24 +504,36 @@ async function handleWorkflow(options) {
|
|
|
497
504
|
taskId: ej.taskName,
|
|
498
505
|
};
|
|
499
506
|
};
|
|
500
|
-
const runJob = async (ej) => {
|
|
507
|
+
const runJob = async (ej, needsContext) => {
|
|
501
508
|
const { taskName, matrixContext } = ej;
|
|
502
509
|
debugCli(`Running: ${path.basename(workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
|
|
503
510
|
const secrets = loadMachineSecrets(repoRoot);
|
|
504
511
|
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
505
512
|
validateSecrets(workflowPath, taskName, secrets, secretsFilePath);
|
|
506
|
-
const steps = await parseWorkflowSteps(workflowPath, taskName, secrets, matrixContext);
|
|
513
|
+
const steps = await parseWorkflowSteps(workflowPath, taskName, secrets, matrixContext, needsContext);
|
|
507
514
|
const services = await parseWorkflowServices(workflowPath, taskName);
|
|
508
515
|
const container = await parseWorkflowContainer(workflowPath, taskName);
|
|
509
516
|
const job = buildJob(ej);
|
|
510
517
|
job.steps = steps;
|
|
511
518
|
job.services = services;
|
|
512
519
|
job.container = container ?? undefined;
|
|
513
|
-
|
|
520
|
+
const result = await executeLocalJob(job, { pauseOnFailure, store });
|
|
521
|
+
// result.outputs now contains raw step outputs (extracted inside executeLocalJob
|
|
522
|
+
// before workspace cleanup). Resolve them to job-level outputs using the
|
|
523
|
+
// output definitions from the workflow YAML.
|
|
524
|
+
if (result.outputs && Object.keys(result.outputs).length > 0) {
|
|
525
|
+
const outputDefs = parseJobOutputDefs(workflowPath, taskName);
|
|
526
|
+
if (Object.keys(outputDefs).length > 0) {
|
|
527
|
+
result.outputs = resolveJobOutputs(outputDefs, result.outputs);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return result;
|
|
514
531
|
};
|
|
515
532
|
pruneOrphanedDockerResources();
|
|
516
533
|
const limiter = createConcurrencyLimiter(maxJobs);
|
|
517
534
|
const allResults = [];
|
|
535
|
+
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
536
|
+
const jobOutputs = new Map();
|
|
518
537
|
// ── Dependency-aware wave scheduling ──────────────────────────────────────
|
|
519
538
|
const deps = parseJobDependencies(workflowPath);
|
|
520
539
|
const waves = topoSort(deps);
|
|
@@ -525,6 +544,73 @@ async function handleWorkflow(options) {
|
|
|
525
544
|
if (filteredWaves.length === 0) {
|
|
526
545
|
filteredWaves.push(Array.from(taskNamesInWf));
|
|
527
546
|
}
|
|
547
|
+
/** Build a needsContext for a job from its dependencies' accumulated outputs */
|
|
548
|
+
const buildNeedsContext = (jobId) => {
|
|
549
|
+
const jobDeps = deps.get(jobId);
|
|
550
|
+
if (!jobDeps || jobDeps.length === 0) {
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
const ctx = {};
|
|
554
|
+
for (const depId of jobDeps) {
|
|
555
|
+
ctx[depId] = jobOutputs.get(depId) ?? {};
|
|
556
|
+
}
|
|
557
|
+
return ctx;
|
|
558
|
+
};
|
|
559
|
+
/** Collect outputs from a completed job result */
|
|
560
|
+
const collectOutputs = (result, taskName) => {
|
|
561
|
+
if (result.outputs && Object.keys(result.outputs).length > 0) {
|
|
562
|
+
jobOutputs.set(taskName, result.outputs);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
// Track job results for if-condition evaluation (success/failure status)
|
|
566
|
+
const jobResultStatus = new Map();
|
|
567
|
+
/** Check if a job should be skipped based on its if: condition */
|
|
568
|
+
const shouldSkipJob = (jobId) => {
|
|
569
|
+
const ifExpr = parseJobIf(workflowPath, jobId);
|
|
570
|
+
if (ifExpr === null) {
|
|
571
|
+
// No if: condition — default behavior is success() (skip if any upstream failed)
|
|
572
|
+
const jobDeps = deps.get(jobId);
|
|
573
|
+
if (jobDeps && jobDeps.length > 0) {
|
|
574
|
+
const anyFailed = jobDeps.some((d) => jobResultStatus.get(d) === "failure");
|
|
575
|
+
if (anyFailed) {
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
// Build upstream job results for the evaluator
|
|
582
|
+
const upstreamResults = {};
|
|
583
|
+
const jobDeps = deps.get(jobId) ?? [];
|
|
584
|
+
for (const depId of jobDeps) {
|
|
585
|
+
upstreamResults[depId] = jobResultStatus.get(depId) ?? "success";
|
|
586
|
+
}
|
|
587
|
+
const needsCtx = buildNeedsContext(jobId);
|
|
588
|
+
return !evaluateJobIf(ifExpr, upstreamResults, needsCtx);
|
|
589
|
+
};
|
|
590
|
+
/** Create a synthetic skipped result for a job that was skipped by if: */
|
|
591
|
+
const skippedResult = (ej) => ({
|
|
592
|
+
name: `agent-ci-skipped-${ej.taskName}`,
|
|
593
|
+
workflow: path.basename(workflowPath),
|
|
594
|
+
taskId: ej.taskName,
|
|
595
|
+
succeeded: true,
|
|
596
|
+
durationMs: 0,
|
|
597
|
+
debugLogPath: "",
|
|
598
|
+
steps: [],
|
|
599
|
+
});
|
|
600
|
+
/** Run a job or skip it based on if: condition */
|
|
601
|
+
const runOrSkipJob = async (ej) => {
|
|
602
|
+
if (shouldSkipJob(ej.taskName)) {
|
|
603
|
+
debugCli(`Skipping ${ej.taskName} (if: condition is false)`);
|
|
604
|
+
const result = skippedResult(ej);
|
|
605
|
+
jobResultStatus.set(ej.taskName, "skipped");
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
const ctx = buildNeedsContext(ej.taskName);
|
|
609
|
+
const result = await runJob(ej, ctx);
|
|
610
|
+
jobResultStatus.set(ej.taskName, result.succeeded ? "success" : "failure");
|
|
611
|
+
collectOutputs(result, ej.taskName);
|
|
612
|
+
return result;
|
|
613
|
+
};
|
|
528
614
|
for (let wi = 0; wi < filteredWaves.length; wi++) {
|
|
529
615
|
const waveJobIds = new Set(filteredWaves[wi]);
|
|
530
616
|
const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
|
|
@@ -534,28 +620,56 @@ async function handleWorkflow(options) {
|
|
|
534
620
|
// ── Warm-cache serialization for the first wave ────────────────────────
|
|
535
621
|
if (!warm && wi === 0 && waveJobs.length > 1) {
|
|
536
622
|
debugCli("Cold cache — running first job to populate warm modules...");
|
|
537
|
-
const firstResult = await
|
|
623
|
+
const firstResult = await runOrSkipJob(waveJobs[0]);
|
|
538
624
|
allResults.push(firstResult);
|
|
539
|
-
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() =>
|
|
625
|
+
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
626
|
+
throw wrapJobError(ej.taskName, error);
|
|
627
|
+
}))));
|
|
540
628
|
for (const r of results) {
|
|
541
629
|
if (r.status === "fulfilled") {
|
|
542
630
|
allResults.push(r.value);
|
|
543
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
|
+
}
|
|
544
639
|
}
|
|
545
640
|
warm = true;
|
|
546
641
|
}
|
|
547
642
|
else {
|
|
548
|
-
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() =>
|
|
643
|
+
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
644
|
+
throw wrapJobError(ej.taskName, error);
|
|
645
|
+
}))));
|
|
549
646
|
for (const r of results) {
|
|
550
647
|
if (r.status === "fulfilled") {
|
|
551
648
|
allResults.push(r.value);
|
|
552
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
|
+
}
|
|
553
657
|
}
|
|
554
658
|
}
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
659
|
+
// Check whether to abort remaining waves on failure
|
|
660
|
+
const waveHadFailures = allResults.some((r) => !r.succeeded);
|
|
661
|
+
if (waveHadFailures && wi < filteredWaves.length - 1) {
|
|
662
|
+
// Check fail-fast setting for jobs in this wave
|
|
663
|
+
const waveFailFastSettings = waveJobs.map((ej) => parseFailFast(workflowPath, ej.taskName));
|
|
664
|
+
// Abort unless ALL jobs in the wave explicitly set fail-fast: false
|
|
665
|
+
const shouldAbort = !waveFailFastSettings.every((ff) => ff === false);
|
|
666
|
+
if (shouldAbort) {
|
|
667
|
+
debugCli(`Wave ${wi + 1} had failures — aborting remaining waves for ${path.basename(workflowPath)}`);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
debugCli(`Wave ${wi + 1} had failures but fail-fast is disabled — continuing`);
|
|
672
|
+
}
|
|
559
673
|
}
|
|
560
674
|
}
|
|
561
675
|
return allResults;
|
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
// Pinned to the
|
|
5
|
+
// Pinned to the cli package root
|
|
6
6
|
export const PROJECT_ROOT = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
|
|
7
7
|
// When running inside a container with Docker-outside-of-Docker (shared socket),
|
|
8
8
|
// /tmp is NOT visible to the Docker host. Use a project-relative directory
|
|
@@ -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/dist/runner/local-job.js
CHANGED
|
@@ -17,7 +17,7 @@ import { prepareWorkspace } from "./workspace.js";
|
|
|
17
17
|
import { createRunDirectories } from "./directory-setup.js";
|
|
18
18
|
import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, } from "../docker/container-config.js";
|
|
19
19
|
import { buildJobResult, sanitizeStepName } from "./result-builder.js";
|
|
20
|
-
import { wrapJobSteps } from "./step-wrapper.js";
|
|
20
|
+
import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
|
|
21
21
|
import { syncWorkspaceForRetry } from "./sync.js";
|
|
22
22
|
// ─── Docker setup ─────────────────────────────────────────────────────────────
|
|
23
23
|
const dockerHost = process.env.DOCKER_HOST || "unix:///var/run/docker.sock";
|
|
@@ -169,7 +169,8 @@ export async function executeLocalJob(job, options) {
|
|
|
169
169
|
default_branch: job.repository?.default_branch || "main",
|
|
170
170
|
}
|
|
171
171
|
: job.repository;
|
|
172
|
-
const
|
|
172
|
+
const wrappedSteps = pauseOnFailure ? wrapJobSteps(job.steps ?? [], true) : job.steps;
|
|
173
|
+
const seededSteps = appendOutputCaptureStep(wrappedSteps ?? []);
|
|
173
174
|
t0 = Date.now();
|
|
174
175
|
const seedResponse = await fetch(`${dtuUrl}/_dtu/seed`, {
|
|
175
176
|
method: "POST",
|
|
@@ -668,6 +669,19 @@ export async function executeLocalJob(job, options) {
|
|
|
668
669
|
if (fs.existsSync(hostRunnerDir)) {
|
|
669
670
|
fs.rmSync(hostRunnerDir, { recursive: true, force: true });
|
|
670
671
|
}
|
|
672
|
+
// Read step outputs captured by the DTU server via the runner's outputs API
|
|
673
|
+
let stepOutputs = {};
|
|
674
|
+
if (jobSucceeded) {
|
|
675
|
+
const outputsFile = path.join(logDir, "outputs.json");
|
|
676
|
+
try {
|
|
677
|
+
if (fs.existsSync(outputsFile)) {
|
|
678
|
+
stepOutputs = JSON.parse(fs.readFileSync(outputsFile, "utf-8"));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
/* best-effort */
|
|
683
|
+
}
|
|
684
|
+
}
|
|
671
685
|
if (jobSucceeded && fs.existsSync(dirs.containerWorkDir)) {
|
|
672
686
|
fs.rmSync(dirs.containerWorkDir, { recursive: true, force: true });
|
|
673
687
|
}
|
|
@@ -682,6 +696,7 @@ export async function executeLocalJob(job, options) {
|
|
|
682
696
|
timelinePath,
|
|
683
697
|
logDir,
|
|
684
698
|
debugLogPath,
|
|
699
|
+
stepOutputs,
|
|
685
700
|
});
|
|
686
701
|
}
|
|
687
702
|
finally {
|
|
@@ -83,11 +83,117 @@ export function extractFailureDetails(timelinePath, failedStepName, logDir) {
|
|
|
83
83
|
}
|
|
84
84
|
return result;
|
|
85
85
|
}
|
|
86
|
+
// ─── Step output extraction ───────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Extract step outputs from the runner's `_runner_file_commands/` directory.
|
|
89
|
+
*
|
|
90
|
+
* The GitHub Actions runner writes step outputs to files named `set_output_<uuid>`
|
|
91
|
+
* in `<workDir>/_runner_file_commands/`. Each file contains key=value pairs,
|
|
92
|
+
* with multiline values using the heredoc format:
|
|
93
|
+
* key<<DELIMITER
|
|
94
|
+
* line1
|
|
95
|
+
* line2
|
|
96
|
+
* DELIMITER
|
|
97
|
+
*
|
|
98
|
+
* @param workDir The container's work directory (bind-mounted from host)
|
|
99
|
+
* @returns Record<string, string> of all output key-value pairs
|
|
100
|
+
*/
|
|
101
|
+
export function extractStepOutputs(workDir) {
|
|
102
|
+
const outputs = {};
|
|
103
|
+
// The runner writes to _temp/_runner_file_commands/ under the work dir
|
|
104
|
+
// $GITHUB_OUTPUT = /home/runner/_work/_temp/_runner_file_commands/set_output_<uuid>
|
|
105
|
+
const candidates = [
|
|
106
|
+
path.join(workDir, "_temp", "_runner_file_commands"),
|
|
107
|
+
path.join(workDir, "_runner_file_commands"),
|
|
108
|
+
];
|
|
109
|
+
for (const fileCommandsDir of candidates) {
|
|
110
|
+
if (!fs.existsSync(fileCommandsDir)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = fs.readdirSync(fileCommandsDir).sort(); // Sort for deterministic override order
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.startsWith("set_output_")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const content = fs.readFileSync(path.join(fileCommandsDir, entry), "utf-8");
|
|
126
|
+
parseOutputFileContent(content, outputs);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best-effort: skip unreadable files
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return outputs;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse the content of a single set_output file into the outputs record.
|
|
137
|
+
* Handles both `key=value` and `key<<DELIMITER\nvalue\nDELIMITER` formats.
|
|
138
|
+
*/
|
|
139
|
+
function parseOutputFileContent(content, outputs) {
|
|
140
|
+
const lines = content.split("\n");
|
|
141
|
+
let i = 0;
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
const line = lines[i];
|
|
144
|
+
// Heredoc format: key<<DELIMITER
|
|
145
|
+
const heredocMatch = line.match(/^([^=]+)<<(.+)$/);
|
|
146
|
+
if (heredocMatch) {
|
|
147
|
+
const key = heredocMatch[1];
|
|
148
|
+
const delimiter = heredocMatch[2];
|
|
149
|
+
const valueLines = [];
|
|
150
|
+
i++;
|
|
151
|
+
while (i < lines.length && lines[i] !== delimiter) {
|
|
152
|
+
valueLines.push(lines[i]);
|
|
153
|
+
i++;
|
|
154
|
+
}
|
|
155
|
+
outputs[key] = valueLines.join("\n");
|
|
156
|
+
i++; // Skip the closing delimiter
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// Simple format: key=value
|
|
160
|
+
const eqIdx = line.indexOf("=");
|
|
161
|
+
if (eqIdx > 0) {
|
|
162
|
+
const key = line.slice(0, eqIdx);
|
|
163
|
+
const value = line.slice(eqIdx + 1);
|
|
164
|
+
outputs[key] = value;
|
|
165
|
+
}
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ─── Job output resolution ────────────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Resolve job output definitions against actual step outputs.
|
|
172
|
+
*
|
|
173
|
+
* Job output templates reference `steps.<stepId>.outputs.<name>`. Since the
|
|
174
|
+
* runner writes all step outputs to `$GITHUB_OUTPUT` files keyed only by
|
|
175
|
+
* output name (not step ID), we resolve by matching the output name from
|
|
176
|
+
* the template against the flat step outputs map.
|
|
177
|
+
*
|
|
178
|
+
* @param outputDefs Job output definitions from parseJobOutputDefs
|
|
179
|
+
* @param stepOutputs Flat step outputs from extractStepOutputs
|
|
180
|
+
* @returns Resolved job outputs
|
|
181
|
+
*/
|
|
182
|
+
export function resolveJobOutputs(outputDefs, stepOutputs) {
|
|
183
|
+
const result = {};
|
|
184
|
+
for (const [outputName, template] of Object.entries(outputDefs)) {
|
|
185
|
+
// Replace ${{ steps.<id>.outputs.<name> }} with the actual step output value
|
|
186
|
+
result[outputName] = template.replace(/\$\{\{\s*steps\.[^.]+\.outputs\.([^\s}]+)\s*\}\}/g, (_match, outputKey) => {
|
|
187
|
+
return stepOutputs[outputKey] ?? "";
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
86
192
|
/**
|
|
87
193
|
* Build the structured `JobResult` from container exit state and timeline data.
|
|
88
194
|
*/
|
|
89
195
|
export function buildJobResult(opts) {
|
|
90
|
-
const { containerName, job, startTime, jobSucceeded, lastFailedStep, containerExitCode, timelinePath, logDir, debugLogPath, } = opts;
|
|
196
|
+
const { containerName, job, startTime, jobSucceeded, lastFailedStep, containerExitCode, timelinePath, logDir, debugLogPath, stepOutputs, } = opts;
|
|
91
197
|
const steps = parseTimelineSteps(timelinePath);
|
|
92
198
|
const result = {
|
|
93
199
|
name: containerName,
|
|
@@ -115,5 +221,9 @@ export function buildJobResult(opts) {
|
|
|
115
221
|
result.lastOutputLines = [];
|
|
116
222
|
}
|
|
117
223
|
}
|
|
224
|
+
// Attach raw step outputs (will be resolved to job outputs by cli.ts)
|
|
225
|
+
if (stepOutputs && Object.keys(stepOutputs).length > 0) {
|
|
226
|
+
result.outputs = stepOutputs;
|
|
227
|
+
}
|
|
118
228
|
return result;
|
|
119
229
|
}
|