diff --git a/.gitignore b/.gitignore index 805ae8e..5e89e81 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ workspaces/ *.log .DS_Store artifacts/ +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5485dfe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## v2.3.0 + +Honest foundation: what the README and labels promise now matches what the code does. + +- `/factory cancel` closes the issue and any PR, removes the labels and the worktree, from every waiting + state (needs-info, awaiting-approval, needs-human, failed, in-review). Before, it only worked at plan + approval and never closed the issue, and in needs-info it was recorded as the answer. +- `.factory/config.json` is validated at boot: unknown or mistyped keys and wrong types refuse to start, + and every problem is listed at once. `riskCriteria` and `_comment` keys stay allowed. +- New `stage_runs` table: one row per stage attempt (duration, tokens, cost, exit, kill reason), so retries + keep their history. Forward-only migration; existing databases are upgraded in place. +- `--json` on `run`, `tick`, `watch --once` and `doctor`, with a documented exit-code table (2 run failed, + 3 paused, 4 doctor failed). Pattern from jiractrl. +- The Claude Code pin moves from 2.0.5 to 2.1.281 (Dockerfile and CI template); every flag the executor passes + is present in that version's `--help`. A test keeps the two pins equal. +- Docs: the `factory:failed` label text, the dashboard bind comment and the README no longer claim a + `factory:monitor` stage (that label marks issues filed by `factory scan`). +- Fix: `claim` and the runner's commits failed on a host with no git identity (a fresh CI runner or + container). The runner now supplies a fallback identity only when none is configured. `make test` runs + with no identity so this cannot hide on a developer machine again. + +Not in this release: a monitor stage (v2.8), any agent besides Claude (v2.5). diff --git a/Dockerfile b/Dockerfile index fd36e18..dbebb0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ FROM oven/bun:1.3.4-slim ARG GH_CLI_VERSION=2.63.2 ARG NODE_MAJOR=20 -ARG CLAUDE_CODE_VERSION=2.0.5 +ARG CLAUDE_CODE_VERSION=2.1.281 ARG UV_VERSION=0.5.11 RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/Makefile b/Makefile index d4594c5..664d3e0 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,10 @@ check: typecheck test skills-check typecheck: bun x tsc --noEmit +# Run with no git identity, like a fresh CI runner or container, so a code path +# that needs the developer's own identity fails here and not only on GitHub. test: - bun test + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=user.useConfigOnly GIT_CONFIG_VALUE_0=true bun test skills-check: @command -v uvx >/dev/null 2>&1 || { \ diff --git a/README.md b/README.md index 1c07f36..2de2ac1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Software Factory -A GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR, monitor. You watch +A GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR. You watch it happen in issues, comments, and a dashboard, instead of an agent's private terminal. Point it at any repo, label an issue `factory:ready`, and it runs the loop end to end: a PR @@ -8,8 +8,8 @@ lands with a test that proves it, or a human gets asked exactly what's missing. ## The loop -A human labels an issue `factory:ready`. The runner claims it, then drives six stages, each a -Claude Code skill working in its own git worktree: +A human labels an issue `factory:ready`. The runner claims it, then drives five stages, each a +Claude Code skill working in its own git worktree, then the runner opens the PR: 1. **triage** classifies risk and posts a triage comment. 2. **plan** researches the code (via `factory-explorer`) and posts a plan comment with acceptance @@ -77,14 +77,14 @@ The issue's label is the state. Everything else is derived from the thread. | Label | Meaning | Moves on when | |---|---|---| | `factory:ready` | a human queued it | the runner claims it | -| `factory:triaging` / `planning` | the agent is working | the stage finishes | +| `factory:triaging` / `factory:planning` | the agent is working | the stage finishes | | `factory:needs-info` | the agent asked a question (max two rounds, then `needs-human`) | a trusted comment answers it | | `factory:awaiting-approval` | plan posted, waiting | `/factory approve`, or auto for low risk | -| `factory:building` / `verifying` | building, or proving the build | gates and verifier finish; a reject goes back to build twice, then `needs-human` | +| `factory:building` / `factory:verifying` | building, or proving the build | gates and verifier finish; a reject goes back to build twice, then `needs-human` | | `factory:in-review` | PR ready for review | a human merges, or asks `/factory revise` | | `factory:failed` | a stage broke (timeout, red gates, boundary, denied tool) | `/factory retry` | | `factory:needs-human` | refused or out of rounds | `/factory retry` | -| `factory:monitor` | merged, being watched | the monitor closes it | +| `factory:monitor` | filed by `factory scan` from a production signal; a provenance label, not a stage | nothing, it stays on the issue | ## Where a human acts @@ -109,6 +109,20 @@ Always through GitHub state, which the runner polls every `pollIntervalSeconds`. Every runner comment carries a hidden `")); } @@ -448,6 +482,7 @@ export async function resumeNeedsInfo(issue: GhIssue, deps: WatchDeps, config: F if (!question) return undefined; const reply = latestTrustedCommentAfter(issue.comments, question.createdAt); if (!reply) return "waiting"; + if (parseChatOps(reply.body).type === "cancel") return cancelRun(issue, deps, config); const derived = deriveIssueState(issue); const worktree = worktreeFor(deps, issue.number); @@ -479,11 +514,7 @@ export async function resumeAwaitingApproval(issue: GhIssue, deps: WatchDeps, co await deps.github.setStateLabel(config.repo, issue.number, [LABEL.awaitingApproval], LABEL.planning); return runFromStage(deps, config, issue, "plan", worktree, ctxFrom(issue)); } - if (command.type === "cancel") { - await deps.github.removeLabels(config.repo, issue.number, [LABEL.awaitingApproval]); - finish(deps, config, issue.number, "cancelled"); - return "cancelled"; - } + if (command.type === "cancel") return cancelRun(issue, deps, config); if (command.type === "retry") { await deps.git.ensureWorktree(deps.cloneDir, worktree, issue.number); await deps.github.setStateLabel(config.repo, issue.number, [LABEL.awaitingApproval], LABEL.planning); @@ -498,7 +529,10 @@ export async function resumeAwaitingApproval(issue: GhIssue, deps: WatchDeps, co // posted a data marker, recovered by deriveIssueState. export async function resumeParked(issue: GhIssue, deps: WatchDeps, config: FactoryConfig): Promise { const latest = issue.comments.filter((c) => isHumanComment(c)).at(-1); - if (!latest || parseChatOps(latest.body).type !== "retry") return undefined; + if (!latest) return undefined; + const verb = parseChatOps(latest.body).type; + if (verb === "cancel") return cancelRun(issue, deps, config); + if (verb !== "retry") return undefined; const currentLabel = labelsOf(issue).find((n) => n === LABEL.failed || n === LABEL.needsHuman); if (!currentLabel) return undefined; @@ -528,6 +562,7 @@ export async function resumeInReview(issue: GhIssue, deps: WatchDeps, config: Fa .at(-1); if (!latest) return undefined; const command = parseChatOps(latest.body); + if (command.type === "cancel") return cancelRun(issue, deps, config); if (command.type !== "revise") return undefined; const worktree = worktreeFor(deps, issue.number); diff --git a/template-ci/factory.yml.example b/template-ci/factory.yml.example index dad0b9f..ba5f26e 100644 --- a/template-ci/factory.yml.example +++ b/template-ci/factory.yml.example @@ -35,9 +35,9 @@ on: required: false env: - FACTORY_RUNNER_REF: v2.2.0 # pinned software-factory release; bump deliberately + FACTORY_RUNNER_REF: v2.3.0 # pinned software-factory release; bump deliberately FACTORY_RUNNER_REPO: learnwithparam/software-factory - CLAUDE_CODE_VERSION: "2.0.5" # pinned claude, same version the Dockerfile installs + CLAUDE_CODE_VERSION: "2.1.281" # pinned claude, same version the Dockerfile installs # One workflow run in flight per issue at a time; a second labeled/comment # event on the same issue queues behind it instead of racing a second diff --git a/tests/cli-output.test.ts b/tests/cli-output.test.ts new file mode 100644 index 0000000..40d950f --- /dev/null +++ b/tests/cli-output.test.ts @@ -0,0 +1,71 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EXIT, failureJson, successJson } from "../src/cli-output"; +import { ConfigError } from "../src/config"; + +const bin = join(import.meta.dir, "..", "bin", "factory"); +const dir = mkdtempSync(join(tmpdir(), "factory-cli-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +async function factory(...args: string[]) { + const proc = Bun.spawn(["bun", bin, ...args], { stdout: "pipe", stderr: "pipe" }); + const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + return { out, err, code }; +} + +function repoDir(config?: object): string { + const d = mkdtempSync(join(dir, "r-")); + if (config) { + mkdirSync(join(d, ".factory")); + writeFileSync(join(d, ".factory/config.json"), JSON.stringify(config)); + } + return d; +} + +describe("--json contract", () => { + test("envelopes", () => { + expect(JSON.parse(successJson({ a: 1 }))).toEqual({ ok: true, data: { a: 1 } }); + expect(JSON.parse(successJson({}, false)).ok).toBe(false); + expect(JSON.parse(failureJson(new ConfigError("bad")))).toEqual({ ok: false, error: { kind: "config", message: "bad" } }); + }); + + test("an invalid config is {ok:false,error.kind:config} on stderr, stdout empty, exit 1", async () => { + const r = await factory("run", "--repo-dir", repoDir({ repo: "a/b", typo: 1 }), "--issue", "1", "--json"); + expect(r.code).toBe(EXIT.error); + expect(r.out).toBe(""); + const body = JSON.parse(r.err); + expect(body.error.kind).toBe("config"); + expect(body.error.message).toContain("typo: unknown key"); + }); + + test("a missing --issue is a usage error", async () => { + const r = await factory("run", "--repo-dir", repoDir({ repo: "a/b" }), "--json"); + expect(r.code).toBe(EXIT.error); + expect(JSON.parse(r.err).error.kind).toBe("usage"); + }); + + test("doctor with an unusable config exits 4 and reports the failed check as JSON", async () => { + const r = await factory("doctor", "--repo-dir", repoDir(), "--json"); + expect(r.code).toBe(EXIT.checksFailed); + const body = JSON.parse(r.out); + expect(body.ok).toBe(false); + expect(body.data.checks[0].ok).toBe(false); + }); +}); + +test("the README exit-code table lists every code the CLI can return", () => { + const readme = readFileSync(join(import.meta.dir, "..", "README.md"), "utf8"); + for (const code of Object.values(EXIT)) expect(readme).toMatch(new RegExp(`^\\| ${code} \\|`, "m")); +}); + +test("bin/factory exits only through EXIT, never a bare number", () => { + const src = readFileSync(bin, "utf8"); + expect([...src.matchAll(/process\.exit\((\d+)\)/g)].map((m) => m[0])).toEqual([]); +}); + +test("every pause-aware --json command exits EXIT.paused, so tick and watch --once agree", () => { + const src = readFileSync(bin, "utf8"); + expect(src.match(/if \(result\.paused\) process\.exit\(EXIT\.paused\)/g)?.length).toBe(2); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index d631bf0..3c97a94 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,7 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadConfig } from "../src/config"; +import { readFileSync } from "node:fs"; +import { configProblems, DEFAULT_CONFIG, loadConfig } from "../src/config"; const dir = mkdtempSync(join(tmpdir(), "factory-cfg-")); afterAll(() => rmSync(dir, { recursive: true, force: true })); @@ -32,3 +33,29 @@ describe("loadConfig", () => { expect(c.base).toBe("main"); }); }); + +describe("config validation at boot", () => { + test("an unknown top-level key refuses to start and names the key", async () => { + await expect(loadConfig(repoWith({ repo: "a/b", maxOpenFactoryPr: 3 }))).rejects.toThrow(/maxOpenFactoryPr: unknown key/); + }); + + test("a wrong type is refused, and every problem is listed at once", () => { + const problems = configProblems({ repo: "a/b", concurrency: "3", riskPolicy: { autoApproveLowRisk: "yes" }, maxBudgetUsd: { build: 0, bulid: 1 }, gates: [{ name: "t", cmd: "x", required: 1 }] }); + expect(problems).toHaveLength(5); + expect(problems.join("\n")).toMatch(/concurrency: expected posInt/); + expect(problems.join("\n")).toMatch(/riskPolicy\.autoApproveLowRisk: expected boolean/); + expect(problems.join("\n")).toMatch(/maxBudgetUsd\.build: expected positive/); + expect(problems.join("\n")).toMatch(/maxBudgetUsd\.bulid: unknown key/); + expect(problems.join("\n")).toMatch(/gates\[0\]\.required: expected boolean/); + }); + + test("agent-facing riskCriteria and _comment keys are allowed", () => { + expect(configProblems({ repo: "a/b", _comment: "hi", riskCriteria: { low: {} } })).toEqual([]); + }); + + test("every key of DEFAULT_CONFIG validates, and the shipped example config passes", () => { + expect(configProblems({ ...DEFAULT_CONFIG, repo: "a/b" })).toEqual([]); + const example = JSON.parse(readFileSync(join(import.meta.dir, "../template/.factory/config.example.json"), "utf8")); + expect(configProblems(example)).toEqual([]); + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts index db4c2c5..5541e08 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -146,7 +146,7 @@ describe("Git.changedFiles", () => { await run(["git", "checkout", "-b", "factory/issue-5"], clone); writeFileSync(join(clone, "src.ts"), "changed\n"); await run(["git", "add", "-A"], clone); - await run(["git", "commit", "-m", "change"], clone); + await run(["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-m", "change"], clone); const git = new Git(new GitCommandRunner()); expect(await git.changedFiles(clone, "main")).toEqual(["src.ts"]); diff --git a/tests/harness.ts b/tests/harness.ts index f185302..caa7edd 100644 --- a/tests/harness.ts +++ b/tests/harness.ts @@ -129,6 +129,12 @@ export class FakeGitHub extends GitHub { if (pr) (pr as { state: string }).state = "closed"; } + closed: number[] = []; + + override async closeIssue(_repo: string, number: number): Promise { + this.closed.push(number); + } + override async listLabels(): Promise { return []; } diff --git a/tests/release.test.ts b/tests/release.test.ts index 38a5c00..2705161 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { LABEL } from "../src/labels"; const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -11,3 +12,17 @@ test("template-ci FACTORY_RUNNER_REF is v", () => { const ref = /FACTORY_RUNNER_REF: (\S+)/.exec(read("template-ci/factory.yml.example"))?.[1]; expect(ref).toBe(`v${version}`); }); + +test("the Dockerfile and the CI template pin the same Claude Code version", () => { + const docker = /ARG CLAUDE_CODE_VERSION=(\S+)/.exec(read("Dockerfile"))?.[1]; + const ci = /CLAUDE_CODE_VERSION: "([^"]+)"/.exec(read("template-ci/factory.yml.example"))?.[1]; + expect(docker).toMatch(/^\d+\.\d+\.\d+$/); + expect(ci).toBe(docker); +}); + +test("the README describes only what exists: every label is listed and no monitor stage is claimed", () => { + const readme = read("README.md"); + for (const label of Object.values(LABEL)) expect(readme).toContain(`\`${label}\``); + expect(readme).not.toMatch(/verify, PR, monitor/); + expect(readme).not.toMatch(/the monitor closes/); +}); diff --git a/tests/repo.test.ts b/tests/repo.test.ts index 83ebe0e..44ed5b2 100644 --- a/tests/repo.test.ts +++ b/tests/repo.test.ts @@ -96,7 +96,7 @@ describe("ensureRepoClone", () => { const dir = await ensureRepoClone(runner, "acme/widgets", { env, remoteUrl: bare }); writeFileSync(join(dir, "LOCAL.md"), "should not survive\n"); await run(["add", "-A"], dir); - await run(["commit", "-m", "local only"], dir); + await run(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-m", "local only"], dir); await ensureRepoClone(runner, "acme/widgets", { env, remoteUrl: bare }); expect(await Bun.file(join(dir, "LOCAL.md")).exists()).toBe(false); diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 9ff4b28..70c51e2 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -4,7 +4,7 @@ // The last test fails if a factory label exists that no scenario reaches. import { afterAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DEFAULT_CONFIG, mergeConfig } from "../src/config"; @@ -126,7 +126,7 @@ describe("approval paths", () => { done(c); }); - test("4. /factory cancel clears the label and stops the run", async () => { + test("4. /factory cancel clears the label, closes the issue and removes the worktree", async () => { const c = setup([LABEL.ready]); c.push("triage", triage({ risk: "medium" })); c.push("plan", plan("medium")); @@ -134,10 +134,30 @@ describe("approval paths", () => { c.github.say(1, "/factory cancel"); expect(await c.step()).toBe("cancelled"); expect(labels(c.github, 1)).toEqual([]); + expect(c.github.closed).toEqual([1]); + expect(existsSync(join(c.workspacesDir, "issue-1"))).toBe(false); expect(c.state.getRun("acme/widgets", 1)!.status).toBe("cancelled"); done(c); }); + test("4b. every stage that runs is recorded as a stage_runs row, retries included", async () => { + const c = setup([LABEL.ready]); + c.state.setToggle("auto_approve_low_risk", true); + c.gateRunner.line = "FACTORY_GATES: status=RED passed=1 failed=1 skipped=0 failed_gates=unit"; + c.push("triage", triage()); + c.push("plan", plan("low")); + c.push("build", build()); + expect(await c.step()).toBe("failed"); + c.gateRunner.line = "FACTORY_GATES: status=GREEN passed=2 failed=0 skipped=0 failed_gates=-"; + c.push("build", build()); + c.push("verify", verdict("pass")); + c.push("pr", pr()); + c.github.say(1, "/factory retry"); + expect(await c.step()).toBe("shipped"); + expect(c.state.listStageRuns("acme/widgets", { issue: 1 }).map((r) => r.stage)).toEqual(["triage", "plan", "build", "build", "verify", "pr"]); + done(c); + }); + test("18. an untrusted /factory approve is ignored", async () => { const c = setup([LABEL.ready]); c.push("triage", triage({ risk: "medium" })); @@ -371,6 +391,68 @@ describe("in-review and claim paths", () => { }); }); +// The README promises `/factory cancel` closes the run from every waiting +// state. One row per state; a new waiting label without a row here fails the +// coverage check below. +describe("cancel from every waiting state", () => { + const reach: Record Promise> = { + [LABEL.needsInfo]: async (c) => { + c.push("triage", { ...triage({ disposition: "needs-info" }), ...question() }); + await c.step(); + }, + [LABEL.awaitingApproval]: async (c) => { + c.push("triage", triage({ risk: "medium" })); + c.push("plan", plan("medium")); + await c.step(); + }, + [LABEL.needsHuman]: async (c) => { + c.push("triage", triage({ disposition: "refused" })); + await c.step(); + }, + [LABEL.failed]: async (c) => { + c.gateRunner.line = "FACTORY_GATES: status=RED passed=1 failed=1 skipped=0 failed_gates=unit"; + c.state.setToggle("auto_approve_low_risk", true); + happy(c); + await c.step(); + }, + [LABEL.inReview]: async (c) => { + c.state.setToggle("auto_approve_low_risk", true); + happy(c); + await c.step(); + }, + }; + + for (const [label, getThere] of Object.entries(reach)) { + test(`22. /factory cancel from ${label} closes the issue and the PR`, async () => { + const c = setup([LABEL.ready]); + await getThere(c); + expect(labels(c.github, 1)).toEqual([label]); + c.github.say(1, "/factory cancel"); + expect(await c.step()).toBe("cancelled"); + expect(labels(c.github, 1)).toEqual([]); + expect(c.github.closed).toEqual([1]); + expect(c.github.prs.every((p) => p.state === "closed")).toBe(true); + expect(existsSync(join(c.workspacesDir, "issue-1"))).toBe(false); + expect(c.state.getRun("acme/widgets", 1)!.status).toBe("cancelled"); + done(c); + }); + } + + test("22b. an untrusted /factory cancel is ignored", async () => { + const c = setup([LABEL.ready]); + await reach[LABEL.awaitingApproval]!(c); + c.github.say(1, "/factory cancel", "NONE"); + expect(await c.step()).toBe("waiting"); + expect(c.github.closed).toEqual([]); + done(c); + }); + + test("22c. every waiting label has a cancel scenario", () => { + const waiting = [LABEL.needsInfo, LABEL.awaitingApproval, LABEL.needsHuman, LABEL.failed, LABEL.inReview]; + expect(Object.keys(reach).sort()).toEqual([...waiting].sort()); + }); +}); + test("every state and parked label is reached by a scenario", () => { const unreached = Object.values(LABEL).filter((l) => l !== LABEL.monitor && !seen.has(l)); expect(unreached).toEqual([]); diff --git a/tests/state.test.ts b/tests/state.test.ts new file mode 100644 index 0000000..140c301 --- /dev/null +++ b/tests/state.test.ts @@ -0,0 +1,52 @@ +import { afterAll, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FactoryState, type StageRunInput } from "../src/state"; + +const dir = mkdtempSync(join(tmpdir(), "factory-state-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const row = (issue: number, over: Partial = {}): StageRunInput => ({ + repo: "acme/widgets", issue, stage: "build", agent: "claude", model: null, + started_at: "2026-01-01T00:00:00.000Z", finished_at: "2026-01-01T00:00:02.000Z", duration_ms: 2000, + tool_calls: 3, tokens_in: 10, tokens_out: 20, cost_usd: 0.5, exit_code: 0, killed_reason: null, ...over, +}); + +test("stage_runs keeps every attempt of a stage, where runs keeps only the latest", () => { + const state = new FactoryState(":memory:"); + state.upsertRun({ issue: 1, repo: "acme/widgets", title: "t", stage: "build", status: "running" }); + state.recordStageRun(row(1, { exit_code: 1, killed_reason: "timeout" })); + state.recordStageRun(row(1)); + const rows = state.listStageRuns("acme/widgets", { issue: 1 }); + expect(rows.map((r) => [r.stage, r.exit_code, r.killed_reason])).toEqual([["build", 1, "timeout"], ["build", 0, null]]); + expect(state.listRuns()).toHaveLength(1); + state.close(); +}); + +test("stage_runs pages by keyset across more than one page and filters by repo", () => { + const state = new FactoryState(":memory:"); + for (let i = 0; i < 25; i += 1) state.recordStageRun(row(i + 1)); + state.recordStageRun(row(1, { repo: "other/repo" })); + const first = state.listStageRuns("acme/widgets", { limit: 10 }); + const second = state.listStageRuns("acme/widgets", { limit: 10, after: first.at(-1)!.id }); + const third = state.listStageRuns("acme/widgets", { limit: 10, after: second.at(-1)!.id }); + expect([first.length, second.length, third.length]).toEqual([10, 10, 5]); + expect(new Set([...first, ...second, ...third].map((r) => r.id)).size).toBe(25); + state.close(); +}); + +test("migrating twice, and on a database from v2.2 without stage_runs, keeps the data", () => { + const path = join(dir, "old.db"); + const old = new Database(path); + old.exec("CREATE TABLE runs (id INTEGER PRIMARY KEY AUTOINCREMENT, issue INTEGER NOT NULL, repo TEXT NOT NULL, title TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, tool_calls INTEGER NOT NULL DEFAULT 0, tokens_in INTEGER NOT NULL DEFAULT 0, tokens_out INTEGER NOT NULL DEFAULT 0, cost_usd REAL NOT NULL DEFAULT 0, gate_line TEXT, pr_url TEXT, reason TEXT, status_comment_id INTEGER, UNIQUE(repo, issue))"); + old.exec("INSERT INTO runs (issue, repo, title, stage, status, started_at, updated_at) VALUES (7, 'acme/widgets', 'kept', 'plan', 'shipped', 'a', 'b')"); + old.close(); + new FactoryState(path).close(); + const state = new FactoryState(path); + expect(state.getRun("acme/widgets", 7)!.title).toBe("kept"); + state.recordStageRun(row(7)); + expect(state.listStageRuns("acme/widgets")).toHaveLength(1); + state.close(); +});