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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions e2e/implement-issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { hostExec } from "../src/lib/exec";
import { createCorpusReplayAdapter } from "../src/replay/adapter";
import { makeAgentRuntime } from "../src/runtime/agent-runtime";
import { startRun } from "../src/runtime/run";
import implementIssue from "./implement-issue";

Expand Down Expand Up @@ -118,14 +119,14 @@ describe("implement-issue workflow, replayed against the recorded round-trip cor
process.env.PATH = `${binDir}:${originalPath}`;

const events: Array<unknown> = [];
const handle = startRun(implementIssue, {
const runtime = makeAgentRuntime(createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS));
const handle = await startRun(implementIssue, runtime, {
runId: "test-run-corpus-replay",
dir: workDir,
input: {
issueNumber: 1,
},
repo: { slug: "local/fixture", baseBranch: "main" },
adapter: createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS),
onEvent: (event) => events.push(event),
});

Expand Down
45 changes: 20 additions & 25 deletions e2e/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { defineConfig } from "../src/config";
import { hostExec } from "../src/lib/exec";
import { appendEvent, openStore } from "../src/persistence/store";
import { createCorpusReplayAdapter, createSlowFakeAdapter } from "../src/replay/adapter";
import { makeAgentRuntime } from "../src/runtime/agent-runtime";
import { startRun } from "../src/runtime/run";
import { startDaemon } from "../src/server/daemon";
import { defineWorkflow, Schema } from "../src/workflow";
Expand Down Expand Up @@ -102,10 +103,6 @@ async function git(dir: string, args: ReadonlyArray<string>): Promise<void> {
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
}

async function awaitRun(handle: ReturnType<typeof startRun>): Promise<void> {
await handle.result;
}

async function seedCorpusRuns(root: string, db: ReturnType<typeof openStore>): Promise<string> {
const remoteDir = join(root, "remote.git");
const workDir = join(root, "work");
Expand Down Expand Up @@ -136,29 +133,27 @@ async function seedCorpusRuns(root: string, db: ReturnType<typeof openStore>): P
chmodSync(fakeGhPath, 0o755);
process.env.PATH = `${binDir}:${originalPath}`;

await awaitRun(
startRun(implementIssue, {
runId: "run-static-corpus",
dir: workDir,
repo: { slug: "local/fixture", baseBranch: "main" },
input: { issueNumber: 1 },
adapter: createCorpusReplayAdapter(CORPUS_ROUND_TRIP),
onEvent: (event) => appendEvent(db, event),
}),
);
const corpusRuntime = makeAgentRuntime(createCorpusReplayAdapter(CORPUS_ROUND_TRIP));
const corpusHandle = await startRun(implementIssue, corpusRuntime, {
runId: "run-static-corpus",
dir: workDir,
repo: { slug: "local/fixture", baseBranch: "main" },
input: { issueNumber: 1 },
onEvent: (event) => appendEvent(db, event),
});
await corpusHandle.result;

await Bun.sleep(20);

// A second, cheap run so list ordering has more than one data point.
await awaitRun(
startRun(echoWorkflow, {
runId: "run-static-echo",
dir: workDir,
input: {},
adapter: createCorpusReplayAdapter(CORPUS_ONE_STEP),
onEvent: (event) => appendEvent(db, event),
}),
);
const echoRuntime = makeAgentRuntime(createCorpusReplayAdapter(CORPUS_ONE_STEP));
const echoHandle = await startRun(echoWorkflow, echoRuntime, {
runId: "run-static-echo",
dir: workDir,
input: {},
onEvent: (event) => appendEvent(db, event),
});
await echoHandle.result;

await Bun.sleep(20);

Expand Down Expand Up @@ -253,7 +248,6 @@ async function main(): Promise<void> {
db.close();
}

const adapter = createSlowFakeAdapter(SLOW_CHUNKS, 1_000);
const config = defineConfig({
repo: {
sshUrl: remoteDir,
Expand All @@ -278,8 +272,9 @@ async function main(): Promise<void> {
timezone: "UTC",
},
],
agent: { adapter: createSlowFakeAdapter(SLOW_CHUNKS, 1_000) },
});
const { server } = await startDaemon({ dbPath, port, adapter, config });
const { server } = await startDaemon({ dbPath, port, config });
console.log(`factory e2e: listening on http://localhost:${server.port}`);
}

Expand Down
54 changes: 54 additions & 0 deletions src/cli-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,57 @@ describe("factory binary: process exit codes", () => {
expect(exitCode).not.toBe(0);
});
});

