Skip to content
Merged
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: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ TEMPORAL_TASK_QUEUE=engineering
ANTHROPIC_API_KEY=sk-ant-...
# WORKER_MODE=api # or "cli"

# Delivery backend (ENG-017): "local" commits the agent's code to a local git repo under ./workspace
# (no GitHub needed — QA verifies real code, tickets can reach `done` offline). Leave unset to use the
# GitHub adapter when the GITHUB_* vars below are set, or to no-op delivery when they aren't.
DELIVERY_BACKEND=

# GitHub delivery (optional). When set, the worker opens/merges PRs for tickets.
GITHUB_TOKEN=
GITHUB_OWNER=
Expand Down
42 changes: 36 additions & 6 deletions apps/server/src/temporal/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
estimateRunCostCents,
proposeFileChanges,
proposeTickets,
repoWorkspacePath,
} from "@eng/agents"
import {
type DeliveryAdapter,
Expand All @@ -14,7 +15,11 @@ import {
type TicketStatus,
} from "@eng/core"
import { createApproval, getBudgetRemaining, reconcileSpend, reserveBudget } from "@eng/db"
import { createGitHubDelivery } from "@eng/integrations"
import {
createGitHubDelivery,
LocalGitDeliveryAdapter,
localGitBranchFiles,
} from "@eng/integrations"
import { persistenceFromEnv } from "../persistence"
import { artifactPath, SHAPING_STAGES } from "../shaping"
import { startTicketLifecycle } from "./client"
Expand Down Expand Up @@ -224,15 +229,24 @@ export async function pickUpBacklog(): Promise<number> {

// --- Delivery (GitHub) -------------------------------------------------------

const BASE_BRANCH = process.env.GITHUB_BASE_BRANCH ?? "main"

/** The local working-code repo path for DELIVERY_BACKEND=local (ENG-017). */
function localRepoDir(): string {
return repoWorkspacePath(process.env.GITHUB_OWNER || "local", process.env.GITHUB_REPO || "app")
}

function deliveryFromEnv(): DeliveryAdapter | null {
// Local-git delivery (ENG-017): commit the agent's code to a local repo — no GitHub needed.
if (process.env.DELIVERY_BACKEND === "local") {
return new LocalGitDeliveryAdapter({ repoDir: localRepoDir(), baseBranch: BASE_BRANCH })
}
const token = process.env.GITHUB_TOKEN
const owner = process.env.GITHUB_OWNER
const repo = process.env.GITHUB_REPO
if (!token || !owner || !repo) return null
return createGitHubDelivery({ token, owner, repo, baseBranch: process.env.GITHUB_BASE_BRANCH })
}

const BASE_BRANCH = process.env.GITHUB_BASE_BRANCH ?? "main"
const DEPLOY_WORKFLOW = process.env.GITHUB_DEPLOY_WORKFLOW
const DEPLOY_REF = process.env.GITHUB_DEPLOY_REF ?? BASE_BRANCH

Expand Down Expand Up @@ -436,14 +450,30 @@ export async function verifyTicket(
persistence.tracker.get(ticketId),
persistence.hierarchy.traceContext(ticketId),
])

// ENG-017: when delivery commits to a local repo, hand the QA agent the actual committed files so it
// can verify real code (the api backend is tool-less and otherwise can't see the changes). This also
// distinguishes "nothing was committed" from "committed but failed verification".
let evidence = ""
if (process.env.DELIVERY_BACKEND === "local") {
const files = await localGitBranchFiles(localRepoDir(), `ticket/${ticketId}`)
evidence = files.length
? `\n\nThe following changes were committed for this ticket — verify them against the criteria:\n${files
.map((f) => `\n----- ${f.path} -----\n${f.content}`)
.join("\n")}`
: "\n\nNote: no code was committed for this ticket; there is nothing to verify."
}

try {
const verdict = await assess({
role: role.id,
systemPrompt: role.systemPrompt,
goalContext,
task: ticket
? `Verify ticket "${ticket.title}" meets its acceptance criteria: ${ticket.acceptanceCriteria.join("; ") || "(none specified)"}.`
: `Verify ticket ${ticketId}.`,
task: `${
ticket
? `Verify ticket "${ticket.title}" meets its acceptance criteria: ${ticket.acceptanceCriteria.join("; ") || "(none specified)"}.`
: `Verify ticket ${ticketId}.`
}${evidence}`,
budgetCentsRemaining: remaining,
})
await reconcileSpend(role.id, held, verdict.costCents)
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ services:
GITHUB_DEPLOY_REF: ${GITHUB_DEPLOY_REF:-main}
PERSISTENCE_BACKEND: ${PERSISTENCE_BACKEND:-postgres}
GITHUB_DOCS_PREFIX: ${GITHUB_DOCS_PREFIX:-docs}
# Delivery backend (ENG-017): set DELIVERY_BACKEND=local to commit the agent's code to a local
# git repo under ./workspace (no GitHub needed); unset/`github` uses the GitHub adapter.
DELIVERY_BACKEND: ${DELIVERY_BACKEND:-}
command: ["pnpm", "--filter", "@eng/server", "worker"]
# Surface the working-code workspace (local-git commits) on the host so you can inspect the repo.
volumes:
- ./workspace:/app/workspace
restart: on-failure

web:
Expand Down
5 changes: 5 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ export { GitHubDeliveryAdapter } from "./github/delivery"
export { GitHubHierarchy } from "./github/hierarchy"
export { GitHubIssueTracker } from "./github/issues"
export { GitHubKnowledgeBase } from "./github/knowledge"
export {
LocalGitDeliveryAdapter,
type LocalGitDeliveryConfig,
localGitBranchFiles,
} from "./local/delivery"
66 changes: 66 additions & 0 deletions packages/integrations/src/local/delivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { LocalGitDeliveryAdapter, localGitBranchFiles } from "./delivery"

describe("LocalGitDeliveryAdapter", () => {
let dir: string
let adapter: LocalGitDeliveryAdapter
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "eng-localgit-"))
adapter = new LocalGitDeliveryAdapter({ repoDir: dir, baseBranch: "main" })
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})

