Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/core/src/actions/autofix/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import type { Config } from '../../config/config.model.js';
import type { IssueStore } from '../../store/store.js';
import type { StoredIssue } from '../../store/store.model.js';
import { GitHubService } from '../../services/github.service.js';
import { createWorktree, commitAll, getDiffAgainstBase, fetchRemoteBranch } from './worktree.js';
import { runSetupCommand } from './setup-runner.js';
import { createRunEnv } from '../../provision/create-run-env.js';
import type { RunEnv } from '../../provision/run-env.js';
import { TokenBudget } from './token-budget.js';
import { runAgentSession, type AgentEvent } from './agent-session.js';
import type { AgentEvent as NormalizedAgentEvent } from '../../agents/agent-runner.js';
Expand Down Expand Up @@ -878,6 +881,23 @@ export class AutofixOrchestrator {
}
}

// Build the runnable project env (native shell or a per-run docker-compose
// project) bound to the worktree. Drives the install/build/test shell-check
// steps. createRunEnv only throws for an explicit compose config that can't
// run here (no compose file / no Docker); auto falls back to native.
let runEnv: RunEnv;
try {
runEnv = await createRunEnv({
worktreePath: worktree.path,
spec: cfg.projectEnv,
runId: `${issueNumber}-${randomUUID().slice(0, 8)}`,
onWarn: (m) => opts.onEvent?.(`[#${issueNumber}] ${m}`),
});
} catch (err) {
await worktree.dispose().catch(() => {});
return this.recordFailure(issueNumber, `project env setup failed: ${(err as Error).message}`, 0, undefined, branch);
}

