Files
redwoodjs__local-ci/scripts/fixtures-check.mjs
T
Peter Pistorius 7a349fdb43 Add opt-in Rust native execution
## Problem

Agent CI still depended on the TypeScript runner as the main execution path. That made it harder to ship a small native binary and harder to prove the Rust path matched the TypeScript path. Review also found places where Rust and TypeScript could drift before the Rust path becomes the default.

## Solution

This PR adds the opt-in Rust native `agent-ci` binary and platform npm packages while keeping the TypeScript fallback. It splits the Rust code into `agent-ci-core`, `agent-ci-runtime`, and a thin CLI/orchestration crate. Pure planning, reusable workflow expansion, event contracts, and run-result contracts now live in core; runtime owns Docker, DTU, runner, macOS VM, workspace, execution-plan adapters, and the generic wave worker pool.

The fixture checks now use real TypeScript helpers instead of a shadow planner. Shared fixtures cover plans, events, run results, Docker socket resolution, and default job-limit CPU/memory vectors. The Rust runner honors `--jobs`, probes Docker memory for the default job limit, supports macOS VM concurrency limits, avoids nested runner container name collisions, has `--all` smoke coverage, and stages `bin/agent-ci` in every platform package.

## Validation

- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test --workspace --all-targets`
- `pnpm check`
- `pnpm fixtures:check`
- `pnpm golden:cli`
- `pnpm rust:smoke:parity`
- `pnpm agent-ci-dev run --all -q -p --json --github-token`

## Notes

Rust execution is still opt-in. The TypeScript implementation and fallback remain in place until the later default-switch and removal gates pass.
2026-05-24 15:22:20 +02:00

227 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getDefaultMaxConcurrentJobsFromInputs } from "../packages/cli/src/output/concurrency.ts";
import { planFixtureWorkflow } from "../packages/cli/src/workflow/fixture-plan.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturesRoot = path.join(root, "crates/agent-ci/fixtures");
const plansDir = path.join(fixturesRoot, "plans");
const workflowsDir = path.join(fixturesRoot, "workflows");
const eventsDir = path.join(fixturesRoot, "events");
const runResultsDir = path.join(fixturesRoot, "run-results");
const dockerSocketDir = path.join(fixturesRoot, "docker-socket");
const jobLimitsDir = path.join(fixturesRoot, "job-limits");
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
const planFiles = fixtureFiles(plansDir);
if (planFiles.length < 10) {
throw new Error(`Expected at least 10 plan fixtures, found ${planFiles.length}`);
}
for (const planFile of planFiles) {
const expected = readJson(path.join(plansDir, planFile));
const workflowPath = path.join(workflowsDir, expected.workflow);
const actual = await planFixtureWorkflow(workflowPath, expected.args ?? {});
assertJsonEqual(actual, expected.plan, `plan fixture ${planFile}`);
}
const eventFiles = checkEventFixtures();
const runResultFiles = checkRunResultFixtures();
const dockerSocketFiles = checkDockerSocketFixtures();
const jobLimitVectors = checkJobLimitFixtures();
console.log(`✓ ${planFiles.length} TypeScript fixture plan contracts passed`);
console.log(`✓ ${eventFiles} TypeScript fixture event contracts passed`);
console.log(`✓ ${runResultFiles} TypeScript fixture run-result contracts passed`);
console.log(`✓ ${dockerSocketFiles} TypeScript fixture docker-socket contracts passed`);
console.log(`✓ ${jobLimitVectors} TypeScript fixture job-limit contracts passed`);
function fixtureFiles(dir) {
return fs
.readdirSync(dir)
.filter((file) => file.endsWith(".json"))
.sort();
}
function normalizeEvent(event) {
const clone = { ...event };
delete clone.ts;
delete clone.runId;
delete clone.durationMs;
delete clone.logPath;
delete clone.debugLogPath;
return clone;
}
function checkEventFixtures() {
const files = fixtureFiles(eventsDir);
if (files.length === 0) {
throw new Error("Expected at least one event fixture");
}
for (const file of files) {
const fixture = readJson(path.join(eventsDir, file));
const actual = fixture.input.map(normalizeEvent);
assertJsonEqual(actual, fixture.normalized, `event fixture ${file}`);
}
return files.length;
}
function normalizeRunResult(result) {
const clone = JSON.parse(JSON.stringify(result));
delete clone.worktreePath;
delete clone.startedAt;
delete clone.finishedAt;
delete clone.headSha;
for (const job of clone.jobs ?? []) {
delete job.durationMs;
delete job.debugLogPath;
for (const step of job.steps ?? []) {
delete step.logPath;
}
}
return clone;
}
function checkRunResultFixtures() {
const files = fixtureFiles(runResultsDir);
if (files.length === 0) {
throw new Error("Expected at least one run-result fixture");
}
for (const file of files) {
const fixture = readJson(path.join(runResultsDir, file));
assertJsonEqual(
normalizeRunResult(fixture.input),
fixture.normalized,
`run-result fixture ${file}`,
);
}
return files.length;
}
function resolveDockerSocket(probe) {
const envHost = probe.env?.AGENT_CI_DOCKER_HOST?.trim();
if (envHost) {
if (!envHost.startsWith("unix://")) {
return { socketPath: "", uri: envHost, bindMountPath: "" };
}
const socketPath = envHost.slice("unix://".length);
const resolved = resolveSocketPath(probe, socketPath);
if (resolved) {
return { socketPath: resolved, uri: `unix://${resolved}`, bindMountPath: socketPath };
}
throw new Error(`AGENT_CI_DOCKER_HOST=${envHost} does not resolve to a working socket.`);
}
if (!pathExists(probe, "/var/run/docker.sock")) {
throw new Error(
[
"/var/run/docker.sock is missing or a dangling symlink",
dockerDesktopHint(probe),
"docs/docker-socket.md",
]
.filter(Boolean)
.join("\n"),
);
}
const defaultResolved = resolveSocketPath(probe, "/var/run/docker.sock");
if (defaultResolved) {
return {
socketPath: defaultResolved,
uri: `unix://${defaultResolved}`,
bindMountPath: "/var/run/docker.sock",
};
}
const contextHost = probe.dockerContextHost;
if (contextHost?.startsWith("unix://")) {
const socketPath = contextHost.slice("unix://".length);
if (pathExists(probe, socketPath)) {
return {
socketPath,
uri: `unix://${socketPath}`,
bindMountPath: "/var/run/docker.sock",
};
}
}
throw new Error("/var/run/docker.sock exists but is not readable\ndocs/docker-socket.md");
}
function pathExists(probe, socketPath) {
return (probe.existingPaths ?? []).includes(socketPath);
}
function resolveSocketPath(probe, socketPath) {
const resolved = probe.realpaths?.[socketPath] ?? socketPath;
return pathExists(probe, socketPath) && (probe.accessiblePaths ?? []).includes(resolved)
? resolved
: undefined;
}
function dockerDesktopHint(probe) {
const home = probe.home;
if (home && pathExists(probe, path.join(home, ".docker/run/docker.sock"))) {
return "Docker Desktop is running but the default socket is disabled";
}
return "";
}
function checkDockerSocketFixtures() {
const files = fixtureFiles(dockerSocketDir);
if (files.length === 0) {
throw new Error("Expected at least one docker-socket fixture");
}
for (const file of files) {
const fixture = readJson(path.join(dockerSocketDir, file));
try {
const actual = resolveDockerSocket(fixture.probe);
if (!fixture.expected) {
throw new Error(`Expected fixture to fail, got ${JSON.stringify(actual)}`);
}
assertJsonEqual(actual, fixture.expected, `docker-socket fixture ${file}`);
} catch (error) {
if (fixture.expected) {
throw error;
}
for (const expected of fixture.expectedErrorContains ?? []) {
if (!String(error.message).includes(expected)) {
throw new Error(
`docker-socket fixture ${file} error did not contain ${expected}: ${error.message}`,
);
}
}
}
}
return files.length;
}
function checkJobLimitFixtures() {
const vectors = readJson(path.join(jobLimitsDir, "default-max-concurrent-jobs.json"));
for (const vector of vectors) {
const actual = getDefaultMaxConcurrentJobsFromInputs(
vector.cpuCount,
vector.dockerAvailableMemoryBytes ?? undefined,
);
if (actual !== vector.expected) {
throw new Error(`job limit fixture mismatch: expected ${vector.expected}, got ${actual}`);
}
}
return vectors.length;
}
function assertJsonEqual(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error(`Fixture mismatch: ${label}`);
console.error("Expected:", JSON.stringify(expected, null, 2));
console.error("Actual:", JSON.stringify(actual, null, 2));
process.exit(1);
}
}