it("commits the agent's files to a local branch, then merges to main", async () => {
await adapter.createBranch("main", "ticket/1")
const { sha } = await adapter.commitFiles("ticket/1", "feat: add server", [
{ path: "src/index.ts", content: "export const x = 1\n" },
{ path: "README.md", content: "# app\n" },
])
expect(sha).toMatch(/^[0-9a-f]{7,}$/)

const files = await localGitBranchFiles(dir, "ticket/1")
expect(files.map((f) => f.path).sort()).toEqual(["README.md", "src/index.ts"])
expect(files.find((f) => f.path === "src/index.ts")?.content).toContain("export const x = 1")

const pr = await adapter.openPullRequest({ branch: "ticket/1", title: "t", body: "b" })
await adapter.merge(pr)
const onMain = await localGitBranchFiles(dir, "main")
expect(onMain.map((f) => f.path).sort()).toEqual(["README.md", "src/index.ts"])
})

it("serializes concurrent ticket commits without cross-contamination", async () => {
await Promise.all([
(async () => {
await adapter.createBranch("main", "ticket/A")
await adapter.commitFiles("ticket/A", "A", [{ path: "a.txt", content: "A" }])
})(),
(async () => {
await adapter.createBranch("main", "ticket/B")
await adapter.commitFiles("ticket/B", "B", [{ path: "b.txt", content: "B" }])
})(),
])
const a = await localGitBranchFiles(dir, "ticket/A")
const b = await localGitBranchFiles(dir, "ticket/B")
expect(a.find((f) => f.path === "a.txt")?.content).toBe("A")
expect(a.find((f) => f.path === "b.txt")).toBeUndefined()
expect(b.find((f) => f.path === "b.txt")?.content).toBe("B")
expect(b.find((f) => f.path === "a.txt")).toBeUndefined()
})

it("rejects writes that escape the repo", async () => {
await adapter.createBranch("main", "ticket/x")
await expect(
adapter.commitFiles("ticket/x", "evil", [{ path: "../escape.txt", content: "no" }]),
).rejects.toThrow(/outside the repo/)
})

it("returns [] for a missing repo/branch", async () => {
expect(await localGitBranchFiles(join(dir, "nope"), "main")).toEqual([])
expect(await localGitBranchFiles(dir, "no-such-branch")).toEqual([])
})
})
179 changes: 179 additions & 0 deletions packages/integrations/src/local/delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { mkdir, writeFile } from "node:fs/promises"
import { dirname, join, resolve as pathResolve } from "node:path"
import type {
CheckStatus,
DeliveryAdapter,
DeploymentRun,
FileChange,
OpenPullRequestArgs,
PullRequestRef,
} from "@eng/core"

// Commit identity supplied per-commit so a freshly `git init`-ed repo doesn't need global git config.
const IDENT = ["-c", "user.email=agent@engineering.local", "-c", "user.name=engineering agent"]

function git(cwd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("git", args, { cwd })
let out = ""
let err = ""
child.stdout.on("data", (d) => (out += d))
child.stderr.on("data", (d) => (err += d))
child.on("error", reject)
child.on("close", (code) =>
code === 0
? resolve(out.trim())
: reject(new Error(`git ${args.join(" ")} → ${code}: ${err.trim()}`)),
)
})
}

export interface LocalGitDeliveryConfig {
repoDir: string
baseBranch?: string
}

