From d6755583ebf547d02583fe88da7d15fd0b933725 Mon Sep 17 00:00:00 2001 From: Patryk Lewczuk Date: Wed, 20 May 2026 06:41:38 +0200 Subject: [PATCH 1/2] feat(autofix): runnable project env (native + docker-compose) with build/test gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give autofix runs a real install/build/test feedback loop on the checked-out worktree, so the fix loop iterates against actual build/test output before opening a PR — and support a per-run docker-compose environment for repos that need services (db, redis, …) to build or test. Core: - New `provision/` module: `RunEnv` interface with `NativeShellEnv` (host shell) and `DockerComposeEnv` (per-run `compose -p cezar-` project; worktree bind-mounted; deps persist via the mount; `down -v` on dispose). `createRunEnv` resolves kind (`auto` detects a compose file; falls back to native when Docker is absent so the managed cloud path still works) and the compose service. - New `shell-check` workflow step kind: runs install/build/test via the run's `RunEnv`, streams output, and gates the run. A gated build/test failure inside the fix loop fails-retriable, carrying the failure tail back as fix retry notes; review only runs once build+test pass. No `RunEnv` / unset command ⇒ the step is a no-op, so triage and env-less runs are unchanged. - autofix workflow: install (before root-cause) + build + test (in the fix loop) shell-checks; new blackboard fields for the results. - Orchestrator builds the `RunEnv` from `autofix.projectEnv`, threads it through the engine on both the autofix and ci-followup paths, and disposes it. - Config: `autofix.projectEnv` (kind / install / build / test / compose {file,service,workdir} / envVars / gateOn{Install,Build,Test}). GUI: - Settings → Configuration gains a "Project environment" section; persisted to `workspaces.config.autofix.projectEnv` and deep-merged in loadWorkspaceConfig. Compose-based runs require a Docker daemon ⇒ self-hosted runner only; the runner already caps parallelism via `--concurrency` (default 1) and per-run worktrees + unique compose project names make >1 safe. Tests: provision unit tests (detect/native/factory) + engine shell-check tests (pass, gated-failure-loops-to-fix, loop-exhausted-fails, ungated, no-env). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/actions/autofix/orchestrator.ts | 39 +++++ packages/core/src/config/config.model.ts | 27 +++ packages/core/src/index.ts | 18 ++ packages/core/src/provision/compose-detect.ts | 55 ++++++ packages/core/src/provision/create-run-env.ts | 108 ++++++++++++ .../core/src/provision/docker-compose-env.ts | 158 +++++++++++++++++ packages/core/src/provision/index.ts | 6 + .../core/src/provision/native-shell-env.ts | 68 ++++++++ packages/core/src/provision/run-env.ts | 45 +++++ .../workflows/definitions/autofix.workflow.ts | 75 +++++++- .../core/src/workflows/workflow-engine.ts | 39 +++++ packages/core/src/workflows/workflow.ts | 30 +++- packages/core/tests/provision/run-env.test.ts | 108 ++++++++++++ .../core/tests/workflows/shell-check.test.ts | 162 ++++++++++++++++++ packages/gui/src/app/settings/actions.ts | 36 ++++ .../gui/src/app/settings/settings-form.tsx | 49 ++++++ packages/gui/src/lib/load-workspace-config.ts | 19 +- 17 files changed, 1036 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/provision/compose-detect.ts create mode 100644 packages/core/src/provision/create-run-env.ts create mode 100644 packages/core/src/provision/docker-compose-env.ts create mode 100644 packages/core/src/provision/index.ts create mode 100644 packages/core/src/provision/native-shell-env.ts create mode 100644 packages/core/src/provision/run-env.ts create mode 100644 packages/core/tests/provision/run-env.test.ts create mode 100644 packages/core/tests/workflows/shell-check.test.ts diff --git a/packages/core/src/actions/autofix/orchestrator.ts b/packages/core/src/actions/autofix/orchestrator.ts index d533930..0431224 100644 --- a/packages/core/src/actions/autofix/orchestrator.ts +++ b/packages/core/src/actions/autofix/orchestrator.ts @@ -1,3 +1,4 @@ +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'; @@ -5,6 +6,8 @@ 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'; @@ -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(autofixWorkflow, { @@ -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 }, @@ -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); @@ -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(ciFollowupWorkflow, { @@ -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, @@ -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); } diff --git a/packages/core/src/config/config.model.ts b/packages/core/src/config/config.model.ts index a54b9a3..4a3dec0 100644 --- a/packages/core/src/config/config.model.ts +++ b/packages/core/src/config/config.model.ts @@ -67,6 +67,33 @@ 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), + }).default({}), draftPr: z.boolean().default(true), prLabels: z.array(z.string()).default(['cezar-autofix']), skillsDir: z.string().default('.ai/skills'), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3fa11bb..c90e897 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,6 +96,24 @@ 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, + type RunEnv, + type ShellResult, + 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, diff --git a/packages/core/src/provision/compose-detect.ts b/packages/core/src/provision/compose-detect.ts new file mode 100644 index 0000000..2cdf5da --- /dev/null +++ b/packages/core/src/provision/compose-detect.ts @@ -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; +} diff --git a/packages/core/src/provision/create-run-env.ts b/packages/core/src/provision/create-run-env.ts new file mode 100644 index 0000000..8f684f6 --- /dev/null +++ b/packages/core/src/provision/create-run-env.ts @@ -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 { + 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 { + 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, + }); +} diff --git a/packages/core/src/provision/docker-compose-env.ts b/packages/core/src/provision/docker-compose-env.ts new file mode 100644 index 0000000..cb69ec1 --- /dev/null +++ b/packages/core/src/provision/docker-compose-env.ts @@ -0,0 +1,158 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; + +/** How to invoke compose on this host: `docker compose …` (v2) or `docker-compose …` (v1). */ +export interface ComposeCommand { + bin: string; + /** Leading sub-args, e.g. `['compose']` for the v2 plugin, `[]` for the v1 binary. */ + lead: string[]; +} + +export interface DockerComposeEnvOpts { + worktreePath: string; + /** Absolute path to the compose file. */ + composeFile: string; + /** The app service install/build/test run inside. */ + service: string; + /** Where the worktree is bind-mounted in the app service. */ + workdir: string; + /** Unique per run — names the compose project so concurrent runs don't collide. */ + project: string; + compose: ComposeCommand; + spec: ProjectEnvSpec; +} + +/** + * Runs project commands inside a per-run `docker compose` project. The worktree + * is bind-mounted into the app service (so the agent edits files on the host + * and the container sees them, and deps written by `install` persist back into + * the worktree across `run --rm` invocations). `depends_on` services (db, + * redis, …) are started by `compose run` automatically. + * + * Isolation: every run gets a unique `-p ` so two concurrent runs on + * the same repo get separate networks / volumes / containers. `dispose()` does + * `down -v --remove-orphans` to clean them up. + */ +export class DockerComposeEnv implements RunEnv { + readonly kind = 'compose' as const; + private overrideDir: string | null = null; + private overrideFile: string | null = null; + private setupPromise: Promise | null = null; + + constructor(private readonly opts: DockerComposeEnvOpts) {} + + install(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.opts.spec.install, onLine); + } + build(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.opts.spec.build, onLine); + } + test(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.opts.spec.test, onLine); + } + + private maybeRun(command: string, onLine?: (line: string) => void): Promise { + const trimmed = command.trim(); + if (!trimmed) return Promise.resolve(null); + return this.run(trimmed, onLine); + } + + /** Lazily write the bind-mount override file (once). */ + private ensureOverride(): Promise { + if (!this.setupPromise) { + this.setupPromise = (async () => { + this.overrideDir = await mkdtemp(join(tmpdir(), 'cezar-compose-')); + this.overrideFile = join(this.overrideDir, 'compose.cezar.override.yaml'); + const yaml = [ + 'services:', + ` ${this.opts.service}:`, + ' volumes:', + ` - ${this.opts.worktreePath}:${this.opts.workdir}`, + '', + ].join('\n'); + await writeFile(this.overrideFile, yaml, 'utf8'); + })(); + } + return this.setupPromise; + } + + private baseArgs(): string[] { + const { compose, project, worktreePath, composeFile } = this.opts; + return [ + ...compose.lead, + '-p', project, + '--project-directory', worktreePath, + '-f', composeFile, + '-f', this.overrideFile!, + ]; + } + + async run(command: string, onLine?: (line: string) => void): Promise { + await this.ensureOverride(); + const envFlags: string[] = []; + for (const [k, v] of Object.entries(this.opts.spec.envVars)) { + envFlags.push('-e', `${k}=${v}`); + } + const args = [ + ...this.baseArgs(), + 'run', '--rm', + '-w', this.opts.workdir, + ...envFlags, + this.opts.service, + 'sh', '-lc', command, + ]; + return spawnCapture(this.opts.compose.bin, args, command, onLine); + } + + async dispose(): Promise { + if (this.setupPromise) { + try { + await spawnCapture(this.opts.compose.bin, [...this.baseArgs(), 'down', '-v', '--remove-orphans'], 'compose down'); + } catch { + // best-effort teardown + } + } + if (this.overrideDir) { + await rm(this.overrideDir, { recursive: true, force: true }).catch(() => {}); + this.overrideDir = null; + this.overrideFile = null; + } + } +} + +/** Spawn a process with array args (no shell), capture stdout/stderr, stream lines. */ +export function spawnCapture( + bin: string, + args: string[], + command: string, + onLine?: (line: string) => void, +): Promise { + const started = Date.now(); + return new Promise((resolve) => { + const child = spawn(bin, args, { env: process.env }); + let stdout = ''; + let stderr = ''; + const onChunk = (kind: 'out' | 'err') => (chunk: Buffer): void => { + const text = chunk.toString(); + if (kind === 'out') stdout += text; + else stderr += text; + if (onLine) { + for (const line of text.split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed) onLine(trimmed); + } + } + }; + child.stdout?.on('data', onChunk('out')); + child.stderr?.on('data', onChunk('err')); + child.on('error', (err) => { + resolve({ command, ok: false, exitCode: null, stdout, stderr: `${stderr}\n${err.message}`, durationMs: Date.now() - started }); + }); + child.on('close', (code) => { + resolve({ command, ok: code === 0, exitCode: code, stdout, stderr, durationMs: Date.now() - started }); + }); + }); +} diff --git a/packages/core/src/provision/index.ts b/packages/core/src/provision/index.ts new file mode 100644 index 0000000..2871fad --- /dev/null +++ b/packages/core/src/provision/index.ts @@ -0,0 +1,6 @@ +export type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; +export { tailLines } from './run-env.js'; +export { NativeShellEnv } from './native-shell-env.js'; +export { DockerComposeEnv, spawnCapture, type ComposeCommand, type DockerComposeEnvOpts } from './docker-compose-env.js'; +export { detectComposeFile, firstComposeService, COMPOSE_FILENAMES } from './compose-detect.js'; +export { createRunEnv, resolveComposeCommand, type CreateRunEnvOpts } from './create-run-env.js'; diff --git a/packages/core/src/provision/native-shell-env.ts b/packages/core/src/provision/native-shell-env.ts new file mode 100644 index 0000000..8b712b0 --- /dev/null +++ b/packages/core/src/provision/native-shell-env.ts @@ -0,0 +1,68 @@ +import { spawn } from 'node:child_process'; +import type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; + +/** + * Runs project commands directly on the host, inside the worktree. The trust + * boundary is the same as a CI script — the worktree is a clone of the user's + * own repo and the command list is configured by the workspace admin. + */ +export class NativeShellEnv implements RunEnv { + readonly kind = 'native' as const; + + constructor( + private readonly worktreePath: string, + private readonly spec: ProjectEnvSpec, + ) {} + + install(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.spec.install, onLine); + } + build(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.spec.build, onLine); + } + test(onLine?: (line: string) => void): Promise { + return this.maybeRun(this.spec.test, onLine); + } + + private maybeRun(command: string, onLine?: (line: string) => void): Promise { + const trimmed = command.trim(); + if (!trimmed) return Promise.resolve(null); + return this.run(trimmed, onLine); + } + + run(command: string, onLine?: (line: string) => void): Promise { + const started = Date.now(); + return new Promise((resolve) => { + const child = spawn(command, { + cwd: this.worktreePath, + shell: true, + env: { ...process.env, ...this.spec.envVars }, + }); + let stdout = ''; + let stderr = ''; + const onChunk = (kind: 'out' | 'err') => (chunk: Buffer): void => { + const text = chunk.toString(); + if (kind === 'out') stdout += text; + else stderr += text; + if (onLine) { + for (const line of text.split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed) onLine(trimmed); + } + } + }; + child.stdout?.on('data', onChunk('out')); + child.stderr?.on('data', onChunk('err')); + child.on('error', (err) => { + resolve({ command, ok: false, exitCode: null, stdout, stderr: `${stderr}\n${err.message}`, durationMs: Date.now() - started }); + }); + child.on('close', (code) => { + resolve({ command, ok: code === 0, exitCode: code, stdout, stderr, durationMs: Date.now() - started }); + }); + }); + } + + dispose(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/core/src/provision/run-env.ts b/packages/core/src/provision/run-env.ts new file mode 100644 index 0000000..89a09c2 --- /dev/null +++ b/packages/core/src/provision/run-env.ts @@ -0,0 +1,45 @@ +import type { Config } from '../config/config.model.js'; + +/** The resolved `autofix.projectEnv` block (zod always supplies defaults). */ +export type ProjectEnvSpec = NonNullable['projectEnv']; + +/** Outcome of running one command inside a `RunEnv`. */ +export interface ShellResult { + /** The command as Cezar invoked it (for the cockpit / comment). */ + command: string; + ok: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + durationMs: number; +} + +/** + * A runnable project environment bound to one worktree. Two implementations: + * - `NativeShellEnv` — runs commands directly on the host, in the worktree. + * - `DockerComposeEnv` — runs commands inside a per-run `docker compose` + * project, with the worktree bind-mounted into the app service. + * + * The autofix workflow's `shell-check` steps drive `install` / `build` / + * `test`; a `null` return means the command was not configured (skip the step). + * Lifecycle: `dispose()` is always called once, even on failure. + */ +export interface RunEnv { + readonly kind: 'native' | 'compose'; + /** The configured install command, or `null` when unset. */ + install(onLine?: (line: string) => void): Promise; + /** The configured build command, or `null` when unset. */ + build(onLine?: (line: string) => void): Promise; + /** The configured test command, or `null` when unset. */ + test(onLine?: (line: string) => void): Promise; + /** Run an arbitrary command in the env. */ + run(command: string, onLine?: (line: string) => void): Promise; + /** Tear the env down (compose: `down -v`; native: no-op). Idempotent. */ + dispose(): Promise; +} + +/** Keep only the last `maxLines` lines of `text` — for failure tails in comments / prompts. */ +export function tailLines(text: string, maxLines = 40): string { + const lines = text.split('\n'); + return lines.slice(-maxLines).join('\n').trim(); +} diff --git a/packages/core/src/workflows/definitions/autofix.workflow.ts b/packages/core/src/workflows/definitions/autofix.workflow.ts index c82ceed..2e1fdc9 100644 --- a/packages/core/src/workflows/definitions/autofix.workflow.ts +++ b/packages/core/src/workflows/definitions/autofix.workflow.ts @@ -25,6 +25,7 @@ import { import { buildCommitMessage, buildPrBody } from '../../actions/autofix/messages.js'; import { AGENT_EXECUTION_GUIDANCE } from '../../actions/autofix/prompts/agent-guidance.js'; import { agentStep, type Workflow, type WorkflowStep, type CommentSection } from '../workflow.js'; +import { tailLines, type ShellResult } from '../../provision/run-env.js'; /** * The `autofix` workflow as data (docs/REFACTOR-PLAN-agent-cockpit.md §3.1): @@ -75,8 +76,12 @@ export interface AutofixBlackboard { commitSha?: string; /** Normalized review verdict; `verdict.verdict === 'pass'` is the loop exit. */ verdict?: Required; - /** Reviewer blocker notes carried into the next fix↔review iteration. */ + /** Reviewer blocker notes (and/or build/test failure tails) carried into the next fix iteration. */ retryNotes?: string; + /** Latest install / build / test results (set by the shell-check steps). */ + installResult?: ShellResult; + buildResult?: ShellResult; + testResult?: ShellResult; prUrl?: string; prNumber?: number; headSha?: string; @@ -161,6 +166,65 @@ const confirmFixGate: WorkflowStep = { commentSection: (): CommentSection => ({ heading: '⏸ Maintainer go-ahead', body: 'Proceeding with the automated fix.' }), }; +// ─── shell-check steps (install / build / test) ───────────────────────────── +// Run only when the workspace configured a runnable project env (and the run +// has a `RunEnv`). Otherwise the engine skips them, so behavior is unchanged. + +function shellCommentSection(label: string, result: ShellResult): CommentSection { + const out = tailLines(`${result.stdout}\n${result.stderr}`, 30); + return { + heading: result.ok ? `✅ ${label} passed` : `❌ ${label} failed (exit ${result.exitCode})`, + body: out ? `\`\`\`\n${out}\n\`\`\`` : '_(no output)_', + }; +} + +/** Build the fixer retry note from a failing build/test result. */ +function failureRetryNote(label: string, prior: string | undefined, result: ShellResult): string { + const tail = tailLines(`${result.stdout}\n${result.stderr}`, 40); + const note = `The ${label} step failed (exit ${result.exitCode}). Fix the underlying cause. Output tail:\n${tail}`; + return prior ? `${prior}\n\n${note}` : note; +} + +const installStep: WorkflowStep = { + id: 'install', + kind: 'shell-check', + builtinSkillId: 'install', + which: 'install', + gate: (cfg) => autofixCfg(cfg).projectEnv.gateOnInstall, + // Install runs once before root-cause; a gated failure is terminal (not in the loop). + retriable: false, + onResult: (result) => ({ installResult: result }), + commentSection: (result): CommentSection => shellCommentSection('Install', result), +}; + +const buildStep: WorkflowStep = { + id: 'build', + kind: 'shell-check', + builtinSkillId: 'build', + which: 'build', + gate: (cfg) => autofixCfg(cfg).projectEnv.gateOnBuild, + retriable: true, + onResult: (result, ctx) => + result.ok + ? { buildResult: result } + : { buildResult: result, retryNotes: failureRetryNote('build', ctx.blackboard.retryNotes, result) }, + commentSection: (result): CommentSection => shellCommentSection('Build', result), +}; + +const testStep: WorkflowStep = { + id: 'test', + kind: 'shell-check', + builtinSkillId: 'test', + which: 'test', + gate: (cfg) => autofixCfg(cfg).projectEnv.gateOnTest, + retriable: true, + onResult: (result, ctx) => + result.ok + ? { testResult: result } + : { testResult: result, retryNotes: failureRetryNote('test', ctx.blackboard.retryNotes, result) }, + commentSection: (result): CommentSection => shellCommentSection('Tests', result), +}; + const rootCauseStep: WorkflowStep = agentStep>({ id: 'root-cause', kind: 'agent', @@ -335,18 +399,23 @@ export const autofixWorkflow: Workflow = { steps: [ verifyInRepoStep, confirmFixGate, + installStep, rootCauseStep, fixStep, commitStep, + buildStep, + testStep, reviewStep, openPrStep, ], - // fix↔commit↔review loop until the reviewer passes; maxIterations mirrors + // fix↔commit↔build↔test↔review loop until the reviewer passes; a gated build + // or test failure short-circuits back to fix (carrying the failure tail as + // retry notes) before the reviewer ever sees the diff. maxIterations mirrors // today's `cfg.autofix.maxAttemptsPerIssue` (the engine clamps to ≥1). loops: [ { id: 'fix-review', - stepIds: ['fix', 'commit', 'review'], + stepIds: ['fix', 'commit', 'build', 'test', 'review'], until: (ctx) => ctx.blackboard.verdict?.verdict === 'pass', maxIterations: 2, }, diff --git a/packages/core/src/workflows/workflow-engine.ts b/packages/core/src/workflows/workflow-engine.ts index f2f3c80..b5bb840 100644 --- a/packages/core/src/workflows/workflow-engine.ts +++ b/packages/core/src/workflows/workflow-engine.ts @@ -7,6 +7,7 @@ import { createAgentRunner } from '../agents/runner-factory.js'; import { discoverSkills, type Skill } from '../skills/skill-catalog.js'; import { resolveStepConfig, type WorkflowBinding, type WorkspaceWorkflowSettings, DEFAULT_WORKSPACE_WORKFLOW_SETTINGS } from './binding.js'; import { commitAll, getDiffAgainstBase, startWorktreeAutosaver } from '../actions/autofix/worktree.js'; +import type { RunEnv } from '../provision/run-env.js'; import { PersistentClaudeSession } from '../agents/persistent-claude-session.js'; import { UNIFIED_AUTOFIX_SYSTEM_PROMPT } from '../actions/autofix/prompts/autofix-unified.js'; import { TokenBudget } from '../actions/autofix/token-budget.js'; @@ -59,6 +60,12 @@ export interface WorkflowRunContext { worktreePath?: string; /** Or: a hook the engine calls to set one up (and dispose it at the end). */ prepareWorktree?: () => Promise<{ path: string; dispose: () => Promise }>; + /** + * The runnable project environment bound to the worktree (native shell or a + * docker-compose project). Drives `shell-check` steps. When absent, those + * steps are no-ops, so env-less runs (tests, triage) behave as before. + */ + runEnv?: RunEnv; /** Per-attempt token budget; created fresh per loop iteration if omitted. */ tokenBudgetPerAttempt?: number; /** Lifecycle progress strings. */ @@ -467,6 +474,8 @@ export class WorkflowEngine { } outcome = step.onPushed({ headSha: headSha ?? '' }, stepCtx); } + } else if (step.kind === 'shell-check') { + outcome = await this.runShellCheck(step, stepCtx, ctx, living); } else { const exhaustive: never = step; throw new Error(`unknown step kind: ${JSON.stringify(exhaustive)}`); @@ -564,6 +573,36 @@ export class WorkflowEngine { return path; } + /** + * Run a `shell-check` step's command in the run's `RunEnv`. No env, or an + * unconfigured command, ⇒ a silent `continue` (env-less runs unaffected). + * Gated failures inside a loop fail-`retriable` so the engine re-enters it. + */ + private async runShellCheck( + step: Extract, { kind: 'shell-check' }>, + stepCtx: WorkflowStepContext, + ctx: WorkflowRunContext, + living: LivingComment, + ): Promise> { + const env = ctx.runEnv; + if (!env) return { kind: 'continue' }; + const result = await env[step.which]((line) => stepCtx.log(` [${step.which}] ${line}`)); + if (!result) return { kind: 'continue' }; // command not configured ⇒ skip the check + const gate = resolveStepValue(step.gate, ctx.config); + const patch = step.onResult?.(result, stepCtx) as Partial | undefined; + if (step.commentSection) await living.appendSection(step.id, step.commentSection(result, stepCtx)); + if (result.ok || !gate) { + if (!result.ok) ctx.onEvent?.(`[#${ctx.issueNumber}] ${step.which} failed (exit ${result.exitCode}) — not gated, continuing`); + return { kind: 'continue', blackboardPatch: patch }; + } + return { + kind: 'fail', + reason: `${step.which} failed (exit ${result.exitCode})`, + retriable: step.retriable === true, + blackboardPatch: patch, + }; + } + private async discoverSkillsSafe(ctx: WorkflowRunContext): Promise { const repoRoot = ctx.config.autofix?.repoRoot; if (!repoRoot) return []; diff --git a/packages/core/src/workflows/workflow.ts b/packages/core/src/workflows/workflow.ts index 8ee329f..de72c7b 100644 --- a/packages/core/src/workflows/workflow.ts +++ b/packages/core/src/workflows/workflow.ts @@ -3,6 +3,7 @@ import type { AgentBackend, AgentEvent } from '../agents/agent-runner.js'; import type { Config } from '../config/config.model.js'; import type { TokenBudget } from '../actions/autofix/token-budget.js'; import type { IssueStore } from '../store/store.js'; +import type { ShellResult } from '../provision/run-env.js'; /** * The declarative workflow types (docs/REFACTOR-PLAN-agent-cockpit.md §3.1). @@ -23,7 +24,7 @@ import type { IssueStore } from '../store/store.js'; export type WorkflowRunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'cancelled'; export type StepRunStatus = 'running' | 'succeeded' | 'failed' | 'skipped'; -export type WorkflowStepKind = 'agent' | 'effect' | 'human-gate' | 'commit' | 'open-pr' | 'push'; +export type WorkflowStepKind = 'agent' | 'effect' | 'human-gate' | 'commit' | 'open-pr' | 'push' | 'shell-check'; // ─── Comment helpers passed to a step ──────────────────────────────────────── @@ -192,13 +193,38 @@ export interface PushStepDef extends BaseStepDef { onPushed: (info: { headSha: string }, ctx: WorkflowStepContext) => StepOutcome; } +/** + * Run a project command (`install` / `build` / `test`) inside the run's + * `RunEnv` (native host shell or a per-run docker-compose project) and gate the + * workflow on its exit code. When `gate` is true and the command fails *inside + * a declared loop*, the step fails-`retriable` so the engine re-enters the loop + * (carrying the `onResult` patch — e.g. the failure tail as fix retry notes). + * + * No `RunEnv` on the run, or an unconfigured command, ⇒ the step is a no-op + * (`continue`), so existing repo-less / env-less runs are unaffected. + */ +export interface ShellCheckStepDef extends BaseStepDef { + kind: 'shell-check'; + /** Which configured command to run. */ + which: 'install' | 'build' | 'test'; + /** Whether a non-zero exit fails the step — config-derivable. */ + gate: StepValue; + /** When gated+failing inside a loop, mark the failure retriable so the loop re-enters. */ + retriable?: boolean; + /** Patch the blackboard from the result (both success and failure paths). */ + onResult?: (result: ShellResult, ctx: WorkflowStepContext) => Partial | undefined; + /** Render this step's section of the living comment from the result. */ + commentSection?: (result: ShellResult, ctx: WorkflowStepContext) => CommentSection; +} + export type WorkflowStep = | AgentStepDef | EffectStepDef | HumanGateStepDef | CommitStepDef | OpenPrStepDef - | PushStepDef; + | PushStepDef + | ShellCheckStepDef; // Helper so callers can author an AgentStepDef with a concrete T and have // it widen safely into WorkflowStep. (TS can't infer T through the union.) diff --git a/packages/core/tests/provision/run-env.test.ts b/packages/core/tests/provision/run-env.test.ts new file mode 100644 index 0000000..7b018f0 --- /dev/null +++ b/packages/core/tests/provision/run-env.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ConfigSchema } from '../../src/config/config.model.js'; +import { detectComposeFile, firstComposeService } from '../../src/provision/compose-detect.js'; +import { NativeShellEnv } from '../../src/provision/native-shell-env.js'; +import { createRunEnv } from '../../src/provision/create-run-env.js'; +import { tailLines } from '../../src/provision/run-env.js'; + +function projectEnv(overrides: Record = {}) { + return ConfigSchema.parse({ autofix: { projectEnv: overrides } }).autofix.projectEnv; +} + +describe('compose-detect', () => { + let dir: string; + beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cezar-detect-')); }); + afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + + it('returns null when no compose file is present', () => { + expect(detectComposeFile(dir)).toBeNull(); + }); + + it('detects docker-compose.yml with the right precedence', async () => { + await writeFile(join(dir, 'compose.yaml'), 'services:\n web: {}\n'); + await writeFile(join(dir, 'docker-compose.yml'), 'services:\n web: {}\n'); + expect(detectComposeFile(dir)).toBe('docker-compose.yml'); + }); + + it('extracts the first service name', async () => { + await writeFile( + join(dir, 'docker-compose.yml'), + ['version: "3"', 'services:', ' app:', ' build: .', ' db:', ' image: postgres', 'volumes:', ' pg: {}'].join('\n'), + ); + expect(firstComposeService(dir, 'docker-compose.yml')).toBe('app'); + }); + + it('returns null for a file with no services block', async () => { + await writeFile(join(dir, 'docker-compose.yml'), 'version: "3"\nvolumes:\n pg: {}\n'); + expect(firstComposeService(dir, 'docker-compose.yml')).toBeNull(); + }); +}); + +describe('NativeShellEnv', () => { + let dir: string; + beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cezar-native-')); }); + afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + + it('runs configured commands and reports success', async () => { + const env = new NativeShellEnv(dir, projectEnv({ install: 'true', test: 'echo hello' })); + const install = await env.install(); + expect(install?.ok).toBe(true); + const test = await env.test(); + expect(test?.ok).toBe(true); + expect(test?.stdout).toContain('hello'); + }); + + it('returns null for unconfigured commands', async () => { + const env = new NativeShellEnv(dir, projectEnv({ install: 'true' })); + expect(await env.build()).toBeNull(); + expect(await env.test()).toBeNull(); + }); + + it('reports a non-zero exit as not ok', async () => { + const env = new NativeShellEnv(dir, projectEnv({ test: 'exit 3' })); + const r = await env.test(); + expect(r?.ok).toBe(false); + expect(r?.exitCode).toBe(3); + }); + + it('injects envVars', async () => { + const env = new NativeShellEnv(dir, projectEnv({ test: 'echo $CEZAR_FOO', envVars: { CEZAR_FOO: 'bar' } })); + const r = await env.test(); + expect(r?.stdout).toContain('bar'); + }); +}); + +describe('createRunEnv', () => { + let dir: string; + beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cezar-factory-')); }); + afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + + it('returns a native env when kind is native', async () => { + const env = await createRunEnv({ worktreePath: dir, spec: projectEnv({ kind: 'native' }), runId: 'r1' }); + expect(env.kind).toBe('native'); + }); + + it('returns native under auto when no compose file exists', async () => { + const env = await createRunEnv({ worktreePath: dir, spec: projectEnv({ kind: 'auto' }), runId: 'r1' }); + expect(env.kind).toBe('native'); + }); + + it('throws under explicit compose when no compose file exists', async () => { + await expect( + createRunEnv({ worktreePath: dir, spec: projectEnv({ kind: 'compose' }), runId: 'r1' }), + ).rejects.toThrow(/no compose file/i); + }); +}); + +describe('tailLines', () => { + it('keeps the last N lines', () => { + const text = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); + const tail = tailLines(text, 5); + expect(tail.split('\n')).toHaveLength(5); + expect(tail).toContain('line 99'); + expect(tail).not.toContain('line 94'); + }); +}); diff --git a/packages/core/tests/workflows/shell-check.test.ts b/packages/core/tests/workflows/shell-check.test.ts new file mode 100644 index 0000000..0d5ed33 --- /dev/null +++ b/packages/core/tests/workflows/shell-check.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { runWorkflow, type WorkflowGitHub } from '../../src/workflows/workflow-engine.js'; +import { autofixWorkflow, type AutofixBlackboard } from '../../src/workflows/definitions/autofix.workflow.js'; +import type { AgentBackend, AgentRunner, AgentRunResult, AgentRunSpec } from '../../src/agents/agent-runner.js'; +import type { RunEnv, ShellResult } from '../../src/provision/run-env.js'; +import { IssueStore } from '../../src/store/store.js'; +import { ConfigSchema } from '../../src/config/config.model.js'; +import type { Store } from '../../src/store/store.model.js'; + +function makeConfig(projectEnv: Record = {}) { + return ConfigSchema.parse({ + github: { owner: 'acme', repo: 'cezar', token: 'token' }, + llm: { apiKey: 'test-key' }, + store: { path: '.issue-store-test' }, + autofix: { enabled: true, repoRoot: '/tmp/repo', maxAttemptsPerIssue: 3, projectEnv }, + }); +} + +async function makeStore(): Promise { + const data: Store = { + meta: { owner: 'acme', repo: 'cezar', lastSyncedAt: null, totalFetched: 0, version: 1, orgMembers: [], orgMembersFetchedAt: null }, + issues: [], + }; + return IssueStore.fromPort({ load: async () => data, save: async () => {} }); +} + +function makeFakeGitHub(): WorkflowGitHub { + let nextId = 3000; + return { + async addComment() { return nextId++; }, + async updateComment() {}, + async getIssueWithComments(n) { + return { issue: { number: n, title: `Bug #${n}`, body: 'It breaks.' }, comments: [] }; + }, + async setLabels() {}, + async addLabel() {}, + async closeIssue() {}, + async pushBranch() {}, + async createPullRequest() { return { url: 'https://example.test/pr/7', number: 7 }; }, + }; +} + +class SequencedRunner implements AgentRunner { + private idx = 0; + readonly calls: string[] = []; + constructor(readonly backend: AgentBackend, private readonly outputs: unknown[]) {} + async run(spec: AgentRunSpec): Promise> { + this.calls.push(spec.systemPrompt.slice(0, 12)); + const parsed = (this.outputs[this.idx] ?? null) as T | null; + this.idx++; + return { text: JSON.stringify(parsed), parsed, toolCalls: [], tokensUsed: 10, budgetExceeded: false }; + } + async interrupt(): Promise {} +} + +function makeFakeGit() { + let n = 0; + return { + async commitAll(): Promise { n++; return `sha${n}000000`; }, + async getDiffAgainstBase(): Promise { return 'diff'; }, + }; +} + +function shell(command: string, ok: boolean): ShellResult { + return { command, ok, exitCode: ok ? 0 : 1, stdout: ok ? 'pass' : '', stderr: ok ? '' : 'FAIL: expected 1 got 2', durationMs: 1 }; +} + +/** A fake env recording which commands ran; `test` fails its first N calls. */ +function makeFakeRunEnv(testFailures = 0): RunEnv & { ran: string[] } { + let testCalls = 0; + const ran: string[] = []; + return { + ran, + kind: 'native', + async install() { ran.push('install'); return shell('install', true); }, + async build() { ran.push('build'); return shell('build', true); }, + async test() { ran.push('test'); testCalls++; return shell('test', testCalls > testFailures); }, + async run(cmd) { return shell(cmd, true); }, + async dispose() {}, + }; +} + +const VERIFY = { isRealUnfixedDefect: true, reason: 'reproduced', confidence: 0.9 }; +const ROOT = { summary: 'null deref', suspectedFiles: ['a.ts'], hypothesis: 'token undefined', confidence: 0.85 }; +const FIX = { changedFiles: ['a.ts'], approach: 'guard token', testCommandsRun: [] }; +const REVIEW_PASS = { verdict: 'pass' as const, summary: 'good', issues: [] }; + +describe('autofix shell-check steps', () => { + it('runs install/build/test and opens a PR when everything passes', async () => { + const runner = new SequencedRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const env = makeFakeRunEnv(0); + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ install: 'yarn', build: 'yarn build', test: 'yarn test' }), + github: makeFakeGitHub(), issueNumber: 10, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 3 }, + }); + + expect(result.status).toBe('succeeded'); + expect(result.prNumber).toBe(7); + expect(env.ran).toEqual(['install', 'build', 'test']); + const bb = result.blackboard as AutofixBlackboard; + expect(bb.installResult?.ok).toBe(true); + expect(bb.testResult?.ok).toBe(true); + }); + + it('loops back to fix when a gated test fails, carrying the failure as retry notes', async () => { + // fix runs twice (iter 0 fails test, iter 1 passes), so 5 agent outputs. + const runner = new SequencedRunner('anthropic-api', [VERIFY, ROOT, FIX, FIX, REVIEW_PASS]); + const env = makeFakeRunEnv(1); // test fails once + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ test: 'yarn test', gateOnTest: true }), + github: makeFakeGitHub(), issueNumber: 11, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 3 }, + }); + + expect(result.status).toBe('succeeded'); + // test ran twice (fail, then pass); review only reached after the pass. + expect(env.ran.filter((c) => c === 'test')).toHaveLength(2); + expect(runner.calls.filter((c) => c.includes('FIX') || c.length > 0)).toBeTruthy(); + }); + + it('fails the run when a gated test never passes (loop exhausted)', async () => { + const runner = new SequencedRunner('anthropic-api', [VERIFY, ROOT, FIX, FIX, FIX]); + const env = makeFakeRunEnv(99); // test always fails + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ test: 'yarn test', gateOnTest: true }), + github: makeFakeGitHub(), issueNumber: 12, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 2 }, + }); + + expect(result.status).toBe('failed'); + expect(result.reason).toMatch(/test failed/i); + }); + + it('does not gate when gateOnTest is false — test failure is informational', async () => { + const runner = new SequencedRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const env = makeFakeRunEnv(99); // test always fails but ungated + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ test: 'yarn test', gateOnTest: false }), + github: makeFakeGitHub(), issueNumber: 13, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 3 }, + }); + + expect(result.status).toBe('succeeded'); + expect(result.prNumber).toBe(7); + }); + + it('skips shell-check steps entirely when no RunEnv is supplied', async () => { + const runner = new SequencedRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ test: 'yarn test' }), + github: makeFakeGitHub(), issueNumber: 14, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), + loopMaxIterations: { 'fix-review': 3 }, + }); + expect(result.status).toBe('succeeded'); + }); +}); diff --git a/packages/gui/src/app/settings/actions.ts b/packages/gui/src/app/settings/actions.ts index a43f340..4bfad24 100644 --- a/packages/gui/src/app/settings/actions.ts +++ b/packages/gui/src/app/settings/actions.ts @@ -53,6 +53,21 @@ export async function saveWorkspaceConfig( fixer: num(formData, 'autofix.maxTurns.fixer', 30), reviewer: num(formData, 'autofix.maxTurns.reviewer', 10), }, + projectEnv: { + kind: enumStr(formData, 'autofix.projectEnv.kind', ['auto', 'native', 'compose'], 'auto'), + install: rawStr(formData, 'autofix.projectEnv.install'), + build: rawStr(formData, 'autofix.projectEnv.build'), + test: rawStr(formData, 'autofix.projectEnv.test'), + compose: { + file: rawStr(formData, 'autofix.projectEnv.compose.file'), + service: rawStr(formData, 'autofix.projectEnv.compose.service'), + workdir: str(formData, 'autofix.projectEnv.compose.workdir', '/app'), + }, + envVars: parseEnvVars(formData.get('autofix.projectEnv.envVars') as string | null), + gateOnInstall: bool(formData, 'autofix.projectEnv.gateOnInstall'), + gateOnBuild: bool(formData, 'autofix.projectEnv.gateOnBuild'), + gateOnTest: bool(formData, 'autofix.projectEnv.gateOnTest'), + }, }, }; @@ -74,6 +89,27 @@ export async function saveWorkspaceConfig( function str(fd: FormData, key: string, fallback: string): string { return (fd.get(key) as string)?.trim() || fallback; } +/** Trimmed string, defaulting to '' (no fallback) — for optional command fields. */ +function rawStr(fd: FormData, key: string): string { + return (fd.get(key) as string)?.trim() || ''; +} +function enumStr(fd: FormData, key: string, allowed: readonly T[], fallback: T): T { + const v = (fd.get(key) as string)?.trim(); + return allowed.includes(v as T) ? (v as T) : fallback; +} +/** Parse `KEY=VALUE` lines into a record; blank lines and lines without `=` are ignored. */ +function parseEnvVars(raw: string | null): Record { + const out: Record = {}; + if (!raw) return out; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return out; +} function num(fd: FormData, key: string, fallback: number): number { const v = Number(fd.get(key)); return Number.isFinite(v) ? v : fallback; diff --git a/packages/gui/src/app/settings/settings-form.tsx b/packages/gui/src/app/settings/settings-form.tsx index 5be7ee2..94e3dc2 100644 --- a/packages/gui/src/app/settings/settings-form.tsx +++ b/packages/gui/src/app/settings/settings-form.tsx @@ -23,6 +23,11 @@ export function SettingsForm({ config, issueAutofixMode, readOnly }: SettingsFor const autofix = (config.autofix ?? {}) as Record; const models = (autofix.models ?? {}) as Record; const maxTurns = (autofix.maxTurns ?? {}) as Record; + const projectEnv = (autofix.projectEnv ?? {}) as Record; + const compose = (projectEnv.compose ?? {}) as Record; + const envVarsText = Object.entries((projectEnv.envVars ?? {}) as Record) + .map(([k, v]) => `${k}=${v}`) + .join('\n'); return (
@@ -79,6 +84,50 @@ export function SettingsForm({ config, issueAutofixMode, readOnly }: SettingsFor + +

+ How Cezar installs deps, builds, and runs tests on the checked-out worktree so the autofix + loop gets real build/test feedback before opening a PR. Compose runs each + command inside a per-run docker compose project (worktree bind-mounted) and + requires a self-hosted runner with Docker. Auto uses compose when a + compose file is present, else the host shell. Leave commands blank to skip that check. +

+
+ +
+ + + +
+ + + +
+ + + +
+ +
+ +
diff --git a/packages/gui/src/lib/load-workspace-config.ts b/packages/gui/src/lib/load-workspace-config.ts index 53aa75e..94e3d80 100644 --- a/packages/gui/src/lib/load-workspace-config.ts +++ b/packages/gui/src/lib/load-workspace-config.ts @@ -50,7 +50,8 @@ export async function loadWorkspaceConfig( if (Object.keys(wAutofix).length > 0) { const models = wAutofix.models as Record | undefined; const maxTurns = wAutofix.maxTurns as Record | undefined; - const { models: _m, maxTurns: _t, ...rest } = wAutofix; + const projectEnv = wAutofix.projectEnv as Record | undefined; + const { models: _m, maxTurns: _t, projectEnv: _p, ...rest } = wAutofix; Object.assign(baseConfig.autofix, rest); if (models && Object.keys(models).length > 0) { Object.assign(baseConfig.autofix.models, models); @@ -58,6 +59,22 @@ export async function loadWorkspaceConfig( if (maxTurns && Object.keys(maxTurns).length > 0) { Object.assign(baseConfig.autofix.maxTurns, maxTurns); } + // Deep-merge projectEnv so a partial workspace override keeps the defaults + // for unset fields (nested `compose` and `envVars` merged too). + if (projectEnv && Object.keys(projectEnv).length > 0) { + const { compose, envVars, ...peRest } = projectEnv as { + compose?: Record; + envVars?: Record; + [k: string]: unknown; + }; + Object.assign(baseConfig.autofix.projectEnv, peRest); + if (compose && Object.keys(compose).length > 0) { + Object.assign(baseConfig.autofix.projectEnv.compose, compose); + } + if (envVars) { + baseConfig.autofix.projectEnv.envVars = envVars; + } + } } // Inject the GUI-configured workflow bindings/settings so a binding actually From cb24741251d0b5ab1eb6b50bcef2819780c90127 Mon Sep 17 00:00:00 2001 From: Patryk Lewczuk Date: Wed, 20 May 2026 22:22:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(autofix):=20Phase=203=20=E2=80=94=20de?= =?UTF-8?q?v=20server=20boot=20+=20agent=20live-app=20probing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot the project's dev server once after install so the agent can probe the running application while diagnosing and verifying its fix. Core: - `RunEnv` gains `startDevServer()` → `DevServerHandle { url, ready, stop }`, plus a `waitForHttp` readiness probe. `NativeShellEnv` spawns the command detached and SIGTERMs the process group on stop/dispose; `DockerComposeEnv` publishes the container port to an ephemeral host port via the override file, `up -d`s the service, and resolves the mapped host port with `compose port` (`parseHostPort`). Both are torn down by `dispose()`. - New `dev-server` workflow step kind: boots the server, records the URL on the run, and (informational by default) keeps going if it's slow to come up; `gate: true` fails the run if it never responds. No env / disabled ⇒ no-op. - The engine threads the dev-server URL to later agent steps: appends the URL to the step prompt and adds `curl` to the bash allowlist, so the agent can read the live app over its existing Bash tool (backend-agnostic "read-url" — a bespoke custom tool would need a per-backend MCP server). - autofix workflow runs `dev-server` after install, before root-cause; new `devServerUrl` blackboard field. - Config: `autofix.projectEnv.devServer { enabled, command, port, readyPath, readyTimeoutSec }`. GUI: dev-server fields in the Settings → Project environment section; persisted to `workspaces.config` and deep-merged in loadWorkspaceConfig. Deferred: headless screenshots (needs a browser dep) — left for a follow-up. Tests: real NativeShellEnv boot/probe/teardown against a throwaway Node server; engine dev-server scenarios (URL+curl injection ordering, unconfigured no-op, ungated boot failure). 162 core tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/config/config.model.ts | 15 ++ packages/core/src/index.ts | 3 + .../core/src/provision/docker-compose-env.ts | 67 ++++++++- packages/core/src/provision/index.ts | 6 +- .../core/src/provision/native-shell-env.ts | 43 +++++- packages/core/src/provision/run-env.ts | 37 ++++- .../workflows/definitions/autofix.workflow.ts | 18 +++ .../core/src/workflows/workflow-engine.ts | 62 +++++++- packages/core/src/workflows/workflow.ts | 23 ++- packages/core/tests/provision/run-env.test.ts | 34 +++++ .../core/tests/workflows/dev-server.test.ts | 138 ++++++++++++++++++ .../core/tests/workflows/shell-check.test.ts | 1 + packages/gui/src/app/settings/actions.ts | 7 + .../gui/src/app/settings/settings-form.tsx | 16 ++ packages/gui/src/lib/load-workspace-config.ts | 6 +- 15 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 packages/core/tests/workflows/dev-server.test.ts diff --git a/packages/core/src/config/config.model.ts b/packages/core/src/config/config.model.ts index 4a3dec0..3fc944d 100644 --- a/packages/core/src/config/config.model.ts +++ b/packages/core/src/config/config.model.ts @@ -93,6 +93,21 @@ export const ConfigSchema = z.object({ 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']), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c90e897..7f755a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,8 +106,11 @@ export { firstComposeService, COMPOSE_FILENAMES, tailLines, + waitForHttp, + parseHostPort, type RunEnv, type ShellResult, + type DevServerHandle, type ProjectEnvSpec, type CreateRunEnvOpts, type ComposeCommand, diff --git a/packages/core/src/provision/docker-compose-env.ts b/packages/core/src/provision/docker-compose-env.ts index cb69ec1..85e7c0c 100644 --- a/packages/core/src/provision/docker-compose-env.ts +++ b/packages/core/src/provision/docker-compose-env.ts @@ -2,7 +2,8 @@ import { spawn } from 'node:child_process'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; +import type { DevServerHandle, ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; +import { waitForHttp } from './run-env.js'; /** How to invoke compose on this host: `docker compose …` (v2) or `docker-compose …` (v1). */ export interface ComposeCommand { @@ -41,6 +42,7 @@ export class DockerComposeEnv implements RunEnv { private overrideDir: string | null = null; private overrideFile: string | null = null; private setupPromise: Promise | null = null; + private devHandle: DevServerHandle | null = null; constructor(private readonly opts: DockerComposeEnvOpts) {} @@ -66,14 +68,21 @@ export class DockerComposeEnv implements RunEnv { this.setupPromise = (async () => { this.overrideDir = await mkdtemp(join(tmpdir(), 'cezar-compose-')); this.overrideFile = join(this.overrideDir, 'compose.cezar.override.yaml'); - const yaml = [ + const ds = this.opts.spec.devServer; + const lines = [ 'services:', ` ${this.opts.service}:`, ' volumes:', ` - ${this.opts.worktreePath}:${this.opts.workdir}`, - '', - ].join('\n'); - await writeFile(this.overrideFile, yaml, 'utf8'); + ]; + // Publish the dev-server container port to an ephemeral host port so + // concurrent runs don't collide. Only affects `up` (run --rm ignores it). + if (ds.enabled && ds.port) { + lines.push(' ports:', ` - "${ds.port}"`); + if (ds.command.trim()) lines.push(` command: ${ds.command}`); + } + lines.push(''); + await writeFile(this.overrideFile, lines.join('\n'), 'utf8'); })(); } return this.setupPromise; @@ -107,7 +116,46 @@ export class DockerComposeEnv implements RunEnv { return spawnCapture(this.opts.compose.bin, args, command, onLine); } + async startDevServer(onLine?: (line: string) => void): Promise { + if (this.devHandle) return this.devHandle; + const ds = this.opts.spec.devServer; + if (!ds.enabled) return null; + if (!ds.port) throw new Error('projectEnv.devServer.port (the container port) is required for the compose dev server'); + await this.ensureOverride(); + + const up = await spawnCapture( + this.opts.compose.bin, + [...this.baseArgs(), 'up', '-d', this.opts.service], + 'compose up -d', + onLine, + ); + if (!up.ok) throw new Error(`compose up failed (exit ${up.exitCode}): ${up.stderr.split('\n').slice(-3).join(' ')}`); + + // Ask compose which host port the container port got published to. + const portOut = await spawnCapture( + this.opts.compose.bin, + [...this.baseArgs(), 'port', this.opts.service, String(ds.port)], + 'compose port', + ); + const hostPort = parseHostPort(portOut.stdout); + if (!hostPort) throw new Error(`could not resolve the published host port for ${this.opts.service}:${ds.port}`); + + const url = `http://127.0.0.1:${hostPort}`; + const status = await waitForHttp(`${url}${ds.readyPath}`, ds.readyTimeoutSec * 1000); + + this.devHandle = { + url, + ready: status != null, + stop: async () => { + await spawnCapture(this.opts.compose.bin, [...this.baseArgs(), 'stop', this.opts.service], 'compose stop').catch(() => {}); + this.devHandle = null; + }, + }; + return this.devHandle; + } + async dispose(): Promise { + this.devHandle = null; // `down -v` below stops everything anyway if (this.setupPromise) { try { await spawnCapture(this.opts.compose.bin, [...this.baseArgs(), 'down', '-v', '--remove-orphans'], 'compose down'); @@ -123,6 +171,15 @@ export class DockerComposeEnv implements RunEnv { } } +/** Parse `docker compose port` output (e.g. `0.0.0.0:54213` / `[::]:54213`) → the host port. */ +export function parseHostPort(out: string): number | null { + for (const line of out.split('\n').map((l) => l.trim()).filter(Boolean)) { + const m = line.match(/:(\d+)\s*$/); + if (m) return Number(m[1]); + } + return null; +} + /** Spawn a process with array args (no shell), capture stdout/stderr, stream lines. */ export function spawnCapture( bin: string, diff --git a/packages/core/src/provision/index.ts b/packages/core/src/provision/index.ts index 2871fad..540f676 100644 --- a/packages/core/src/provision/index.ts +++ b/packages/core/src/provision/index.ts @@ -1,6 +1,6 @@ -export type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; -export { tailLines } from './run-env.js'; +export type { ProjectEnvSpec, RunEnv, ShellResult, DevServerHandle } from './run-env.js'; +export { tailLines, waitForHttp } from './run-env.js'; export { NativeShellEnv } from './native-shell-env.js'; -export { DockerComposeEnv, spawnCapture, type ComposeCommand, type DockerComposeEnvOpts } from './docker-compose-env.js'; +export { DockerComposeEnv, spawnCapture, parseHostPort, type ComposeCommand, type DockerComposeEnvOpts } from './docker-compose-env.js'; export { detectComposeFile, firstComposeService, COMPOSE_FILENAMES } from './compose-detect.js'; export { createRunEnv, resolveComposeCommand, type CreateRunEnvOpts } from './create-run-env.js'; diff --git a/packages/core/src/provision/native-shell-env.ts b/packages/core/src/provision/native-shell-env.ts index 8b712b0..d9f74f8 100644 --- a/packages/core/src/provision/native-shell-env.ts +++ b/packages/core/src/provision/native-shell-env.ts @@ -1,5 +1,6 @@ -import { spawn } from 'node:child_process'; -import type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; +import { spawn, type ChildProcess } from 'node:child_process'; +import type { DevServerHandle, ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; +import { waitForHttp } from './run-env.js'; /** * Runs project commands directly on the host, inside the worktree. The trust @@ -8,6 +9,7 @@ import type { ProjectEnvSpec, RunEnv, ShellResult } from './run-env.js'; */ export class NativeShellEnv implements RunEnv { readonly kind = 'native' as const; + private devServer: { child: ChildProcess; handle: DevServerHandle } | null = null; constructor( private readonly worktreePath: string, @@ -62,7 +64,40 @@ export class NativeShellEnv implements RunEnv { }); } - dispose(): Promise { - return Promise.resolve(); + async startDevServer(onLine?: (line: string) => void): Promise { + if (this.devServer) return this.devServer.handle; + const ds = this.spec.devServer; + if (!ds.enabled || !ds.command.trim()) return null; + if (!ds.port) throw new Error('projectEnv.devServer.port is required for the native dev server'); + + // detached so we can signal the whole process group on stop (yarn → node → …). + const child = spawn(ds.command, { + cwd: this.worktreePath, + shell: true, + detached: true, + env: { ...process.env, ...this.spec.envVars }, + }); + child.stdout?.on('data', (c: Buffer) => onLine?.(`[dev] ${c.toString().trimEnd()}`)); + child.stderr?.on('data', (c: Buffer) => onLine?.(`[dev] ${c.toString().trimEnd()}`)); + + const url = `http://127.0.0.1:${ds.port}`; + const status = await waitForHttp(`${url}${ds.readyPath}`, ds.readyTimeoutSec * 1000); + + const handle: DevServerHandle = { + url, + ready: status != null, + stop: async () => { + if (this.devServer?.child.pid && !this.devServer.child.killed) { + try { process.kill(-this.devServer.child.pid, 'SIGTERM'); } catch { /* group may be gone */ } + } + this.devServer = null; + }, + }; + this.devServer = { child, handle }; + return handle; + } + + async dispose(): Promise { + await this.devServer?.handle.stop(); } } diff --git a/packages/core/src/provision/run-env.ts b/packages/core/src/provision/run-env.ts index 89a09c2..89b4479 100644 --- a/packages/core/src/provision/run-env.ts +++ b/packages/core/src/provision/run-env.ts @@ -3,6 +3,16 @@ import type { Config } from '../config/config.model.js'; /** The resolved `autofix.projectEnv` block (zod always supplies defaults). */ export type ProjectEnvSpec = NonNullable['projectEnv']; +/** A running dev server the agent can probe. */ +export interface DevServerHandle { + /** Base URL the agent can hit, e.g. `http://127.0.0.1:54213`. */ + url: string; + /** Did the readiness probe get an HTTP response within the timeout? */ + ready: boolean; + /** Stop the server. Also called implicitly by `RunEnv.dispose()`. */ + stop(): Promise; +} + /** Outcome of running one command inside a `RunEnv`. */ export interface ShellResult { /** The command as Cezar invoked it (for the cockpit / comment). */ @@ -34,10 +44,35 @@ export interface RunEnv { test(onLine?: (line: string) => void): Promise; /** Run an arbitrary command in the env. */ run(command: string, onLine?: (line: string) => void): Promise; - /** Tear the env down (compose: `down -v`; native: no-op). Idempotent. */ + /** + * Boot the configured dev server and wait (best-effort) for it to respond. + * Returns `null` when no dev server is configured/enabled. Idempotent — a + * second call returns the same handle. The server is also stopped by + * `dispose()`. + */ + startDevServer(onLine?: (line: string) => void): Promise; + /** Tear the env down (stops the dev server; compose: `down -v`; native: no-op). Idempotent. */ dispose(): Promise; } +/** + * Poll `url` until it returns any HTTP response or `timeoutMs` elapses. "Any + * response" (even a 4xx/5xx) means the server is listening — good enough for a + * readiness gate. Returns the final status, or `null` on timeout. + */ +export async function waitForHttp(url: string, timeoutMs: number, intervalMs = 750): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(Math.min(5000, intervalMs * 4)) }); + return res.status; + } catch { + if (Date.now() >= deadline) return null; + await new Promise((r) => setTimeout(r, intervalMs)); + } + } +} + /** Keep only the last `maxLines` lines of `text` — for failure tails in comments / prompts. */ export function tailLines(text: string, maxLines = 40): string { const lines = text.split('\n'); diff --git a/packages/core/src/workflows/definitions/autofix.workflow.ts b/packages/core/src/workflows/definitions/autofix.workflow.ts index 2e1fdc9..951efe0 100644 --- a/packages/core/src/workflows/definitions/autofix.workflow.ts +++ b/packages/core/src/workflows/definitions/autofix.workflow.ts @@ -82,6 +82,8 @@ export interface AutofixBlackboard { installResult?: ShellResult; buildResult?: ShellResult; testResult?: ShellResult; + /** Dev server URL once booted (set by the dev-server step). */ + devServerUrl?: string; prUrl?: string; prNumber?: number; headSha?: string; @@ -225,6 +227,21 @@ const testStep: WorkflowStep = { commentSection: (result): CommentSection => shellCommentSection('Tests', result), }; +// Boot the dev server (if configured) after install, before diagnosis, so +// root-cause / fix can probe the live app via curl. Torn down with the run env. +const devServerStep: WorkflowStep = { + id: 'dev-server', + kind: 'dev-server', + builtinSkillId: 'dev-server', + // Informational: don't fail the run if the server is slow to come up. + gate: false, + onReady: (info) => ({ devServerUrl: info.url }), + commentSection: (info): CommentSection => + info.url + ? { heading: info.ready ? '🌐 Dev server ready' : '🌐 Dev server started (not confirmed ready)', body: `Running at \`${info.url}\` — the agent can probe it with \`curl\`.` } + : null, +}; + const rootCauseStep: WorkflowStep = agentStep>({ id: 'root-cause', kind: 'agent', @@ -400,6 +417,7 @@ export const autofixWorkflow: Workflow = { verifyInRepoStep, confirmFixGate, installStep, + devServerStep, rootCauseStep, fixStep, commitStep, diff --git a/packages/core/src/workflows/workflow-engine.ts b/packages/core/src/workflows/workflow-engine.ts index b5bb840..4f2ac0a 100644 --- a/packages/core/src/workflows/workflow-engine.ts +++ b/packages/core/src/workflows/workflow-engine.ts @@ -274,6 +274,7 @@ export class WorkflowEngine { let headSha: string | undefined; let prUrl: string | undefined; let prNumber = ctx.prNumber; + let devServerUrl: string | undefined; // Phase B: unified persistent session for autofix. // Per docs/REFACTOR-PLAN-persistent-autofix-session.md §5 Phase B. @@ -393,7 +394,7 @@ export class WorkflowEngine { const stepCtx = this.makeStepContext({ blackboard, iteration, config: ctx.config, issue: { number: ctx.issueNumber, title: issueTitle, body: issueBody, comments: issueComments, digest }, - prNumber, branch, worktreePath, + prNumber, branch, worktreePath, devServerUrl, tokenBudget: undefined, // set per agent step below onAgentEvent: ctx.onAgentEvent, onEvent: ctx.onEvent, appendCommentSection: (section) => living.appendSection(step.id, section), @@ -476,6 +477,10 @@ export class WorkflowEngine { } } else if (step.kind === 'shell-check') { outcome = await this.runShellCheck(step, stepCtx, ctx, living); + } else if (step.kind === 'dev-server') { + const r = await this.runDevServer(step, stepCtx, ctx, living); + if (r.url) devServerUrl = r.url; // exposed to later agent steps + outcome = r.outcome; } else { const exhaustive: never = step; throw new Error(`unknown step kind: ${JSON.stringify(exhaustive)}`); @@ -603,6 +608,44 @@ export class WorkflowEngine { }; } + /** + * Boot the run's dev server. Returns the URL (so the engine can expose it to + * later agent steps) plus the step outcome. No env / disabled dev server ⇒ a + * silent `continue`. A boot error, or a never-ready server when `gate` is on, + * fails the run; otherwise it's recorded and the run continues. + */ + private async runDevServer( + step: Extract, { kind: 'dev-server' }>, + stepCtx: WorkflowStepContext, + ctx: WorkflowRunContext, + living: LivingComment, + ): Promise<{ outcome: StepOutcome; url?: string }> { + const env = ctx.runEnv; + if (!env) return { outcome: { kind: 'continue' } }; + const gate = step.gate ? resolveStepValue(step.gate, ctx.config) : false; + + let handle; + try { + handle = await env.startDevServer((line) => stepCtx.log(` ${line}`)); + } catch (err) { + const reason = `dev server failed to start: ${(err as Error).message}`; + if (step.commentSection) await living.appendSection(step.id, step.commentSection({ url: null, ready: false }, stepCtx)); + return { outcome: gate ? { kind: 'fail', reason } : { kind: 'continue' } }; + } + if (!handle) return { outcome: { kind: 'continue' } }; // not configured + + if (step.commentSection) await living.appendSection(step.id, step.commentSection({ url: handle.url, ready: handle.ready }, stepCtx)); + const patch = step.onReady?.({ url: handle.url, ready: handle.ready }, stepCtx) as Partial | undefined; + + if (!handle.ready && gate) { + return { outcome: { kind: 'fail', reason: `dev server at ${handle.url} never became ready` }, url: handle.url }; + } + if (!handle.ready) { + ctx.onEvent?.(`[#${ctx.issueNumber}] dev server at ${handle.url} not confirmed ready — continuing`); + } + return { outcome: { kind: 'continue', blackboardPatch: patch }, url: handle.url }; + } + private async discoverSkillsSafe(ctx: WorkflowRunContext): Promise { const repoRoot = ctx.config.autofix?.repoRoot; if (!repoRoot) return []; @@ -622,6 +665,7 @@ export class WorkflowEngine { prNumber?: number; branch?: string; worktreePath?: string; + devServerUrl?: string; tokenBudget?: TokenBudget; onAgentEvent?: (e: AgentEvent) => void; onEvent?: (e: string) => void; @@ -636,6 +680,7 @@ export class WorkflowEngine { prNumber: args.prNumber, branch: args.branch, worktreePath: args.worktreePath, + devServerUrl: args.devServerUrl, tokenBudget: args.tokenBudget, emit: (e) => args.onAgentEvent?.(e), log: (m) => args.onEvent?.(m), @@ -747,12 +792,23 @@ export class WorkflowEngine { const runner = runnerFactory(resolved.backend); const allowedTools = [...builtinTools, ...resolved.extraTools]; + + // When a dev server is up, give this agent step the live URL and let it + // probe the running app with `curl` over Bash (backend-agnostic "read-url"). + let userPrompt = step.buildUserPrompt(ctxWithBudget); + let effectiveBashAllowlist = bashAllowlist; + if (stepCtx.devServerUrl && allowedTools.includes('Bash')) { + userPrompt += `\n\n---\nA dev server for this project is running at ${stepCtx.devServerUrl}. You can probe the live application with curl (e.g. \`curl -sS ${stepCtx.devServerUrl}/\`) to reproduce the issue or verify your change.`; + // Only widen an existing allowlist; an undefined allowlist already permits curl. + if (bashAllowlist) effectiveBashAllowlist = [...bashAllowlist, 'curl']; + } + const spec: AgentRunSpec = { systemPrompt: resolved.systemPrompt, - userPrompt: step.buildUserPrompt(ctxWithBudget), + userPrompt, cwd: args.worktreePath ?? process.cwd(), allowedTools, - bashAllowlist, + bashAllowlist: effectiveBashAllowlist, model: resolved.model, maxTurns, tokenBudget: budget, diff --git a/packages/core/src/workflows/workflow.ts b/packages/core/src/workflows/workflow.ts index de72c7b..2c43fac 100644 --- a/packages/core/src/workflows/workflow.ts +++ b/packages/core/src/workflows/workflow.ts @@ -24,7 +24,7 @@ import type { ShellResult } from '../provision/run-env.js'; export type WorkflowRunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'cancelled'; export type StepRunStatus = 'running' | 'succeeded' | 'failed' | 'skipped'; -export type WorkflowStepKind = 'agent' | 'effect' | 'human-gate' | 'commit' | 'open-pr' | 'push' | 'shell-check'; +export type WorkflowStepKind = 'agent' | 'effect' | 'human-gate' | 'commit' | 'open-pr' | 'push' | 'shell-check' | 'dev-server'; // ─── Comment helpers passed to a step ──────────────────────────────────────── @@ -78,6 +78,8 @@ export interface WorkflowStepContext { readonly branch?: string; /** The git worktree path (autofix/ci-followup); undefined for repo-less workflows (triage). */ readonly worktreePath?: string; + /** Base URL of the run's dev server once a `dev-server` step has booted it (else undefined). */ + readonly devServerUrl?: string; /** Per-attempt token budget (autofix loop iterations get a fresh one). */ readonly tokenBudget?: TokenBudget; /** Stream a normalized agent event (or a lifecycle note) out to the caller. */ @@ -217,6 +219,22 @@ export interface ShellCheckStepDef extends BaseStepDef { commentSection?: (result: ShellResult, ctx: WorkflowStepContext) => CommentSection; } +/** + * Boot the run's dev server (via the `RunEnv`) so subsequent agent steps can + * probe the live app with `curl` over Bash (the engine appends the URL to their + * prompt + adds `curl` to the bash allowlist). No `RunEnv` / a disabled dev + * server ⇒ a no-op `continue`. The server is torn down with the run env. + */ +export interface DevServerStepDef extends BaseStepDef { + kind: 'dev-server'; + /** When true, fail the run if the server never became ready — config-derivable. */ + gate?: StepValue; + /** Patch the blackboard once the server is up. */ + onReady?: (info: { url: string; ready: boolean }, ctx: WorkflowStepContext) => Partial | undefined; + /** Render this step's living-comment section. */ + commentSection?: (info: { url: string | null; ready: boolean }, ctx: WorkflowStepContext) => CommentSection; +} + export type WorkflowStep = | AgentStepDef | EffectStepDef @@ -224,7 +242,8 @@ export type WorkflowStep = | CommitStepDef | OpenPrStepDef | PushStepDef - | ShellCheckStepDef; + | ShellCheckStepDef + | DevServerStepDef; // Helper so callers can author an AgentStepDef with a concrete T and have // it widen safely into WorkflowStep. (TS can't infer T through the union.) diff --git a/packages/core/tests/provision/run-env.test.ts b/packages/core/tests/provision/run-env.test.ts index 7b018f0..ea3972d 100644 --- a/packages/core/tests/provision/run-env.test.ts +++ b/packages/core/tests/provision/run-env.test.ts @@ -75,6 +75,40 @@ describe('NativeShellEnv', () => { }); }); +describe('NativeShellEnv dev server', () => { + let dir: string; + beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cezar-dev-')); }); + afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + + it('boots a server, probes readiness, and stops it on dispose', async () => { + const port = 49000 + Math.floor(Math.random() * 10000); + const command = `node -e "require('http').createServer((q,s)=>s.end('ok')).listen(${port})"`; + const env = new NativeShellEnv(dir, projectEnv({ + devServer: { enabled: true, command, port, readyPath: '/', readyTimeoutSec: 10 }, + })); + + const handle = await env.startDevServer(); + expect(handle).not.toBeNull(); + expect(handle?.url).toBe(`http://127.0.0.1:${port}`); + expect(handle?.ready).toBe(true); + + // a second call returns the same handle (idempotent) + const again = await env.startDevServer(); + expect(again?.url).toBe(handle?.url); + + await env.dispose(); + // after dispose the port should be free again — a fresh probe fails + const { waitForHttp } = await import('../../src/provision/run-env.js'); + const status = await waitForHttp(`http://127.0.0.1:${port}/`, 1000, 200); + expect(status).toBeNull(); + }, 20000); + + it('returns null when the dev server is disabled', async () => { + const env = new NativeShellEnv(dir, projectEnv({ devServer: { enabled: false } })); + expect(await env.startDevServer()).toBeNull(); + }); +}); + describe('createRunEnv', () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cezar-factory-')); }); diff --git a/packages/core/tests/workflows/dev-server.test.ts b/packages/core/tests/workflows/dev-server.test.ts new file mode 100644 index 0000000..5672d60 --- /dev/null +++ b/packages/core/tests/workflows/dev-server.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { runWorkflow, type WorkflowGitHub } from '../../src/workflows/workflow-engine.js'; +import { autofixWorkflow, type AutofixBlackboard } from '../../src/workflows/definitions/autofix.workflow.js'; +import type { AgentBackend, AgentRunner, AgentRunResult, AgentRunSpec } from '../../src/agents/agent-runner.js'; +import type { DevServerHandle, RunEnv, ShellResult } from '../../src/provision/run-env.js'; +import { IssueStore } from '../../src/store/store.js'; +import { ConfigSchema } from '../../src/config/config.model.js'; +import type { Store } from '../../src/store/store.model.js'; + +function makeConfig(projectEnv: Record = {}) { + return ConfigSchema.parse({ + github: { owner: 'acme', repo: 'cezar', token: 'token' }, + llm: { apiKey: 'test-key' }, + store: { path: '.issue-store-test' }, + autofix: { enabled: true, repoRoot: '/tmp/repo', maxAttemptsPerIssue: 2, projectEnv }, + }); +} + +async function makeStore(): Promise { + const data: Store = { + meta: { owner: 'acme', repo: 'cezar', lastSyncedAt: null, totalFetched: 0, version: 1, orgMembers: [], orgMembersFetchedAt: null }, + issues: [], + }; + return IssueStore.fromPort({ load: async () => data, save: async () => {} }); +} + +function makeFakeGitHub(): WorkflowGitHub { + let nextId = 4000; + return { + async addComment() { return nextId++; }, + async updateComment() {}, + async getIssueWithComments(n) { return { issue: { number: n, title: `Bug #${n}`, body: 'breaks' }, comments: [] }; }, + async setLabels() {}, async addLabel() {}, async closeIssue() {}, + async pushBranch() {}, + async createPullRequest() { return { url: 'https://example.test/pr/9', number: 9 }; }, + }; +} + +/** Captures the full spec of every agent run so we can assert prompt/tool injection. */ +class CapturingRunner implements AgentRunner { + private idx = 0; + readonly specs: AgentRunSpec[] = []; + constructor(readonly backend: AgentBackend, private readonly outputs: unknown[]) {} + async run(spec: AgentRunSpec): Promise> { + this.specs.push(spec as AgentRunSpec); + const parsed = (this.outputs[this.idx++] ?? null) as T | null; + return { text: JSON.stringify(parsed), parsed, toolCalls: [], tokensUsed: 5, budgetExceeded: false }; + } + async interrupt(): Promise {} +} + +function makeFakeGit() { + let n = 0; + return { async commitAll(): Promise { n++; return `sha${n}00000`; }, async getDiffAgainstBase(): Promise { return 'diff'; } }; +} + +const ok = (cmd: string): ShellResult => ({ command: cmd, ok: true, exitCode: 0, stdout: '', stderr: '', durationMs: 1 }); + +/** Fake env whose dev server boots to a fixed URL. */ +function makeFakeEnv(opts: { devUrl?: string; ready?: boolean; throwOnStart?: boolean } = {}): RunEnv & { started: number } { + const state = { started: 0 }; + return { + started: 0, + kind: 'native', + async install() { return ok('install'); }, + async build() { return null; }, + async test() { return null; }, + async run(c) { return ok(c); }, + async startDevServer(): Promise { + state.started++; + this.started = state.started; + if (opts.throwOnStart) throw new Error('boom: port in use'); + if (!opts.devUrl) return null; + return { url: opts.devUrl, ready: opts.ready ?? true, stop: async () => {} }; + }, + async dispose() {}, + }; +} + +const VERIFY = { isRealUnfixedDefect: true, reason: 'reproduced', confidence: 0.9 }; +const ROOT = { summary: 'null deref', suspectedFiles: ['a.ts'], hypothesis: 'token undefined', confidence: 0.85 }; +const FIX = { changedFiles: ['a.ts'], approach: 'guard token', testCommandsRun: [] }; +const REVIEW_PASS = { verdict: 'pass' as const, summary: 'good', issues: [] }; + +describe('autofix dev-server step', () => { + it('boots the dev server and injects the URL + curl into agent steps', async () => { + const runner = new CapturingRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const env = makeFakeEnv({ devUrl: 'http://127.0.0.1:51000', ready: true }); + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), + config: makeConfig({ devServer: { enabled: true, command: 'yarn dev', port: 3000 } }), + github: makeFakeGitHub(), issueNumber: 20, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 2 }, + }); + + expect(result.status).toBe('succeeded'); + expect(env.started).toBe(1); + expect((result.blackboard as AutofixBlackboard).devServerUrl).toBe('http://127.0.0.1:51000'); + + // verify-in-repo runs BEFORE the dev-server step ⇒ no URL in its prompt. + const verifySpec = runner.specs[0]; + expect(verifySpec.userPrompt).not.toContain('51000'); + + // root-cause / fix run AFTER ⇒ URL injected, and fix (which has a bash + // allowlist) gets `curl` added. + const fixSpec = runner.specs.find((s) => s.userPrompt.includes('guard') || s.systemPrompt.includes('FIXER')) + ?? runner.specs[2]; + expect(fixSpec.userPrompt).toContain('http://127.0.0.1:51000'); + expect(fixSpec.bashAllowlist).toContain('curl'); + }); + + it('continues (no URL) when the dev server is not configured', async () => { + const runner = new CapturingRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const env = makeFakeEnv({}); // startDevServer → null + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({}), + github: makeFakeGitHub(), issueNumber: 21, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 2 }, + }); + expect(result.status).toBe('succeeded'); + expect((result.blackboard as AutofixBlackboard).devServerUrl).toBeUndefined(); + expect(runner.specs.every((s) => !s.userPrompt.includes('dev server'))).toBe(true); + }); + + it('does not fail the run when boot throws (ungated dev-server step)', async () => { + const runner = new CapturingRunner('anthropic-api', [VERIFY, ROOT, FIX, REVIEW_PASS]); + const env = makeFakeEnv({ throwOnStart: true }); + const result = await runWorkflow(autofixWorkflow, { + store: await makeStore(), config: makeConfig({ devServer: { enabled: true, command: 'yarn dev', port: 3000 } }), + github: makeFakeGitHub(), issueNumber: 22, apply: true, worktreePath: '/tmp/wt', + runnerFactory: () => runner, gitOps: makeFakeGit(), runEnv: env, + loopMaxIterations: { 'fix-review': 2 }, + }); + expect(result.status).toBe('succeeded'); // dev-server step is informational (gate: false) + }); +}); diff --git a/packages/core/tests/workflows/shell-check.test.ts b/packages/core/tests/workflows/shell-check.test.ts index 0d5ed33..cd8f4b1 100644 --- a/packages/core/tests/workflows/shell-check.test.ts +++ b/packages/core/tests/workflows/shell-check.test.ts @@ -76,6 +76,7 @@ function makeFakeRunEnv(testFailures = 0): RunEnv & { ran: string[] } { async build() { ran.push('build'); return shell('build', true); }, async test() { ran.push('test'); testCalls++; return shell('test', testCalls > testFailures); }, async run(cmd) { return shell(cmd, true); }, + async startDevServer() { return null; }, async dispose() {}, }; } diff --git a/packages/gui/src/app/settings/actions.ts b/packages/gui/src/app/settings/actions.ts index 4bfad24..8e97d3b 100644 --- a/packages/gui/src/app/settings/actions.ts +++ b/packages/gui/src/app/settings/actions.ts @@ -67,6 +67,13 @@ export async function saveWorkspaceConfig( gateOnInstall: bool(formData, 'autofix.projectEnv.gateOnInstall'), gateOnBuild: bool(formData, 'autofix.projectEnv.gateOnBuild'), gateOnTest: bool(formData, 'autofix.projectEnv.gateOnTest'), + devServer: { + enabled: bool(formData, 'autofix.projectEnv.devServer.enabled'), + command: rawStr(formData, 'autofix.projectEnv.devServer.command'), + port: num(formData, 'autofix.projectEnv.devServer.port', 0), + readyPath: str(formData, 'autofix.projectEnv.devServer.readyPath', '/'), + readyTimeoutSec: num(formData, 'autofix.projectEnv.devServer.readyTimeoutSec', 60), + }, }, }, }; diff --git a/packages/gui/src/app/settings/settings-form.tsx b/packages/gui/src/app/settings/settings-form.tsx index 94e3dc2..b48d247 100644 --- a/packages/gui/src/app/settings/settings-form.tsx +++ b/packages/gui/src/app/settings/settings-form.tsx @@ -25,6 +25,7 @@ export function SettingsForm({ config, issueAutofixMode, readOnly }: SettingsFor const maxTurns = (autofix.maxTurns ?? {}) as Record; const projectEnv = (autofix.projectEnv ?? {}) as Record; const compose = (projectEnv.compose ?? {}) as Record; + const devServer = (projectEnv.devServer ?? {}) as Record; const envVarsText = Object.entries((projectEnv.envVars ?? {}) as Record) .map(([k, v]) => `${k}=${v}`) .join('\n'); @@ -126,6 +127,21 @@ export function SettingsForm({ config, issueAutofixMode, readOnly }: SettingsFor placeholder={'DATABASE_URL=postgres://localhost/test\nNODE_ENV=test'} />
+ +

+ Dev server (optional). Booted once after install so the agent can probe the + live app with curl while diagnosing and verifying its fix. Native binds the + port on the host; compose publishes the container port to an ephemeral host port. Torn down + when the run ends. +

+
+ +
+ + + + +
diff --git a/packages/gui/src/lib/load-workspace-config.ts b/packages/gui/src/lib/load-workspace-config.ts index 94e3d80..6d04e19 100644 --- a/packages/gui/src/lib/load-workspace-config.ts +++ b/packages/gui/src/lib/load-workspace-config.ts @@ -62,8 +62,9 @@ export async function loadWorkspaceConfig( // Deep-merge projectEnv so a partial workspace override keeps the defaults // for unset fields (nested `compose` and `envVars` merged too). if (projectEnv && Object.keys(projectEnv).length > 0) { - const { compose, envVars, ...peRest } = projectEnv as { + const { compose, devServer, envVars, ...peRest } = projectEnv as { compose?: Record; + devServer?: Record; envVars?: Record; [k: string]: unknown; }; @@ -71,6 +72,9 @@ export async function loadWorkspaceConfig( if (compose && Object.keys(compose).length > 0) { Object.assign(baseConfig.autofix.projectEnv.compose, compose); } + if (devServer && Object.keys(devServer).length > 0) { + Object.assign(baseConfig.autofix.projectEnv.devServer, devServer); + } if (envVars) { baseConfig.autofix.projectEnv.envVars = envVars; }