let result;
try {
result = await runWorkflow<AutofixBlackboard>(autofixWorkflow, {
Expand All @@ -887,6 +907,7 @@ export class AutofixOrchestrator {
issueNumber,
apply: opts.apply,
worktreePath: worktree.path,
runEnv,
bindings: this.config.workflow?.bindings,
settings: this.config.workflow?.settings,
loopMaxIterations: { 'fix-review': cfg.maxAttemptsPerIssue },
Expand Down Expand Up @@ -917,10 +938,12 @@ export class AutofixOrchestrator {
: undefined,
});
} catch (err) {
await runEnv.dispose().catch(() => {});
await worktree.dispose().catch(() => {});
return this.recordFailure(issueNumber, (err as Error).message, 0, undefined, branch);
}

await runEnv.dispose().catch(() => {});
await worktree.dispose().catch(() => {});

const outcome = workflowResultToAutofixOutcome(result);
Expand Down Expand Up @@ -987,6 +1010,19 @@ export class AutofixOrchestrator {
};
const configWithSeed = { ...this.config, __ciFollowup: seed } as Config;

let runEnv: RunEnv;
try {
runEnv = await createRunEnv({
worktreePath: worktree.path,
spec: cfg.projectEnv,
runId: `ci-${input.issueNumber}-${randomUUID().slice(0, 8)}`,
onWarn: (m) => opts.onEvent?.(`[#${input.issueNumber}] ${m}`),
});
} catch (err) {
await worktree.dispose().catch(() => {});
return { status: 'failed', reason: `project env setup failed: ${(err as Error).message}`, branch: input.branch };
}

let result;
try {
result = await runWorkflow<CiFollowupBlackboard>(ciFollowupWorkflow, {
Expand All @@ -998,6 +1034,7 @@ export class AutofixOrchestrator {
branch: input.branch,
apply: true,
worktreePath: worktree.path,
runEnv,
bindings: this.config.workflow?.bindings,
settings: this.config.workflow?.settings,
tokenBudgetPerAttempt: cfg.ciFixTokenBudget ?? cfg.tokenBudgetPerAttempt,
Expand All @@ -1008,10 +1045,12 @@ export class AutofixOrchestrator {
cancelRequested: opts.cancelRequested,
});
} catch (err) {
await runEnv.dispose().catch(() => {});
await worktree.dispose().catch(() => {});
return { status: 'failed', reason: (err as Error).message, branch: input.branch };
}

await runEnv.dispose().catch(() => {});
await worktree.dispose().catch(() => {});
return workflowResultToCiFollowupOutcome(result);
}
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/config/config.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,48 @@ export const ConfigSchema = z.object({
'git show',
]),
setupCommands: z.array(z.string()).default([]),
// Runnable project environment: how to install deps, build, and run tests
// for the checked-out worktree so the autofix loop gets real build/test
// feedback before opening a PR. `kind: 'auto'` picks `compose` when a
// compose file is present in the repo, else `native` (host shell).
// Compose requires a Docker daemon ⇒ self-hosted-runner only; the managed
// cloud path can't run it. See docs/REFACTOR-PLAN-agent-cockpit.md §3.8.
projectEnv: z.object({
kind: z.enum(['auto', 'native', 'compose']).default('auto'),
// Commands run inside the env. Empty ⇒ that check is skipped.
install: z.string().default(''),
build: z.string().default(''),
test: z.string().default(''),
compose: z.object({
// '' ⇒ auto-detect (docker-compose.yml | docker-compose.yaml | compose.yml | compose.yaml).
file: z.string().default(''),
// The "app" service install/build/test run inside. '' ⇒ first service in the file.
service: z.string().default(''),
// Where the worktree is bind-mounted inside the app service.
workdir: z.string().default('/app'),
}).default({}),
// Extra env vars injected into every command (compose: into the run; native: process env overlay).
envVars: z.record(z.string(), z.string()).default({}),
// When true, a non-zero exit of that check fails the run (build/test loop back to fix).
gateOnInstall: z.boolean().default(true),
gateOnBuild: z.boolean().default(false),
gateOnTest: z.boolean().default(true),
// Optional runnable dev server: booted once after install so the agent can
// probe the live app (via `curl` over Bash) while diagnosing / fixing.
// Disposed with the run env. Native binds `port` on the host; compose
// publishes the container `port` to an ephemeral host port.
devServer: z.object({
enabled: z.boolean().default(false),
// Native: the command to launch (e.g. `yarn dev`). Compose: optional —
// empty ⇒ `up -d` the service with its own command.
command: z.string().default(''),
// The app's listen port: container port (compose) or host port (native).
port: z.number().default(0),
// Probed for readiness after launch (an HTTP response — any status — = up).
readyPath: z.string().default('/'),
readyTimeoutSec: z.number().default(60),
}).default({}),
}).default({}),
draftPr: z.boolean().default(true),
prLabels: z.array(z.string()).default(['cezar-autofix']),
skillsDir: z.string().default('.ai/skills'),
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ export * from './actions/autofix/prompts/fixer.js';
export * from './actions/autofix/prompts/reviewer.js';
export * from './actions/autofix/ci-attribution.js';

// Runnable project environment (native shell / docker-compose) for autofix runs.
export {
createRunEnv,
resolveComposeCommand,
NativeShellEnv,
DockerComposeEnv,
detectComposeFile,
firstComposeService,
COMPOSE_FILENAMES,
tailLines,
waitForHttp,
parseHostPort,
type RunEnv,
type ShellResult,
type DevServerHandle,
type ProjectEnvSpec,
type CreateRunEnvOpts,
type ComposeCommand,
type DockerComposeEnvOpts,
} from './provision/index.js';

// Skills (Phase 1a) — merged built-in + repo `.ai/skills/**/*.md` catalog.
export {
discoverSkills,
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/provision/compose-detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';

/** The compose file names Cezar auto-detects, in precedence order. */
export const COMPOSE_FILENAMES = [
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
] as const;

/**
* Returns the first compose file present at the repo root (relative path), or
* `null` if none. Cheap, synchronous — used by `kind: 'auto'` resolution.
*/
export function detectComposeFile(repoRoot: string): string | null {
for (const name of COMPOSE_FILENAMES) {
if (existsSync(join(repoRoot, name))) return name;
}
return null;
}

/**
* Best-effort: pull the first service name out of a compose file's top-level
* `services:` block. Used when the workspace didn't name an app service.
*
* Deliberately a tiny line-scanner rather than a YAML dep — we only need the
* first key under `services:`, and a malformed file should degrade to `null`
* (the caller then errors with a clear "set compose.service" message) rather
* than crash the run.
*/
export function firstComposeService(repoRoot: string, composeFile: string): string | null {
let text: string;
try {
text = readFileSync(join(repoRoot, composeFile), 'utf8');
} catch {
return null;
}
const lines = text.split('\n');
let inServices = false;
for (const raw of lines) {
const line = raw.replace(/\t/g, ' ');
if (/^\s*#/.test(line) || line.trim() === '') continue;
if (!inServices) {
if (/^services\s*:/.test(line)) inServices = true;
continue;
}
// A new top-level key (no indentation) ends the services block.
if (/^\S/.test(line)) break;
// Service entries are indented exactly one level: ` name:`.
const m = line.match(/^\s{2}([A-Za-z0-9._-]+)\s*:/);
if (m) return m[1];
}
return null;
}
108 changes: 108 additions & 0 deletions packages/core/src/provision/create-run-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { execFile } from 'node:child_process';
import { resolve } from 'node:path';
import { promisify } from 'node:util';
import type { ProjectEnvSpec, RunEnv } from './run-env.js';
import { NativeShellEnv } from './native-shell-env.js';
import { DockerComposeEnv, type ComposeCommand } from './docker-compose-env.js';
import { detectComposeFile, firstComposeService } from './compose-detect.js';

const execFileAsync = promisify(execFile);

export interface CreateRunEnvOpts {
worktreePath: string;
spec: ProjectEnvSpec;
/** Unique per run; used to name the compose project. */
runId: string;
onWarn?: (message: string) => void;
}

/** Sanitize a run id into a compose-project-safe slug (lowercase alnum + dashes). */
function composeProjectName(runId: string): string {
const slug = runId.toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 24) || 'run';
return `cezar-${slug}`;
}

/**
* Detect how compose is invoked on this host. Prefers the v2 plugin
* (`docker compose`), falls back to the v1 binary (`docker-compose`), returns
* `null` when neither is available.
*/
export async function resolveComposeCommand(): Promise<ComposeCommand | null> {
try {
await execFileAsync('docker', ['compose', 'version']);
return { bin: 'docker', lead: ['compose'] };
} catch {
// fall through
}
try {
await execFileAsync('docker-compose', ['version']);
return { bin: 'docker-compose', lead: [] };
} catch {
return null;
}
}

/**
* Build the `RunEnv` for a checked-out worktree from the resolved
* `autofix.projectEnv` spec.
*
* - `kind: 'native'` → host shell.
* - `kind: 'compose'` → docker compose (throws if Docker isn't available —
* compose-based runs are self-hosted-runner only).
* - `kind: 'auto'` → compose if a compose file exists in the worktree AND
* Docker is available, else native.
*/
export async function createRunEnv(opts: CreateRunEnvOpts): Promise<RunEnv> {
const { worktreePath, spec } = opts;

const explicitCompose = spec.kind === 'compose';
const composeFileRel = spec.compose.file.trim() || detectComposeFile(worktreePath);
const wantCompose = explicitCompose || (spec.kind === 'auto' && composeFileRel != null);

if (!wantCompose) {
return new NativeShellEnv(worktreePath, spec);
}

if (!composeFileRel) {
throw new Error(
`projectEnv.kind is 'compose' but no compose file was found in the repo. ` +
`Add a docker-compose.yml or set autofix.projectEnv.compose.file.`,
);
}

const compose = await resolveComposeCommand();
if (!compose) {
if (spec.kind === 'auto') {
// Auto-detected a compose file but this host has no Docker — fall back to
// native rather than failing the run. (The managed cloud path has no
// Docker; compose runs belong on a self-hosted runner.)
opts.onWarn?.(
`[run-env] found ${composeFileRel} but Docker is not available here; falling back to the native shell env. ` +
`Run this workspace on a self-hosted runner to use the compose env.`,
);
return new NativeShellEnv(worktreePath, spec);
}
throw new Error(
`projectEnv.kind is 'compose' but Docker is not available on this runner. ` +
`Compose-based runs require a self-hosted runner with a Docker daemon.`,
);
}

const service = spec.compose.service.trim() || firstComposeService(worktreePath, composeFileRel);
if (!service) {
throw new Error(
`could not determine the app service in ${composeFileRel}. ` +
`Set autofix.projectEnv.compose.service to the service install/build/test should run in.`,
);
}

return new DockerComposeEnv({
worktreePath,
composeFile: resolve(worktreePath, composeFileRel),
service,
workdir: spec.compose.workdir || '/app',
project: composeProjectName(opts.runId),
compose,
spec,
});
}
Loading