/**
* PR #56 merged this branch onto `33-parse-cli-with-effect-cli` with a bad
* conflict resolution: `src/cli.ts`'s `import.meta.main` block regressed to
* the pre-#52 hand-rolled `USAGE`/`parseFlags`/`usageError` parser while
* `factoryCommand` above kept passing — the tests here only ever drove
* `factoryCommand` directly, never the actual binary entrypoint, so CI stayed
* green while the shipped CLI silently lost `effect/unstable/cli` (generated
* help, typed flag validation, `--wizard`/`--completions`, …). These tests
* exercise `src/cli.ts` itself — via `import.meta.main`, the same path
* `bin/factory.js` runs in production — so that regression can't recur
* unnoticed.
*/
describe("cli.ts entrypoint wiring", () => {
test("cli.ts contains no hand-rolled argv parser", async () => {
const source = await Bun.file(CLI).text();
expect(source).toContain('import { factoryCommand } from "./cli-commands"');
expect(source).toMatch(/Command\.run\(factoryCommand/);
expect(source).not.toMatch(/\bconst USAGE\b/);
expect(source).not.toMatch(/\bfunction usageError\b/);
expect(source).not.toMatch(/\bfunction parseFlags\b/);
expect(source).not.toMatch(/\bfunction parseArgs\b/);
expect(source).not.toMatch(/\bfunction parseStartArgs\b/);
expect(source).not.toMatch(/\bfunction parseServeArgs\b/);
});

test("--help at the real entrypoint is generated by effect/unstable/cli, not a hand-rolled banner", async () => {
const proc = Bun.spawn(["bun", CLI, "--help"], { stdout: "pipe", stderr: "pipe" });
const [exitCode, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
expect(exitCode).toBe(0);
// effect/unstable/cli's generated help renders these section headings;
// the hand-rolled USAGE banner (a lowercase "usage:" line) never did.
expect(stdout).toContain("SUBCOMMANDS");
expect(stdout).toContain("GLOBAL FLAGS");
expect(stdout).not.toContain("usage:\n");
});

test("factory serve --port at the real entrypoint rejects a non-numeric value before starting the daemon", async () => {
// The hand-rolled parser did `Number(portRaw)` with no validation at all —
// a bad --port silently became NaN. effect/unstable/cli's typed Int flag
// rejects it up front.
const proc = Bun.spawn(["bun", CLI, "serve", "--port", "abc"], {
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
expect(exitCode).not.toBe(0);
expect(stdout + stderr).toContain("Invalid value for flag --port");
});
});
2 changes: 0 additions & 2 deletions src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Effect, Option } from "effect";
import { Argument, Command, Flag } from "effect/unstable/cli";
import { findFactoryConfig, loadFactoryConfig } from "./config";
import { initCli } from "./init";
import { opencodeAdapter } from "./runtime/opencode-adapter";
import { resolve } from "node:path";
import {
runCli,
Expand Down Expand Up @@ -222,7 +221,6 @@ export const runCommand = Command.make(
() => `.factory/runs/run-${Date.now()}/events.ndjson`,
),
dbPath: config.db,
adapter: opencodeAdapter,
};

const exitCode = yield* Effect.promise(() => runCli(options));
Expand Down
18 changes: 10 additions & 8 deletions src/cli.start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ async function startTestDaemon(delayMs: number, root: string): Promise<TestDaemo
const daemon = await startDaemon({
dbPath,
port: 0,
adapter: createSlowFakeAdapter(
[
{ type: "TEXT_MESSAGE_START" },
{ type: "TEXT_MESSAGE_CONTENT", delta: "hi" },
{ type: "TEXT_MESSAGE_END" },
],
delayMs,
),
config: defineConfig({
agent: {
adapter: createSlowFakeAdapter(
[
{ type: "TEXT_MESSAGE_START" },
{ type: "TEXT_MESSAGE_CONTENT", delta: "hi" },
{ type: "TEXT_MESSAGE_END" },
],
delayMs,
),
},
repo: {
sshUrl: seed,
identity: { name: "Factory", email: "factory@factory.test" },
Expand Down
19 changes: 13 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@

import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { Effect, FileSystem, Layer, Path, Stdio, Terminal } from "effect";
import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stdio, Terminal } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { CliError, Command } from "effect/unstable/cli";
import type { RunEvent } from "./events";
import { loadFactoryConfig } from "./config";
import type { RunRepo } from "./runtime/run";
import type { GitIdentity } from "./lib/clone";
import { resetClone } from "./lib/clone";
import { resetClone, type GitIdentity } from "./lib/clone";
import { loadWorkflow } from "./lib/load-workflow";
import { streamSse } from "./lib/sse-client";
import { appendEvent, getRunEvents, listRuns, openStore } from "./persistence/store";
import type { AgentAdapter } from "./runtime/agent-adapter";
import { opencodeAdapter } from "./runtime/opencode-adapter";
import { AgentRuntimeLayer } from "./runtime/agent-runtime";
import { startRun } from "./runtime/run";
import { factoryCommand } from "./cli-commands";

Expand All @@ -38,7 +39,11 @@ export interface CliOptions {
readonly clone?: { readonly sshUrl: string; readonly identity: GitIdentity };
readonly outPath: string;
readonly dbPath: string;
readonly adapter: AgentAdapter;
/**
* Injectable agent adapter for tests. When omitted, `runCli` falls back to
* `factory.config.ts`'s `agent.adapter` (or the live opencode adapter).
*/
readonly adapter?: AgentAdapter;
}

function formatEvent(event: RunEvent): string {
Expand All @@ -61,17 +66,19 @@ export async function runCli(options: CliOptions): Promise<number> {

const runId = `run-${Date.now()}`;
let repo: RunRepo | undefined;
let adapter = options.adapter ?? opencodeAdapter;
try {
const config = await loadFactoryConfig();
repo = { slug: config.repo.slug, baseBranch: config.repo.baseBranch };
adapter = options.adapter ?? config.agent.adapter;
} catch {
repo = undefined;
}
const handle = startRun(workflow, {
const runtime = ManagedRuntime.make(AgentRuntimeLayer(adapter));
const handle = await startRun(workflow, runtime, {
runId,
dir: options.dir,
input: options.input,
adapter: options.adapter,
prepareWorkspace: options.clone !== undefined,
...(repo !== undefined ? { repo } : {}),
onEvent: (event) => {
Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import { Cron, Result, SchemaParser } from "effect";
import type { GitIdentity } from "./lib/clone";
import type { WorkflowDefinition } from "./workflow";
import type { AgentAdapter } from "./runtime/agent-adapter";
import { opencodeAdapter } from "./runtime/opencode-adapter";

export const DEFAULT_WORKSPACE_ROOT = ".factory/workspaces";
export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
Expand Down Expand Up @@ -69,6 +71,11 @@ export interface FactoryConfig {
*/
readonly maxDispatchDepth: number;
readonly maxChildrenPerRun: number;
/**
* Issue #36: the agent runtime. `defineConfig` defaults to the live opencode
* adapter when unset, so existing configs keep working unchanged.
*/
readonly agent: { readonly adapter: AgentAdapter };
}

/**
Expand Down Expand Up @@ -169,6 +176,11 @@ export interface FactoryConfigInput {
readonly maxDispatchDepth?: number;
readonly maxChildrenPerRun?: number;
readonly schedules?: ReadonlyArray<ScheduleConfigInput | ScheduleDefinition<any>>;
/**
* Issue #36: the agent runtime. Optional — when omitted `defineConfig`
* uses the live opencode adapter.
*/
readonly agent?: { readonly adapter?: AgentAdapter };
}

export function defineConfig(config: FactoryConfigInput): FactoryConfig {
Expand Down Expand Up @@ -216,6 +228,7 @@ export function defineConfig(config: FactoryConfigInput): FactoryConfig {
retainedWorkspaces,
maxDispatchDepth,
maxChildrenPerRun,
agent: { adapter: config.agent?.adapter ?? opencodeAdapter },
};
}

Expand Down
Loading
Loading