/**
* A `DeliveryAdapter` that commits to a **local** git repo (ENG-017) — no GitHub account or remote
* required. The coding agent's proposed file changes become real, inspectable commits in the
* working-code workspace, so QA can verify actual code and a ticket can reach `done` entirely offline.
*
* Concurrency: the worker is a single process, so all git operations are serialized through an
* in-process queue and each `commitFiles`/`merge` is a self-contained `checkout -f → write → commit`
* unit — concurrent ticket lifecycles can't race on the shared working tree. A "PR" is a local
* branch; "merge" is a local `--no-ff` merge into the base branch; deploy hooks are no-ops (deploy
* stays unconfigured locally).
*/
export class LocalGitDeliveryAdapter implements DeliveryAdapter {
private readonly repoDir: string
private readonly baseBranch: string
private queue: Promise<unknown> = Promise.resolve()

constructor(cfg: LocalGitDeliveryConfig) {
this.repoDir = pathResolve(cfg.repoDir)
this.baseBranch = cfg.baseBranch || "main"
}

private lock<T>(fn: () => Promise<T>): Promise<T> {
const run = this.queue.then(fn, fn)
this.queue = run.then(
() => undefined,
() => undefined,
)
return run
}

private async ensureRepo(): Promise<void> {
if (existsSync(join(this.repoDir, ".git"))) return
await mkdir(this.repoDir, { recursive: true })
await git(this.repoDir, ["-c", `init.defaultBranch=${this.baseBranch}`, "init"])
await git(this.repoDir, [
...IDENT,
"commit",
"--allow-empty",
"-m",
"chore: initialize repository",
])
}

async createBranch(base: string, name: string): Promise<void> {
await this.lock(async () => {
await this.ensureRepo()
await git(this.repoDir, ["branch", "-f", name, base || this.baseBranch])
})
}

async commitFiles(
branch: string,
message: string,
files: FileChange[],
): Promise<{ sha: string }> {
return this.lock(async () => {
await this.ensureRepo()
await git(this.repoDir, ["checkout", "-f", branch])
for (const f of files) {
const abs = pathResolve(this.repoDir, f.path)
if (abs !== this.repoDir && !abs.startsWith(`${this.repoDir}/`)) {
throw new Error(`refusing to write outside the repo: ${f.path}`)
}
await mkdir(dirname(abs), { recursive: true })
await writeFile(abs, f.content)
}
await git(this.repoDir, ["add", "-A"])
await git(this.repoDir, [...IDENT, "commit", "-m", message])
return { sha: await git(this.repoDir, ["rev-parse", "HEAD"]) }
})
}

async openPullRequest(args: OpenPullRequestArgs): Promise<PullRequestRef> {
return { number: 0, url: `file://${this.repoDir}#${args.branch}`, branch: args.branch }
}

async getChecks(): Promise<CheckStatus[]> {
return [] // no CI locally → aggregates to success
}

async merge(pr: PullRequestRef): Promise<void> {
await this.lock(async () => {
await git(this.repoDir, ["checkout", "-f", this.baseBranch])
await git(this.repoDir, [...IDENT, "merge", "--no-ff", pr.branch, "-m", `merge ${pr.branch}`])
})
}

// Deploy is a no-op locally (the deploy workflow stays unconfigured); present to satisfy the port.
dispatchWorkflow(): Promise<void> {
return Promise.resolve()
}
latestRunId(): Promise<number | null> {
return Promise.resolve(null)
}
deploymentRunAfter(): Promise<DeploymentRun | null> {
return Promise.resolve(null)
}
}

/**
* Read the files committed on `branch` of a local repo, for QA verification (ENG-017). Reads via git
* (not the working tree) so it's safe under concurrent ticket runs, and is bounded so a large tree
* can't blow up the prompt. Returns `[]` when the repo or branch doesn't exist yet.
*/
export async function localGitBranchFiles(
repoDir: string,
branch: string,
opts: { maxFiles?: number; maxBytes?: number } = {},
): Promise<FileChange[]> {
const dir = pathResolve(repoDir)
if (!existsSync(join(dir, ".git"))) return []
const maxFiles = opts.maxFiles ?? 40
const maxBytes = opts.maxBytes ?? 60_000
let list: string
try {
list = await git(dir, ["ls-tree", "-r", "--name-only", branch])
} catch {
return [] // branch doesn't exist
}
const paths = list
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
.slice(0, maxFiles)
const files: FileChange[] = []
let bytes = 0
for (const p of paths) {
let content: string
try {
content = await git(dir, ["show", `${branch}:${p}`])
} catch {
continue
}
bytes += content.length
if (bytes > maxBytes) {
files.push({ path: p, content: "(omitted — size budget reached)" })
break
}
files.push({ path: p, content })
}
return files
}
Loading
Loading