diff --git a/.env.example b/.env.example index 3286097..df6ca4a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ AGENT_ROUTER_PORT=8787 AGENT_ROUTER_PUBLIC_URL=http://localhost:8787 AGENT_ROUTER_DB=.state/agent-router.sqlite +AGENT_ROUTER_WORKSPACE_ROOT=~/Projects AGENT_ROUTER_WEBHOOK_SECRET=change-me AGENT_ROUTER_RUNNER_TOKEN=change-me-too AGENT_ROUTER_SETUP_SECRET=change-me-three diff --git a/.gitignore b/.gitignore index 6f473f6..a43f126 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .state/ +agent-router.config.json .env .env.* !.env.example diff --git a/README.md b/README.md index 14f3418..fbab9b2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ In another terminal, run a local worker lane: pnpm runner -- --agent codex ``` +By default the runner previews the next queued job without claiming it. Add `--execute` to claim the job and start the local agent. + ## GitHub App Setup 1. Put the server on a public URL that GitHub can reach. For local development, use a tunnel and set `AGENT_ROUTER_PUBLIC_URL` to the tunnel URL. @@ -58,7 +60,9 @@ pnpm setup:url HuntDynamics org ```bash pnpm dev # start the webhook server -pnpm runner -- --agent codex # claim queued Codex jobs +pnpm runner -- --agent codex # preview the next Codex job +pnpm runner -- --agent codex --execute --once + # claim one job and run Codex locally pnpm setup:url fschrhunt user # generate a GitHub App manifest URL pnpm test # run tests pnpm verify # typecheck and test @@ -82,6 +86,20 @@ Use `agent-router.config.json` to restrict which repositories can dispatch jobs. cp agent-router.config.example.json agent-router.config.json ``` +The runner resolves jobs to fixed local worktrees from `workspaceRoot` and `repositories`: + +```json +{ + "workspaceRoot": "/Volumes/Thorium/Projects", + "repositories": { + "fschrhunt/AgentRouter": "AgentRouter", + "HuntDynamics/Halos": "Halos" + } +} +``` + +For a `@codex` PR job on `fschrhunt/AgentRouter`, that maps to `/Volumes/Thorium/Projects/AgentRouter/codex` and requires the branch to already be `codex/workspace`. + ## Safety Rules - No agent runs unless a comment or review contains `@codex`, `@claude`, or `@huma`. @@ -119,6 +137,7 @@ Runtime state is intentionally local and ignored by git: - `.env` - `.state/` +- `agent-router.config.json` - SQLite files ## License diff --git a/agent-router.config.example.json b/agent-router.config.example.json index 30617ff..781611d 100644 --- a/agent-router.config.example.json +++ b/agent-router.config.example.json @@ -1,6 +1,11 @@ { + "workspaceRoot": "~/Projects", "allowedRepositories": [ "fschrhunt/AgentRouter", "HuntDynamics/Halos" - ] + ], + "repositories": { + "fschrhunt/AgentRouter": "AgentRouter", + "HuntDynamics/Halos": "Halos" + } } diff --git a/src/config.ts b/src/config.ts index a62138f..bf0847b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ const envSchema = z.object({ AGENT_ROUTER_PORT: z.coerce.number().int().positive().default(8787), AGENT_ROUTER_PUBLIC_URL: z.string().url().default("http://localhost:8787"), AGENT_ROUTER_DB: z.string().min(1).default(".state/agent-router.sqlite"), + AGENT_ROUTER_WORKSPACE_ROOT: z.string().min(1).default("~/Projects"), AGENT_ROUTER_WEBHOOK_SECRET: z.string().min(1), AGENT_ROUTER_RUNNER_TOKEN: z.string().min(1), AGENT_ROUTER_SETUP_SECRET: z.string().min(1).optional(), @@ -16,18 +17,22 @@ const envSchema = z.object({ const fileConfigSchema = z.object({ allowedRepositories: z.array(z.string()).default([]), + workspaceRoot: z.string().optional(), + repositories: z.record(z.string(), z.string()).default({}), }); export interface AppConfig { port: number; publicUrl: string; databasePath: string; + workspaceRoot: string; webhookSecret: string; runnerToken: string; setupSecret: string; githubAppId?: string; githubPrivateKey?: string; allowedRepositories: Set; + repositories: Map; } export function loadConfig(cwd = process.cwd()): AppConfig { @@ -41,6 +46,7 @@ export function loadConfig(cwd = process.cwd()): AppConfig { port: env.AGENT_ROUTER_PORT, publicUrl: env.AGENT_ROUTER_PUBLIC_URL.replace(/\/$/, ""), databasePath: path.resolve(cwd, env.AGENT_ROUTER_DB), + workspaceRoot: expandHome(fileConfig.workspaceRoot ?? env.AGENT_ROUTER_WORKSPACE_ROOT), webhookSecret: env.AGENT_ROUTER_WEBHOOK_SECRET, runnerToken: env.AGENT_ROUTER_RUNNER_TOKEN, setupSecret: env.AGENT_ROUTER_SETUP_SECRET ?? env.AGENT_ROUTER_RUNNER_TOKEN, @@ -49,6 +55,7 @@ export function loadConfig(cwd = process.cwd()): AppConfig { ? Buffer.from(env.GITHUB_PRIVATE_KEY_B64, "base64").toString("utf8") : undefined, allowedRepositories: new Set(fileConfig.allowedRepositories.map((repo) => repo.toLowerCase())), + repositories: new Map(Object.entries(fileConfig.repositories).map(([repo, localName]) => [repo.toLowerCase(), localName])), }; } @@ -56,3 +63,9 @@ export function isRepositoryAllowed(config: AppConfig, repository: string): bool if (config.allowedRepositories.size === 0) return true; return config.allowedRepositories.has(repository.toLowerCase()); } + +function expandHome(value: string): string { + if (value === "~") return process.env.HOME ?? value; + if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2)); + return value; +} diff --git a/src/runner/main.ts b/src/runner/main.ts index 6e001f9..8ee54e7 100644 --- a/src/runner/main.ts +++ b/src/runner/main.ts @@ -1,40 +1,72 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import { Command } from "commander"; -import "dotenv/config"; -import type { AgentName } from "../types.js"; +import { loadConfig } from "../config.js"; +import type { AgentJob, AgentName } from "../types.js"; +import { buildAgentPrompt } from "./prompt.js"; +import { resolveWorktreeTarget, type WorktreeTarget } from "./workspace.js"; const program = new Command() .requiredOption("--agent ", "agent lane: codex, claude, or huma") .option("--server ", "AgentRouter server URL", "http://localhost:8787") - .option("--once", "claim one job and exit", false); + .option("--once", "claim one job and exit", false) + .option("--execute", "execute the local agent instead of only previewing the next job", false); program.parse(); -const options = program.opts<{ agent: AgentName; server: string; once: boolean }>(); +const options = program.opts<{ agent: AgentName; server: string; once: boolean; execute: boolean }>(); if (!["codex", "claude", "huma"].includes(options.agent)) { throw new Error("agent must be codex, claude, or huma"); } -const token = process.env.AGENT_ROUTER_RUNNER_TOKEN; -if (!token) throw new Error("AGENT_ROUTER_RUNNER_TOKEN is required"); +const config = loadConfig(); +const token = config.runnerToken; while (true) { - const job = await claimJob(options.server, options.agent, token); + const job = options.execute + ? await claimJob(options.server, options.agent, token) + : await peekJob(options.server, options.agent, token); + if (job) { - console.log(JSON.stringify(job, null, 2)); - await completeJob(options.server, job.id, token); + const target = resolveWorktreeTarget(config, job); + const context = collectRunnerContext(job, target); + const prompt = buildAgentPrompt(job, target, context); + + if (!options.execute) { + console.log(JSON.stringify({ mode: "preview", job, target, prompt }, null, 2)); + } else { + try { + runAgent(job.agent, target, prompt); + await completeJob(options.server, job.id, token); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failJob(options.server, job.id, token, message); + throw error; + } + } } else if (options.once) { break; } + if (options.once) break; await new Promise((resolve) => setTimeout(resolve, 3_000)); } -async function claimJob(server: string, agent: AgentName, token: string): Promise { +async function peekJob(server: string, agent: AgentName, token: string): Promise { + const res = await fetch(`${server}/runner/jobs/peek?agent=${agent}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`peek failed: ${res.status} ${await res.text()}`); + const payload = await res.json() as { job: AgentJob | null }; + return payload.job; +} + +async function claimJob(server: string, agent: AgentName, token: string): Promise { const res = await fetch(`${server}/runner/jobs/next?agent=${agent}`, { headers: { authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`claim failed: ${res.status} ${await res.text()}`); - const payload = await res.json() as { job: any | null }; + const payload = await res.json() as { job: AgentJob | null }; return payload.job; } @@ -49,3 +81,98 @@ async function completeJob(server: string, id: number, token: string): Promise { + const res = await fetch(`${server}/runner/jobs/${id}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ status: "failed", error }), + }); + if (!res.ok) throw new Error(`fail failed: ${res.status} ${await res.text()}`); +} + +function collectRunnerContext(job: AgentJob, target: WorktreeTarget): { prSummary: string; checkSummary: string } { + ensureWorktree(target); + ensureBranch(target); + + if (!job.isPullRequest) { + return { + prSummary: `Issue: https://github.com/${job.repository}/issues/${job.targetNumber}`, + checkSummary: "Not a pull request job.", + }; + } + + return { + prSummary: runText("gh", [ + "pr", + "view", + String(job.targetNumber), + "--json", + "url,title,state,mergeable,reviewDecision,headRefName,baseRefName,body", + ], target.worktreeDir), + checkSummary: runText("gh", ["pr", "checks", String(job.targetNumber)], target.worktreeDir, true), + }; +} + +function ensureWorktree(target: WorktreeTarget): void { + if (!fs.existsSync(target.worktreeDir)) { + throw new Error(`missing worktree: ${target.worktreeDir}`); + } +} + +function ensureBranch(target: WorktreeTarget): void { + const branch = runText("git", ["rev-parse", "--abbrev-ref", "HEAD"], target.worktreeDir).trim(); + if (branch !== target.branch) { + throw new Error(`wrong branch in ${target.worktreeDir}: expected ${target.branch}, got ${branch}`); + } + runText("git", ["fetch", "origin", target.branch], target.worktreeDir, true); + runText("git", ["pull", "--ff-only"], target.worktreeDir, true); +} + +function runAgent(agent: AgentName, target: WorktreeTarget, prompt: string): void { + if (agent === "codex") { + runStreaming("codex", [ + "exec", + "--cd", + target.worktreeDir, + "--ask-for-approval", + "never", + "--sandbox", + "danger-full-access", + prompt, + ], target.worktreeDir); + return; + } + + if (agent === "claude") { + runStreaming("claude", [ + "--print", + "--permission-mode", + "acceptEdits", + "--add-dir", + target.worktreeDir, + prompt, + ], target.worktreeDir); + return; + } + + throw new Error("Huma execution is not wired yet; job was not run."); +} + +function runText(command: string, args: string[], cwd: string, allowFailure = false): string { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (result.status !== 0 && !allowFailure) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); +} + +function runStreaming(command: string, args: string[], cwd: string): void { + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`${command} exited with ${result.status}`); + } +} diff --git a/src/runner/prompt.ts b/src/runner/prompt.ts new file mode 100644 index 0000000..3eaae01 --- /dev/null +++ b/src/runner/prompt.ts @@ -0,0 +1,38 @@ +import type { AgentJob } from "../types.js"; +import type { WorktreeTarget } from "./workspace.js"; + +export interface RunnerContext { + prSummary: string; + checkSummary: string; +} + +export function buildAgentPrompt(job: AgentJob, target: WorktreeTarget, context: RunnerContext): string { + const prUrl = `https://github.com/${job.repository}/pull/${job.targetNumber}`; + return [ + `You were summoned from GitHub by @${job.agent}.`, + "", + "Task:", + job.body.trim(), + "", + "Repository:", + `- ${job.repository}`, + `- PR: ${prUrl}`, + `- Worktree: ${target.worktreeDir}`, + `- Branch: ${target.branch}`, + `- Base branch: ${job.pullRequestBaseBranch ?? "unknown"}`, + "", + "Hard rules:", + "- Work only in this existing worktree.", + `- Stay on ${target.branch}.`, + "- Do not create a new branch or worktree.", + "- Do not merge the PR.", + "- Fix only what is relevant to the PR blocker or the GitHub mention.", + "- Verify before claiming success.", + "", + "PR context:", + context.prSummary.trim() || "(no PR summary available)", + "", + "Checks:", + context.checkSummary.trim() || "(no check output available)", + ].join("\n"); +} diff --git a/src/runner/workspace.ts b/src/runner/workspace.ts new file mode 100644 index 0000000..7c18a24 --- /dev/null +++ b/src/runner/workspace.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import type { AppConfig } from "../config.js"; +import type { AgentJob } from "../types.js"; + +export interface WorktreeTarget { + repositoryDir: string; + worktreeDir: string; + branch: string; +} + +export function resolveWorktreeTarget(config: AppConfig, job: AgentJob): WorktreeTarget { + const repositoryDir = config.repositories.get(job.repository.toLowerCase()) ?? job.repo; + const branch = job.pullRequestHeadBranch ?? `${job.agent}/workspace`; + const lane = laneFromBranch(branch); + return { + repositoryDir, + branch, + worktreeDir: path.join(config.workspaceRoot, repositoryDir, lane), + }; +} + +export function laneFromBranch(branch: string): string { + const match = /^(codex|claude|huma)\/workspace$/.exec(branch); + if (!match) { + throw new Error(`unsupported agent branch: ${branch}`); + } + return match[1]!; +} diff --git a/src/server/main.ts b/src/server/main.ts index 0bee164..901d42b 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -50,6 +50,15 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { job: store.claimNext(agent) }); } + if (req.method === "GET" && url.pathname === "/runner/jobs/peek") { + if (!isRunnerAuthorized(config.runnerToken, req.headers.authorization?.replace(/^Bearer\s+/i, ""))) { + return json(res, 401, { error: "unauthorized" }); + } + const agent = parseAgent(url.searchParams.get("agent")); + if (!agent) return json(res, 400, { error: "invalid agent" }); + return json(res, 200, { job: store.peekNext(agent) }); + } + if (req.method === "POST" && url.pathname.startsWith("/runner/jobs/")) { if (!isRunnerAuthorized(config.runnerToken, req.headers.authorization?.replace(/^Bearer\s+/i, ""))) { return json(res, 401, { error: "unauthorized" }); diff --git a/src/store/job-store.ts b/src/store/job-store.ts index e76f7bb..f46d966 100644 --- a/src/store/job-store.ts +++ b/src/store/job-store.ts @@ -105,6 +105,13 @@ export class JobStore { return row ? mapJob(row) : null; } + peekNext(agent: AgentName): AgentJob | null { + const row = asOptionalJobRow(this.db.prepare(` + select * from jobs where agent = ? and status = 'queued' order by id asc limit 1 + `).get(agent)); + return row ? mapJob(row) : null; + } + completeJob(id: number): AgentJob | null { const row = asOptionalJobRow(this.db.prepare(` update jobs diff --git a/test/job-store.test.ts b/test/job-store.test.ts index 4075186..6f00de1 100644 --- a/test/job-store.test.ts +++ b/test/job-store.test.ts @@ -31,6 +31,16 @@ describe("JobStore", () => { expect(store.claimNext("claude")?.agent).toBe("claude"); }); + it("peeks without claiming jobs", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-router-")); + const store = new JobStore(path.join(dir, "jobs.sqlite")); + const created = store.createJob(trigger()); + + expect(store.peekNext("codex")?.id).toBe(created.id); + expect(store.peekNext("codex")?.status).toBe("queued"); + expect(store.claimNext("codex")?.id).toBe(created.id); + }); + it("preserves pull request branch context", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-router-")); const store = new JobStore(path.join(dir, "jobs.sqlite")); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index ec89417..1812cd0 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -6,10 +6,12 @@ const config: AppConfig = { port: 8787, publicUrl: "https://router.example.com", databasePath: "/tmp/router.sqlite", + workspaceRoot: "/tmp", webhookSecret: "webhook", runnerToken: "runner", setupSecret: "setup", allowedRepositories: new Set(), + repositories: new Map(), }; describe("GitHub App manifest", () => { diff --git a/test/runner.test.ts b/test/runner.test.ts new file mode 100644 index 0000000..5274b92 --- /dev/null +++ b/test/runner.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { AppConfig } from "../src/config.js"; +import { buildAgentPrompt } from "../src/runner/prompt.js"; +import { laneFromBranch, resolveWorktreeTarget } from "../src/runner/workspace.js"; +import type { AgentJob } from "../src/types.js"; + +describe("runner workspace routing", () => { + it("resolves fixed lane worktrees from PR branch context", () => { + const target = resolveWorktreeTarget(config(), job()); + + expect(target).toEqual({ + repositoryDir: "AgentRouter", + branch: "codex/workspace", + worktreeDir: "/Projects/AgentRouter/codex", + }); + }); + + it("rejects unsupported branches", () => { + expect(() => laneFromBranch("feature/foo")).toThrow(/unsupported agent branch/); + }); +}); + +describe("agent prompt", () => { + it("states the no-new-branch rule", () => { + const prompt = buildAgentPrompt(job(), resolveWorktreeTarget(config(), job()), { + prSummary: "title: Fix router", + checkSummary: "CI failing", + }); + + expect(prompt).toContain("Do not create a new branch or worktree."); + expect(prompt).toContain("Stay on codex/workspace."); + expect(prompt).toContain("https://github.com/fschrhunt/AgentRouter/pull/5"); + }); +}); + +function config(): AppConfig { + return { + port: 8787, + publicUrl: "http://localhost:8787", + databasePath: ":memory:", + workspaceRoot: "/Projects", + webhookSecret: "webhook", + runnerToken: "runner", + setupSecret: "setup", + allowedRepositories: new Set(), + repositories: new Map([["fschrhunt/agentrouter", "AgentRouter"]]), + }; +} + +function job(): AgentJob { + return { + id: 1, + agent: "codex", + source: "issue_comment", + repository: "fschrhunt/AgentRouter", + owner: "fschrhunt", + repo: "AgentRouter", + actor: "fschrhunt", + targetNumber: 5, + isPullRequest: true, + body: "@codex fix the failing check", + htmlUrl: "https://github.com/fschrhunt/AgentRouter/pull/5#issuecomment-1", + installationId: 123, + pullRequestHeadBranch: "codex/workspace", + pullRequestHeadRepository: "fschrhunt/AgentRouter", + pullRequestBaseBranch: "main", + status: "queued", + createdAt: "now", + updatedAt: "now", + }; +}