diff --git a/.dw/config.yml b/.dw/config.yml new file mode 100644 index 0000000000000..64fccdfa465e7 --- /dev/null +++ b/.dw/config.yml @@ -0,0 +1,9 @@ +# softfac (the Rust factory) reads this block from the default branch on every pass. +# Design and plan: factory/docs/design.md once issue #1 lands; issues #1 to #21. +factory: + instructors: [tomkaczocha, rawphp] + base: main + provision: [vp i] + gate: [factory/scripts/check.sh] + limits: { gate_retries: 2 } + autonomy: pr diff --git a/.github/workflows/factory-ci.yml b/.github/workflows/factory-ci.yml new file mode 100644 index 0000000000000..eb23e2c26637f --- /dev/null +++ b/.github/workflows/factory-ci.yml @@ -0,0 +1,44 @@ +name: Factory CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: factory-ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# This is the fork's own workflow. Upstream's workflows are disabled on this repo. +jobs: + check: + name: Check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Install + run: vp i + + - name: Check + run: factory/scripts/check.sh diff --git a/apps/server/src/factory/boundary.test.ts b/apps/server/src/factory/boundary.test.ts new file mode 100644 index 0000000000000..5c7d370c154ce --- /dev/null +++ b/apps/server/src/factory/boundary.test.ts @@ -0,0 +1,157 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +interface SourceFile { + /** Relative to apps/server/src, with `/` separators. */ + readonly path: string; + readonly imports: ReadonlyArray; +} + +const resolveRelative = (file: string, specifier: string) => + NodePath.posix.normalize(NodePath.posix.join(NodePath.posix.dirname(file), specifier)); + +const packageName = (specifier: string) => + specifier + .split("/") + .slice(0, specifier.startsWith("@") ? 2 : 1) + .join("/"); + +/** + * Factory code (except factory/t3/) may not import T3 code: neither relative paths outside + * factory/ nor the server's workspace packages. Nothing outside factory/ may import it, + * except server.ts. + */ +export const findBoundaryViolations = ( + files: ReadonlyArray, + workspacePackages: ReadonlySet, +): Array => { + const violations: Array = []; + for (const file of files) { + const relative = file.imports.filter((specifier) => specifier.startsWith(".")); + if (file.path.startsWith("factory/")) { + if (file.path.startsWith("factory/t3/")) continue; + for (const specifier of relative) { + if (!resolveRelative(file.path, specifier).startsWith("factory/")) { + violations.push(`${file.path} imports ${specifier} from outside factory/`); + } + } + for (const specifier of file.imports) { + if (workspacePackages.has(packageName(specifier))) { + violations.push(`${file.path} imports ${specifier}, a T3 package`); + } + } + } else if (file.path !== "server.ts") { + for (const specifier of relative) { + if (resolveRelative(file.path, specifier).startsWith("factory/")) { + violations.push(`${file.path} imports ${specifier} from factory/`); + } + } + } + } + return violations; +}; + +const IMPORT_PATTERNS = [ + /\bfrom\s*"([^"]+)"/g, + /\bfrom\s*'([^']+)'/g, + /\bimport\s*"([^"]+)"/g, + /\bimport\s*'([^']+)'/g, + /\bimport\(\s*"([^"]+)"\s*\)/g, + /\bimport\(\s*'([^']+)'\s*\)/g, +]; + +const extractImports = (source: string): Array => + IMPORT_PATTERNS.flatMap((pattern) => Array.from(source.matchAll(pattern), (match) => match[1]!)); + +const srcRoot = NodePath.resolve(import.meta.dirname, ".."); + +/** The server's `workspace:` dependencies, which are T3 code. */ +const readWorkspacePackages = (): ReadonlySet => { + const manifest = JSON.parse( + NodeFS.readFileSync(NodePath.resolve(srcRoot, "../package.json"), "utf8"), + ) as Record | undefined>; + return new Set( + [manifest.dependencies, manifest.devDependencies].flatMap((deps) => + Object.entries(deps ?? {}) + .filter(([, version]) => version.startsWith("workspace:")) + .map(([name]) => name), + ), + ); +}; + +const listSourceFiles = (dir: string): Array => + NodeFS.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (entry.name === "node_modules") return []; + const full = NodePath.join(dir, entry.name); + if (entry.isDirectory()) return listSourceFiles(full); + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + +describe("factory boundary", () => { + it("holds for the real tree", () => { + const files = listSourceFiles(srcRoot).map((full) => ({ + path: NodePath.relative(srcRoot, full).split(NodePath.sep).join("/"), + imports: extractImports(NodeFS.readFileSync(full, "utf8")), + })); + expect(files.some((file) => file.path === "factory/index.ts")).toBe(true); + expect(files.find((file) => file.path === "server.ts")?.imports).toContain( + "./factory/index.ts", + ); + const workspacePackages = readWorkspacePackages(); + expect(workspacePackages.has("@t3tools/contracts")).toBe(true); + expect(findBoundaryViolations(files, workspacePackages)).toEqual([]); + }); + + it("flags factory code importing from outside factory/, except under factory/t3/", () => { + const violations = findBoundaryViolations( + [ + { path: "factory/foo.ts", imports: ["../config.ts", "effect/Effect", "./bar.ts"] }, + { path: "factory/t3/paths.ts", imports: ["../../config.ts"] }, + ], + new Set(), + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("factory/foo.ts"); + expect(violations[0]).toContain("../config.ts"); + }); + + it("flags factory code importing T3 workspace packages, except under factory/t3/", () => { + const violations = findBoundaryViolations( + [ + { + path: "factory/domain/lifecycle.ts", + imports: ["@t3tools/contracts", "@t3tools/shared/git", "effect/Effect", "node:fs"], + }, + { path: "factory/t3/threads.ts", imports: ["@t3tools/contracts"] }, + ], + new Set(["@t3tools/contracts", "@t3tools/shared"]), + ); + expect(violations).toEqual([ + "factory/domain/lifecycle.ts imports @t3tools/contracts, a T3 package", + "factory/domain/lifecycle.ts imports @t3tools/shared/git, a T3 package", + ]); + }); + + it("flags code outside factory/ importing the factory, except server.ts", () => { + const violations = findBoundaryViolations( + [ + { path: "orchestration-v2/x.ts", imports: ["../factory/index.ts"] }, + { path: "server.ts", imports: ["./factory/index.ts"] }, + ], + new Set(), + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain("orchestration-v2/x.ts"); + }); + + it("extracts static, side-effect and dynamic imports", () => { + expect( + extractImports( + `import a from "./a.ts";\nimport 'b';\nexport * from './c.ts';\nconst d = await import("./d.ts");`, + ).toSorted(), + ).toEqual(["./a.ts", "./c.ts", "./d.ts", "b"]); + }); +}); diff --git a/apps/server/src/factory/check.test.ts b/apps/server/src/factory/check.test.ts new file mode 100644 index 0000000000000..bd280af670d13 --- /dev/null +++ b/apps/server/src/factory/check.test.ts @@ -0,0 +1,108 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +const realScript = NodePath.resolve(import.meta.dirname, "../../../../factory/scripts/check.sh"); + +const SEAMS = "seams"; +const TEST = "vp test run apps/server/src/factory"; +const SERVER_TYPECHECK = "vp run --filter t3 typecheck"; +const WEB_TYPECHECK = "vp run --filter @t3tools/web typecheck"; + +// Both stubs append their invocation to $CHECK_TEST_LOG, then exit with the code +// from $CHECK_TEST_FAIL ("=") when it names this invocation. +const stub = (invocation: string) => `#!/usr/bin/env bash +invocation=${invocation} +echo "$invocation" >> "$CHECK_TEST_LOG" +case "\${CHECK_TEST_FAIL:-}" in + "$invocation="*) exit "\${CHECK_TEST_FAIL##*=}" ;; +esac +exit 0 +`; + +let root: string; +let script: string; + +const write = (file: string, content: string) => { + const full = NodePath.join(root, file); + NodeFS.mkdirSync(NodePath.dirname(full), { recursive: true }); + NodeFS.writeFileSync(full, content, { mode: 0o755 }); +}; + +let runs = 0; + +const run = (fail?: string, cwd = root) => { + const log = NodePath.join(root, `log-${runs++}`); + const result = NodeChildProcess.spawnSync("bash", [script], { + cwd, + encoding: "utf8", + env: { + ...process.env, + PATH: `${NodePath.join(root, "bin")}:${process.env.PATH}`, + CHECK_TEST_LOG: log, + CHECK_TEST_FAIL: fail ?? "", + }, + }); + const calls = NodeFS.existsSync(log) ? NodeFS.readFileSync(log, "utf8").trim().split("\n") : []; + return { status: result.status, stdout: result.stdout, calls }; +}; + +describe("check.sh", () => { + beforeAll(() => { + root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "check-")); + script = NodePath.join(root, "factory/scripts/check.sh"); + write("factory/scripts/check.sh", NodeFS.readFileSync(realScript, "utf8")); + write("factory/scripts/check-seams.sh", stub(SEAMS)); + write( + "bin/vp", + `#!/usr/bin/env bash +invocation="vp $*" +echo "$invocation" >> "$CHECK_TEST_LOG" +case "\${CHECK_TEST_FAIL:-}" in + "$invocation="*) exit "\${CHECK_TEST_FAIL##*=}" ;; +esac +exit 0 +`, + ); + }); + + afterAll(() => { + NodeFS.rmSync(root, { recursive: true, force: true }); + }); + + it("runs every step in order and exits 0", () => { + const { status, stdout, calls } = run(); + expect(status).toBe(0); + expect(calls).toEqual([SEAMS, TEST, SERVER_TYPECHECK, WEB_TYPECHECK]); + const at = ["factory/scripts/check-seams.sh", TEST, SERVER_TYPECHECK, WEB_TYPECHECK].map( + (name) => stdout.indexOf(name), + ); + expect(at[0]).toBeGreaterThanOrEqual(0); + expect(at).toEqual([...at].sort((a, b) => a - b)); + expect(new Set(at).size).toBe(4); + }); + + it("runs from a subdirectory of the repo", () => { + const { status, calls } = run(undefined, NodePath.join(root, "bin")); + expect(status).toBe(0); + expect(calls).toEqual([SEAMS, TEST, SERVER_TYPECHECK, WEB_TYPECHECK]); + }); + + it("stops at the first failure with that command's exit code", () => { + const { status, stdout, calls } = run(`${SERVER_TYPECHECK}=3`); + expect(status).toBe(3); + expect(calls).toEqual([SEAMS, TEST, SERVER_TYPECHECK]); + expect(stdout).toContain(SERVER_TYPECHECK); + expect(stdout).not.toContain(WEB_TYPECHECK); + }); + + it("a failing seam check stops before any vp command", () => { + const { status, calls } = run(`${SEAMS}=2`); + expect(status).toBe(2); + expect(calls).toEqual([SEAMS]); + }); +}); diff --git a/apps/server/src/factory/checkSeams.test.ts b/apps/server/src/factory/checkSeams.test.ts new file mode 100644 index 0000000000000..516bb5debfd2b --- /dev/null +++ b/apps/server/src/factory/checkSeams.test.ts @@ -0,0 +1,103 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +const script = NodePath.resolve(import.meta.dirname, "../../../../factory/scripts/check-seams.sh"); + +let repo: string; + +const git = (...args: Array) => { + const result = NodeChildProcess.spawnSync("git", args, { cwd: repo, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`); + return result.stdout.trim(); +}; + +const write = (file: string, content: string) => { + const full = NodePath.join(repo, file); + NodeFS.mkdirSync(NodePath.dirname(full), { recursive: true }); + NodeFS.writeFileSync(full, content); +}; + +const commit = (message: string) => { + git("add", "-A"); + git("commit", "-q", "-m", message); +}; + +const runScript = () => { + const result = NodeChildProcess.spawnSync("bash", [script], { cwd: repo, encoding: "utf8" }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; +}; + +describe("check-seams.sh", () => { + beforeAll(() => { + repo = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "check-seams-")); + git("init", "-q"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "commit.gpgsign", "false"); + write("apps/server/src/server.ts", "base\n"); + write("apps/server/src/ws.ts", "base\n"); + write("apps/server/src/rpc.ts", "rpc base\n"); + commit("base"); + const base = git("rev-parse", "HEAD"); + write("factory/UPSTREAM", `v0.0.0-test ${base}\n`); + write("factory/SEAMS", "apps/server/src/server.ts\n"); + commit("factory metadata"); + }); + + afterAll(() => { + NodeFS.rmSync(repo, { recursive: true, force: true }); + }); + + it("passes when every change is allowed or a listed seam", () => { + write("apps/server/src/server.ts", "changed\n"); + write("apps/server/src/factory/index.ts", "x\n"); + write("apps/web/src/factory/page.tsx", "x\n"); + write(".dw/config.yml", "x: 1\n"); + write("apps/web/src/routes/factory.tsx", "x\n"); + write(".github/workflows/factory-ci.yml", "x: 1\n"); + commit("allowed changes"); + + const { status, output } = runScript(); + expect(output).not.toContain("not a listed seam"); + expect(status).toBe(0); + }); + + it("fails and names an unlisted upstream change", () => { + write("apps/server/src/server.ts", "changed again\n"); + write("apps/server/src/ws.ts", "changed\n"); + commit("unlisted change"); + + const { status, output } = runScript(); + expect(status).toBe(1); + expect(output).toContain("apps/server/src/ws.ts"); + expect(output).not.toContain("apps/server/src/server.ts"); + }); + + it("names an upstream file renamed into an allowed path", () => { + git("mv", "apps/server/src/rpc.ts", "factory/rpc.ts"); + commit("rename upstream file"); + + const { status, output } = runScript(); + expect(status).toBe(1); + expect(output).toContain("apps/server/src/rpc.ts"); + expect(output).not.toContain("factory/rpc.ts"); + }); + + // Runs last: it deletes the upstream commit's root tree, which every diff needs. + it("fails when git diff fails", () => { + const upstream = NodeFS.readFileSync(NodePath.join(repo, "factory/UPSTREAM"), "utf8").split( + " ", + )[1]!; + const tree = git("rev-parse", `${upstream.trim()}^{tree}`); + NodeFS.rmSync(NodePath.join(repo, ".git/objects", tree.slice(0, 2), tree.slice(2))); + + const { status, output } = runScript(); + expect(status).not.toBe(0); + expect(output).toContain("fatal"); + }); +}); diff --git a/apps/server/src/factory/config.test.ts b/apps/server/src/factory/config.test.ts new file mode 100644 index 0000000000000..e4c6d58874fbe --- /dev/null +++ b/apps/server/src/factory/config.test.ts @@ -0,0 +1,655 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; +import { parse, stringify } from "yaml"; + +import { + type ConfigError, + ConfigErrors, + formatConfigError, + loadMachineConfig, + loadProjectConfig, + parseMachineConfig, + parseProjectConfig, + timeLimitOnlyProjectConfig, +} from "./config.ts"; + +const PROJECT = ` +tracker: + backend: do-work-io +factory: + instructors: [tomkaczocha] + base: main + provision: [cargo fetch] + gate: + - cargo fmt --check + - cargo test + limits: { minutes: 90, gate_retries: 2 } + autonomy: pr +`; + +const MACHINE = ` +gh_config_dir: /Users/me/.config/softfac/gh +max_concurrent_runs: 1 +model: claude-sonnet-5-5 +repos: + - repo: rawphp/softfac + path: /Users/me/EA/projects/softfac +`; + +const PFILE = "/r/.dw/config.yml"; +const MFILE = "/m/config.yml"; + +type Tree = any; + +const isIndex = (part: string) => /^\d+$/.test(part); +const step = (cur: Tree, part: string): Tree => cur[isIndex(part) ? Number(part) : part]; + +/** Parse `base`, apply `f` to the plain tree, and return the edited text. */ +const edit = (base: string, f: (tree: Tree) => void): string => { + const tree = parse(base); + f(tree); + return stringify(tree); +}; + +/** Set a dotted path (numeric segments index lists) to the value of the YAML text `yaml`. */ +const set = (tree: Tree, path: string, yaml: string) => { + const parts = path.split("."); + const parent = parts.slice(0, -1).reduce(step, tree); + const last = parts[parts.length - 1]!; + parent[isIndex(last) ? Number(last) : last] = parse(yaml); +}; + +const remove = (tree: Tree, path: string) => { + const parts = path.split("."); + const parent = parts.slice(0, -1).reduce(step, tree); + delete parent[parts[parts.length - 1]!]; +}; + +const projectErr = (text: string): ReadonlyArray => { + const result = parseProjectConfig(text, PFILE); + if (Result.isSuccess(result)) throw new Error("expected invalid project config"); + return result.failure; +}; + +const machineErr = (text: string): ReadonlyArray => { + const result = parseMachineConfig(text, MFILE); + if (Result.isSuccess(result)) throw new Error("expected invalid machine config"); + return result.failure; +}; + +const project = (text: string) => { + const result = parseProjectConfig(text, PFILE); + if (Result.isFailure(result)) + throw new Error(`unexpected errors: ${JSON.stringify(result.failure)}`); + return result.success; +}; + +const machine = (text: string) => { + const result = parseMachineConfig(text, MFILE); + if (Result.isFailure(result)) + throw new Error(`unexpected errors: ${JSON.stringify(result.failure)}`); + return result.success; +}; + +/** Assert exactly one error whose field and file are exactly as given. */ +const assertOne = (errs: ReadonlyArray, field: string, file: string) => { + expect(errs, JSON.stringify(errs)).toHaveLength(1); + expect(errs[0]!.field).toBe(field); + expect(errs[0]!.file).toBe(file); +}; + +/** Assert exactly one file-level error (no field) on `file`. */ +const assertFileLevel = (errs: ReadonlyArray, file: string, messagePart: string) => { + expect(errs, JSON.stringify(errs)).toHaveLength(1); + expect(errs[0]!.field).toBeUndefined(); + expect(errs[0]!.file).toBe(file); + expect(errs[0]!.message).toContain(messagePart); +}; + +const fields = (errs: ReadonlyArray) => + errs.flatMap((e) => (e.field === undefined ? [] : [e.field])); + +const projectMissing = (path: string) => { + assertOne(projectErr(edit(PROJECT, (t) => remove(t, path))), path, PFILE); +}; + +const projectBad = (path: string, yaml: string) => { + assertOne(projectErr(edit(PROJECT, (t) => set(t, path, yaml))), path, PFILE); +}; + +const machineMissing = (path: string, errorPath = path) => { + assertOne(machineErr(edit(MACHINE, (t) => remove(t, path))), errorPath, MFILE); +}; + +/** Set `editPath` (tree address, e.g. `repos.0.path`), expect the error at `errorPath` (`repos[0].path`). */ +const machineBadAt = (editPath: string, errorPath: string, yaml: string) => { + assertOne(machineErr(edit(MACHINE, (t) => set(t, editPath, yaml))), errorPath, MFILE); +}; + +const machineBad = (path: string, yaml: string) => machineBadAt(path, path, yaml); + +describe("project config", () => { + it("the config of a repo that never loaded has the default time limit and no base", () => { + const config = timeLimitOnlyProjectConfig(); + expect(config.limits.minutes).toBe(180); + expect(config.limits.gateRetries).toBe(0); + expect(config.limits.regates).toBe(5); + expect(config.limits.checksMinutes).toBe(30); + expect(config.limits.interruptionResumes).toBe(2); + expect(config.base).toBe(""); + expect(config.autonomy).toBe("pr"); + expect(config.instructors).toEqual([]); + expect(config.provision).toEqual([]); + expect(config.gate).toEqual([]); + expect(config.requiredChecks).toEqual([]); + }); + + describe("valid", () => { + it("project valid resolves all fields", () => { + const c = project(PROJECT); + expect(c.instructors).toEqual(["tomkaczocha"]); + expect(c.base).toBe("main"); + expect(c.provision).toEqual(["cargo fetch"]); + expect(c.gate).toEqual(["cargo fmt --check", "cargo test"]); + expect(c.limits.minutes).toBe(90); + expect(c.limits.gateRetries).toBe(2); + expect(c.requiredChecks).toEqual([]); + expect(c.limits.checksMinutes).toBe(30); + expect(c.autonomy).toBe("pr"); + }); + }); + + describe("the re-gate limit", () => { + const regates = (value?: string) => + project( + edit(PROJECT, (t) => { + if (value !== undefined) set(t, "factory.limits.regates", value); + }), + ).limits.regates; + + it("project regates defaults to 5 and can be set", () => { + expect(regates()).toBe(5); + expect(regates("1")).toBe(1); + expect(regates("12")).toBe(12); + }); + + it("project regates is read from a limits block written inline", () => { + const t = PROJECT.replace( + "limits: { minutes: 90, gate_retries: 2 }", + "limits: { minutes: 90, gate_retries: 2, regates: 3 }", + ); + expect(project(t).limits.regates).toBe(3); + }); + + it("project regates must be an integer of at least one", () => { + for (const bad of ['"many"', "0", "-1", "1.5", "[1]", "null", "true"]) { + const errs = projectErr(edit(PROJECT, (t) => set(t, "factory.limits.regates", bad))); + assertOne(errs, "factory.limits.regates", PFILE); + } + const errs = projectErr(edit(PROJECT, (t) => set(t, "factory.limits.regates", "0"))); + expect(errs[0]!.message).toBe("must be an integer of at least 1"); + }); + }); + + describe("the required checks", () => { + const CHECKS = "factory.required_checks"; + const CHECKS_MINUTES = "factory.limits.checks_minutes"; + + it("project required checks are not supported yet: a non-empty list is one error", () => { + const errs = projectErr(edit(PROJECT, (t) => set(t, CHECKS, '[gate, "build (linux)"]'))); + assertOne(errs, CHECKS, PFILE); + expect(errs[0]!.message).toBe("required_checks is not supported yet"); + }); + + it("project required checks may be an empty list", () => { + const c = project(edit(PROJECT, (t) => set(t, CHECKS, "[]"))); + expect(c.requiredChecks).toEqual([]); + }); + + it("project required checks must be a list of non-empty strings", () => { + for (const bad of ["gate", "5", "null", "[1]", '[""]', '[gate, ""]']) { + const errs = projectErr(edit(PROJECT, (t) => set(t, CHECKS, bad))); + expect(errs.length, bad).toBeGreaterThan(0); + for (const e of errs) expect(e.field?.startsWith(CHECKS), `${bad}: ${e.field}`).toBe(true); + expect( + errs.some((e) => e.message === "required_checks is not supported yet"), + bad, + ).toBe(false); + } + const errs = projectErr(edit(PROJECT, (t) => set(t, CHECKS, '[gate, ""]'))); + assertOne(errs, "factory.required_checks[1]", PFILE); + }); + + it("project checks minutes defaults to 30 and can be set", () => { + expect(project(edit(PROJECT, (t) => set(t, CHECKS_MINUTES, "45"))).limits.checksMinutes).toBe( + 45, + ); + expect(project(edit(PROJECT, (t) => set(t, CHECKS_MINUTES, "1"))).limits.checksMinutes).toBe( + 1, + ); + }); + + it("project checks minutes must be an integer of at least one", () => { + for (const bad of ['"soon"', "0", "-1", "1.5", "[1]", "null", "true"]) { + const errs = projectErr(edit(PROJECT, (t) => set(t, CHECKS_MINUTES, bad))); + assertOne(errs, CHECKS_MINUTES, PFILE); + } + }); + }); + + it("project ignores the needs-info limits an older version read", () => { + const t = PROJECT.replace( + "limits: { minutes: 90, gate_retries: 2 }", + "limits: { minutes: 90, gate_retries: 2, needs_info_nudge_hours: 5, needs_info_timeout_hours: 3, needs_info_rounds: 0 }", + ); + expect(project(t)).toEqual(project(PROJECT)); + }); + + describe("the interruption resumes", () => { + const RESUMES = "factory.limits.interruption_resumes"; + const withResumes = (resumes: string) => edit(PROJECT, (t) => set(t, RESUMES, resumes)); + + it("project interruption resumes default to 2", () => { + expect(project(PROJECT).limits.interruptionResumes).toBe(2); + }); + + it("project interruption resumes can be set and can be zero", () => { + for (const [text, want] of [ + ["0", 0], + ["1", 1], + ["2", 2], + ["10", 10], + ] as const) { + expect(project(withResumes(text)).limits.interruptionResumes).toBe(want); + } + }); + + it("project interruption resumes are read from a limits block written inline", () => { + const t = PROJECT.replace( + "limits: { minutes: 90, gate_retries: 2 }", + "limits: { minutes: 90, gate_retries: 2, interruption_resumes: 4 }", + ); + expect(project(t).limits.interruptionResumes).toBe(4); + }); + + it("project interruption resumes must be an integer of at least zero", () => { + for (const bad of ['"many"', "-1", "1.5", "[1]", "null", "true"]) { + assertOne(projectErr(withResumes(bad)), RESUMES, PFILE); + } + expect(projectErr(withResumes("-1"))[0]!.message).toBe("must be an integer of at least 0"); + }); + }); + + it("project ignores other top level keys", () => { + const t = + "tracker: 5\nmodels: [a]\nfactory:\n instructors: [a]\n base: main\n provision: []\n gate: [make]\n limits: { minutes: 1, gate_retries: 0 }\n autonomy: pr\n"; + const c = project(t); + expect(c.provision).toEqual([]); + expect(c.limits.gateRetries).toBe(0); + }); + + it("project ignores unknown keys inside the factory block", () => { + const t = edit(PROJECT, (tree) => set(tree, "factory.something_else", "5")); + expect(project(t)).toEqual(project(PROJECT)); + }); + + describe("missing fields", () => { + it("project missing factory block", () => { + assertOne(projectErr("tracker: x\n"), "factory", PFILE); + }); + it("project missing instructors", () => projectMissing("factory.instructors")); + it("project missing base", () => projectMissing("factory.base")); + it("project missing provision", () => projectMissing("factory.provision")); + it("project missing gate", () => projectMissing("factory.gate")); + it("project missing limits", () => projectMissing("factory.limits")); + it("project limits minutes defaults to 180 when missing", () => { + const t = edit(PROJECT, (tree) => remove(tree, "factory.limits.minutes")); + expect(project(t).limits.minutes).toBe(180); + }); + it("project missing limits gate retries", () => projectMissing("factory.limits.gate_retries")); + it("project missing autonomy", () => projectMissing("factory.autonomy")); + }); + + describe("wrong types and values", () => { + it("project factory not a mapping", () => { + assertOne(projectErr("factory: 3\n"), "factory", PFILE); + }); + it("project instructors not a list", () => projectBad("factory.instructors", "tomkaczocha")); + it("project instructors empty", () => projectBad("factory.instructors", "[]")); + it("project instructors item not a string", () => { + const t = edit(PROJECT, (tree) => set(tree, "factory.instructors", "[a, 5]")); + assertOne(projectErr(t), "factory.instructors[1]", PFILE); + }); + it("project instructors empty item", () => { + const t = edit(PROJECT, (tree) => set(tree, "factory.instructors", '[""]')); + assertOne(projectErr(t), "factory.instructors[0]", PFILE); + }); + it("project gate empty item", () => { + const t = edit(PROJECT, (tree) => set(tree, "factory.gate", '[""]')); + assertOne(projectErr(t), "factory.gate[0]", PFILE); + }); + it("project base not a string", () => projectBad("factory.base", "[main]")); + it("project base empty", () => projectBad("factory.base", '""')); + it("project provision not a list", () => projectBad("factory.provision", "cargo fetch")); + it("project provision item not a string", () => { + const t = edit(PROJECT, (tree) => set(tree, "factory.provision", "[{a: b}]")); + assertOne(projectErr(t), "factory.provision[0]", PFILE); + }); + it("project gate not a list", () => projectBad("factory.gate", "cargo test")); + it("project gate empty", () => projectBad("factory.gate", "[]")); + it("project limits not a mapping", () => projectBad("factory.limits", "90")); + it("project minutes not an integer", () => projectBad("factory.limits.minutes", '"ninety"')); + it("project minutes zero", () => projectBad("factory.limits.minutes", "0")); + it("project minutes negative", () => projectBad("factory.limits.minutes", "-5")); + it("project minutes fractional", () => projectBad("factory.limits.minutes", "1.5")); + it("project minutes written as 90.0 is fractional", () => { + const errs = projectErr(PROJECT.replace("minutes: 90", "minutes: 90.0")); + assertOne(errs, "factory.limits.minutes", PFILE); + expect(errs[0]!.message).toBe("must be an integer of at least 1"); + }); + it("project gate retries not an integer", () => + projectBad("factory.limits.gate_retries", "many")); + it("project gate retries negative", () => projectBad("factory.limits.gate_retries", "-1")); + it("project autonomy merge is rejected with phase note", () => { + const errs = projectErr(edit(PROJECT, (tree) => set(tree, "factory.autonomy", "merge"))); + assertOne(errs, "factory.autonomy", PFILE); + expect(errs[0]!.message).toBe("unsupported value `merge`; only `pr` is accepted in phase 1"); + }); + it("project autonomy deploy is rejected", () => projectBad("factory.autonomy", "deploy")); + it("project autonomy not a string", () => { + const errs = projectErr(edit(PROJECT, (tree) => set(tree, "factory.autonomy", "7"))); + assertOne(errs, "factory.autonomy", PFILE); + expect(errs[0]!.message).toBe("must be a string; only `pr` is accepted in phase 1"); + }); + it("project key present with null is a type error, not absent", () => { + const errs = projectErr(edit(PROJECT, (tree) => set(tree, "factory.base", "null"))); + assertOne(errs, "factory.base", PFILE); + expect(errs[0]!.message).toBe("must be a string"); + }); + }); + + it("project reports all errors not just the first", () => { + const t = edit(PROJECT, (tree) => { + remove(tree, "factory.base"); + set(tree, "factory.limits.minutes", "0"); + set(tree, "factory.autonomy", "merge"); + }); + const errs = projectErr(t); + expect(errs).toHaveLength(3); + expect(fields(errs)).toEqual(["factory.base", "factory.limits.minutes", "factory.autonomy"]); + }); + + it("project invalid yaml names the file", () => { + assertFileLevel(projectErr("factory: [unclosed\n"), PFILE, "not valid YAML"); + }); + + it("project document not a mapping", () => { + assertFileLevel(projectErr("- a\n- b\n"), PFILE, "top level must be a mapping"); + assertFileLevel(projectErr(""), PFILE, "top level must be a mapping"); + assertFileLevel(projectErr("5\n"), PFILE, "top level must be a mapping"); + }); + + effectIt.effect("project missing file names the file", () => + Effect.gen(function* () { + const file = "/definitely/not/here/.dw/config.yml"; + const failure = yield* Effect.flip(loadProjectConfig(file)); + expect(failure).toBeInstanceOf(ConfigErrors); + assertFileLevel(failure.errors, file, "cannot read"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("formatConfigError", () => { + it("names the file, the field and the message", () => { + expect(formatConfigError({ file: "/m/c.yml", field: "model", message: "is missing" })).toBe( + "/m/c.yml: model: is missing", + ); + }); + it("leaves the field out of a file-level error", () => { + expect(formatConfigError({ file: "/m/c.yml", message: "top level must be a mapping" })).toBe( + "/m/c.yml: top level must be a mapping", + ); + }); +}); + +describe("machine config", () => { + it("machine valid resolves every field and the defaults", () => { + expect(machine(MACHINE)).toEqual({ + ghConfigDir: "/Users/me/.config/softfac/gh", + pollSeconds: 60, + maxConcurrentRuns: 1, + maxConcurrentGates: 2, + modelInstance: "claudeAgent", + model: "claude-sonnet-5-5", + repos: [{ repo: "rawphp/softfac", path: "/Users/me/EA/projects/softfac" }], + }); + }); + + it("machine every key can be set", () => { + const t = edit(MACHINE, (tree) => { + set(tree, "poll_seconds", "10"); + set(tree, "max_concurrent_runs", "3"); + set(tree, "max_concurrent_gates", "4"); + set(tree, "model_instance", "codex"); + }); + const c = machine(t); + expect(c.pollSeconds).toBe(10); + expect(c.maxConcurrentRuns).toBe(3); + expect(c.maxConcurrentGates).toBe(4); + expect(c.modelInstance).toBe("codex"); + }); + + describe("required keys", () => { + it("machine missing gh config dir", () => machineMissing("gh_config_dir")); + it("machine missing max concurrent runs", () => machineMissing("max_concurrent_runs")); + it("machine missing model", () => machineMissing("model")); + it("machine missing repos", () => machineMissing("repos")); + it("machine repo missing repo", () => machineMissing("repos.0.repo", "repos[0].repo")); + it("machine repo missing path", () => { + const t = edit(MACHINE, (tree) => remove(tree, "repos.0.path")); + assertOne(machineErr(t), "repos[0].path", MFILE); + expect(machineErr(t)[0]!.message).toBe("is missing"); + }); + }); + + describe("integers", () => { + it("machine poll seconds below 10 is rejected", () => { + machineBad("poll_seconds", "9"); + expect(machineErr(edit(MACHINE, (t) => set(t, "poll_seconds", "9")))[0]!.message).toBe( + "must be an integer of at least 10", + ); + }); + it("machine poll seconds as a string is not an integer", () => { + machineBad("poll_seconds", '"60"'); + expect(machineErr(edit(MACHINE, (t) => set(t, "poll_seconds", '"60"')))[0]!.message).toBe( + "must be an integer", + ); + }); + it("machine max concurrent runs of 0 is rejected", () => { + machineBad("max_concurrent_runs", "0"); + expect(machineErr(edit(MACHINE, (t) => set(t, "max_concurrent_runs", "0")))[0]!.message).toBe( + "must be an integer of at least 1", + ); + }); + it("machine max concurrent gates of 0 is rejected", () => { + machineBad("max_concurrent_gates", "0"); + expect( + machineErr(edit(MACHINE, (t) => set(t, "max_concurrent_gates", "0")))[0]!.message, + ).toBe("must be an integer of at least 1"); + }); + }); + + describe("strings", () => { + const KEYS = ["model", "model_instance", "gh_config_dir"]; + it.each(KEYS)("machine %s empty", (key) => { + machineBad(key, '""'); + expect(machineErr(edit(MACHINE, (t) => set(t, key, '""')))[0]!.message).toBe( + "must not be empty", + ); + }); + it.each(KEYS)("machine %s not a string", (key) => { + machineBad(key, "[x]"); + expect(machineErr(edit(MACHINE, (t) => set(t, key, "[x]")))[0]!.message).toBe( + "must be a string", + ); + }); + }); + + describe("repos", () => { + it("machine repos as a string is not a list", () => { + machineBad("repos", "nope"); + expect(machineErr(edit(MACHINE, (t) => set(t, "repos", "nope")))[0]!.message).toBe( + "must be a list", + ); + }); + it("machine repos empty", () => { + machineBad("repos", "[]"); + expect(machineErr(edit(MACHINE, (t) => set(t, "repos", "[]")))[0]!.message).toBe( + "must not be empty", + ); + }); + it("machine repo item not a mapping", () => { + machineBadAt("repos.0", "repos[0]", "5"); + expect(machineErr(edit(MACHINE, (t) => set(t, "repos.0", "5")))[0]!.message).toBe( + "must be a mapping", + ); + }); + it("machine repo and path must be non-empty strings", () => { + machineBadAt("repos.0.repo", "repos[0].repo", '""'); + machineBadAt("repos.0.path", "repos[0].path", "[x]"); + }); + it("machine reads several repos in order", () => { + const t = edit(MACHINE, (tree) => { + tree.repos.push({ repo: "a/b", path: "/b" }); + }); + expect(machine(t).repos.map((r) => r.repo)).toEqual(["rawphp/softfac", "a/b"]); + }); + }); + + describe("unknown keys", () => { + it("machine unknown top-level key is one file-level error", () => { + const errs = machineErr(edit(MACHINE, (t) => set(t, "self_updte", "1"))); + assertFileLevel(errs, MFILE, "unknown key self_updte"); + expect(formatConfigError(errs[0]!)).toBe(`${MFILE}: unknown key self_updte`); + }); + it("machine unknown key inside a repo is named by its path", () => { + const errs = machineErr(edit(MACHINE, (t) => set(t, "repos.0.pathh", "x"))); + assertFileLevel(errs, MFILE, "unknown key repos[0].pathh"); + }); + it("machine two unknown keys are two errors, after the field errors", () => { + const t = edit(MACHINE, (tree) => { + set(tree, "zzz", "1"); + remove(tree, "model"); + set(tree, "repos.0.pathh", "x"); + }); + const errs = machineErr(t); + expect(errs.map(formatConfigError)).toEqual([ + `${MFILE}: model: is missing`, + `${MFILE}: unknown key zzz`, + `${MFILE}: unknown key repos[0].pathh`, + ]); + }); + it("machine does not look for unknown keys inside a value of the wrong type", () => { + const errs = machineErr(edit(MACHINE, (t) => set(t, "repos", "{a: 1}"))); + assertOne(errs, "repos", MFILE); + }); + }); + + it("machine reports all errors together", () => { + const t = edit(MACHINE, (tree) => { + remove(tree, "model"); + set(tree, "poll_seconds", "1"); + set(tree, "max_concurrent_runs", "0"); + }); + expect(fields(machineErr(t))).toEqual(["poll_seconds", "max_concurrent_runs", "model"]); + }); + + it("machine invalid yaml names the file", () => { + assertFileLevel(machineErr("repos: [unclosed\n"), MFILE, "not valid YAML"); + }); + + it("machine document not a mapping", () => { + assertFileLevel(machineErr("- a\n"), MFILE, "top level must be a mapping"); + assertFileLevel(machineErr(""), MFILE, "top level must be a mapping"); + }); +}); + +describe("loaders", () => { + const withTempDir = ( + body: ( + dir: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect, + ) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "factory-config-" }); + return yield* body(dir, fs, path); + }), + ).pipe(Effect.provide(NodeServices.layer)); + + effectIt.effect("loadMachineConfig on a missing path is none", () => + withTempDir((dir, _fs, path) => + Effect.gen(function* () { + const loaded = yield* loadMachineConfig(path.join(dir, "nope.yml")); + expect(Option.isNone(loaded)).toBe(true); + }), + ), + ); + + effectIt.effect("loadMachineConfig reads the file on every call", () => + withTempDir((dir, fs, path) => + Effect.gen(function* () { + const file = path.join(dir, "config.yml"); + yield* fs.writeFileString(file, MACHINE); + const first = yield* loadMachineConfig(file); + expect(Option.map(first, (c) => c.model)).toEqual(Option.some("claude-sonnet-5-5")); + + yield* fs.writeFileString(file, MACHINE.replace("claude-sonnet-5-5", "claude-opus-4-8")); + const second = yield* loadMachineConfig(file); + expect(Option.map(second, (c) => c.model)).toEqual(Option.some("claude-opus-4-8")); + }), + ), + ); + + effectIt.effect("loadMachineConfig fails with every error of an invalid file", () => + withTempDir((dir, fs, path) => + Effect.gen(function* () { + const file = path.join(dir, "config.yml"); + yield* fs.writeFileString(file, MACHINE.replace("model: claude-sonnet-5-5\n", "")); + const failure = yield* Effect.flip(loadMachineConfig(file)); + expect(failure.errors.map(formatConfigError)).toEqual([`${file}: model: is missing`]); + }), + ), + ); + + effectIt.effect("loadMachineConfig on a directory fails with cannot read file", () => + withTempDir((dir) => + Effect.gen(function* () { + const failure = yield* Effect.flip(loadMachineConfig(dir)); + assertFileLevel(failure.errors, dir, "cannot read file: "); + }), + ), + ); + + effectIt.effect("loadProjectConfig reads the file on every call", () => + withTempDir((dir, fs, path) => + Effect.gen(function* () { + const file = path.join(dir, "config.yml"); + yield* fs.writeFileString(file, PROJECT); + expect((yield* loadProjectConfig(file)).base).toBe("main"); + yield* fs.writeFileString(file, PROJECT.replace("base: main", "base: develop")); + expect((yield* loadProjectConfig(file)).base).toBe("develop"); + }), + ), + ); +}); diff --git a/apps/server/src/factory/config.ts b/apps/server/src/factory/config.ts new file mode 100644 index 0000000000000..14721f638c810 --- /dev/null +++ b/apps/server/src/factory/config.ts @@ -0,0 +1,484 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { parse } from "yaml"; + +export const ConfigErrorSchema = Schema.Struct({ + file: Schema.String, + field: Schema.optionalKey(Schema.String), + message: Schema.String, +}); +export type ConfigError = typeof ConfigErrorSchema.Type; + +/** `: : `, or `: ` with no field. */ +export const formatConfigError = (error: ConfigError): string => + error.field === undefined + ? `${error.file}: ${error.message}` + : `${error.file}: ${error.field}: ${error.message}`; + +/** The typed failure of the loaders: every problem found in one file. */ +export class ConfigErrors extends Schema.TaggedError()("FactoryConfigErrors", { + file: Schema.String, + errors: Schema.Array(ConfigErrorSchema), +}) { + override get message(): string { + return this.errors.map(formatConfigError).join("\n"); + } +} + +export interface Limits { + readonly minutes: number; + readonly gateRetries: number; + readonly regates: number; + readonly checksMinutes: number; + readonly interruptionResumes: number; +} + +export type Autonomy = "pr"; + +export interface ProjectConfig { + readonly instructors: ReadonlyArray; + readonly base: string; + readonly provision: ReadonlyArray; + readonly gate: ReadonlyArray; + readonly requiredChecks: ReadonlyArray; + readonly limits: Limits; + readonly autonomy: Autonomy; +} + +const DEFAULT_MINUTES = 180; +const DEFAULT_CHECKS_MINUTES = 30; +const DEFAULT_REGATES = 5; +const DEFAULT_INTERRUPTION_RESUMES = 2; +const DEFAULT_POLL_SECONDS = 60; +const DEFAULT_MAX_CONCURRENT_GATES = 2; +const DEFAULT_MODEL_INSTANCE = "claudeAgent"; + +/** What a repo's runs are timed by when none of its project configs has loaded. */ +export const timeLimitOnlyProjectConfig = (): ProjectConfig => ({ + instructors: [], + base: "", + provision: [], + gate: [], + requiredChecks: [], + limits: { + minutes: DEFAULT_MINUTES, + gateRetries: 0, + regates: DEFAULT_REGATES, + checksMinutes: DEFAULT_CHECKS_MINUTES, + interruptionResumes: DEFAULT_INTERRUPTION_RESUMES, + }, + autonomy: "pr", +}); + +export const PROJECT_CONFIG_PATH = ".dw/config.yml"; + +export interface WatchedRepo { + readonly repo: string; + readonly path: string; +} + +export interface MachineConfig { + readonly ghConfigDir: string; + readonly pollSeconds: number; + readonly maxConcurrentRuns: number; + readonly maxConcurrentGates: number; + readonly modelInstance: string; + readonly model: string; + readonly repos: ReadonlyArray; +} + +type Mapping = Record; + +const isMapping = (value: unknown): value is Mapping => + typeof value === "object" && value !== null && !Array.isArray(value); + +const join = (path: string, key: string) => (path === "" ? key : `${path}.${key}`); + +/** + * Collects every problem of one file. A value of `undefined` stands for "no value": YAML never + * produces it, so it marks an absent key or a parent that was already reported. + */ +const makeChecker = (file: string) => { + const errors: Array = []; + + const err = (field: string, message: string) => { + errors.push(field === "" ? { file, message } : { file, field, message }); + }; + + const child = (parent: Mapping | undefined, path: string, key: string): unknown => { + if (parent === undefined) return undefined; + if (!Object.hasOwn(parent, key)) { + err(join(path, key), "is missing"); + return undefined; + } + return parent[key]; + }; + + const mapping = (value: unknown, path: string): Mapping | undefined => { + if (value === undefined) return undefined; + if (isMapping(value)) return value; + err(path, "must be a mapping"); + return undefined; + }; + + const nonEmptyString = (value: unknown, path: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string") { + err(path, "must be a string"); + return undefined; + } + if (value === "") { + err(path, "must not be empty"); + return undefined; + } + return value; + }; + + /** An integer at or above `min`. YAML integers arrive as bigint, floats as number. */ + const uint = (value: unknown, path: string, min: number): number | undefined => { + if (value === undefined) return undefined; + if (typeof value === "bigint" && value >= BigInt(min)) return Number(value); + if (typeof value === "bigint" || typeof value === "number") { + err(path, `must be an integer of at least ${min}`); + return undefined; + } + err(path, "must be an integer"); + return undefined; + }; + + /** Like `uint` for `key` under `parent`, but an absent key takes `fallback`. */ + const optionalUint = ( + parent: Mapping | undefined, + path: string, + key: string, + fallback: number, + min: number, + ): number | undefined => { + if (parent === undefined) return undefined; + if (!Object.hasOwn(parent, key)) return fallback; + return uint(parent[key], join(path, key), min); + }; + + const optionalNonEmptyString = ( + parent: Mapping | undefined, + path: string, + key: string, + fallback: string, + ): string | undefined => { + if (parent === undefined) return undefined; + if (!Object.hasOwn(parent, key)) return fallback; + return nonEmptyString(parent[key], join(path, key)); + }; + + const stringList = ( + value: unknown, + path: string, + allowEmpty: boolean, + ): ReadonlyArray | undefined => { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + err(path, "must be a list of strings"); + return undefined; + } + if (value.length === 0 && !allowEmpty) { + err(path, "must not be empty"); + return undefined; + } + const before = errors.length; + const out: Array = []; + value.forEach((item, i) => { + const text = nonEmptyString(item, `${path}[${i}]`); + if (text !== undefined) out.push(text); + }); + return errors.length === before ? out : undefined; + }; + + const autonomy = (value: unknown, path: string): Autonomy | undefined => { + if (value === undefined) return undefined; + if (value === "pr") return "pr"; + if (typeof value === "string") { + err(path, `unsupported value \`${value}\`; only \`pr\` is accepted in phase 1`); + } else { + err(path, "must be a string; only `pr` is accepted in phase 1"); + } + return undefined; + }; + + /** One file-level error per key of `value` that is not in `known`, in document order. */ + const unknownKeys = (value: Mapping, known: ReadonlyArray, prefix: string) => { + for (const key of Object.keys(value)) { + if (!known.includes(key)) err("", `unknown key ${prefix}${key}`); + } + }; + + /** Parse the text into a YAML mapping, or the single file-level error. */ + const root = (text: string): Result.Result> => { + let value: unknown; + try { + value = parse(text, { intAsBigInt: true }); + } catch (cause) { + err("", `not valid YAML: ${cause instanceof Error ? cause.message : String(cause)}`); + return Result.fail(errors); + } + if (!isMapping(value)) { + err("", "top level must be a mapping"); + return Result.fail(errors); + } + return Result.succeed(value); + }; + + const finish = (value: T | undefined): Result.Result> => + value !== undefined && errors.length === 0 ? Result.succeed(value) : Result.fail(errors); + + return { + err, + child, + mapping, + nonEmptyString, + uint, + optionalUint, + optionalNonEmptyString, + stringList, + autonomy, + unknownKeys, + root, + finish, + }; +}; + +type Checker = ReturnType; + +const projectFrom = (c: Checker, root: Mapping): ProjectConfig | undefined => { + const factory = c.mapping(c.child(root, "", "factory"), "factory"); + const instructors = c.stringList( + c.child(factory, "factory", "instructors"), + "factory.instructors", + false, + ); + const base = c.nonEmptyString(c.child(factory, "factory", "base"), "factory.base"); + const provision = c.stringList( + c.child(factory, "factory", "provision"), + "factory.provision", + true, + ); + const gate = c.stringList(c.child(factory, "factory", "gate"), "factory.gate", false); + + let requiredChecks: ReadonlyArray | undefined; + if (factory !== undefined) { + if (Object.hasOwn(factory, "required_checks")) { + requiredChecks = c.stringList(factory["required_checks"], "factory.required_checks", true); + if (requiredChecks !== undefined && requiredChecks.length > 0) { + c.err("factory.required_checks", "required_checks is not supported yet"); + requiredChecks = undefined; + } + } else { + requiredChecks = []; + } + } + + const limits = c.mapping(c.child(factory, "factory", "limits"), "factory.limits"); + const minutes = c.optionalUint(limits, "factory.limits", "minutes", DEFAULT_MINUTES, 1); + const gateRetries = c.uint( + c.child(limits, "factory.limits", "gate_retries"), + "factory.limits.gate_retries", + 0, + ); + const regates = c.optionalUint(limits, "factory.limits", "regates", DEFAULT_REGATES, 1); + const checksMinutes = c.optionalUint( + limits, + "factory.limits", + "checks_minutes", + DEFAULT_CHECKS_MINUTES, + 1, + ); + const interruptionResumes = c.optionalUint( + limits, + "factory.limits", + "interruption_resumes", + DEFAULT_INTERRUPTION_RESUMES, + 0, + ); + const autonomy = c.autonomy(c.child(factory, "factory", "autonomy"), "factory.autonomy"); + + if ( + instructors === undefined || + base === undefined || + provision === undefined || + gate === undefined || + requiredChecks === undefined || + minutes === undefined || + gateRetries === undefined || + regates === undefined || + checksMinutes === undefined || + interruptionResumes === undefined || + autonomy === undefined + ) { + return undefined; + } + return { + instructors, + base, + provision, + gate, + requiredChecks, + limits: { minutes, gateRetries, regates, checksMinutes, interruptionResumes }, + autonomy, + }; +}; + +const MACHINE_KEYS = [ + "gh_config_dir", + "poll_seconds", + "max_concurrent_runs", + "max_concurrent_gates", + "model_instance", + "model", + "repos", +] as const; + +const REPO_KEYS = ["repo", "path"] as const; + +const reposFrom = (c: Checker, root: Mapping): ReadonlyArray | undefined => { + const value = c.child(root, "", "repos"); + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + c.err("repos", "must be a list"); + return undefined; + } + if (value.length === 0) { + c.err("repos", "must not be empty"); + return undefined; + } + const out: Array = []; + let ok = true; + value.forEach((item, i) => { + const at = `repos[${i}]`; + const entry = c.mapping(item, at); + const repo = c.nonEmptyString(c.child(entry, at, "repo"), `${at}.repo`); + const path = c.nonEmptyString(c.child(entry, at, "path"), `${at}.path`); + if (repo === undefined || path === undefined) ok = false; + else out.push({ repo, path }); + }); + return ok ? out : undefined; +}; + +/** Unknown keys at any depth, after the known-field errors. Wrong-typed values are skipped. */ +const checkUnknownKeys = (c: Checker, root: Mapping) => { + c.unknownKeys(root, MACHINE_KEYS, ""); + const repos = root["repos"]; + if (!Array.isArray(repos)) return; + repos.forEach((item, i) => { + if (isMapping(item)) c.unknownKeys(item, REPO_KEYS, `repos[${i}].`); + }); +}; + +const machineFrom = (c: Checker, root: Mapping): MachineConfig | undefined => { + const ghConfigDir = c.nonEmptyString(c.child(root, "", "gh_config_dir"), "gh_config_dir"); + const pollSeconds = c.optionalUint(root, "", "poll_seconds", DEFAULT_POLL_SECONDS, 10); + const maxConcurrentRuns = c.uint( + c.child(root, "", "max_concurrent_runs"), + "max_concurrent_runs", + 1, + ); + const maxConcurrentGates = c.optionalUint( + root, + "", + "max_concurrent_gates", + DEFAULT_MAX_CONCURRENT_GATES, + 1, + ); + const modelInstance = c.optionalNonEmptyString( + root, + "", + "model_instance", + DEFAULT_MODEL_INSTANCE, + ); + const model = c.nonEmptyString(c.child(root, "", "model"), "model"); + const repos = reposFrom(c, root); + checkUnknownKeys(c, root); + + if ( + ghConfigDir === undefined || + pollSeconds === undefined || + maxConcurrentRuns === undefined || + maxConcurrentGates === undefined || + modelInstance === undefined || + model === undefined || + repos === undefined + ) { + return undefined; + } + return { + ghConfigDir, + pollSeconds, + maxConcurrentRuns, + maxConcurrentGates, + modelInstance, + model, + repos, + }; +}; + +export const parseProjectConfig = ( + text: string, + file: string, +): Result.Result> => { + const c = makeChecker(file); + const parsed = c.root(text); + if (Result.isFailure(parsed)) return Result.fail(parsed.failure); + return c.finish(projectFrom(c, parsed.success)); +}; + +export const parseMachineConfig = ( + text: string, + file: string, +): Result.Result> => { + const c = makeChecker(file); + const parsed = c.root(text); + if (Result.isFailure(parsed)) return Result.fail(parsed.failure); + return c.finish(machineFrom(c, parsed.success)); +}; + +const cannotRead = (file: string, cause: { readonly message: string }) => + new ConfigErrors({ file, errors: [{ file, message: `cannot read file: ${cause.message}` }] }); + +const toConfigErrors = ( + file: string, + parsed: Result.Result>, +): Effect.Effect => + Result.isSuccess(parsed) + ? Effect.succeed(parsed.success) + : Effect.fail(new ConfigErrors({ file, errors: parsed.failure })); + +/** Reads the file on every call. Nothing is cached. */ +export const loadProjectConfig = ( + file: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs + .readFileString(file) + .pipe(Effect.mapError((cause) => cannotRead(file, cause))); + return yield* toConfigErrors(file, parseProjectConfig(text, file)); + }); + +/** Reads the file on every call. A file that does not exist is `Option.none()`. */ +export const loadMachineConfig = ( + file: string, +): Effect.Effect, ConfigErrors, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(file).pipe( + Effect.asSome, + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail(cannotRead(file, cause)), + ), + ); + if (Option.isNone(text)) return Option.none(); + return Option.some(yield* toConfigErrors(file, parseMachineConfig(text.value, file))); + }); diff --git a/apps/server/src/factory/domain/lifecycle.test.ts b/apps/server/src/factory/domain/lifecycle.test.ts new file mode 100644 index 0000000000000..1451b2f7ff9ad --- /dev/null +++ b/apps/server/src/factory/domain/lifecycle.test.ts @@ -0,0 +1,379 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + InvalidTransition, + STATES, + stateColor, + stateDescription, + stateFromLabel, + stateLabel, + transition, + type Action, + type Event, + type State, + type Transition, +} from "./lifecycle.ts"; + +/** The label table of softfac's docs/lifecycle.md, pinned: the first two columns of each row. */ +const LABEL_TABLE_ROWS = [ + "| `factory:proposed` | A bot filed it. The factory ignores it until a person sets `factory:ready`. |", + "| `factory:ready` | Approved for the factory. |", + "| `factory:triage` | Claimed. The session is being launched. |", + "| `factory:needs-info` | The factory asked questions on the issue. Answer them, then set `factory:ready`. |", + "| `factory:building` | A session is building it. |", + "| `factory:pr-open` | The PR is open and has passed the gate. |", + "| `factory:stuck` | The factory gave up and said why in a comment. |", +]; + +const ok = (state: State, event: Event): Transition => { + const result = transition(state, event); + if (Result.isFailure(result)) throw new Error(result.failure.message); + return result.success; +}; + +const invalid = (state: State, event: Event): InvalidTransition | undefined => { + const result = transition(state, event); + return Result.isFailure(result) ? result.failure : undefined; +}; + +const stuckActions = (reason: string): Array => [ + { kind: "post-stuck-comment", reason }, + { kind: "set-label", state: "stuck" }, + { kind: "unassign" }, + { kind: "update-status-comment" }, +]; + +const questionsActions = (questions: string): Array => [ + { kind: "post-questions", questions }, + { kind: "set-label", state: "needs-info" }, + { kind: "unassign" }, + { kind: "update-status-comment" }, +]; + +describe("lifecycle states", () => { + it("every state has a distinct six digit colour", () => { + const seen: Array = []; + for (const state of STATES) { + const color = stateColor(state); + expect(color, color).toMatch(/^[0-9a-f]{6}$/); + expect(seen, `${color} is used twice`).not.toContain(color); + seen.push(color); + } + }); + + it("the descriptions are the meanings in the lifecycle label table", () => { + expect(LABEL_TABLE_ROWS).toHaveLength(STATES.length); + for (const state of STATES) { + const row = `| \`${stateLabel(state)}\` | ${stateDescription(state)} |`; + expect(LABEL_TABLE_ROWS, `the label table has no row ${row}`).toContain(row); + // GitHub cuts a label description at 100 characters. + expect([...stateDescription(state)].length).toBeLessThanOrEqual(100); + } + }); + + it("labels round trip", () => { + const expected = [ + ["proposed", "factory:proposed"], + ["ready", "factory:ready"], + ["triage", "factory:triage"], + ["needs-info", "factory:needs-info"], + ["building", "factory:building"], + ["pr-open", "factory:pr-open"], + ["stuck", "factory:stuck"], + ] as const; + expect(expected).toHaveLength(STATES.length); + expect(STATES).toEqual(expected.map(([state]) => state)); + for (const [state, label] of expected) { + expect(stateLabel(state)).toBe(label); + expect(stateFromLabel(label)).toBe(state); + } + }); + + it("unknown labels are rejected", () => { + expect(stateFromLabel("factory:nope")).toBeUndefined(); + expect(stateFromLabel("ready")).toBeUndefined(); + expect(stateFromLabel("")).toBeUndefined(); + expect(stateFromLabel("Factory:ready")).toBeUndefined(); + }); +}); + +describe("lifecycle transitions", () => { + it("proposed approved", () => { + const t = ok("proposed", { kind: "approved" }); + expect(t.next).toBe("ready"); + expect(t.actions).toEqual([]); + }); + + it("ready unapproved", () => { + const t = ok("ready", { kind: "unapproved", reason: "edited" }); + expect(t.next).toBe("proposed"); + expect(t.actions).toEqual([ + { kind: "post-unapproved", reason: "edited" }, + { kind: "set-label", state: "proposed" }, + ]); + }); + + it("ready claimed", () => { + const t = ok("ready", { kind: "claimed" }); + expect(t.next).toBe("triage"); + expect(t.actions).toEqual([ + { kind: "assign-to-machine" }, + { kind: "set-label", state: "triage" }, + ]); + }); + + it("triage session starting", () => { + const t = ok("triage", { kind: "session-starting" }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([ + { kind: "set-label", state: "building" }, + { kind: "launch-session" }, + { kind: "update-status-comment" }, + ]); + }); + + it("triage questions raised", () => { + const t = ok("triage", { kind: "questions-raised", questions: "why?" }); + expect(t.next).toBe("needs-info"); + expect(t.actions).toEqual(questionsActions("why?")); + }); + + it("triage failed", () => { + const t = ok("triage", { kind: "failed", reason: "boom" }); + expect(t.next).toBe("stuck"); + expect(t.actions).toEqual(stuckActions("boom")); + }); + + it("building instructor commented", () => { + const t = ok("building", { kind: "instructor-commented", commentId: "42" }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([{ kind: "forward-comment", commentId: "42" }]); + }); + + it("building body edited", () => { + const t = ok("building", { kind: "body-edited" }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([{ kind: "forward-body-edit" }]); + }); + + it("building gate failed", () => { + const t = ok("building", { kind: "gate-failed", output: "red" }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([{ kind: "send-gate-failure", output: "red" }]); + }); + + it("building pr ready", () => { + const t = ok("building", { kind: "pr-ready", pr: 9 }); + expect(t.next).toBe("pr-open"); + expect(t.actions).toEqual([ + { kind: "mark-pr-ready", pr: 9 }, + { kind: "post-gate-results", pr: 9 }, + { kind: "set-label", state: "pr-open" }, + { kind: "update-status-comment" }, + ]); + }); + + it("pr open failed", () => { + const t = ok("pr-open", { kind: "failed", reason: "r" }); + expect(t.next).toBe("stuck"); + expect(t.actions).toEqual(stuckActions("r")); + }); + + it("pr open regate failed puts the pr back to draft and the issue back to building", () => { + const t = ok("pr-open", { kind: "regate-failed", pr: 12, output: "boom" }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([ + { kind: "mark-pr-draft", pr: 12 }, + { kind: "assign-to-machine" }, + { kind: "set-label", state: "building" }, + { kind: "send-gate-failure", output: "boom" }, + { kind: "update-status-comment" }, + ]); + }); + + it("pr open instructor feedback puts the pr back to draft and the issue straight to building", () => { + const t = ok("pr-open", { kind: "instructor-feedback", pr: 12 }); + expect(t.next).toBe("building"); + expect(t.actions).toEqual([ + { kind: "mark-pr-draft", pr: 12 }, + { kind: "assign-to-machine" }, + { kind: "set-label", state: "building" }, + { kind: "update-status-comment" }, + ]); + }); + + it("building questions raised", () => { + const t = ok("building", { kind: "questions-raised", questions: "which?" }); + expect(t.next).toBe("needs-info"); + expect(t.actions).toEqual(questionsActions("which?")); + }); + + it("building failed", () => { + const t = ok("building", { kind: "failed", reason: "timeout" }); + expect(t.next).toBe("stuck"); + expect(t.actions).toEqual(stuckActions("timeout")); + }); + + it("needs info approved", () => { + const t = ok("needs-info", { kind: "approved" }); + expect(t.next).toBe("ready"); + expect(t.actions).toEqual([]); + }); + + it("stuck approved", () => { + const t = ok("stuck", { kind: "approved" }); + expect(t.next).toBe("ready"); + expect(t.actions).toEqual([]); + }); +}); + +/** Every event kind. Typed as a record so adding an `Event` kind fails typecheck until listed. */ +const EVENT_KINDS = { + approved: true, + claimed: true, + "session-starting": true, + "questions-raised": true, + failed: true, + "instructor-commented": true, + "body-edited": true, + "gate-failed": true, + "pr-ready": true, + unapproved: true, + "regate-failed": true, + "instructor-feedback": true, +} as const satisfies Record; + +/** Every event kind with a sample value and its hand-written expected name. */ +const everyEvent: ReadonlyArray = [ + [{ kind: "approved" }, "approved"], + [{ kind: "claimed" }, "claimed"], + [{ kind: "session-starting" }, "session-starting"], + [{ kind: "questions-raised", questions: "q" }, "questions-raised"], + [{ kind: "failed", reason: "r" }, "failed"], + [{ kind: "instructor-commented", commentId: "1" }, "instructor-commented"], + [{ kind: "body-edited" }, "body-edited"], + [{ kind: "gate-failed", output: "o" }, "gate-failed"], + [{ kind: "pr-ready", pr: 2 }, "pr-ready"], + [{ kind: "unapproved", reason: "r" }, "unapproved"], + [{ kind: "regate-failed", pr: 3, output: "o" }, "regate-failed"], + [{ kind: "instructor-feedback", pr: 4 }, "instructor-feedback"], +]; + +const VALID: ReadonlyArray = [ + ["proposed", "approved"], + ["ready", "claimed"], + ["ready", "unapproved"], + ["triage", "session-starting"], + ["triage", "questions-raised"], + ["triage", "failed"], + ["building", "instructor-commented"], + ["building", "body-edited"], + ["building", "gate-failed"], + ["building", "pr-ready"], + ["building", "questions-raised"], + ["building", "failed"], + ["pr-open", "failed"], + ["pr-open", "regate-failed"], + ["pr-open", "instructor-feedback"], + ["needs-info", "approved"], + ["stuck", "approved"], +]; + +describe("lifecycle transition table", () => { + it("every event is listed", () => { + expect(everyEvent.map(([event]) => event.kind)).toEqual(Object.keys(EVENT_KINDS)); + }); + + it("event names are pinned", () => { + for (const [event, name] of everyEvent) { + expect(event.kind).toBe(name); + } + }); + + it("exactly the table pairs succeed", () => { + let validSeen = 0; + for (const state of STATES) { + for (const [event, name] of everyEvent) { + const error = invalid(state, event); + if (VALID.some(([s, n]) => s === state && n === name)) { + expect(error, `${state} / ${name} should be valid`).toBeUndefined(); + validSeen += 1; + } else { + expect(error, `${state} / ${name} should be invalid`).toBeInstanceOf(InvalidTransition); + expect(error?.state).toBe(state); + expect(error?.event).toBe(name); + } + } + } + expect(validSeen).toBe(17); + expect(VALID).toHaveLength(17); + }); + + it("pr open accepts only failed, regate failed and instructor feedback", () => { + for (const [event, name] of everyEvent) { + if (name === "failed" || name === "regate-failed" || name === "instructor-feedback") { + continue; + } + const error = invalid("pr-open", event); + expect(error).toBeInstanceOf(InvalidTransition); + expect(error?.state).toBe("pr-open"); + expect(error?.event).toBe(name); + } + }); + + it("an invalid pair is a typed failure, not a throw", () => { + const result = transition("building", { kind: "claimed" }); + expect(Result.isFailure(result) && result.failure._tag).toBe("InvalidTransition"); + }); + + it("invalid transition display", () => { + expect(invalid("building", { kind: "claimed" })?.message).toBe( + "event `claimed` is not valid in state `building`", + ); + expect(invalid("pr-open", { kind: "pr-ready", pr: 1 })?.message).toBe( + "event `pr-ready` is not valid in state `pr-open`", + ); + }); + + it("set label always matches next state", () => { + for (const state of STATES) { + for (const [event] of everyEvent) { + if (invalid(state, event)) continue; + const t = ok(state, event); + const labels = t.actions.flatMap((action) => + action.kind === "set-label" ? [action.state] : [], + ); + if (labels.length === 0) { + // Self-loops keep their label; a person already moved proposed/needs-info/stuck to ready. + expect( + t.next === state || state === "proposed" || state === "needs-info" || state === "stuck", + `${state} -> ${t.next} must set a label`, + ).toBe(true); + } else { + expect(labels).toEqual([t.next]); + } + } + } + }); + + it("assignment follows the state entered", () => { + for (const state of STATES) { + for (const [event] of everyEvent) { + if (invalid(state, event)) continue; + const t = ok(state, event); + const unassigns = t.actions.filter((action) => action.kind === "unassign").length; + const assigns = t.actions.filter((action) => action.kind === "assign-to-machine").length; + // Only entering a state counts: a comment forwarded to the session is a self-loop. + const entered = t.next !== state; + // Taking an issue (from `ready`) assigns it; giving it up releases it. + let want: readonly [number, number] = [0, 0]; + if ((t.next === "needs-info" || t.next === "stuck") && entered) want = [1, 0]; + else if (state === "ready" && t.next === "triage") want = [0, 1]; + // A ready pull request whose gate failed again: the issue is ours again. + else if (state === "pr-open" && t.next === "building") want = [0, 1]; + expect([unassigns, assigns], `${state} -> ${t.next}`).toEqual(want); + } + } + }); +}); diff --git a/apps/server/src/factory/domain/lifecycle.ts b/apps/server/src/factory/domain/lifecycle.ts new file mode 100644 index 0000000000000..9ea7cd0529b36 --- /dev/null +++ b/apps/server/src/factory/domain/lifecycle.ts @@ -0,0 +1,267 @@ +/** The issue lifecycle as a pure function: state and event in, next state and actions out. */ + +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +/** The `factory:` label that currently holds an issue, without its `factory:` prefix. */ +export type State = + | "proposed" + | "ready" + | "triage" + | "needs-info" + | "building" + | "pr-open" + | "stuck"; + +/** Every state, in lifecycle order. */ +export const STATES = [ + "proposed", + "ready", + "triage", + "needs-info", + "building", + "pr-open", + "stuck", +] as const satisfies ReadonlyArray; + +/** The GitHub label for a state, e.g. `factory:needs-info`. */ +export const stateLabel = (state: State): string => `factory:${state}`; + +/** The state for a `factory:` label (exact, case-sensitive), or undefined if it is not ours. */ +export const stateFromLabel = (label: string): State | undefined => + STATES.find((state) => stateLabel(state) === label); + +const COLORS: Record = { + proposed: "c5def5", + ready: "0e8a16", + triage: "fbca04", + "needs-info": "d93f0b", + building: "1d76db", + "pr-open": "5319e7", + stuck: "b60205", +}; + +/** The label's colour on GitHub: six lowercase hex digits without the `#`. */ +export const stateColor = (state: State): string => COLORS[state]; + +const DESCRIPTIONS: Record = { + proposed: "A bot filed it. The factory ignores it until a person sets `factory:ready`.", + ready: "Approved for the factory.", + triage: "Claimed. The session is being launched.", + "needs-info": "The factory asked questions on the issue. Answer them, then set `factory:ready`.", + building: "A session is building it.", + "pr-open": "The PR is open and has passed the gate.", + stuck: "The factory gave up and said why in a comment.", +}; + +/** What the label means: the label table's text in the lifecycle docs and the label's GitHub description. */ +export const stateDescription = (state: State): string => DESCRIPTIONS[state]; + +/** + * Something that happened to an issue. The caller decides which one; this module only reacts. + * `kind` is the event's kebab-case name, as used in error messages. + */ +export type Event = + | { readonly kind: "approved" } + | { readonly kind: "claimed" } + | { readonly kind: "session-starting" } + | { readonly kind: "questions-raised"; readonly questions: string } + | { readonly kind: "failed"; readonly reason: string } + | { readonly kind: "instructor-commented"; readonly commentId: string } + | { readonly kind: "body-edited" } + | { readonly kind: "gate-failed"; readonly output: string } + | { readonly kind: "pr-ready"; readonly pr: number } + /** The factory refused to build a `ready` issue: it was not approved by an instructor. */ + | { readonly kind: "unapproved"; readonly reason: string } + /** + * A pull request that was ready for review failed the gate again on the moved base branch. + * `output` is what goes into the session. + */ + | { readonly kind: "regate-failed"; readonly pr: number; readonly output: string } + /** + * An instructor left feedback on a pull request that was ready for review, or on its issue. + * The feedback goes to a new session, not through the lifecycle. + */ + | { readonly kind: "instructor-feedback"; readonly pr: number }; + +/** A side effect for the caller to perform, in order. */ +export type Action = + | { readonly kind: "assign-to-machine" } + | { readonly kind: "unassign" } + | { readonly kind: "set-label"; readonly state: State } + | { readonly kind: "launch-session" } + | { readonly kind: "update-status-comment" } + | { readonly kind: "post-questions"; readonly questions: string } + | { readonly kind: "post-stuck-comment"; readonly reason: string } + | { readonly kind: "forward-comment"; readonly commentId: string } + | { readonly kind: "forward-body-edit" } + | { readonly kind: "send-gate-failure"; readonly output: string } + | { readonly kind: "mark-pr-ready"; readonly pr: number } + | { readonly kind: "mark-pr-draft"; readonly pr: number } + | { readonly kind: "post-gate-results"; readonly pr: number } + | { readonly kind: "post-unapproved"; readonly reason: string }; + +/** The result of a valid transition. */ +export type Transition = { + readonly next: State; + readonly actions: ReadonlyArray; +}; + +/** The failure of {@link transition}: the event is not valid in the current state. */ +export class InvalidTransition extends Schema.TaggedError()( + "InvalidTransition", + { + state: Schema.Literals(STATES), + /** The event's `kind`. */ + event: Schema.String, + }, +) { + override get message(): string { + return `event \`${this.event}\` is not valid in state \`${this.state}\``; + } +} + +const setLabel = (state: State): Action => ({ kind: "set-label", state }); + +/** The table row for the pair, or undefined when the pair is not in the table. */ +const tableRow = (state: State, event: Event): Transition | undefined => { + switch (event.kind) { + case "approved": + // softfac's table has no needs-info pair; its docs say an answered issue leaves needs-info + // when a person sets `factory:ready`. This deliberately follows the docs, not the table. + if (state === "proposed" || state === "needs-info" || state === "stuck") { + return { next: "ready", actions: [] }; + } + break; + case "unapproved": + if (state === "ready") { + return { + next: "proposed", + actions: [{ kind: "post-unapproved", reason: event.reason }, setLabel("proposed")], + }; + } + break; + case "claimed": + if (state === "ready") { + return { + next: "triage", + actions: [{ kind: "assign-to-machine" }, setLabel("triage")], + }; + } + break; + case "session-starting": + if (state === "triage") { + return { + next: "building", + actions: [ + setLabel("building"), + { kind: "launch-session" }, + { kind: "update-status-comment" }, + ], + }; + } + break; + case "questions-raised": + if (state === "triage" || state === "building") { + return { + next: "needs-info", + actions: [ + { kind: "post-questions", questions: event.questions }, + setLabel("needs-info"), + { kind: "unassign" }, + { kind: "update-status-comment" }, + ], + }; + } + break; + case "failed": + if (state === "triage" || state === "building" || state === "pr-open") { + return { + next: "stuck", + actions: [ + { kind: "post-stuck-comment", reason: event.reason }, + setLabel("stuck"), + { kind: "unassign" }, + { kind: "update-status-comment" }, + ], + }; + } + break; + case "instructor-commented": + if (state === "building") { + return { + next: "building", + actions: [{ kind: "forward-comment", commentId: event.commentId }], + }; + } + break; + case "body-edited": + if (state === "building") { + return { next: "building", actions: [{ kind: "forward-body-edit" }] }; + } + break; + case "gate-failed": + if (state === "building") { + return { + next: "building", + actions: [{ kind: "send-gate-failure", output: event.output }], + }; + } + break; + case "pr-ready": + if (state === "building") { + return { + next: "pr-open", + actions: [ + { kind: "mark-pr-ready", pr: event.pr }, + { kind: "post-gate-results", pr: event.pr }, + setLabel("pr-open"), + { kind: "update-status-comment" }, + ], + }; + } + break; + case "regate-failed": + if (state === "pr-open") { + return { + next: "building", + actions: [ + { kind: "mark-pr-draft", pr: event.pr }, + { kind: "assign-to-machine" }, + setLabel("building"), + { kind: "send-gate-failure", output: event.output }, + { kind: "update-status-comment" }, + ], + }; + } + break; + case "instructor-feedback": + if (state === "pr-open") { + return { + next: "building", + actions: [ + { kind: "mark-pr-draft", pr: event.pr }, + { kind: "assign-to-machine" }, + setLabel("building"), + { kind: "update-status-comment" }, + ], + }; + } + break; + } + return undefined; +}; + +/** + * Next state and actions for `event` arriving while an issue is in `state`. Does no I/O. Fails + * with {@link InvalidTransition} for a pair that is not in the table. + */ +export const transition = ( + state: State, + event: Event, +): Result.Result => { + const row = tableRow(state, event); + return row === undefined + ? Result.fail(new InvalidTransition({ state, event: event.kind })) + : Result.succeed(row); +}; diff --git a/apps/server/src/factory/domain/resultLine.test.ts b/apps/server/src/factory/domain/resultLine.test.ts new file mode 100644 index 0000000000000..6e3bce0743c2b --- /dev/null +++ b/apps/server/src/factory/domain/resultLine.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MALFORMED_URL, + NO_MESSAGE, + NO_QUESTIONS, + NO_RESULT_LINE, + NOT_ON_REPO, + SAID_STUCK, + parseSessionResult, + pullRequestUrl, + splitLastLine, + type SessionResult, +} from "./resultLine.ts"; + +const REPO = "rawphp/softfac"; +const URL = "https://github.com/rawphp/softfac/pull/12"; + +const stuck = (reason: string, sessionText: string | undefined): SessionResult => ({ + kind: "stuck", + reason, + sessionText, +}); + +const done = (pr: number, prUrl: string): SessionResult => ({ kind: "done", pr, prUrl }); + +const parse = (message: string) => parseSessionResult(REPO, message); + +describe("session result line", () => { + it("no message or an empty one is stuck", () => { + const reason = "the session ended without a final message"; + expect(NO_MESSAGE).toBe(reason); + expect(parseSessionResult(REPO, undefined)).toEqual(stuck(reason, undefined)); + expect(parse("")).toEqual(stuck(reason, undefined)); + expect(parse(" \n\r\n\t\n")).toEqual(stuck(reason, undefined)); + }); + + it("done with a pull request on this repo", () => { + expect(parse(`Built it.\nFACTORY-DONE: ${URL}`)).toEqual(done(12, URL)); + expect(parse(`FACTORY-DONE: ${URL}`)).toEqual(done(12, URL)); + }); + + it("done survives crlf, trailing blank lines and trailing whitespace", () => { + const want = done(12, URL); + expect(parse(`Built.\r\nFACTORY-DONE: ${URL}\r\n`)).toEqual(want); + expect(parse(`Built.\nFACTORY-DONE: ${URL}\n\n\n`)).toEqual(want); + expect(parse(`Built.\nFACTORY-DONE: ${URL} \t\n \n`)).toEqual(want); + expect(parse(`Built.\r\nFACTORY-DONE: ${URL}\r\n\r\n`)).toEqual(want); + }); + + it("done compares the repo case insensitively and keeps the url as given", () => { + const url = "https://github.com/RawPHP/SoftFac/pull/7"; + expect(parse(`FACTORY-DONE: ${url}`)).toEqual(done(7, url)); + expect(parseSessionResult("RAWPHP/softfac", `FACTORY-DONE: ${URL}`)).toEqual(done(12, URL)); + }); + + it("done for another repo is stuck with the message", () => { + for (const url of [ + "https://github.com/rawphp/other/pull/12", + "https://github.com/someone/softfac/pull/12", + "https://github.com/rawphp/softfac2/pull/12", + "https://github.com/rawphp/soft/pull/12", + ]) { + const message = `Done.\nFACTORY-DONE: ${url}`; + expect(parse(message)).toEqual(stuck(NOT_ON_REPO, message)); + } + }); + + it("done with a malformed url is stuck with the message", () => { + for (const url of [ + "https://github.com/rawphp/softfac/pull/12?x=1", + "https://github.com/rawphp/softfac/pull/12#issuecomment-1", + "https://github.com/rawphp/softfac/pull/12/files", + "https://github.com/rawphp/softfac/pull/12/", + "https://github.com/rawphp/softfac/pull/0", + "https://github.com/rawphp/softfac/pull/abc", + "https://github.com/rawphp/softfac/pull/", + "https://github.com/rawphp/softfac/pull/-1", + "https://github.com/rawphp/softfac/pull/+1", + "https://github.com/rawphp/softfac/pull/99999999999999999999999", + "https://github.com/rawphp/softfac/pull/007", + "https://github.com/rawphp/softfac/pull/012", + "https://github.com/rawphp/softfac/issues/12", + "https://github.com/rawphp/softfac", + "http://github.com/rawphp/softfac/pull/12", + "https://gitlab.com/rawphp/softfac/pull/12", + "https://github.zerozr99.workers.dev.evil.example/rawphp/softfac/pull/12", + "https://evil.example/https://github.com/rawphp/softfac/pull/12", + "https://user@github.com/rawphp/softfac/pull/12", + "github.com/rawphp/softfac/pull/12", + "#12", + ]) { + const message = `FACTORY-DONE: ${url}`; + expect(parse(message), url).toEqual(stuck(MALFORMED_URL, message)); + } + }); + + it("done with text after the url or odd spacing is malformed", () => { + let message = `FACTORY-DONE: ${URL} thanks`; + expect(parse(message)).toEqual(stuck(MALFORMED_URL, message)); + message = `FACTORY-DONE: ${URL}`; + expect(parse(message)).toEqual(stuck(MALFORMED_URL, message)); + expect(parse("FACTORY-DONE:")).toEqual(stuck(NO_RESULT_LINE, "FACTORY-DONE:")); + }); + + it("a long bad url is never echoed in the reason", () => { + const long = `https://github.com/rawphp/softfac/pull/${"9".repeat(500)}`; + const junk = "x".repeat(500); + for (const line of [`FACTORY-DONE: ${long}`, `FACTORY-DONE: ${junk}`]) { + const result = parse(line); + expect(result.kind).toBe("stuck"); + expect(result.kind === "stuck" ? result.reason : undefined).toBe(MALFORMED_URL); + } + }); + + it("needs info carries everything before the marker", () => { + expect(parse("1. Which database?\n2. Which user?\nFACTORY-STOP: needs-info")).toEqual({ + kind: "needs-info", + questions: "1. Which database?\n2. Which user?", + }); + expect(parse("\n I have questions.\n\n1. Why?\n\nFACTORY-STOP: needs-info\n\n")).toEqual({ + kind: "needs-info", + questions: "I have questions.\n\n1. Why?", + }); + expect(parse("1. Why?\r\n2. Who?\r\nFACTORY-STOP: needs-info\r\n")).toEqual({ + kind: "needs-info", + questions: "1. Why?\r\n2. Who?", + }); + }); + + it("needs info without questions is stuck", () => { + const reason = "the session asked for information but gave no questions"; + expect(NO_QUESTIONS).toBe(reason); + expect(parse("FACTORY-STOP: needs-info")).toEqual(stuck(reason, undefined)); + expect(parse("\n \nFACTORY-STOP: needs-info\n")).toEqual(stuck(reason, undefined)); + }); + + it("stuck carries the message before the marker as session text", () => { + const reason = "the session stopped and said it was stuck"; + expect(SAID_STUCK).toBe(reason); + expect(parse("The tests cannot run here.\nFACTORY-STOP: stuck")).toEqual( + stuck(reason, "The tests cannot run here."), + ); + expect(parse("Line one.\n\nLine two.\n\nFACTORY-STOP: stuck\n\n")).toEqual( + stuck(reason, "Line one.\n\nLine two."), + ); + }); + + it("stuck without a reason has no session text", () => { + const reason = "the session stopped and said it was stuck"; + expect(parse("FACTORY-STOP: stuck")).toEqual(stuck(reason, undefined)); + expect(parse(" \nFACTORY-STOP: stuck\n")).toEqual(stuck(reason, undefined)); + }); + + it("the marker must be the whole last non-empty line", () => { + const doneThenText = `FACTORY-DONE: ${URL}\nthanks, bye`; + for (const message of [ + "no marker at all", + "FACTORY-STOP: stuck\nand then more words", + doneThenText, + "FACTORY-STOP: needs-info\nQ1?", + "FACTORY-STOP: stuck now", + "FACTORY-STOP: needs-info please", + "FACTORY-STOP: needs-info.", + "FACTORY-STOP: Stuck", + "FACTORY-STOP:stuck", + "factory-stop: stuck", + "FACTORY-STOP: other", + "note FACTORY-STOP: stuck", + " FACTORY-STOP: stuck", + "> FACTORY-STOP: stuck", + "`FACTORY-STOP: stuck`", + "FACTORY-STOP", + `note FACTORY-DONE: ${URL}`, + ` FACTORY-DONE: ${URL}`, + `> FACTORY-DONE: ${URL}`, + ]) { + expect(parse(message), message).toEqual(stuck(NO_RESULT_LINE, message)); + } + }); +}); + +describe("result line helpers", () => { + it("splitLastLine returns the text before the last non-empty line and that line", () => { + expect(splitLastLine("")).toBeUndefined(); + expect(splitLastLine(" \r\n\t")).toBeUndefined(); + expect(splitLastLine("only")).toEqual({ before: "", last: "only" }); + expect(splitLastLine("a\n\nb \n\n")).toEqual({ before: "a\n", last: "b" }); + expect(splitLastLine("a\r\nb\r\n")).toEqual({ before: "a\r", last: "b" }); + // Rust's White_Space set, not JS trim: U+0085 is stripped, U+FEFF is kept. + expect(splitLastLine("a\nb\u0085")).toEqual({ before: "a", last: "b" }); + expect(splitLastLine("\u0085")).toBeUndefined(); + expect(splitLastLine("a\nb\uFEFF")).toEqual({ before: "a", last: "b\uFEFF" }); + expect(parseSessionResult(REPO, "FACTORY-STOP: stuck\u0085")).toEqual( + stuck(SAID_STUCK, undefined), + ); + expect(parseSessionResult(REPO, "FACTORY-STOP: stuck\uFEFF")).toEqual( + stuck(NO_RESULT_LINE, "FACTORY-STOP: stuck\uFEFF"), + ); + }); + + it("a long whitespace run inside the message is trimmed in linear time", () => { + // A backtracking `[...]+$` trim ran for over a minute on this input. + const run = " ".repeat(200_000); + expect(parse(`${run}x\nFACTORY-STOP: stuck`)).toEqual(stuck(SAID_STUCK, "x")); + expect(splitLastLine(`a\nb${run}c${run}`)).toEqual({ before: "a", last: `b${run}c` }); + }); + + it("pullRequestUrl compares the number as GitHub writes it", () => { + expect(pullRequestUrl(URL)).toEqual({ owner: "rawphp", name: "softfac", pr: 12 }); + expect(pullRequestUrl("https://github.com/a.b/c_d-e/pull/1")).toEqual({ + owner: "a.b", + name: "c_d-e", + pr: 1, + }); + expect(pullRequestUrl("https://github.com/rawphp/softfac/pull/007")).toBeUndefined(); + expect(pullRequestUrl("https://github.com/rawphp/softfac/pull/0")).toBeUndefined(); + // Past 2^53 a JS number loses digits, so the round trip rejects it. Rust accepts anything that + // fits a u64; the divergence is deliberate and unreachable, no GitHub number gets that large. + expect( + pullRequestUrl("https://github.com/rawphp/softfac/pull/9007199254740993"), + ).toBeUndefined(); + expect( + pullRequestUrl("https://github.com/rawphp/softfac/pull/99999999999999999999999"), + ).toBeUndefined(); + expect(pullRequestUrl("https://github.com//softfac/pull/1")).toBeUndefined(); + }); + + it("only ascii case folding is used when comparing the repo", () => { + // U+212A (Kelvin sign) lowercases to a plain k in JS, but is not an ascii case variant of it. + expect( + parseSessionResult("rawphp/Kr", "FACTORY-DONE: https://github.com/rawphp/kr/pull/1"), + ).toEqual(stuck(NOT_ON_REPO, "FACTORY-DONE: https://github.com/rawphp/kr/pull/1")); + }); +}); diff --git a/apps/server/src/factory/domain/resultLine.ts b/apps/server/src/factory/domain/resultLine.ts new file mode 100644 index 0000000000000..5de6e039e08f1 --- /dev/null +++ b/apps/server/src/factory/domain/resultLine.ts @@ -0,0 +1,141 @@ +/** + * How a factory session ended, read from the last line of its last assistant message. + * + * The message is agent output shaped by issue text, so it is untrusted: only the exact markers + * count, and a pull request URL is accepted only when it is a pull request on the repo. + */ + +/** The last line of a finished session: `FACTORY-DONE: `. */ +const DONE_MARKER = "FACTORY-DONE: "; +/** The last line of a session that needs answers, after its numbered questions. */ +const NEEDS_INFO_LINE = "FACTORY-STOP: needs-info"; +/** The last line of a session that gave up. */ +const STUCK_LINE = "FACTORY-STOP: stuck"; + +/** The public reason when the session's run ended with no assistant message at all. */ +export const NO_MESSAGE = "the session ended without a final message"; +/** The public reason when the session's last message has no result line. */ +export const NO_RESULT_LINE = "the session ended without a FACTORY-DONE or FACTORY-STOP line"; +/** The public reason when the session stopped and said it was stuck. */ +export const SAID_STUCK = "the session stopped and said it was stuck"; +/** The public reason when the reported pull request is on another repo. */ +export const NOT_ON_REPO = "the session reported a pull request that is not on this repo"; +/** The public reason when the reported pull request URL is not exactly a GitHub pull request URL. */ +export const MALFORMED_URL = "the session reported a malformed pull request URL"; +/** The public reason when the session asked for information without writing any questions. */ +export const NO_QUESTIONS = "the session asked for information but gave no questions"; + +/** + * How a session ended. `done` carries the pull request number and the URL as the session wrote + * it. `stuck.reason` is the factory's own fixed words; `sessionText` is what the session said, + * raw, when there is anything to show. + */ +export type SessionResult = + | { readonly kind: "done"; readonly pr: number; readonly prUrl: string } + | { readonly kind: "needs-info"; readonly questions: string } + | { + readonly kind: "stuck"; + readonly reason: string; + readonly sessionText: string | undefined; + }; + +// Rust's `trim`/`trim_end` strip Unicode White_Space. JS `trim` differs on U+0085 (not stripped) +// and U+FEFF (stripped), so spell the Rust set out. Every member is one UTF-16 code unit. +const WHITE_SPACE = /[\t-\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/; + +// Index loops, not `[...]+$`: that regex backtracks quadratically on a long whitespace run +// that is followed by other text, and the message is untrusted. +const trimEnd = (text: string) => { + let end = text.length; + while (end > 0 && WHITE_SPACE.test(text.charAt(end - 1))) end--; + return text.slice(0, end); +}; +const trim = (text: string) => { + const trimmed = trimEnd(text); + let start = 0; + while (start < trimmed.length && WHITE_SPACE.test(trimmed.charAt(start))) start++; + return trimmed.slice(start); +}; + +/** Lowercases ASCII letters only, so a non-ASCII character never folds into an ASCII one. */ +const asciiLower = (text: string) => text.replace(/[A-Z]/g, (c) => c.toLowerCase()); + +/** + * The last non-empty line, with trailing whitespace (and the `\r` of a CRLF) removed, and the + * raw text before it. Undefined when the message is blank. + */ +export const splitLastLine = ( + message: string, +): { readonly before: string; readonly last: string } | undefined => { + const trimmed = trimEnd(message); + if (trimmed === "") return undefined; + const index = trimmed.lastIndexOf("\n"); + if (index === -1) return { before: "", last: trimmed }; + return { before: trimmed.slice(0, index), last: trimEnd(trimmed.slice(index + 1)) }; +}; + +const NAME_PATTERN = /^[A-Za-z0-9._-]+$/; +const DIGITS_PATTERN = /^[0-9]+$/; +const PULL_URL_PREFIX = "https://github.com/"; + +/** + * The owner, name and pull request number if `url` is exactly + * `https://github.com///pull/`. The number is written the way GitHub writes it: + * positive, no leading zeros, and small enough to be exact. + */ +export const pullRequestUrl = ( + url: string, +): { readonly owner: string; readonly name: string; readonly pr: number } | undefined => { + if (!url.startsWith(PULL_URL_PREFIX)) return undefined; + const parts = url.slice(PULL_URL_PREFIX.length).split("/"); + if (parts.length !== 4 || parts[2] !== "pull") return undefined; + const [owner, name, , number] = parts as [string, string, string, string]; + if (!NAME_PATTERN.test(owner) || !NAME_PATTERN.test(name) || !DIGITS_PATTERN.test(number)) { + return undefined; + } + const pr = Number(number); + // `007` is not the number 7 as GitHub writes it, and a number past 2^53 does not round-trip. + if (!Number.isSafeInteger(pr) || pr <= 0 || String(pr) !== number) return undefined; + return { owner, name, pr }; +}; + +/** + * Reads the session's last assistant message. The marker must be the whole last non-empty line. + * The text is agent output shaped by issue text, so it is untrusted: only the exact markers + * count, and a pull request URL is accepted only when it is a pull request on `repo` + * (`owner/name`, compared ASCII case-insensitively). + */ +export const parseSessionResult = ( + repo: string, + lastAssistantMessage: string | undefined, +): SessionResult => { + const stuck = (reason: string, sessionText: string | undefined): SessionResult => ({ + kind: "stuck", + reason, + sessionText, + }); + const split = + lastAssistantMessage === undefined ? undefined : splitLastLine(lastAssistantMessage); + if (lastAssistantMessage === undefined || split === undefined) { + return stuck(NO_MESSAGE, undefined); + } + const whole = lastAssistantMessage; + const { last } = split; + if (last.startsWith(DONE_MARKER)) { + const url = last.slice(DONE_MARKER.length); + const parsed = pullRequestUrl(url); + if (parsed === undefined) return stuck(MALFORMED_URL, whole); + if (asciiLower(`${parsed.owner}/${parsed.name}`) !== asciiLower(repo)) { + return stuck(NOT_ON_REPO, whole); + } + return { kind: "done", pr: parsed.pr, prUrl: url }; + } + const before = trim(split.before); + if (last === NEEDS_INFO_LINE) { + return before === "" + ? stuck(NO_QUESTIONS, undefined) + : { kind: "needs-info", questions: before }; + } + if (last === STUCK_LINE) return stuck(SAID_STUCK, before === "" ? undefined : before); + return stuck(NO_RESULT_LINE, whole); +}; diff --git a/apps/server/src/factory/index.test.ts b/apps/server/src/factory/index.test.ts new file mode 100644 index 0000000000000..60e46394a8131 --- /dev/null +++ b/apps/server/src/factory/index.test.ts @@ -0,0 +1,82 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; + +import { FactoryRuntime } from "./index.ts"; +import * as Paths from "./t3/paths.ts"; + +const messageText = (message: unknown): string => { + const first = Array.isArray(message) ? message[0] : message; + return typeof first === "string" ? first : String(first); +}; + +const VALID = `gh_config_dir: /gh +max_concurrent_runs: 1 +model: claude-sonnet-5-5 +repos: + - repo: a/b + path: /b +`; + +/** Builds FactoryRuntime over a temp base dir whose factory dir holds `config` (or no file) and returns the log lines. */ +const logsFor = (config: string | undefined) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "factory-index-" }); + const factoryDir = join(baseDir, "factory"); + yield* fs.makeDirectory(factoryDir); + const machineConfigPath = join(factoryDir, "config.yml"); + if (config !== undefined) yield* fs.writeFileString(machineConfigPath, config); + + const logs: Array = []; + const capture = Logger.make(({ message }) => { + logs.push(messageText(message)); + }); + + yield* Effect.scoped( + Layer.build( + FactoryRuntime.pipe( + Layer.provide( + Layer.succeed(Paths.FactoryPaths, { + baseDir, + worktreesDir: join(baseDir, "worktrees"), + factoryDir, + machineConfigPath, + }), + ), + ), + ), + ).pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: false }))); + + return { logs, machineConfigPath }; + }), + ).pipe(Effect.provide(NodeServices.layer)); + +it.effect("logs that there is no config when the machine file does not exist", () => + Effect.gen(function* () { + const { logs, machineConfigPath } = yield* logsFor(undefined); + assert.deepStrictEqual(logs, [`no factory config at ${machineConfigPath}`]); + }), +); + +it.effect("logs factory started once for a valid machine file", () => + Effect.gen(function* () { + const { logs } = yield* logsFor(VALID); + assert.deepStrictEqual(logs, ["factory started"]); + }), +); + +it.effect("logs each config error and does not start for an invalid machine file", () => + Effect.gen(function* () { + const { logs, machineConfigPath } = yield* logsFor( + VALID.replace("model: claude-sonnet-5-5\n", ""), + ); + assert.deepStrictEqual(logs, [`${machineConfigPath}: model: is missing`]); + }), +); diff --git a/apps/server/src/factory/index.ts b/apps/server/src/factory/index.ts new file mode 100644 index 0000000000000..d46ebf8881f89 --- /dev/null +++ b/apps/server/src/factory/index.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; + +import { formatConfigError, loadMachineConfig } from "./config.ts"; +import * as Paths from "./t3/paths.ts"; + +const start = Effect.gen(function* () { + const { machineConfigPath } = yield* Paths.FactoryPaths; + const loaded = yield* Effect.result(loadMachineConfig(machineConfigPath)); + if (Result.isFailure(loaded)) { + for (const error of loaded.failure.errors) { + yield* Effect.logError(formatConfigError(error)); + } + return; + } + if (Option.isNone(loaded.success)) { + yield* Effect.logInfo(`no factory config at ${machineConfigPath}`); + return; + } + yield* Effect.logInfo("factory started"); +}); + +/** Needs FactoryPaths and FileSystem; what index.test.ts builds with a test FactoryPaths. */ +export const FactoryRuntime = Layer.effectDiscard(start); + +/** The factory's root layer. server.ts merges it into the runtime; nothing else imports it. */ +export const FactoryLayer = FactoryRuntime.pipe(Layer.provide(Paths.layer)); diff --git a/apps/server/src/factory/ledger/appendFile.ts b/apps/server/src/factory/ledger/appendFile.ts new file mode 100644 index 0000000000000..027493b632732 --- /dev/null +++ b/apps/server/src/factory/ledger/appendFile.ts @@ -0,0 +1,224 @@ +// @effect-diagnostics nodeBuiltinImport:off - softfac's write rules need raw fds: O_NOFOLLOW, one write per line and fsync. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { LedgerNotFoundError, LedgerIoError, errnoCode } from "./errors.ts"; +import { NEWLINE } from "./limits.ts"; + +/** + * The two file operations an append performs. A seam so a test can make them fail the way a full + * or failing disk does; the real implementation is `node:fs`. `writeSync` may write less than the + * whole buffer and returns how many bytes it wrote. + */ +export type AppendIo = { + writeSync(fd: number, buffer: Uint8Array): number; + fsyncSync(fd: number): void; +}; + +export const nodeIo: AppendIo = { + writeSync: (fd, buffer) => NodeFS.writeSync(fd, buffer), + fsyncSync: (fd) => NodeFS.fsyncSync(fd), +}; + +/** Flush a directory's entries to disk, so a file created in it survives a crash. */ +export type SyncDir = (dir: string) => void; + +export const syncDir: SyncDir = (dir) => { + const fd = NodeFS.openSync(dir, "r"); + try { + NodeFS.fsyncSync(fd); + } finally { + NodeFS.closeSync(fd); + } +}; + +/** + * Create `path` if it is missing, owner-only (`0o700`). Missing parents are made the ordinary + * way, and a directory that already exists is left as it is, whatever its mode. Throws what + * `node:fs` throws. + */ +export const ensureDir = (path: string): void => { + try { + NodeFS.statSync(path); + return; + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + } + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + try { + NodeFS.mkdirSync(path, { mode: 0o700 }); + } catch (error) { + // Someone else made it between the check and here. + if (errnoCode(error) !== "EEXIST") throw error; + } +}; + +/** `sync` of `dir` with its failure reported as an `Io` error for `dir`. */ +export const syncAt = (sync: SyncDir, dir: string): void => { + try { + sync(dir); + } catch (error) { + throw new LedgerIoError({ path: dir, cause: error }); + } +}; + +/** True when the file is empty or its last byte is a newline. Throws what `node:fs` throws. */ +const endsInNewline = (fd: number): boolean => { + const size = NodeFS.fstatSync(fd).size; + if (size === 0) return true; + const last = Buffer.alloc(1); + NodeFS.readSync(fd, last, 0, 1, size - 1); + return last[0] === NEWLINE; +}; + +/** + * A last line with no newline is a write that was cut off: end it with one `\n` write and an + * fsync through `io`, so the next event starts on its own line, and return true. The fragment + * stays in the file. Throws what `io` or `node:fs` throws. + */ +const endCutOffLine = (fd: number, io: AppendIo): boolean => { + if (endsInNewline(fd)) return false; + if (io.writeSync(fd, Buffer.from("\n")) !== 1) throw new Error("failed to write the line break"); + io.fsyncSync(fd); + return true; +}; + +/** + * Open an existing regular file for append, never creating it and never following a symlink + * (`O_RDWR | O_APPEND | O_NOFOLLOW`: the read access is needed, since an append looks at how + * the file ends and what other handles wrote). A line cut off at the end is ended first. A + * missing file is `NotFound`; anything else that goes wrong is `Io`. + */ +export const openForAppend = (path: string, io: AppendIo = nodeIo): AppendFile => { + let fd: number; + try { + fd = NodeFS.openSync( + path, + NodeFS.constants.O_RDWR | NodeFS.constants.O_APPEND | NodeFS.constants.O_NOFOLLOW, + ); + } catch (error) { + if (errnoCode(error) === "ENOENT") throw new LedgerNotFoundError({ path }); + throw new LedgerIoError({ path, cause: error }); + } + try { + endCutOffLine(fd, io); + } catch (error) { + NodeFS.closeSync(fd); + throw new LedgerIoError({ path, cause: error }); + } + return new AppendFile(path, fd, io); +}; + +/** The directory that holds `dir`, which the sync after making `dir` has to reach. */ +export const parentOf = (dir: string): string => NodePath.dirname(dir); + +/** + * An open file that lines are appended to, one write each: the part a run file and the machine + * log have in common. The fd must be open for reading too. Several handles may append to one + * file; nothing locks it, so two appends made at the same instant are not ordered. + */ +export class AppendFile { + readonly path: string; + private fd: number | null; + private readonly io: AppendIo; + /** + * An earlier write or sync failed, so the file may end in a partial line with no newline. The + * next append starts with a `\n` so it cannot be glued onto that fragment. + */ + private dirty = false; + /** How far `readNewLines` has read: always the end of a whole line. */ + private seen = 0; + + constructor(path: string, fd: number, io: AppendIo = nodeIo) { + this.path = path; + this.fd = fd; + this.io = io; + } + + private openFd(): number { + if (this.fd === null) + throw new LedgerIoError({ path: this.path, cause: new Error("the file is closed") }); + return this.fd; + } + + /** + * End a line cut off at the end of the file, by this handle or another one, as opening the + * file does, so a whole line that only lacked its newline counts before the next append. + */ + endCutOffLine(): void { + const fd = this.openFd(); + let ended: boolean; + try { + ended = endCutOffLine(fd, this.io); + } catch (error) { + throw new LedgerIoError({ path: this.path, cause: error }); + } + // That newline is the break an earlier failed write owed, so it is not written twice. + if (ended) this.dirty = false; + } + + /** + * The whole lines added to the file since the last call, by this handle or any other, and on + * the first call every whole line. A last line with no newline yet is left for a later call. + */ + readNewLines(): Buffer { + const fd = this.openFd(); + try { + const size = NodeFS.fstatSync(fd).size; + if (size <= this.seen) return Buffer.alloc(0); + const bytes = Buffer.alloc(size - this.seen); + let offset = 0; + while (offset < bytes.length) { + const count = NodeFS.readSync(fd, bytes, offset, bytes.length - offset, this.seen + offset); + if (count === 0) break; + offset += count; + } + const whole = bytes.subarray(0, bytes.subarray(0, offset).lastIndexOf(NEWLINE) + 1); + this.seen += whole.length; + return whole; + } catch (error) { + throw new LedgerIoError({ path: this.path, cause: error }); + } + } + + /** + * Append `line` (which ends in a newline) as one write of the whole line, then sync, so a + * crash loses at most the line in flight. When the file does not end in a newline (a write of + * this handle or another one was cut off), the write starts with one. + */ + writeLine(line: string): void { + const fd = this.openFd(); + let breakFirst: boolean; + try { + breakFirst = this.dirty || !endsInNewline(fd); + } catch (error) { + throw new LedgerIoError({ path: this.path, cause: error }); + } + const bytes = Buffer.from(breakFirst ? `\n${line}` : line, "utf8"); + try { + let offset = 0; + while (offset < bytes.length) { + const count = this.io.writeSync(fd, bytes.subarray(offset)); + if (!(count > 0)) throw new Error("failed to write the whole line"); + offset += count; + } + } catch (error) { + this.dirty = true; + throw new LedgerIoError({ path: this.path, cause: error }); + } + try { + this.io.fsyncSync(fd); + } catch (error) { + this.dirty = true; + throw new LedgerIoError({ path: this.path, cause: error }); + } + this.dirty = false; + } + + close(): void { + if (this.fd === null) return; + const fd = this.fd; + this.fd = null; + NodeFS.closeSync(fd); + } +} diff --git a/apps/server/src/factory/ledger/errors.ts b/apps/server/src/factory/ledger/errors.ts new file mode 100644 index 0000000000000..2caa478d843d0 --- /dev/null +++ b/apps/server/src/factory/ledger/errors.ts @@ -0,0 +1,86 @@ +import * as Schema from "effect/Schema"; + +// Why a ledger operation failed, one class per case so an Effect caller can pick them apart with +// `Effect.catchTags`. The messages are the ones softfac's Rust `Display` prints. + +/** A run id or repo that does not have the shape `______`. */ +export class InvalidRunIdError extends Schema.TaggedError()( + "InvalidRunIdError", + { input: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `invalid run id \`${this.input}\`: ${this.reason}`; + } +} + +/** `create` found a file for that run already; it was left untouched. */ +export class LedgerAlreadyExistsError extends Schema.TaggedError()( + "LedgerAlreadyExistsError", + { path: Schema.String }, +) { + override get message(): string { + return `run file ${this.path} already exists`; + } +} + +/** The run already wrote its `finished` line; nothing more can be appended. */ +export class LedgerAlreadyFinishedError extends Schema.TaggedError()( + "LedgerAlreadyFinishedError", + { path: Schema.String }, +) { + override get message(): string { + return `run file ${this.path} is already finished`; + } +} + +/** There is no file for that run. */ +export class LedgerNotFoundError extends Schema.TaggedError()( + "LedgerNotFoundError", + { path: Schema.String }, +) { + override get message(): string { + return `no run file at ${this.path}`; + } +} + +/** An event name that ledger.md does not list. Nothing was written. */ +export class LedgerUnknownEventError extends Schema.TaggedError()( + "LedgerUnknownEventError", + { event: Schema.String }, +) { + override get message(): string { + return `unknown event \`${this.event}\`: ledger.md does not list it`; + } +} + +/** + * Reading or writing the file failed; `cause` is what the file system threw. The message is + * softfac's, the path and then the file system's own words, so the two factories log alike. + */ +export class LedgerIoError extends Schema.TaggedError()("LedgerIoError", { + path: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + const cause = this.cause instanceof Error ? this.cause.message : String(this.cause); + return `${this.path}: ${cause}`; + } +} + +export const LedgerError = Schema.Union([ + InvalidRunIdError, + LedgerAlreadyExistsError, + LedgerAlreadyFinishedError, + LedgerNotFoundError, + LedgerUnknownEventError, + LedgerIoError, +]); +export type LedgerError = typeof LedgerError.Type; + +export const isLedgerError = Schema.is(LedgerError); + +/** The `code` of an error thrown by `node:fs`, e.g. `ENOENT`. */ +export const errnoCode = (error: unknown): string | undefined => + typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; diff --git a/apps/server/src/factory/ledger/events.ts b/apps/server/src/factory/ledger/events.ts new file mode 100644 index 0000000000000..bede51a31dee2 --- /dev/null +++ b/apps/server/src/factory/ledger/events.ts @@ -0,0 +1,504 @@ +/** + * The events a run ledger can hold, and the fields of each in the order they are written to the + * file. `docs/reference/ledger.md` in softfac is the spec; the order of the fields is the order + * of the fields in softfac's `Event` enum, which is the order serde writes them in. + */ + +import { isU32 } from "./limits.ts"; + +/** Which part of the factory wrote a line. */ +export type CauseStep = + | "start" + | "watch" + | "forward" + | "advance" + | "gate" + | "pass" + | "retro" + | "operator"; + +/** How a run ended. */ +export type Outcome = "pr-open" | "stuck" | "needs-info"; + +/** How a run's pull request ended, for a run that ended because it did. */ +export type PrEnd = "merged" | "closed"; + +/** The label names without their `factory:` prefix. */ +export type StateName = + | "proposed" + | "ready" + | "triage" + | "needs-info" + | "building" + | "pr-open" + | "stuck"; + +/** What an `error` line was about. */ +export type ErrorKind = "github" | "tracker" | "t3" | "git" | "gate" | "ledger" | "config"; + +/** One required check in a `checks_read` line. */ +export type CheckRead = { readonly name: string; readonly state: string }; + +/** The earlier run and branch a launch continued from. */ +export type ResumedFrom = { readonly run: string; readonly branch: string }; + +/** + * Something that happened in a run, as `{ event, data }`. A field that softfac omits when it is + * absent is optional here and left out of the line when `undefined`; every other field is + * written, as `null` when softfac writes `null`. No event holds a token. Two kinds of field do + * hold text the factory sent elsewhere, written as given, not cleaned or cut: the `questions` + * of `questions_posted` (what was posted to the issue), and the `message` of + * `regate_relaunching`, `feedback_relaunching` and `feedback_forwarding` (what was sent to the + * agent session). + */ +export type RunEvent = + | { readonly event: "claimed"; readonly data: { machine: string; github_user: string } } + | { + readonly event: "triage_result"; + readonly data: { passed: boolean; reasons: ReadonlyArray }; + } + | { readonly event: "questions_posted"; readonly data: { questions: string } } + | { + readonly event: "answer_received"; + readonly data: { comment_id: string | null; body_edited: boolean }; + } + | { + readonly event: "session_launched"; + readonly data: { + thread_id: string; + worktree: string | null; + branch: string | null; + model_instance: string | null; + model: string | null; + resumed_from: ResumedFrom | null; + }; + } + | { readonly event: "comment_forwarded"; readonly data: { comment_id: string } } + | { + readonly event: "session_interrupted"; + readonly data: { + resumes: number; + relaunch: boolean; + run_ordinal: number | null; + branch: string | null; + t3_error: string | null; + ended_in_pass: number; + }; + } + | { readonly event: "session_paused"; readonly data: { run_ordinal: number | null } } + | { readonly event: "stop_requested"; readonly data: { by: string } } + | { readonly event: "step_changed"; readonly data: { step: string } } + | { readonly event: "pr_opened"; readonly data: { pr: number; url: string } } + | { + readonly event: "pr_adopted"; + readonly data: { pr: number; branch: string; run: string }; + } + | { readonly event: "branch_bound"; readonly data: { pr: number; branch: string } } + | { readonly event: "pr_set_draft"; readonly data: { pr: number } } + | { readonly event: "pr_ready_outside"; readonly data: { pr: number; by?: string } } + | { + readonly event: "gate_command"; + readonly data: { attempt: number; command: string; exit_code: number; duration_ms: number }; + } + | { + readonly event: "gate_sent_back"; + readonly data: { attempt: number; run_ordinal: number }; + } + | { + readonly event: "gate_checkout"; + readonly data: { attempt: number; dir: string; sha: string; base_sha: string }; + } + | { + readonly event: "gate_regate"; + readonly data: { sha: string; base_sha: string; previous_base_sha: string }; + } + | { + readonly event: "regate_relaunching"; + readonly data: { attempt: number; branch: string; message: string }; + } + | { + readonly event: "feedback_relaunching"; + readonly data: { n: number; branch: string; items: ReadonlyArray; message: string }; + } + | { + readonly event: "feedback_forwarding"; + readonly data: { + n: number; + run_ordinal: number | null; + items: ReadonlyArray; + message: string; + }; + } + | { readonly event: "gate_cleanup_failed"; readonly data: { dir: string; error: string } } + | { readonly event: "worktree_removed"; readonly data: { path: string } } + | { readonly event: "worktree_remove_failed"; readonly data: { path: string; error: string } } + | { + readonly event: "checks_read"; + readonly data: { sha: string; checks: ReadonlyArray }; + } + | { readonly event: "state_changed"; readonly data: { from: StateName; to: StateName } } + | { + readonly event: "stuck"; + readonly data: { reason: string; branch?: string; commits_ahead?: number }; + } + | { + readonly event: "error"; + readonly data: { step: CauseStep; kind: ErrorKind; message: string }; + } + | { + readonly event: "finished"; + readonly data: { + outcome: Outcome; + total_ms: number; + gate_attempts: number; + review_cycles: number | null; + pr_url: string | null; + pr_end: PrEnd | null; + }; + }; + +export type RunEventName = RunEvent["event"]; + +type RunDataKeys = keyof Extract["data"]; + +/** + * The 30 event types a run file can hold, each with the names of its `data` fields in the order + * they are written. + */ +export const RUN_EVENT_FIELDS = { + claimed: ["machine", "github_user"], + triage_result: ["passed", "reasons"], + questions_posted: ["questions"], + answer_received: ["comment_id", "body_edited"], + session_launched: ["thread_id", "worktree", "branch", "model_instance", "model", "resumed_from"], + comment_forwarded: ["comment_id"], + session_interrupted: [ + "resumes", + "relaunch", + "run_ordinal", + "branch", + "t3_error", + "ended_in_pass", + ], + session_paused: ["run_ordinal"], + stop_requested: ["by"], + step_changed: ["step"], + pr_opened: ["pr", "url"], + pr_adopted: ["pr", "branch", "run"], + branch_bound: ["pr", "branch"], + pr_set_draft: ["pr"], + pr_ready_outside: ["pr", "by"], + gate_command: ["attempt", "command", "exit_code", "duration_ms"], + gate_sent_back: ["attempt", "run_ordinal"], + gate_checkout: ["attempt", "dir", "sha", "base_sha"], + gate_regate: ["sha", "base_sha", "previous_base_sha"], + regate_relaunching: ["attempt", "branch", "message"], + feedback_relaunching: ["n", "branch", "items", "message"], + feedback_forwarding: ["n", "run_ordinal", "items", "message"], + gate_cleanup_failed: ["dir", "error"], + worktree_removed: ["path"], + worktree_remove_failed: ["path", "error"], + checks_read: ["sha", "checks"], + state_changed: ["from", "to"], + stuck: ["reason", "branch", "commits_ahead"], + error: ["step", "kind", "message"], + finished: ["outcome", "total_ms", "gate_attempts", "review_cycles", "pr_url", "pr_end"], +} as const satisfies { readonly [N in RunEventName]: ReadonlyArray> }; + +// `satisfies` rejects a listed field the type lacks. This rejects the other mistake: a field the +// type has and the table omits, which `buildData` would silently drop from the file. +type RunFieldsOmitted = { + [N in RunEventName]: Exclude, (typeof RUN_EVENT_FIELDS)[N][number]>; +}[RunEventName]; +const _runFieldsComplete: [RunFieldsOmitted] extends [never] ? true : RunFieldsOmitted = true; +void _runFieldsComplete; + +const NESTED_FIELDS = { + resumed_from: ["run", "branch"], + check: ["name", "state"], +} as const; + +const inOrder = (value: unknown, keys: ReadonlyArray): unknown => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return value; + const given = value as Record; + const ordered: Record = {}; + for (const key of keys) if (given[key] !== undefined) ordered[key] = given[key]; + return ordered; +}; + +/** + * The `data` object of a line: the event's fields in the order of `fields`, leaving out any that + * are `undefined` and anything that is not a field. `overrides` replace a field's value in place. + */ +export const buildData = ( + fields: ReadonlyArray, + data: object, + overrides: Readonly> = {}, +): Record => { + const given = data as Record; + const out: Record = {}; + for (const field of fields) { + const value = Object.hasOwn(overrides, field) ? overrides[field] : given[field]; + if (value !== undefined) out[field] = value; + } + return out; +}; + +/** + * `buildData` for a run event, with the two nested shapes (`resumed_from` and the entries of + * `checks`) also written in softfac's field order. + */ +export const runEventData = ( + event: RunEvent, + overrides: Readonly> = {}, +): Record => { + const out = buildData(RUN_EVENT_FIELDS[event.event], event.data, overrides); + if (event.event === "session_launched") { + out.resumed_from = inOrder(out.resumed_from, NESTED_FIELDS.resumed_from); + } else if (event.event === "checks_read" && Array.isArray(out.checks)) { + out.checks = out.checks.map((check) => inOrder(check, NESTED_FIELDS.check)); + } + return out; +}; + +/** + * Why a line was written: the part of the factory that wrote it, a reason code from the lists in + * ledger.md, and, only when they apply, who did something, which gate command, which attempt + * and which label. None of it is text from a comment, a prompt or a session. + */ +export type Cause = { + readonly step: CauseStep; + readonly reason: string; + readonly actor?: string; + readonly command?: string; + readonly attempt?: number; + readonly label?: string; +}; + +export type CauseExtras = { + readonly actor?: string; + readonly command?: string; + readonly attempt?: number; + readonly label?: string; +}; + +const CAUSE_FIELD_LIMIT = 100; +const CAUSE_COMMAND_LIMIT = 1_000; + +/** + * A character that must not reach a terminal as itself: C0 and C1 controls, DEL, and the bidi + * controls that can make text read in a different order than it is stored. + */ +const isUnsafeCodePoint = (cp: number) => + cp <= 0x1f || + (cp >= 0x7f && cp <= 0x9f) || + (cp >= 0x202a && cp <= 0x202e) || + (cp >= 0x2066 && cp <= 0x2069); + +/** `text` on one line with no control or bidi characters, cut at `limit` characters. */ +const cleanCauseText = (text: string, limit: number): string => { + let out = ""; + let count = 0; + for (const char of text) { + if (isUnsafeCodePoint(char.codePointAt(0)!)) continue; + if (count === limit) break; + out += char; + count += 1; + } + return out; +}; + +/** + * A cause. `actor` and `label` are cleaned and cut at 100 characters, `command` is cleaned and + * cut at 1000, and an `attempt` that is not a u32 is left out, since softfac would read the + * whole cause as none; the keys come in the order step, reason, actor, command, attempt, label, + * and only when set. + */ +export const makeCause = (step: CauseStep, reason: string, extras: CauseExtras = {}): Cause => ({ + step, + reason, + ...(extras.actor !== undefined && { actor: cleanCauseText(extras.actor, CAUSE_FIELD_LIMIT) }), + ...(extras.command !== undefined && { + command: cleanCauseText(extras.command, CAUSE_COMMAND_LIMIT), + }), + ...(isU32(extras.attempt) && { attempt: extras.attempt }), + ...(extras.label !== undefined && { label: cleanCauseText(extras.label, CAUSE_FIELD_LIMIT) }), +}); + +/** + * The cause as written to a line: the keys in softfac's order, whatever order they were built in, + * with the text fields cleaned and cut here too, so a `Cause` built by hand cannot put session + * or comment text in the file. `reason`, `actor` and `label` are cut at 100, `command` at 1000, + * and an `attempt` that is not a u32 is left out. + */ +export const causeObject = (cause: Cause): Record => { + const clean = (text: string | undefined, limit: number) => + text === undefined ? undefined : cleanCauseText(text, limit); + return buildData(["step", "reason", "actor", "command", "attempt", "label"], cause, { + reason: clean(cause.reason, CAUSE_FIELD_LIMIT), + actor: clean(cause.actor, CAUSE_FIELD_LIMIT), + command: clean(cause.command, CAUSE_COMMAND_LIMIT), + attempt: isU32(cause.attempt) ? cause.attempt : undefined, + label: clean(cause.label, CAUSE_FIELD_LIMIT), + }); +}; + +/** How a retro ended. */ +export type RetroResult = + | "thread-gone" + | "done" + | "stopped" + | "failed" + | "interrupted" + | "cancelled" + | "rolled_back" + | "timed-out"; + +/** How a half-made claim was put right. */ +export type ClaimRepair = "finished" | "released" | "reconciled"; + +/** Where a self-update failed. */ +export type UpdateStep = "check" | "checkout" | "install" | "verify" | "config"; + +/** Which report to the fleet app failed. */ +export type FleetCall = "token" | "heartbeat" | "events" | "desired"; + +/** What the fleet app asked this machine for. */ +export type DesiredWhat = "target-commit" | "config"; + +/** What came of a request from the fleet app. */ +export type DesiredState = "applied" | "refused" | "failed"; + +/** + * Something that happened that belongs to no run, as `{ event, data }`. `reason` is a code from + * ledger.md; it is not checked here. An issue is the issue key string (`"8"`, `"ENG-42"`), written + * by `issueKeyJson`. Three event types have more than one shape: a `claim_skipped` for the + * claims pause has only a `reason`, and an `attention` for the machine has no `run`. + */ +export type MachineEvent = + | { readonly event: "pass_started"; readonly data: Record } + | { + readonly event: "pass_finished"; + readonly data: { runs: number; claimed: number; errors: number; attention: number }; + } + | { readonly event: "preflight_failed"; readonly data: { check: string; why: string } } + | { + readonly event: "repo_skipped"; + readonly data: { repo: string; reason: string; message: string }; + } + | { + readonly event: "claim_skipped"; + readonly data: { repo: string; issue: string; reason: string } | { reason: string }; + } + | { + readonly event: "claim_repaired"; + readonly data: { repo: string; issue: string; outcome: ClaimRepair }; + } + | { + readonly event: "attention"; + readonly data: + | { run: string; reason: string } + | { reason: string; volume: string; free_bytes: number; floor_gb: number; held: number } + | { reason: string; url: string; user: string; gh_config_dir: string }; + } + | { readonly event: "self_update_started"; readonly data: { from: string; to: string } } + | { + readonly event: "self_update_installed"; + readonly data: { from: string; to: string; duration_ms: number }; + } + | { + readonly event: "self_update_failed"; + readonly data: { from: string; to: string; step: UpdateStep; reason: string }; + } + | { readonly event: "self_update_rolled_back"; readonly data: { from: string; to: string } } + | { + readonly event: "fleet_failed"; + readonly data: { call: FleetCall; reason: string; message: string }; + } + | { + readonly event: "fleet_desired"; + readonly data: { + what: DesiredWhat; + asked: string; + state: DesiredState; + reason: string; + detail?: string; + }; + } + | { + readonly event: "retro_started"; + readonly data: { + n: number; + thread_id: string; + runs: number; + since_ms: number | null; + model_instance: string; + model: string; + }; + } + | { + readonly event: "retro_finished"; + readonly data: { n: number; thread_id: string; result: RetroResult; duration_ms: number }; + } + | { readonly event: "worktrees_swept"; readonly data: { paths: ReadonlyArray } } + | { + readonly event: "error"; + readonly data: { run?: string; step: CauseStep; kind: ErrorKind; message: string }; + }; + +export type MachineEventName = MachineEvent["event"]; + +/** + * An issue key as softfac's `IssueKey` serialises it: a GitHub number as a JSON number, a Linear + * identifier as its capitalised string. The key is the canonical form `parseIssueKey` returns. + * The number is written from its digits, so a key above 2^53 is not rounded. + */ +export const issueKeyJson = (key: string): unknown => (/^\d+$/.test(key) ? JSON.rawJSON(key) : key); + +type KeysOfEach = T extends unknown ? keyof T : never; +type MachineDataKeys = KeysOfEach< + Extract["data"] +>; + +/** + * The 17 event types the machine log can hold, each with the names of its `data` fields in the + * order they are written. A type with more than one shape lists the fields of all of them: the + * ones that are absent are left out, and the shapes share no field that is ordered differently. + */ +export const MACHINE_EVENT_FIELDS = { + pass_started: [], + pass_finished: ["runs", "claimed", "errors", "attention"], + preflight_failed: ["check", "why"], + repo_skipped: ["repo", "reason", "message"], + claim_skipped: ["repo", "issue", "reason"], + claim_repaired: ["repo", "issue", "outcome"], + attention: [ + "run", + "reason", + "volume", + "free_bytes", + "floor_gb", + "held", + "url", + "user", + "gh_config_dir", + ], + self_update_started: ["from", "to"], + self_update_installed: ["from", "to", "duration_ms"], + self_update_failed: ["from", "to", "step", "reason"], + self_update_rolled_back: ["from", "to"], + fleet_failed: ["call", "reason", "message"], + fleet_desired: ["what", "asked", "state", "reason", "detail"], + retro_started: ["n", "thread_id", "runs", "since_ms", "model_instance", "model"], + retro_finished: ["n", "thread_id", "result", "duration_ms"], + worktrees_swept: ["paths"], + error: ["run", "step", "kind", "message"], +} as const satisfies { readonly [N in MachineEventName]: ReadonlyArray> }; + +// As for the run table: a field of any shape that the table omits fails the type check. +type MachineFieldsOmitted = { + [N in MachineEventName]: Exclude, (typeof MACHINE_EVENT_FIELDS)[N][number]>; +}[MachineEventName]; +const _machineFieldsComplete: [MachineFieldsOmitted] extends [never] ? true : MachineFieldsOmitted = + true; +void _machineFieldsComplete; diff --git a/apps/server/src/factory/ledger/ledger.test.ts b/apps/server/src/factory/ledger/ledger.test.ts new file mode 100644 index 0000000000000..6e3406a54ecb1 --- /dev/null +++ b/apps/server/src/factory/ledger/ledger.test.ts @@ -0,0 +1,1865 @@ +// @effect-diagnostics globalDate:off - one test checks lines are stamped with the wall clock. +// @effect-diagnostics nodeBuiltinImport:off - the tests read and lay out real run files. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + InvalidRunIdError, + LedgerAlreadyExistsError, + LedgerAlreadyFinishedError, + LedgerNotFoundError, + LedgerUnknownEventError, + LedgerIoError, +} from "./errors.ts"; +import { RUN_EVENT_FIELDS, makeCause, type Cause, type RunEvent } from "./events.ts"; +import { Ledger, type LedgerOptions } from "./ledger.ts"; +import type { AppendIo } from "./appendFile.ts"; +import { parseLines, readRun, type Entry, type ReadCause } from "./read.ts"; +import { formatTs } from "./time.ts"; +import { catchLedgerError, scripted, T0, ticking, useTempDirs } from "./testSupport.ts"; +import type { RunId } from "./runId.ts"; + +const tempDir = useTempDirs(); + +const softfacRun: RunId = { repo: "rawphp/softfac", issue: "8", attempt: 1 }; + +const runFileFor = (dataDir: string, id: RunId = softfacRun) => + NodePath.join(dataDir, "runs", `${id.repo.replace("/", "__")}__${id.issue}__${id.attempt}.jsonl`); + +const readText = (path: string) => NodeFS.readFileSync(path, "utf8"); + +/** The cause of a line that a test writes only to have something in the ledger. */ +const testCause = (): Cause => makeCause("pass", "claimed"); + +/** The cause as a read gives it back. */ +const testCauseRead: ReadCause = { step: "pass", reason: "claimed" }; + +// Open handles are closed after each test: a run of this file would otherwise hold hundreds. +const handles: Array = []; +afterEach(() => { + for (const ledger of handles.splice(0)) ledger.close(); +}); + +const create = ( + dataDir: string, + clock: () => number = ticking(T0, 1000), + id: RunId = softfacRun, + options: Omit = {}, +) => { + const ledger = Ledger.create(dataDir, id, { clock, ...options }); + handles.push(ledger); + return ledger; +}; + +const reopen = ( + dataDir: string, + clock: () => number = ticking(T0, 1000), + id: RunId = softfacRun, + options: Omit = {}, +) => { + const ledger = Ledger.open(dataDir, id, { clock, ...options }); + handles.push(ledger); + return ledger; +}; + +const step = (name: string): RunEvent => ({ event: "step_changed", data: { step: name } }); + +const gate = (attempt: number, command: string): RunEvent => ({ + event: "gate_command", + data: { attempt, command, exit_code: 0, duration_ms: 10 }, +}); + +const stuck = (reason = "r"): RunEvent => ({ event: "stuck", data: { reason } }); + +/** The last line of the run file, without its newline. */ +const lastLine = (dataDir: string) => readText(runFileFor(dataDir)).trimEnd().split("\n").pop()!; + +const PASS_CLAIMED = `"cause":{"step":"pass","reason":"claimed"}`; + +/** A line as written at T0 with the test cause. */ +const lineAtT0 = (event: string, dataJson: string) => + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"${event}","data":${dataJson},${PASS_CLAIMED}}\n`; + +const RED = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"step_changed","data":{"step":"red"},${PASS_CLAIMED}}\n`; +const GREEN = `{"ts":"2026-10-04T10:13:17.123Z","ms":1791108797123,"event":"step_changed","data":{"step":"green"},${PASS_CLAIMED}}\n`; +const REFACTOR = `{"ts":"2026-10-04T10:13:18.123Z","ms":1791108798123,"event":"step_changed","data":{"step":"refactor"},${PASS_CLAIMED}}\n`; +const SHIP = `{"ts":"2026-10-04T10:13:19.123Z","ms":1791108799123,"event":"step_changed","data":{"step":"ship"},${PASS_CLAIMED}}\n`; + +const GOOD_LINE = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"step_changed","data":{"step":"red"}}\n`; + +const SHA = "4f1c2d3e4a5b60718293a4b5c6d7e8f901234567"; +const BASE_SHA = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"; + +/** Every event type but `finished`, with the exact line it is written as at `T0`. */ +const sampleEvents: ReadonlyArray<{ readonly event: RunEvent; readonly line: string }> = [ + { + event: { event: "claimed", data: { machine: "mac-mini", github_user: "rawphp" } }, + line: lineAtT0("claimed", `{"machine":"mac-mini","github_user":"rawphp"}`), + }, + { + event: { + event: "triage_result", + data: { passed: false, reasons: ["no acceptance criteria", "too vague"] }, + }, + line: lineAtT0( + "triage_result", + `{"passed":false,"reasons":["no acceptance criteria","too vague"]}`, + ), + }, + { + event: { event: "triage_result", data: { passed: true, reasons: [] } }, + line: lineAtT0("triage_result", `{"passed":true,"reasons":[]}`), + }, + { + event: { + event: "questions_posted", + data: { questions: 'Which table?\nAnd "why"? café' }, + }, + line: lineAtT0("questions_posted", '{"questions":"Which table?\\nAnd \\"why\\"? café"}'), + }, + { + event: { event: "answer_received", data: { comment_id: null, body_edited: true } }, + line: lineAtT0("answer_received", `{"comment_id":null,"body_edited":true}`), + }, + { + event: { event: "answer_received", data: { comment_id: "42", body_edited: false } }, + line: lineAtT0("answer_received", `{"comment_id":"42","body_edited":false}`), + }, + { + event: { + event: "session_launched", + data: { + thread_id: "thr_1", + worktree: "/tmp/wt", + branch: "issue-8", + model_instance: "claudeAgent", + model: "claude-opus-5-5", + resumed_from: { run: "rawphp__softfac__8__1", branch: "t3code/abc" }, + }, + }, + line: lineAtT0( + "session_launched", + `{"thread_id":"thr_1","worktree":"/tmp/wt","branch":"issue-8","model_instance":"claudeAgent","model":"claude-opus-5-5","resumed_from":{"run":"rawphp__softfac__8__1","branch":"t3code/abc"}}`, + ), + }, + { + event: { + event: "session_launched", + data: { + thread_id: "thr_2", + worktree: null, + branch: null, + model_instance: null, + model: null, + resumed_from: null, + }, + }, + line: lineAtT0( + "session_launched", + `{"thread_id":"thr_2","worktree":null,"branch":null,"model_instance":null,"model":null,"resumed_from":null}`, + ), + }, + { + event: { event: "comment_forwarded", data: { comment_id: "42" } }, + line: lineAtT0("comment_forwarded", `{"comment_id":"42"}`), + }, + { + event: step("review"), + line: lineAtT0("step_changed", `{"step":"review"}`), + }, + { + event: { + event: "pr_opened", + data: { pr: 12, url: "https://github.com/rawphp/softfac/pull/12" }, + }, + line: lineAtT0("pr_opened", `{"pr":12,"url":"https://github.com/rawphp/softfac/pull/12"}`), + }, + { + event: { + event: "gate_command", + data: { attempt: 1, command: "cargo test", exit_code: -1, duration_ms: 4200 }, + }, + line: lineAtT0( + "gate_command", + `{"attempt":1,"command":"cargo test","exit_code":-1,"duration_ms":4200}`, + ), + }, + { + event: { event: "state_changed", data: { from: "building", to: "needs-info" } }, + line: lineAtT0("state_changed", `{"from":"building","to":"needs-info"}`), + }, + { + event: stuck("gate failed 3 times"), + line: lineAtT0("stuck", `{"reason":"gate failed 3 times"}`), + }, + { + event: { event: "gate_sent_back", data: { attempt: 2, run_ordinal: 7 } }, + line: lineAtT0("gate_sent_back", `{"attempt":2,"run_ordinal":7}`), + }, + { + event: { + event: "gate_checkout", + data: { + attempt: 2, + dir: "/data/gate/rawphp__softfac__8__1/2", + sha: SHA, + base_sha: BASE_SHA, + }, + }, + line: lineAtT0( + "gate_checkout", + `{"attempt":2,"dir":"/data/gate/rawphp__softfac__8__1/2","sha":"${SHA}","base_sha":"${BASE_SHA}"}`, + ), + }, + { + event: { + event: "gate_regate", + data: { + sha: SHA, + base_sha: "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432", + previous_base_sha: BASE_SHA, + }, + }, + line: lineAtT0( + "gate_regate", + `{"sha":"${SHA}","base_sha":"9f8e7d6c5b4a39281706f5e4d3c2b1a098765432","previous_base_sha":"${BASE_SHA}"}`, + ), + }, + { + event: { + event: "regate_relaunching", + data: { + attempt: 1, + branch: "t3code/abc", + message: "The factory gate failed.\nCommand: cargo test", + }, + }, + line: lineAtT0( + "regate_relaunching", + `{"attempt":1,"branch":"t3code/abc","message":"The factory gate failed.\\nCommand: cargo test"}`, + ), + }, + { + event: { + event: "feedback_relaunching", + data: { + n: 1, + branch: "t3code/abc", + items: ["review:11", "issue-comment:42"], + message: "The factory forwards new feedback.\nFeedback 1 of 2", + }, + }, + line: lineAtT0( + "feedback_relaunching", + `{"n":1,"branch":"t3code/abc","items":["review:11","issue-comment:42"],"message":"The factory forwards new feedback.\\nFeedback 1 of 2"}`, + ), + }, + { + event: { + event: "session_interrupted", + data: { + resumes: 1, + relaunch: false, + run_ordinal: 3, + branch: "t3code/abc", + t3_error: "T3 Code is not reachable: timed out", + ended_in_pass: 2, + }, + }, + line: lineAtT0( + "session_interrupted", + `{"resumes":1,"relaunch":false,"run_ordinal":3,"branch":"t3code/abc","t3_error":"T3 Code is not reachable: timed out","ended_in_pass":2}`, + ), + }, + { + event: { + event: "session_interrupted", + data: { + resumes: 2, + relaunch: true, + run_ordinal: null, + branch: null, + t3_error: null, + ended_in_pass: 1, + }, + }, + line: lineAtT0( + "session_interrupted", + `{"resumes":2,"relaunch":true,"run_ordinal":null,"branch":null,"t3_error":null,"ended_in_pass":1}`, + ), + }, + { + event: { event: "session_paused", data: { run_ordinal: 3 } }, + line: lineAtT0("session_paused", `{"run_ordinal":3}`), + }, + { + event: { event: "stop_requested", data: { by: "operator" } }, + line: lineAtT0("stop_requested", `{"by":"operator"}`), + }, + { + event: { + event: "feedback_forwarding", + data: { + n: 1, + run_ordinal: 3, + items: ["pr-comment:7", "review:11"], + message: "The factory forwards new feedback.\nFeedback 1 of 2", + }, + }, + line: lineAtT0( + "feedback_forwarding", + `{"n":1,"run_ordinal":3,"items":["pr-comment:7","review:11"],"message":"The factory forwards new feedback.\\nFeedback 1 of 2"}`, + ), + }, + { + event: { + event: "pr_adopted", + data: { pr: 12, branch: "t3code/abc", run: "rawphp__softfac__8__1" }, + }, + line: lineAtT0("pr_adopted", `{"pr":12,"branch":"t3code/abc","run":"rawphp__softfac__8__1"}`), + }, + { + event: { event: "branch_bound", data: { pr: 12, branch: "t3code/abc" } }, + line: lineAtT0("branch_bound", `{"pr":12,"branch":"t3code/abc"}`), + }, + { + event: { event: "pr_set_draft", data: { pr: 12 } }, + line: lineAtT0("pr_set_draft", `{"pr":12}`), + }, + { + event: { event: "pr_ready_outside", data: { pr: 12, by: "alice" } }, + line: lineAtT0("pr_ready_outside", `{"pr":12,"by":"alice"}`), + }, + { + event: { event: "pr_ready_outside", data: { pr: 12 } }, + line: lineAtT0("pr_ready_outside", `{"pr":12}`), + }, + { + event: { + event: "gate_cleanup_failed", + data: { + dir: "/data/gate/rawphp__softfac__8__1/2", + error: "could not remove the checkout: permission denied", + }, + }, + line: lineAtT0( + "gate_cleanup_failed", + `{"dir":"/data/gate/rawphp__softfac__8__1/2","error":"could not remove the checkout: permission denied"}`, + ), + }, + { + event: { event: "worktree_removed", data: { path: "/t3/worktrees/softfac/t3-01ac8cf3" } }, + line: lineAtT0("worktree_removed", `{"path":"/t3/worktrees/softfac/t3-01ac8cf3"}`), + }, + { + event: { + event: "worktree_remove_failed", + data: { + path: "/t3/worktrees/softfac/t3-01ac8cf3", + error: + "`git worktree remove --force -- /t3/worktrees/softfac/t3-01ac8cf3` exited with status 128: fatal: cannot remove the worktree", + }, + }, + line: lineAtT0( + "worktree_remove_failed", + `{"path":"/t3/worktrees/softfac/t3-01ac8cf3","error":"\`git worktree remove --force -- /t3/worktrees/softfac/t3-01ac8cf3\` exited with status 128: fatal: cannot remove the worktree"}`, + ), + }, + { + event: { + event: "checks_read", + data: { + sha: SHA, + checks: [ + { name: "gate", state: "passed" }, + { name: "lint", state: "pending" }, + ], + }, + }, + line: lineAtT0( + "checks_read", + `{"sha":"${SHA}","checks":[{"name":"gate","state":"passed"},{"name":"lint","state":"pending"}]}`, + ), + }, + { + event: { + event: "stuck", + data: { reason: "the run passed its time limit", branch: "t3code/abc", commits_ahead: 2 }, + }, + line: lineAtT0( + "stuck", + `{"reason":"the run passed its time limit","branch":"t3code/abc","commits_ahead":2}`, + ), + }, + { + event: { + event: "error", + data: { step: "gate", kind: "github", message: "the pull request could not be read" }, + }, + line: lineAtT0( + "error", + `{"step":"gate","kind":"github","message":"the pull request could not be read"}`, + ), + }, +]; + +/** `finished` written as the only line of a run: the ledger fills in the summary itself. */ +const finishedSample: { readonly event: RunEvent; readonly line: string } = { + event: { + event: "finished", + data: { + outcome: "pr-open", + total_ms: 0, + gate_attempts: 0, + review_cycles: null, + pr_url: null, + pr_end: null, + }, + }, + line: lineAtT0( + "finished", + `{"outcome":"pr-open","total_ms":0,"gate_attempts":0,"review_cycles":null,"pr_url":null,"pr_end":null}`, + ), +}; + +/** What reading `event` written at `secs` after `T0` gives back, for a line with the test cause. */ +const at = (secs: number, event: RunEvent): Entry => ({ + ts: formatTs(T0 + secs * 1000), + ms: T0 + secs * 1000, + event: event.event, + data: JSON.parse(JSON.stringify(event.data)), + cause: testCauseRead, +}); + +/** An entry as read from a line written before causes existed. */ +const oldAt = (secs: number, event: RunEvent): Entry => ({ ...at(secs, event), cause: null }); + +const goodEntry = (): Entry => oldAt(0, step("red")); + +/** A run file holding exactly `content`. */ +const writeRun = (dataDir: string, content: string | Uint8Array) => { + NodeFS.mkdirSync(NodePath.join(dataDir, "runs"), { recursive: true }); + NodeFS.writeFileSync(runFileFor(dataDir), content); +}; + +const readSoftfacRun = (dataDir: string) => readRun(dataDir, softfacRun); + +const FRAGMENT = `{"ts":"2026-10-04T10:13:17.123Z","ms":17911087`; + +describe("create", () => { + it("create makes the runs dir and an empty file", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + expect(ledger.path).toBe(runFileFor(dataDir)); + expect(NodeFS.readFileSync(runFileFor(dataDir)).length).toBe(0); + }); + + it("create rejects an invalid run id and makes nothing", () => { + const dataDir = tempDir(); + const error = catchLedgerError( + () => create(dataDir, ticking(T0, 1), { ...softfacRun, repo: "../x" }), + InvalidRunIdError, + ); + expect(error._tag).toBe("InvalidRunIdError"); + expect(error.input).toBe("../x"); + expect(NodeFS.existsSync(NodePath.join(dataDir, "runs"))).toBe(false); + }); + + it("create on an existing file is already exists and leaves it alone", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(step("red"), testCause()); + ledger.close(); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + + const error = catchLedgerError(() => create(dataDir), LedgerAlreadyExistsError); + expect(error._tag).toBe("LedgerAlreadyExistsError"); + expect(error.path).toBe(runFileFor(dataDir)); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("create now stamps events with the system clock", () => { + const dataDir = tempDir(); + const before = Date.now(); + const ledger = Ledger.create(dataDir, softfacRun); + handles.push(ledger); + ledger.append(stuck(), testCause()); + const after = Date.now(); + + const line = JSON.parse(readText(runFileFor(dataDir)).trimEnd()) as { ts: string; ms: number }; + expect(line.ms).toBeGreaterThanOrEqual(before); + expect(line.ms).toBeLessThanOrEqual(after); + expect(line.ts).toBe(formatTs(line.ms)); + }); + + it("create makes new directories private and the file owner only", () => { + const root = tempDir(); + const dataDir = NodePath.join(root, "fresh", "data"); + create(dataDir, ticking(T0, 1)); + const mode = (path: string) => NodeFS.statSync(path).mode & 0o777; + expect(mode(dataDir)).toBe(0o700); + expect(mode(NodePath.join(dataDir, "runs"))).toBe(0o700); + expect(mode(runFileFor(dataDir))).toBe(0o600); + // The parents made on the way are not forced private: they get whatever a plain `mkdir` + // gives here, whatever the umask is. + NodeFS.mkdirSync(NodePath.join(root, "plain")); + expect(mode(NodePath.join(root, "fresh"))).toBe(mode(NodePath.join(root, "plain"))); + }); + + it("create does not change the mode of directories that already exist", () => { + const dataDir = tempDir(); + const runs = NodePath.join(dataDir, "runs"); + NodeFS.mkdirSync(runs); + for (const dir of [dataDir, runs]) NodeFS.chmodSync(dir, 0o755); + create(dataDir, ticking(T0, 1)); + expect(NodeFS.statSync(dataDir).mode & 0o777).toBe(0o755); + expect(NodeFS.statSync(runs).mode & 0o777).toBe(0o755); + expect(NodeFS.statSync(runFileFor(dataDir)).mode & 0o777).toBe(0o600); + }); + + it("create in an existing data dir makes only runs private", () => { + const dataDir = tempDir(); + NodeFS.chmodSync(dataDir, 0o755); + create(dataDir, ticking(T0, 1)); + expect(NodeFS.statSync(dataDir).mode & 0o777).toBe(0o755); + expect(NodeFS.statSync(NodePath.join(dataDir, "runs")).mode & 0o777).toBe(0o700); + }); + + it("create syncs runs, then the data dir, then its parent, whether or not it made them", () => { + const root = tempDir(); + const dataDir = NodePath.join(root, "data"); + const synced: Array = []; + create(dataDir, ticking(T0, 1), softfacRun, { syncDir: (dir) => void synced.push(dir) }); + expect(synced).toEqual([NodePath.join(dataDir, "runs"), dataDir, root]); + + synced.length = 0; + const second: RunId = { ...softfacRun, attempt: 2 }; + create(dataDir, ticking(T0, 1), second, { syncDir: (dir) => void synced.push(dir) }); + expect(synced).toEqual([NodePath.join(dataDir, "runs"), dataDir, root]); + }); + + it("a create retried after a failed sync still syncs the directories the first try made", () => { + const root = tempDir(); + const dataDir = NodePath.join(root, "data"); + let failing = true; + const synced: Array = []; + const syncDir = (dir: string) => { + if (failing && dir === dataDir) throw new Error("injected disk error"); + synced.push(dir); + }; + expect( + catchLedgerError( + () => create(dataDir, ticking(T0, 1), softfacRun, { syncDir }), + LedgerIoError, + ).path, + ).toBe(dataDir); + failing = false; + synced.length = 0; + create(dataDir, ticking(T0, 1), softfacRun, { syncDir }); + expect(synced).toEqual([NodePath.join(dataDir, "runs"), dataDir, root]); + }); + + it("create removes the file it just made if the directory sync fails", () => { + const dataDir = tempDir(); + const error = catchLedgerError( + () => + create(dataDir, ticking(T0, 1000), softfacRun, { + syncDir: () => { + throw new Error("injected disk error"); + }, + }), + LedgerIoError, + ); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(NodePath.join(dataDir, "runs")); + expect(NodeFS.existsSync(runFileFor(dataDir))).toBe(false); + + // The retry is not blocked by an orphan. + const ledger = create(dataDir); + ledger.append(step("red"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(RED); + }); + + it("create that finds the file already there does not remove it", () => { + const dataDir = tempDir(); + writeRun(dataDir, RED); + const error = catchLedgerError(() => + create(dataDir, ticking(T0, 1000), softfacRun, { + syncDir: () => { + throw new Error("injected disk error"); + }, + }), + ); + expect(error._tag).toBe("LedgerAlreadyExistsError"); + expect(readText(runFileFor(dataDir))).toBe(RED); + }); + + it("create over a symlink named like a run is already exists and writes nothing", () => { + const dataDir = tempDir(); + const target = NodePath.join(dataDir, "elsewhere.jsonl"); + NodeFS.mkdirSync(NodePath.join(dataDir, "runs")); + NodeFS.symlinkSync(target, runFileFor(dataDir)); + expect(catchLedgerError(() => create(dataDir))._tag).toBe("LedgerAlreadyExistsError"); + expect(NodeFS.existsSync(target)).toBe(false); + }); +}); + +describe("append", () => { + it("append writes one exact line per event type", () => { + const dataDir = tempDir(); + const samples = [...sampleEvents, finishedSample]; + const seen = new Set(); + samples.forEach(({ event, line }, i) => { + const ledger = create(dataDir, ticking(T0, 0), { ...softfacRun, attempt: i + 1 }); + ledger.append(event, testCause()); + expect(readText(ledger.path), event.event).toBe(line); + seen.add(event.event); + }); + // Every one of the 30 event types has at least one exact line. + expect([...seen].sort()).toEqual(Object.keys(RUN_EVENT_FIELDS).sort()); + expect(seen.size).toBe(30); + }); + + it("event name is the snake case type, and data holds exactly the field list in order", () => { + const dataDir = tempDir(); + [...sampleEvents, finishedSample].forEach(({ event }, i) => { + const ledger = create(dataDir, ticking(T0, 0), { ...softfacRun, attempt: i + 1 }); + ledger.append(event, testCause()); + const written = JSON.parse(readText(ledger.path)) as { + event: string; + data: Record; + }; + expect(written.event).toBe(event.event); + const given = event.data as Record; + // A field that is left out (`undefined`) is not written; every other one is, in order. + const expected = RUN_EVENT_FIELDS[event.event].filter((field) => given[field] !== undefined); + expect(Object.keys(written.data), event.event).toEqual(expected); + }); + }); + + it("append writes the fields in the doc order whatever order the caller built the object in", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, ticking(T0, 0)); + ledger.append( + { + event: "session_launched", + data: { + resumed_from: { branch: "t3code/abc", run: "r" }, + model: "m", + model_instance: "i", + branch: "b", + worktree: "w", + thread_id: "t", + }, + }, + testCause(), + ); + expect(readText(ledger.path)).toBe( + lineAtT0( + "session_launched", + `{"thread_id":"t","worktree":"w","branch":"b","model_instance":"i","model":"m","resumed_from":{"run":"r","branch":"t3code/abc"}}`, + ), + ); + }); + + it("append adds lines without changing earlier bytes", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + const steps: ReadonlyArray = [ + [ + { event: "claimed", data: { machine: "m", github_user: "u" } }, + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"claimed","data":{"machine":"m","github_user":"u"},${PASS_CLAIMED}}\n`, + ], + [ + step("red"), + `{"ts":"2026-10-04T10:13:17.123Z","ms":1791108797123,"event":"step_changed","data":{"step":"red"},${PASS_CLAIMED}}\n`, + ], + [ + stuck("r"), + `{"ts":"2026-10-04T10:13:18.123Z","ms":1791108798123,"event":"stuck","data":{"reason":"r"},${PASS_CLAIMED}}\n`, + ], + ]; + let expected = ""; + for (const [event, line] of steps) { + ledger.append(event, testCause()); + expected += line; + expect(readText(ledger.path)).toBe(expected); + } + }); + + it("append refuses an event name the doc does not list, writes nothing and names the event", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, ticking(T0, 0)); + ledger.append(step("red"), testCause()); + const before = readText(ledger.path); + for (const name of ["merged", "toString", "__proto__", "constructor"]) { + const error = catchLedgerError( + () => + ledger.append({ event: name, data: { sha: "abc" } } as unknown as RunEvent, testCause()), + LedgerUnknownEventError, + ); + expect(error._tag).toBe("LedgerUnknownEventError"); + expect(error.event).toBe(name); + expect(error.message).toContain(name); + } + expect(readText(ledger.path)).toBe(before); + // The refusal does not mark the run finished or dirty: the next append is a clean line. + ledger.append(step("green"), testCause()); + expect(readText(ledger.path)).toBe(`${before}${lineAtT0("step_changed", `{"step":"green"}`)}`); + }); + + it("append cleans the cause: actor, command, attempt and label come in that order and only when set", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, ticking(T0, 0)); + const events: ReadonlyArray = [ + [makeCause("start", "approved"), `{"step":"start","reason":"approved"}`], + [ + makeCause("gate", "gate-command-ran", { + label: "factory:ready", + attempt: 1, + command: "cargo test", + actor: "alice", + }), + `{"step":"gate","reason":"gate-command-ran","actor":"alice","command":"cargo test","attempt":1,"label":"factory:ready"}`, + ], + [ + makeCause("gate", "gate-regated", { attempt: 0 }), + `{"step":"gate","reason":"gate-regated","attempt":0}`, + ], + [ + makeCause("start", "new-attempt-started", { label: "factory:ready" }), + `{"step":"start","reason":"new-attempt-started","label":"factory:ready"}`, + ], + ]; + let expected = ""; + for (const [cause, causeJson] of events) { + ledger.append(step("s"), cause); + expected += `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"step_changed","data":{"step":"s"},"cause":${causeJson}}\n`; + } + expect(readText(ledger.path)).toBe(expected); + }); + + it("append cleans a hand-built cause on the way to the file", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, ticking(T0, 1000)); + const cause: Cause = { + step: "start", + reason: "approved", + actor: `al\u202ei${"x".repeat(150)}`, + command: `a\nb${"c".repeat(1500)}`, + label: "la\u0007bel\u2066", + }; + ledger.append(step("s"), cause); + const written = JSON.parse(readText(ledger.path).trimEnd()).cause; + expect(written).toEqual({ + step: "start", + reason: "approved", + actor: `ali${"x".repeat(97)}`, + command: `ab${"c".repeat(998)}`, + label: "label", + }); + }); + + it("a cause attempt that is not a u32 is left out, so the cause still reads back", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, ticking(T0, 0)); + const handBuilt = (attempt: number): Cause => ({ + step: "gate", + reason: "gate-regated", + attempt, + }); + const causes = [ + makeCause("gate", "gate-regated", { attempt: -1 }), + handBuilt(1.5), + handBuilt(2 ** 32), + handBuilt(Number.NaN), + handBuilt(4294967295), + ]; + for (const cause of causes) ledger.append(step("s"), cause); + expect(readSoftfacRun(dataDir).entries.map((entry) => entry.cause)).toEqual([ + { step: "gate", reason: "gate-regated" }, + { step: "gate", reason: "gate-regated" }, + { step: "gate", reason: "gate-regated" }, + { step: "gate", reason: "gate-regated" }, + { step: "gate", reason: "gate-regated", attempt: 4294967295 }, + ]); + expect(makeCause("gate", "gate-regated", { attempt: -1 })).toEqual({ + step: "gate", + reason: "gate-regated", + }); + }); + + it("a cause cleans actor and label of control and bidi characters and cuts them at 100, and cuts a command at 1000", () => { + const actor = makeCause("start", "approved", { actor: "al\u0007i‮ce⁦\u0000\n\u007f\u0085" }); + expect(actor.actor).toBe("alice"); + const label = makeCause("start", "approved", { label: "x".repeat(150) }); + expect(label.label).toBe("x".repeat(100)); + // Characters, not UTF-16 units. + const emoji = makeCause("start", "approved", { actor: "\u{1f600}".repeat(150) }); + expect([...emoji.actor!]).toHaveLength(100); + const command = makeCause("gate", "gate-command-ran", { command: `${"c".repeat(1500)}` }); + expect(command.command).toBe("c".repeat(1000)); + // A command is cleaned the same way as the others: one line, no control characters. + expect(makeCause("gate", "gate-command-ran", { command: "a\nb\tc" }).command).toBe("abc"); + // Nothing else goes in a cause, and an unset extra is left out. + expect(Object.keys(makeCause("pass", "claimed"))).toEqual(["step", "reason"]); + }); +}); + +describe("open", () => { + it("open a missing run is not found", () => { + const dataDir = tempDir(); + const error = catchLedgerError(() => reopen(dataDir), LedgerNotFoundError); + expect(error._tag).toBe("LedgerNotFoundError"); + expect(error.path).toBe(runFileFor(dataDir)); + }); + + it("open appends after the existing lines", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(step("red"), testCause()); + ledger.close(); + + const second = reopen(dataDir, ticking(T0 + 5000, 1000)); + second.append(step("green"), testCause()); + expect(readText(runFileFor(dataDir))).toBe( + `${RED}{"ts":"2026-10-04T10:13:21.123Z","ms":1791108801123,"event":"step_changed","data":{"step":"green"},${PASS_CLAIMED}}\n`, + ); + }); + + it("open leaves a file that ends in a newline alone", () => { + const dataDir = tempDir(); + writeRun(dataDir, GOOD_LINE); + reopen(dataDir, ticking(T0, 1)); + expect(readText(runFileFor(dataDir))).toBe(GOOD_LINE); + }); + + it("open terminates an incomplete last line before appending", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}${FRAGMENT}`); + + const ledger = reopen(dataDir, ticking(T0 + 9000, 1000)); + expect(readText(runFileFor(dataDir))).toBe(`${GOOD_LINE}${FRAGMENT}\n`); + + ledger.append(stuck("r"), testCause()); + expect(readText(runFileFor(dataDir))).toBe( + `${GOOD_LINE}${FRAGMENT}\n{"ts":"2026-10-04T10:13:25.123Z","ms":1791108805123,"event":"stuck","data":{"reason":"r"},${PASS_CLAIMED}}\n`, + ); + }); + + it("open rebuilds start time and gate attempts from the file", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(step("red"), testCause()); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.close(); + + const second = reopen(dataDir, ticking(T0 + 60_000, 1000)); + second.append(gate(2, "cargo test"), testCause()); + second.finish("pr-open", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:14:17.123Z","ms":1791108857123,"event":"finished","data":{"outcome":"pr-open","total_ms":61000,"gate_attempts":2,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("open a finished run then append is an error and writes nothing", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.finish("stuck", null, null, testCause()); + ledger.close(); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + + const second = reopen(dataDir); + expect(catchLedgerError(() => second.append(stuck(), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("a finished line this code cannot read still ends the run after open", () => { + const dataDir = tempDir(); + const newerFinished = `{"ts":"2026-10-04T10:13:17.123Z","ms":1791108797123,"event":"finished","data":{"outcome":"merged","total_ms":"soon"}}\n`; + writeRun(dataDir, `${RED}${newerFinished}`); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + const ledger = reopen(dataDir); + expect(catchLedgerError(() => ledger.append(step("green"), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(catchLedgerError(() => ledger.finish("stuck", null, null, testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("a gate command line that does not parse is not counted", () => { + const dataDir = tempDir(); + const bad = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"gate_command","data":{"attempt":"two","command":"c"}}\n`; + writeRun(dataDir, `${bad}${bad}`); + const ledger = reopen(dataDir, ticking(T0 + 5000, 1000)); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:22.123Z","ms":1791108802123,"event":"finished","data":{"outcome":"stuck","total_ms":6000,"gate_attempts":1,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("a gate command line counts only when every field parses", () => { + const dataDir = tempDir(); + const line = (data: string) => + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"gate_command","data":${data}}\n`; + writeRun( + dataDir, + [ + // No exit_code. + line(`{"attempt":2,"command":"c","duration_ms":5}`), + // A string duration_ms. + line(`{"attempt":3,"command":"c","exit_code":0,"duration_ms":"5"}`), + // A non-integer exit_code. + line(`{"attempt":4,"command":"c","exit_code":1.5,"duration_ms":5}`), + // A negative duration_ms is not a u64. + line(`{"attempt":5,"command":"c","exit_code":0,"duration_ms":-1}`), + // An exit_code outside i32. + line(`{"attempt":6,"command":"c","exit_code":2147483648,"duration_ms":5}`), + // A missing command. + line(`{"attempt":7,"exit_code":0,"duration_ms":5}`), + // An attempt below or above u32. + line(`{"attempt":-1,"command":"c","exit_code":0,"duration_ms":5}`), + line(`{"attempt":4294967296,"command":"c","exit_code":0,"duration_ms":5}`), + ].join(""), + ); + const ledger = reopen(dataDir, ticking(T0 + 5000, 1000)); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toContain(`"gate_attempts":1,`); + }); + + it("read and open refuse a symlink named like a run", () => { + const dataDir = tempDir(); + const target = NodePath.join(dataDir, "elsewhere.jsonl"); + NodeFS.writeFileSync(target, RED); + NodeFS.mkdirSync(NodePath.join(dataDir, "runs")); + NodeFS.symlinkSync(target, runFileFor(dataDir)); + + const readError = catchLedgerError(() => readSoftfacRun(dataDir), LedgerNotFoundError); + expect(readError._tag).toBe("LedgerNotFoundError"); + expect(readError.path).toBe(runFileFor(dataDir)); + const openError = catchLedgerError(() => reopen(dataDir), LedgerNotFoundError); + expect(openError._tag).toBe("LedgerNotFoundError"); + expect(openError.path).toBe(runFileFor(dataDir)); + expect(readText(target)).toBe(RED); + // Nothing was created or truncated through the link. + expect(NodeFS.lstatSync(runFileFor(dataDir)).isSymbolicLink()).toBe(true); + expect(NodeFS.readdirSync(NodePath.join(dataDir, "runs"))).toEqual([ + NodePath.basename(runFileFor(dataDir)), + ]); + }); + + effectIt.effect( + "a ledger error is caught by its tag in an Effect and keeps softfac's message", + () => + Effect.gen(function* () { + const dataDir = tempDir(); + const error = catchLedgerError(() => reopen(dataDir)); + const caught = yield* Effect.fail(error).pipe( + Effect.catchTags({ + LedgerNotFoundError: (failure) => Effect.succeed(failure.path), + LedgerIoError: () => Effect.succeed("io"), + }), + ); + expect(caught).toBe(runFileFor(dataDir)); + expect(error.message).toBe(`no run file at ${runFileFor(dataDir)}`); + }), + ); + + it("open a missing run is not found and creates no file", () => { + const dataDir = tempDir(); + expect(catchLedgerError(() => reopen(dataDir))._tag).toBe("LedgerNotFoundError"); + expect(NodeFS.existsSync(runFileFor(dataDir))).toBe(false); + // The same with a runs directory that exists but holds no such run. + NodeFS.mkdirSync(NodePath.join(dataDir, "runs")); + expect(catchLedgerError(() => reopen(dataDir))._tag).toBe("LedgerNotFoundError"); + expect(NodeFS.existsSync(runFileFor(dataDir))).toBe(false); + }); + + it("read and open refuse a directory named like a run", () => { + const dataDir = tempDir(); + NodeFS.mkdirSync(runFileFor(dataDir), { recursive: true }); + expect(catchLedgerError(() => readSoftfacRun(dataDir))._tag).toBe("LedgerNotFoundError"); + expect(catchLedgerError(() => reopen(dataDir))._tag).toBe("LedgerNotFoundError"); + }); + + it("open counts a whole finished line that a crash left without its newline", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${RED}${finishedSample.line.trimEnd()}`); + const ledger = reopen(dataDir); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + expect(catchLedgerError(() => ledger.append(step("green"), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("open counts a whole gate command line that a crash left without its newline", () => { + const dataDir = tempDir(); + writeRun( + dataDir, + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"gate_command","data":{"attempt":1,"command":"c","exit_code":0,"duration_ms":5}}`, + ); + const ledger = reopen(dataDir, ticking(T0 + 5000, 1000)); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toContain(`"total_ms":5000,"gate_attempts":1,`); + }); + + it("a handle refuses to append after another handle finished the run", () => { + const dataDir = tempDir(); + const first = create(dataDir); + const second = reopen(dataDir); + first.finish("stuck", null, null, testCause()); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + expect(catchLedgerError(() => second.append(step("green"), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(catchLedgerError(() => second.finish("stuck", null, null, testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("a handle's finish counts the lines another handle wrote after it opened", () => { + const dataDir = tempDir(); + const first = create(dataDir, scripted([T0, T0 + 1000])); + const second = reopen(dataDir, scripted([T0 + 2000, T0 + 9000])); + first.append(step("red"), testCause()); + first.append(gate(1, "cargo test"), testCause()); + second.append(gate(2, "cargo test"), testCause()); + second.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toContain(`"total_ms":9000,"gate_attempts":2,`); + }); + + it("a created ledger and a reopened one interleave appends without overwriting", () => { + const dataDir = tempDir(); + const first = create(dataDir, ticking(T0, 10_000)); + const second = reopen(dataDir, ticking(T0 + 1000, 10_000)); + first.append(step("red"), testCause()); + second.append(step("green"), testCause()); + first.append(step("refactor"), testCause()); + second.append(step("ship"), testCause()); + expect(readText(runFileFor(dataDir))).toBe( + `${RED}${GREEN}{"ts":"2026-10-04T10:13:26.123Z","ms":1791108806123,"event":"step_changed","data":{"step":"refactor"},${PASS_CLAIMED}}\n{"ts":"2026-10-04T10:13:27.123Z","ms":1791108807123,"event":"step_changed","data":{"step":"ship"},${PASS_CLAIMED}}\n`, + ); + }); +}); + +describe("read", () => { + it("read gives back every event type equal", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + for (const { event } of sampleEvents) ledger.append(event, testCause()); + ledger.close(); + + const record = readSoftfacRun(dataDir); + expect(record.id).toEqual(softfacRun); + expect(record.entries).toEqual(sampleEvents.map(({ event }, i) => at(i, event))); + expect(record.truncated).toBe(false); + expect(record.skipped).toBe(0); + }); + + it("read an empty file is an empty untruncated record", () => { + const dataDir = tempDir(); + writeRun(dataDir, ""); + expect(readSoftfacRun(dataDir)).toEqual({ + id: softfacRun, + entries: [], + truncated: false, + skipped: 0, + }); + }); + + it("read a missing run is not found", () => { + const dataDir = tempDir(); + const error = catchLedgerError(() => readSoftfacRun(dataDir), LedgerNotFoundError); + expect(error._tag).toBe("LedgerNotFoundError"); + expect(error.path).toBe(runFileFor(dataDir)); + }); + + it("read refuses an invalid run id", () => { + const dataDir = tempDir(); + const error = catchLedgerError( + () => readRun(dataDir, { ...softfacRun, repo: "../x" }), + InvalidRunIdError, + ); + expect(error._tag).toBe("InvalidRunIdError"); + expect(error.input).toBe("../x"); + }); + + it("read drops a last line cut off mid json", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}${FRAGMENT}`); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([goodEntry()]); + expect(record.truncated).toBe(true); + expect(record.skipped).toBe(0); + }); + + it("read drops a last line with no newline even if it is valid json", () => { + const dataDir = tempDir(); + writeRun(dataDir, GOOD_LINE.trimEnd()); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([]); + expect(record.truncated).toBe(true); + expect(record.skipped).toBe(0); + }); + + it("read drops a last line that ends in a newline but is not json", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}{"ts":\n`); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([goodEntry()]); + expect(record.truncated).toBe(true); + expect(record.skipped).toBe(0); + }); + + it("read skips and counts an invalid line in the middle", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}not json at all\n${GOOD_LINE}`); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([goodEntry(), goodEntry()]); + expect(record.truncated).toBe(false); + expect(record.skipped).toBe(1); + }); + + it("read skips lines that are not ledger lines", () => { + const dataDir = tempDir(); + const bad: ReadonlyArray = [ + Buffer.from("[]\n"), + Buffer.from("42\n"), + Buffer.from(`{"event":"stuck","data":{"reason":"r"}}\n`), + Buffer.from(`{"ts":"t","ms":"1","event":"stuck","data":{"reason":"r"}}\n`), + Buffer.from([0xff, 0xfe, 0x0a]), + ]; + writeRun(dataDir, Buffer.concat(bad.flatMap((line) => [line, Buffer.from(GOOD_LINE)]))); + const record = readSoftfacRun(dataDir); + expect(record.entries).toHaveLength(5); + expect(record.truncated).toBe(false); + expect(record.skipped).toBe(5); + }); + + it("read skips a line whose ms, ts or event has the wrong type or is not a whole number", () => { + const line = (fields: string) => `{${fields},"data":{}}\n`; + const content = [ + line(`"ts":"t","ms":1.5,"event":"e"`), + line(`"ts":"t","ms":-1,"event":"e"`), + line(`"ts":1,"ms":1,"event":"e"`), + line(`"ts":"t","ms":1,"event":5`), + line(`"ts":"t","ms":null,"event":"e"`), + line(`"ts":"t","ms":0,"event":"e"`), + ].join(""); + const { entries, truncated, skipped } = parseLines(Buffer.from(content)); + expect(entries).toEqual([{ ts: "t", ms: 0, event: "e", data: {}, cause: null }]); + expect(truncated).toBe(false); + expect(skipped).toBe(5); + }); + + it("read after open and append keeps old entries and skips the fragment", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}${FRAGMENT}`); + const ledger = reopen(dataDir, ticking(T0 + 9000, 1000)); + ledger.append(stuck("r"), testCause()); + + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([goodEntry(), at(9, stuck("r"))]); + expect(record.truncated).toBe(false); + expect(record.skipped).toBe(1); + }); + + it("read keeps an unknown event type as written", () => { + const dataDir = tempDir(); + writeRun( + dataDir, + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"merged","data":{"sha":"abc","n":1}}\n`, + ); + expect(readSoftfacRun(dataDir).entries).toEqual([ + { + ts: "2026-10-04T10:13:16.123Z", + ms: T0, + event: "merged", + data: { sha: "abc", n: 1 }, + cause: null, + }, + ]); + }); + + it("read keeps a known type with the wrong data and a missing data key as written", () => { + const dataDir = tempDir(); + writeRun( + dataDir, + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"claimed","data":{"machine":1}}\n{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"stuck"}\n`, + ); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([ + { + ts: "2026-10-04T10:13:16.123Z", + ms: T0, + event: "claimed", + data: { machine: 1 }, + cause: null, + }, + { ts: "2026-10-04T10:13:16.123Z", ms: T0, event: "stuck", data: null, cause: null }, + ]); + expect(record.skipped).toBe(0); + }); + + it("read keeps a state changed with an unknown state name as written", () => { + const dataDir = tempDir(); + writeRun( + dataDir, + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"state_changed","data":{"from":"building","to":"merged"}}\n`, + ); + expect(readSoftfacRun(dataDir).entries).toEqual([ + oldAt(0, { + event: "state_changed", + data: { from: "building", to: "merged" }, + } as unknown as RunEvent), + ]); + }); + + it("lines of the answer step that older versions wrote still read", () => { + // The `answer` step, the `needs-info-nudged` step and the `unanswered` and `kept-asking` + // reasons are no longer written. A ledger that holds them reads as it was written. + const dataDir = tempDir(); + const prefix = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event"`; + writeRun( + dataDir, + [ + `${prefix}:"stuck","data":{"reason":"the questions went unanswered"},"cause":{"step":"answer","reason":"unanswered"}}\n`, + `${prefix}:"step_changed","data":{"step":"needs-info-nudged"},"cause":{"step":"answer","reason":"needs-info-nudged"}}\n`, + `${prefix}:"state_changed","data":{"from":"needs-info","to":"building"},"cause":{"step":"answer","reason":"reply-received"}}\n`, + ].join(""), + ); + const { entries, skipped } = readSoftfacRun(dataDir); + expect(skipped).toBe(0); + expect(entries.map((entry) => [entry.event, entry.data, entry.cause])).toEqual([ + [ + "stuck", + { reason: "the questions went unanswered" }, + { step: "answer", reason: "unanswered" }, + ], + [ + "step_changed", + { step: "needs-info-nudged" }, + { step: "answer", reason: "needs-info-nudged" }, + ], + [ + "state_changed", + { from: "needs-info", to: "building" }, + { step: "answer", reason: "reply-received" }, + ], + ]); + }); + + it("lines written before this change with a numeric comment id still read", () => { + const dataDir = tempDir(); + const prefix = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event"`; + writeRun( + dataDir, + [ + `${prefix}:"answer_received","data":{"comment_id":42,"body_edited":false},${PASS_CLAIMED}}\n`, + `${prefix}:"comment_forwarded","data":{"comment_id":42},${PASS_CLAIMED}}\n`, + ].join(""), + ); + const { entries } = readSoftfacRun(dataDir); + expect(entries.map((entry) => [entry.event, entry.data, entry.cause])).toEqual([ + ["answer_received", { comment_id: 42, body_edited: false }, testCauseRead], + ["comment_forwarded", { comment_id: 42 }, testCauseRead], + ]); + }); + + it("lines from before the pr end and model fields read as written", () => { + const oldFinished = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"finished","data":{"outcome":"pr-open","total_ms":3000,"gate_attempts":1,"review_cycles":null,"pr_url":null},${PASS_CLAIMED}}\n`; + const oldLaunched = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"session_launched","data":{"thread_id":"thr_1","worktree":null,"branch":null},${PASS_CLAIMED}}\n`; + const { entries, truncated, skipped } = parseLines(Buffer.from(`${oldLaunched}${oldFinished}`)); + expect([truncated, skipped]).toEqual([false, 0]); + expect(entries[0]?.data).toEqual({ thread_id: "thr_1", worktree: null, branch: null }); + expect(entries[1]?.data).toEqual({ + outcome: "pr-open", + total_ms: 3000, + gate_attempts: 1, + review_cycles: null, + pr_url: null, + }); + }); + + it("a stuck line with a branch and commits round trips and an old one reads without", () => { + const dataDir = tempDir(); + const id: RunId = { repo: "o/r", issue: "1", attempt: 1 }; + const ledger = create(dataDir, ticking(T0, 1), id); + const event: RunEvent = { + event: "stuck", + data: { reason: "the run passed its time limit", branch: "t3code/abc", commits_ahead: 2 }, + }; + ledger.append(event, testCause()); + expect(readText(ledger.path)).toContain( + `"data":{"reason":"the run passed its time limit","branch":"t3code/abc","commits_ahead":2}`, + ); + expect(readRun(dataDir, id).entries[0]?.data).toEqual(event.data); + + const old = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"stuck","data":{"reason":"gate failed 3 times"},${PASS_CLAIMED}}\n`; + expect(parseLines(Buffer.from(old)).entries[0]?.data).toEqual({ + reason: "gate failed 3 times", + }); + }); + + it("read ignores empty lines without counting them", () => { + for (const [name, content] of [ + ["lf", `${GOOD_LINE}\n${GOOD_LINE}`], + ["crlf", `${GOOD_LINE}\r\n${GOOD_LINE}`], + ["several", `\n${GOOD_LINE}\n\r\n\n${GOOD_LINE}`], + ] as const) { + const dataDir = tempDir(); + writeRun(dataDir, content); + const record = readSoftfacRun(dataDir); + expect(record.entries, name).toEqual([goodEntry(), goodEntry()]); + expect(record.truncated, name).toBe(false); + expect(record.skipped, name).toBe(0); + } + }); + + it("read an empty final line is not a truncation", () => { + for (const [name, content] of [ + ["blank-last", `${GOOD_LINE}\n`], + ["cr-last", `${GOOD_LINE}\r\n`], + ["bare-cr-tail", `${GOOD_LINE}\r`], + ["only-newlines", "\n\n"], + ] as const) { + const dataDir = tempDir(); + writeRun(dataDir, content); + const record = readSoftfacRun(dataDir); + expect(record.entries, name).toEqual(content.includes('{"ts"') ? [goodEntry()] : []); + expect(record.truncated, name).toBe(false); + expect(record.skipped, name).toBe(0); + } + }); + + it("a blank line does not hide a real truncation", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${GOOD_LINE}\n{"ts":"2026`); + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([goodEntry()]); + expect(record.truncated).toBe(true); + expect(record.skipped).toBe(0); + }); + + it("a cause that is not an object with a step and a reason reads as no cause, and one that is keeps its extra keys", () => { + const base = `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"stuck","data":{"reason":"r"}`; + const causes: ReadonlyArray = [ + [`"cause":null`, null], + [`"cause":"claimed"`, null], + [`"cause":["pass","claimed"]`, null], + [`"cause":{"step":"pass"}`, null], + [`"cause":{"reason":"claimed"}`, null], + [`"cause":{"step":1,"reason":"claimed"}`, null], + [`"cause":{"step":"pass","reason":"claimed","attempt":"1"}`, null], + [`"cause":{"step":"pass","reason":"claimed","actor":5}`, null], + [ + `"cause":{"step":"newer","reason":"unknown-code","actor":"alice","attempt":2,"extra":{"k":1}}`, + { step: "newer", reason: "unknown-code", actor: "alice", attempt: 2, extra: { k: 1 } }, + ], + ]; + const dataDir = tempDir(); + writeRun(dataDir, causes.map(([cause]) => `${base},${cause}}\n`).join("")); + const record = readSoftfacRun(dataDir); + expect(record.skipped).toBe(0); + expect(record.entries.map((entry) => entry.cause)).toEqual(causes.map(([, read]) => read)); + }); +}); + +describe("finish", () => { + it("finish writes the summary line with total time and zero gate attempts", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, scripted([T0, T0 + 90_000])); + ledger.append({ event: "claimed", data: { machine: "m", github_user: "u" } }, testCause()); + ledger.finish("pr-open", 2, "https://github.com/rawphp/softfac/pull/12", testCause()); + expect(readText(runFileFor(dataDir))).toBe( + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"claimed","data":{"machine":"m","github_user":"u"},${PASS_CLAIMED}}\n` + + `{"ts":"2026-10-04T10:14:46.123Z","ms":1791108886123,"event":"finished","data":{"outcome":"pr-open","total_ms":90000,"gate_attempts":0,"review_cycles":2,"pr_url":"https://github.com/rawphp/softfac/pull/12","pr_end":null},${PASS_CLAIMED}}\n`, + ); + }); + + it("finish counts commands in one attempt as one attempt", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + for (const command of ["cargo fmt --check", "cargo clippy", "cargo test"]) { + ledger.append(gate(1, command), testCause()); + } + ledger.finish("pr-open", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:19.123Z","ms":1791108799123,"event":"finished","data":{"outcome":"pr-open","total_ms":3000,"gate_attempts":1,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("finish counts distinct gate attempts", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + for (const [attempt, command] of [ + [1, "cargo fmt --check"], + [1, "cargo test"], + [2, "cargo fmt --check"], + [2, "cargo test"], + [3, "cargo test"], + ] as const) { + ledger.append(gate(attempt, command), testCause()); + } + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:21.123Z","ms":1791108801123,"event":"finished","data":{"outcome":"stuck","total_ms":5000,"gate_attempts":3,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("a gate checkout line is not a gate attempt by itself", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append( + { + event: "gate_checkout", + data: { attempt: 1, dir: "/d", sha: "a".repeat(40), base_sha: "b".repeat(40) }, + }, + testCause(), + ); + ledger.append( + { event: "gate_cleanup_failed", data: { dir: "/d", error: "busy" } }, + testCause(), + ); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:18.123Z","ms":1791108798123,"event":"finished","data":{"outcome":"stuck","total_ms":2000,"gate_attempts":0,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("a gate sent back line is not a gate attempt by itself", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(gate(1, "cargo test"), testCause()); + for (const attempt of [1, 2, 3]) { + ledger.append({ event: "gate_sent_back", data: { attempt, run_ordinal: 1 } }, testCause()); + } + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:20.123Z","ms":1791108800123,"event":"finished","data":{"outcome":"stuck","total_ms":4000,"gate_attempts":1,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("finish counts distinct attempt numbers not the highest", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.append(gate(3, "cargo test"), testCause()); + ledger.finish("needs-info", 0, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:18.123Z","ms":1791108798123,"event":"finished","data":{"outcome":"needs-info","total_ms":2000,"gate_attempts":2,"review_cycles":0,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("finish as the only line has zero total time", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"finished","data":{"outcome":"stuck","total_ms":0,"gate_attempts":0,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("finish total time never goes negative if the clock steps back", () => { + const dataDir = tempDir(); + const ledger = create(dataDir, scripted([T0, T0 - 5000])); + ledger.append(step("red"), testCause()); + ledger.finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toContain(`"total_ms":0,`); + }); + + it("append computes the summary even if the caller filled it in", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.append( + { + event: "finished", + data: { + outcome: "stuck", + total_ms: 999_999, + gate_attempts: 99, + review_cycles: null, + pr_url: null, + pr_end: null, + }, + }, + testCause(), + ); + expect(lastLine(dataDir)).toBe( + `{"ts":"2026-10-04T10:13:17.123Z","ms":1791108797123,"event":"finished","data":{"outcome":"stuck","total_ms":1000,"gate_attempts":1,"review_cycles":null,"pr_url":null,"pr_end":null},${PASS_CLAIMED}}`, + ); + }); + + it("finished reads back as a finished event", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.append(gate(1, "cargo test"), testCause()); + ledger.finish("pr-open", 1, "https://x/pull/1", testCause()); + ledger.close(); + expect(readSoftfacRun(dataDir).entries[1]).toEqual( + at(1, { + event: "finished", + data: { + outcome: "pr-open", + total_ms: 1000, + gate_attempts: 1, + review_cycles: 1, + pr_url: "https://x/pull/1", + pr_end: null, + }, + }), + ); + }); + + it("append after finished is an error and writes nothing", () => { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.finish("stuck", null, null, testCause()); + const before = NodeFS.readFileSync(runFileFor(dataDir)); + for (const attempt of [ + () => ledger.append(stuck(), testCause()), + () => ledger.finish("stuck", null, null, testCause()), + ]) { + const error = catchLedgerError(attempt, LedgerAlreadyFinishedError); + expect(error._tag).toBe("LedgerAlreadyFinishedError"); + expect(error.path).toBe(runFileFor(dataDir)); + } + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(before); + }); + + it("a finish with a pr end writes it last and reads back", () => { + for (const prEnd of ["merged", "closed"] as const) { + const dataDir = tempDir(); + const ledger = create(dataDir); + ledger.finish("pr-open", null, "https://x/pull/1", testCause(), prEnd); + ledger.close(); + expect(lastLine(dataDir)).toContain( + `"pr_url":"https://x/pull/1","pr_end":"${prEnd}"},"cause"`, + ); + expect(readSoftfacRun(dataDir).entries[0]?.data).toEqual({ + outcome: "pr-open", + total_ms: 0, + gate_attempts: 0, + review_cycles: null, + pr_url: "https://x/pull/1", + pr_end: prEnd, + }); + } + }); + + it("a plain finish writes a null pr end", () => { + const dataDir = tempDir(); + create(dataDir).finish("stuck", null, null, testCause()); + expect(lastLine(dataDir)).toContain(`"pr_url":null,"pr_end":null}`); + }); + + it("append keeps the pr end when it fills in the summary", () => { + const dataDir = tempDir(); + create(dataDir).append( + { + event: "finished", + data: { + outcome: "pr-open", + total_ms: 0, + gate_attempts: 0, + review_cycles: null, + pr_url: null, + pr_end: "closed", + }, + }, + testCause(), + ); + expect(lastLine(dataDir)).toContain(`"pr_end":"closed"`); + }); +}); + +/** What the injected `AppendIo` should do wrong, once, and what it was asked to do. */ +type Faults = { + /** The next write puts this many bytes into the real file and then fails. */ + failWriteAfter: number | null; + failSync: boolean; + calls: Array<"write" | "sync">; + /** At most this many bytes go into the file per write call (a short write). */ + maxPerWrite: number | null; +}; + +/** A real file that fails on demand, standing in for a full or failing disk. */ +const faultyIo = (): { io: AppendIo; faults: Faults } => { + const faults: Faults = { failWriteAfter: null, failSync: false, calls: [], maxPerWrite: null }; + const io: AppendIo = { + writeSync(fd, buffer) { + faults.calls.push("write"); + if (faults.failWriteAfter !== null) { + const keep = faults.failWriteAfter; + faults.failWriteAfter = null; + NodeFS.writeSync(fd, buffer.subarray(0, Math.min(keep, buffer.length))); + throw new Error("injected disk error"); + } + const limit = faults.maxPerWrite ?? buffer.length; + return NodeFS.writeSync(fd, buffer.subarray(0, limit)); + }, + fsyncSync(fd) { + faults.calls.push("sync"); + if (faults.failSync) { + faults.failSync = false; + throw new Error("injected disk error"); + } + NodeFS.fsyncSync(fd); + }, + }; + return { io, faults }; +}; + +describe("failed writes", () => { + it("a write that fails partway does not swallow the next event", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + + faults.failWriteAfter = 10; + const error = catchLedgerError(() => ledger.append(step("green"), testCause()), LedgerIoError); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(runFileFor(dataDir)); + expect(readText(runFileFor(dataDir))).toBe(`${RED}{"ts":"202`); + + // The append first ends the cut-off line with its own write and sync, as a reopen does; + // the line is then one write and one sync. + faults.calls.length = 0; + ledger.append(step("refactor"), testCause()); + expect(faults.calls).toEqual(["write", "sync", "write", "sync"]); + ledger.append(step("ship"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(`${RED}{"ts":"202\n${REFACTOR}${SHIP}`); + + const record = readSoftfacRun(dataDir); + expect(record.entries).toEqual([ + at(0, step("red")), + at(2, step("refactor")), + at(3, step("ship")), + ]); + expect(record.truncated).toBe(false); + expect(record.skipped).toBe(1); + }); + + it("an Io error's message is the path and the file system's own words, as softfac prints it", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + faults.failSync = true; + const error = catchLedgerError(() => ledger.append(step("red"), testCause()), LedgerIoError); + expect(error.message).toBe(`${runFileFor(dataDir)}: injected disk error`); + expect(error.cause).toEqual(new Error("injected disk error")); + }); + + it("an append whose repair of a cut-off line fails writes nothing, and the next one repairs it", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + faults.failWriteAfter = 10; + expect(catchLedgerError(() => ledger.append(step("green"), testCause()))._tag).toBe( + "LedgerIoError", + ); + + faults.failWriteAfter = 0; + const error = catchLedgerError( + () => ledger.append(step("refactor"), testCause()), + LedgerIoError, + ); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(runFileFor(dataDir)); + expect(readText(runFileFor(dataDir))).toBe(`${RED}{"ts":"202`); + + faults.failSync = true; + expect(catchLedgerError(() => ledger.append(step("ship"), testCause()))._tag).toBe( + "LedgerIoError", + ); + // The newline went in before its sync failed, and the event was not written. + expect(readText(runFileFor(dataDir))).toBe(`${RED}{"ts":"202\n`); + + ledger.append(step("ship"), testCause()); + expect(readSoftfacRun(dataDir).entries.map((entry) => entry.data)).toEqual([ + { step: "red" }, + { step: "ship" }, + ]); + }); + + it("an open whose repair of a cut-off line fails is an Io error, and a later open repairs it", () => { + const dataDir = tempDir(); + writeRun(dataDir, `${RED}${FRAGMENT}`); + const { io, faults } = faultyIo(); + faults.failWriteAfter = 0; + const thrown = catchLedgerError( + () => reopen(dataDir, ticking(T0, 1000), softfacRun, { io }), + LedgerIoError, + ); + expect(thrown._tag).toBe("LedgerIoError"); + expect(thrown.path).toBe(runFileFor(dataDir)); + const writesNothing: AppendIo = { writeSync: () => 0, fsyncSync: () => {} }; + const refused = catchLedgerError(() => + reopen(dataDir, ticking(T0, 1000), softfacRun, { io: writesNothing }), + ); + expect(refused._tag).toBe("LedgerIoError"); + expect(readText(runFileFor(dataDir))).toBe(`${RED}${FRAGMENT}`); + + reopen(dataDir); + expect(readText(runFileFor(dataDir))).toBe(`${RED}${FRAGMENT}\n`); + }); + + it("a write that fails before writing anything still terminates the line next time", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + faults.failWriteAfter = 0; + expect(catchLedgerError(() => ledger.append(step("green"), testCause()))._tag).toBe( + "LedgerIoError", + ); + ledger.append(step("refactor"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(`${RED}\n${REFACTOR}`); + }); + + it("a failed sync also gets a line break before the next event", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + faults.failSync = true; + expect(catchLedgerError(() => ledger.append(step("red"), testCause()))._tag).toBe( + "LedgerIoError", + ); + ledger.append(step("green"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(`${RED}\n${GREEN}`); + // The repair line break is not a damaged line. + const record = readSoftfacRun(dataDir); + expect(record.entries).toHaveLength(2); + expect(record.skipped).toBe(0); + expect(record.truncated).toBe(false); + }); + + it("the line break is only added once after a failure", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + faults.failWriteAfter = 3; + expect(catchLedgerError(() => ledger.append(step("red"), testCause()))._tag).toBe( + "LedgerIoError", + ); + ledger.append(step("green"), testCause()); + ledger.append(step("refactor"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(`{"t\n${GREEN}${REFACTOR}`); + }); + + it("a finish whose write failed can be retried", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + faults.failWriteAfter = 5; + expect(catchLedgerError(() => ledger.finish("stuck", null, null, testCause()))._tag).toBe( + "LedgerIoError", + ); + ledger.finish("stuck", null, null, testCause()); + + const record = readSoftfacRun(dataDir); + expect(record.entries).toHaveLength(2); + expect(record.entries[1]?.event).toBe("finished"); + expect(record.skipped).toBe(1); + expect(record.truncated).toBe(false); + }); + + it("a finish whose sync failed still counts as finished", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + faults.failSync = true; + expect(catchLedgerError(() => ledger.finish("stuck", null, null, testCause()))._tag).toBe( + "LedgerIoError", + ); + const afterFailure = NodeFS.readFileSync(runFileFor(dataDir)); + + expect(catchLedgerError(() => ledger.finish("stuck", null, null, testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(catchLedgerError(() => ledger.append(step("red"), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + expect(NodeFS.readFileSync(runFileFor(dataDir))).toEqual(afterFailure); + expect(readSoftfacRun(dataDir).entries).toHaveLength(1); + }); + + it("a finished line cut off just before its newline still ends the run for live handles", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + const other = reopen(dataDir); + faults.failWriteAfter = Buffer.byteLength(finishedSample.line) - 1; + expect(catchLedgerError(() => ledger.finish("pr-open", null, null, testCause()))._tag).toBe( + "LedgerIoError", + ); + for (const handle of [ledger, other]) { + expect(catchLedgerError(() => handle.append(step("green"), testCause()))._tag).toBe( + "LedgerAlreadyFinishedError", + ); + } + // The refusal ended the cut-off line, as a reopen would, so a reader sees the run finished. + expect(readText(runFileFor(dataDir))).toBe(finishedSample.line); + expect(readSoftfacRun(dataDir).entries.map((entry) => entry.event)).toEqual(["finished"]); + }); + + it("a gate command whose sync failed still counts toward the summary", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + faults.failSync = true; + expect(catchLedgerError(() => ledger.append(gate(1, "cargo test"), testCause()))._tag).toBe( + "LedgerIoError", + ); + ledger.finish("stuck", null, null, testCause()); + + const finished = readSoftfacRun(dataDir).entries.at(-1); + expect(finished?.event).toBe("finished"); + expect(finished?.data).toEqual({ + outcome: "stuck", + total_ms: 1000, + gate_attempts: 1, + review_cycles: null, + pr_url: null, + pr_end: null, + }); + }); + + it("each append writes and syncs once in that order", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + ledger.append(step("green"), testCause()); + ledger.finish("stuck", null, null, testCause()); + expect(faults.calls).toEqual(["write", "sync", "write", "sync", "write", "sync"]); + }); + + it("a short write is carried on until the whole line is in the file", () => { + const dataDir = tempDir(); + const { io, faults } = faultyIo(); + faults.maxPerWrite = 7; + const ledger = create(dataDir, ticking(T0, 1000), softfacRun, { io }); + ledger.append(step("red"), testCause()); + expect(readText(runFileFor(dataDir))).toBe(RED); + // Many writes, but one sync for the line. + expect(faults.calls.filter((call) => call === "sync")).toHaveLength(1); + expect(faults.calls.filter((call) => call === "write").length).toBeGreaterThan(1); + }); +}); diff --git a/apps/server/src/factory/ledger/ledger.ts b/apps/server/src/factory/ledger/ledger.ts new file mode 100644 index 0000000000000..700303e6b7a88 --- /dev/null +++ b/apps/server/src/factory/ledger/ledger.ts @@ -0,0 +1,241 @@ +// @effect-diagnostics nodeBuiltinImport:off - softfac's write rules need raw fds: O_EXCL, O_NOFOLLOW and fsync. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + AppendFile, + ensureDir, + nodeIo, + openForAppend, + parentOf, + syncAt, + syncDir, + type AppendIo, + type SyncDir, +} from "./appendFile.ts"; +import { + LedgerAlreadyExistsError, + LedgerAlreadyFinishedError, + LedgerUnknownEventError, + LedgerIoError, + errnoCode, +} from "./errors.ts"; +import { isU32 } from "./limits.ts"; +import { + RUN_EVENT_FIELDS, + causeObject, + runEventData, + type Cause, + type Outcome, + type PrEnd, + type RunEvent, +} from "./events.ts"; +import { parseLines, requireRegularFile } from "./read.ts"; +import { RUNS_DIR, runPath, type RunId } from "./runId.ts"; +import { formatTs } from "./time.ts"; + +// The largest u64 reads as 2^64 once JSON.parse has made it a double. +const U64_MAX = 2 ** 64; +const I32_MIN = -2147483648; +const I32_MAX = 2147483647; + +const isIntegerIn = (value: unknown, min: number, max: number): value is number => + typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; + +export type LedgerOptions = { + /** Returns the current time as Unix epoch milliseconds. Defaults to the system clock. */ + readonly clock?: () => number; + /** The write and fsync an append performs. Defaults to `node:fs`; a test injects failures. */ + readonly io?: AppendIo; + /** Flushes a directory to disk after `create` makes the file. Defaults to an fsync. */ + readonly syncDir?: SyncDir; +}; + +/** An open run file that events are appended to. */ +export class Ledger { + private readonly out: AppendFile; + private readonly clock: () => number; + /** The `ms` of the first line, which `finished` measures total time from. */ + private firstMs: number | null = null; + /** The distinct `attempt` values among the `gate_command` lines. */ + private readonly gateAttempts = new Set(); + private finished = false; + + private constructor(out: AppendFile, clock: () => number) { + this.out = out; + this.clock = clock; + } + + /** The run file. */ + get path(): string { + return this.out.path; + } + + /** + * Start a new run file. Throws `AlreadyExists`, leaving the file alone, if there is one. + * Directories this makes are `0o700` and the file `0o600`; the file's directory entry is + * synced, and the file removed again if that fails, so a retry does not meet an orphan. + */ + static create(dataDir: string, id: RunId, options: LedgerOptions = {}): Ledger { + const path = runPath(dataDir, id); + const runs = NodePath.join(dataDir, RUNS_DIR); + const sync = options.syncDir ?? syncDir; + ensureDirAt(dataDir); + ensureDirAt(runs); + let fd: number; + try { + // The ledger holds issue and PR details, so keep it to the owner. Read access lets an + // append see what other handles wrote; O_EXCL never follows a symlink. + fd = NodeFS.openSync( + path, + NodeFS.constants.O_RDWR | + NodeFS.constants.O_APPEND | + NodeFS.constants.O_CREAT | + NodeFS.constants.O_EXCL, + 0o600, + ); + } catch (error) { + if (errnoCode(error) === "EEXIST") throw new LedgerAlreadyExistsError({ path }); + throw new LedgerIoError({ path, cause: error }); + } + // The new file's directory entry must be durable too, or a crash could lose the whole file + // and the events synced into it. Every directory on the way is synced each time, not only + // the ones this call made: a call that made them and then failed to sync leaves them for + // the retry to sync. + try { + syncAt(sync, runs); + syncAt(sync, dataDir); + syncAt(sync, parentOf(dataDir)); + } catch (error) { + NodeFS.closeSync(fd); + try { + NodeFS.unlinkSync(path); + } catch { + // The sync error is the one to report. + } + throw error; + } + return new Ledger(new AppendFile(path, fd, options.io ?? nodeIo), options.clock ?? Date.now); + } + + /** + * Reopen an existing run for append after a restart. If the last line is incomplete (a crash + * mid-write), it is terminated with a newline first, so the next event starts on its own + * line. The fragment stays in the file and readers skip it. A symlink or anything else that + * is not a regular file is `NotFound`. + */ + static open(dataDir: string, id: RunId, options: LedgerOptions = {}): Ledger { + const path = runPath(dataDir, id); + requireRegularFile(path); + return new Ledger(openForAppend(path, options.io), options.clock ?? Date.now); + } + + /** + * Fold the lines written since the last append, by this handle or another one, into what the + * summary needs, so `finished` and the counts are what the file says. + */ + private catchUp(): void { + for (const entry of parseLines(this.out.readNewLines()).entries) { + this.observe(entry.ms, entry.event, entry.data); + } + } + + /** Fold a line that is in the file into what the summary needs. */ + private observe(ms: number, event: string, data: unknown): void { + this.firstMs ??= ms; + // Only a `gate_command` that softfac would read as a whole counts: it falls back to an + // unknown event, which is not counted, when any field is missing or the wrong type. + if (event === "gate_command" && typeof data === "object" && data !== null) { + const { attempt, command, exit_code, duration_ms } = data as Record; + if ( + isU32(attempt) && + typeof command === "string" && + isIntegerIn(exit_code, I32_MIN, I32_MAX) && + isIntegerIn(duration_ms, 0, U64_MAX) + ) { + this.gateAttempts.add(attempt); + } + } + // A `finished` line ends the run even if its data cannot be read. + if (event === "finished") this.finished = true; + } + + /** + * Append one event as one line, with why it was written: a single write of the whole line, + * then a sync, so a crash loses at most the event in flight. A `finished` event has its + * `total_ms` and `gate_attempts` replaced with the ledger's own count, taken from the file; + * use `finish`. Throws `UnknownEvent`, writing nothing, for an event name that ledger.md does + * not list, and `AlreadyFinished` after a `finished` line, whichever handle wrote it. A line + * cut off at the end of the file is ended before that check, as a reopen would, so a + * `finished` line that only lacked its newline counts; the refusal writes nothing else. + */ + append(event: RunEvent, cause: Cause): void { + if (!Object.hasOwn(RUN_EVENT_FIELDS, event.event)) { + throw new LedgerUnknownEventError({ event: String(event.event) }); + } + this.out.endCutOffLine(); + this.catchUp(); + if (this.finished) throw new LedgerAlreadyFinishedError({ path: this.out.path }); + const ms = this.clock(); + const data = runEventData( + event, + event.event === "finished" + ? { + total_ms: Math.max(0, ms - (this.firstMs ?? ms)), + gate_attempts: this.gateAttempts.size, + } + : {}, + ); + let line: string; + try { + line = `${JSON.stringify({ ts: formatTs(ms), ms, event: event.event, data, cause: causeObject(cause) })}\n`; + } catch (error) { + throw new LedgerIoError({ path: this.out.path, cause: error }); + } + // A line whose sync fails is still in the file, so the next append's catch-up counts it: a + // failed sync on `finished` does not allow a second `finished` line. + this.out.writeLine(line); + } + + /** + * Write the summary line that ends the run. The ledger fills in the total time since the + * first line and the number of distinct gate attempts; later appends fail. `prEnd` is how the + * pull request ended, for a run that ended because it did. + */ + finish( + outcome: Outcome, + reviewCycles: number | null, + prUrl: string | null, + cause: Cause, + prEnd: PrEnd | null = null, + ): void { + this.append( + { + event: "finished", + data: { + outcome, + total_ms: 0, + gate_attempts: 0, + review_cycles: reviewCycles, + pr_url: prUrl, + pr_end: prEnd, + }, + }, + cause, + ); + } + + /** Release the file handle. Appending after this fails with `Io`. */ + close(): void { + this.out.close(); + } +} + +/** `ensureDir` with its failure reported as an `Io` error for `dir`. */ +const ensureDirAt = (dir: string): void => { + try { + ensureDir(dir); + } catch (error) { + throw new LedgerIoError({ path: dir, cause: error }); + } +}; diff --git a/apps/server/src/factory/ledger/limits.ts b/apps/server/src/factory/ledger/limits.ts new file mode 100644 index 0000000000000..73a3a9e493063 --- /dev/null +++ b/apps/server/src/factory/ledger/limits.ts @@ -0,0 +1,9 @@ +/** `\n`, which ends every line of a ledger file. */ +export const NEWLINE = 0x0a; + +/** The largest u32: softfac's type for an attempt number. */ +export const U32_MAX = 4294967295; + +/** A whole number from 0 to `U32_MAX`, which is what softfac reads an attempt as. */ +export const isU32 = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= U32_MAX; diff --git a/apps/server/src/factory/ledger/machineLog.test.ts b/apps/server/src/factory/ledger/machineLog.test.ts new file mode 100644 index 0000000000000..5fca92612e8eb --- /dev/null +++ b/apps/server/src/factory/ledger/machineLog.test.ts @@ -0,0 +1,664 @@ +// @effect-diagnostics globalDate:off - one test checks lines are stamped with the wall clock. +// @effect-diagnostics nodeBuiltinImport:off - the tests read and lay out a real machine log. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { LedgerUnknownEventError, LedgerIoError } from "./errors.ts"; +import type { AppendIo } from "./appendFile.ts"; +import { MACHINE_EVENT_FIELDS, type MachineEvent } from "./events.ts"; +import { MachineLog, scanMachineLog, type MachineLogOptions } from "./machineLog.ts"; +import { catchLedgerError, scripted, T0, ticking, useTempDirs } from "./testSupport.ts"; + +const tempDir = useTempDirs(); + +const logFile = (dataDir: string) => NodePath.join(dataDir, "events.jsonl"); +const readText = (path: string) => NodeFS.readFileSync(path, "utf8"); + +// Open handles are closed after each test: a run of this file would otherwise hold many. +const handles: Array = []; +afterEach(() => { + for (const log of handles.splice(0)) log.close(); +}); + +const open = ( + dataDir: string, + clock: () => number = ticking(T0, 1000), + options: Omit = {}, +) => { + const log = MachineLog.open(dataDir, { clock, ...options }); + handles.push(log); + return log; +}; + +/** A line as written at `T0`. */ +const lineAtT0 = (event: string, dataJson: string) => + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"${event}","data":${dataJson}}\n`; + +const passStarted: MachineEvent = { event: "pass_started", data: {} }; + +/** Every machine event, with the exact line it is written as at `T0`. */ +const samples: ReadonlyArray<{ readonly event: MachineEvent; readonly line: string }> = [ + { event: passStarted, line: lineAtT0("pass_started", `{}`) }, + { + event: { event: "pass_finished", data: { runs: 3, claimed: 1, errors: 2, attention: 0 } }, + line: lineAtT0("pass_finished", `{"runs":3,"claimed":1,"errors":2,"attention":0}`), + }, + { + event: { event: "preflight_failed", data: { check: "t3", why: "T3 Code is not reachable" } }, + line: lineAtT0("preflight_failed", `{"check":"t3","why":"T3 Code is not reachable"}`), + }, + { + event: { + event: "repo_skipped", + data: { + repo: "rawphp/softfac", + reason: "config-not-loaded", + message: "the project config is invalid", + }, + }, + line: lineAtT0( + "repo_skipped", + `{"repo":"rawphp/softfac","reason":"config-not-loaded","message":"the project config is invalid"}`, + ), + }, + { + event: { + event: "claim_skipped", + data: { repo: "rawphp/softfac", issue: "12", reason: "limit" }, + }, + line: lineAtT0("claim_skipped", `{"repo":"rawphp/softfac","issue":12,"reason":"limit"}`), + }, + { + event: { + event: "claim_skipped", + data: { repo: "acme/widgets", issue: "ENG-42", reason: "blocked" }, + }, + line: lineAtT0("claim_skipped", `{"repo":"acme/widgets","issue":"ENG-42","reason":"blocked"}`), + }, + { + event: { event: "claim_skipped", data: { reason: "paused" } }, + line: lineAtT0("claim_skipped", `{"reason":"paused"}`), + }, + { + event: { + event: "claim_repaired", + data: { repo: "rawphp/softfac", issue: "12", outcome: "finished" }, + }, + line: lineAtT0("claim_repaired", `{"repo":"rawphp/softfac","issue":12,"outcome":"finished"}`), + }, + { + event: { event: "attention", data: { run: "rawphp__softfac__8__1", reason: "abandoned" } }, + line: lineAtT0("attention", `{"run":"rawphp__softfac__8__1","reason":"abandoned"}`), + }, + { + event: { + event: "attention", + data: { reason: "disk-floor", volume: "/", free_bytes: 5000000000, floor_gb: 10, held: 2 }, + }, + line: lineAtT0( + "attention", + `{"reason":"disk-floor","volume":"/","free_bytes":5000000000,"floor_gb":10,"held":2}`, + ), + }, + { + event: { + event: "attention", + data: { + reason: "t3-gh-config-dir", + url: "http://localhost:3773", + user: "rawphp", + gh_config_dir: "/home/me/.config/gh-rawphp", + }, + }, + line: lineAtT0( + "attention", + `{"reason":"t3-gh-config-dir","url":"http://localhost:3773","user":"rawphp","gh_config_dir":"/home/me/.config/gh-rawphp"}`, + ), + }, + { + event: { event: "self_update_started", data: { from: "abc123", to: "def456" } }, + line: lineAtT0("self_update_started", `{"from":"abc123","to":"def456"}`), + }, + { + event: { + event: "self_update_installed", + data: { from: "abc123", to: "def456", duration_ms: 1200 }, + }, + line: lineAtT0("self_update_installed", `{"from":"abc123","to":"def456","duration_ms":1200}`), + }, + { + event: { + event: "self_update_failed", + data: { from: "abc123", to: "def456", step: "install", reason: "update-build-failed" }, + }, + line: lineAtT0( + "self_update_failed", + `{"from":"abc123","to":"def456","step":"install","reason":"update-build-failed"}`, + ), + }, + { + event: { event: "self_update_rolled_back", data: { from: "def456", to: "abc123" } }, + line: lineAtT0("self_update_rolled_back", `{"from":"def456","to":"abc123"}`), + }, + { + event: { + event: "fleet_failed", + data: { + call: "events", + reason: "fleet-timeout", + message: "the call took longer than 10 seconds", + }, + }, + line: lineAtT0( + "fleet_failed", + `{"call":"events","reason":"fleet-timeout","message":"the call took longer than 10 seconds"}`, + ), + }, + { + event: { + event: "fleet_desired", + data: { + what: "config", + asked: "7", + state: "refused", + reason: "config-key-not-allowed", + detail: "poll_seconds", + }, + }, + line: lineAtT0( + "fleet_desired", + `{"what":"config","asked":"7","state":"refused","reason":"config-key-not-allowed","detail":"poll_seconds"}`, + ), + }, + { + event: { + event: "fleet_desired", + data: { + what: "target-commit", + asked: "abc123", + state: "applied", + reason: "target-installed", + }, + }, + line: lineAtT0( + "fleet_desired", + `{"what":"target-commit","asked":"abc123","state":"applied","reason":"target-installed"}`, + ), + }, + { + event: { + event: "retro_started", + data: { + n: 1, + thread_id: "thr_9", + runs: 5, + since_ms: null, + model_instance: "claudeAgent", + model: "claude-opus-5-5", + }, + }, + line: lineAtT0( + "retro_started", + `{"n":1,"thread_id":"thr_9","runs":5,"since_ms":null,"model_instance":"claudeAgent","model":"claude-opus-5-5"}`, + ), + }, + { + event: { + event: "retro_finished", + data: { n: 1, thread_id: "thr_9", result: "done", duration_ms: 60000 }, + }, + line: lineAtT0( + "retro_finished", + `{"n":1,"thread_id":"thr_9","result":"done","duration_ms":60000}`, + ), + }, + { + event: { event: "worktrees_swept", data: { paths: ["/t3/worktrees/a", "/t3/worktrees/b"] } }, + line: lineAtT0("worktrees_swept", `{"paths":["/t3/worktrees/a","/t3/worktrees/b"]}`), + }, + { + event: { + event: "error", + data: { step: "retro", kind: "t3", message: "the retro thread could not be read" }, + }, + line: lineAtT0( + "error", + `{"step":"retro","kind":"t3","message":"the retro thread could not be read"}`, + ), + }, + { + event: { + event: "error", + data: { + run: "rawphp__softfac__8__1", + step: "pass", + kind: "ledger", + message: + "run rawphp__softfac__8__1: its ledger file cannot be read, so nothing was done for it and it counts as active", + }, + }, + line: lineAtT0( + "error", + `{"run":"rawphp__softfac__8__1","step":"pass","kind":"ledger","message":"run rawphp__softfac__8__1: its ledger file cannot be read, so nothing was done for it and it counts as active"}`, + ), + }, +]; + +describe("open", () => { + it("open makes the data dir and an empty owner-only file, and fsyncs the directories", () => { + const root = tempDir(); + const dataDir = NodePath.join(root, "fresh", "data"); + const synced: Array = []; + open(dataDir, ticking(T0, 1), { syncDir: (dir) => void synced.push(dir) }); + expect(NodeFS.readFileSync(logFile(dataDir)).length).toBe(0); + expect(NodeFS.statSync(logFile(dataDir)).mode & 0o777).toBe(0o600); + expect(NodeFS.statSync(dataDir).mode & 0o777).toBe(0o700); + expect(synced).toEqual([dataDir, NodePath.join(root, "fresh")]); + }); + + it("open in an existing data dir leaves its mode alone and still syncs it and its parent", () => { + const dataDir = tempDir(); + NodeFS.chmodSync(dataDir, 0o755); + const synced: Array = []; + open(dataDir, ticking(T0, 1), { syncDir: (dir) => void synced.push(dir) }); + expect(NodeFS.statSync(dataDir).mode & 0o777).toBe(0o755); + expect(synced).toEqual([dataDir, NodePath.dirname(dataDir)]); + }); + + it("open on an existing log syncs the data dir and its parent and leaves the bytes alone", () => { + const dataDir = tempDir(); + const content = lineAtT0("pass_started", `{}`); + NodeFS.writeFileSync(logFile(dataDir), content); + NodeFS.chmodSync(logFile(dataDir), 0o644); + const synced: Array = []; + open(dataDir, ticking(T0, 1), { syncDir: (dir) => void synced.push(dir) }); + expect(synced).toEqual([dataDir, NodePath.dirname(dataDir)]); + expect(readText(logFile(dataDir))).toBe(content); + expect(NodeFS.statSync(logFile(dataDir)).mode & 0o777).toBe(0o644); + }); + + it("an open whose directory sync fails is an Io error, and the retry syncs and appends", () => { + const root = tempDir(); + const dataDir = NodePath.join(root, "data"); + const error = catchLedgerError( + () => + open(dataDir, ticking(T0, 1), { + syncDir: () => { + throw new Error("injected disk error"); + }, + }), + LedgerIoError, + ); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(dataDir); + + const synced: Array = []; + const log = open(dataDir, ticking(T0, 1), { syncDir: (dir) => void synced.push(dir) }); + expect(synced).toEqual([dataDir, root]); + log.append(passStarted); + expect(readText(logFile(dataDir))).toBe(lineAtT0("pass_started", `{}`)); + }); + + it("open ends an incomplete last line with a newline and leaves one that ends in a newline alone", () => { + const whole = lineAtT0("pass_started", `{}`); + const fragment = `{"ts":"2026-10-04T10:13:17.123Z","ms":17911087`; + + const torn = tempDir(); + NodeFS.writeFileSync(logFile(torn), `${whole}${fragment}`); + const log = open(torn, ticking(T0 + 9000, 1000)); + expect(readText(logFile(torn))).toBe(`${whole}${fragment}\n`); + log.append(passStarted); + expect(readText(logFile(torn))).toBe( + `${whole}${fragment}\n{"ts":"2026-10-04T10:13:25.123Z","ms":1791108805123,"event":"pass_started","data":{}}\n`, + ); + + const clean = tempDir(); + NodeFS.writeFileSync(logFile(clean), whole); + open(clean); + expect(readText(logFile(clean))).toBe(whole); + }); + + it("open refuses a symlink or a directory at events.jsonl", () => { + const linked = tempDir(); + const target = NodePath.join(linked, "elsewhere.jsonl"); + NodeFS.writeFileSync(target, ""); + NodeFS.symlinkSync(target, logFile(linked)); + const error = catchLedgerError(() => open(linked), LedgerIoError); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(logFile(linked)); + expect(readText(target)).toBe(""); + + const dangling = tempDir(); + NodeFS.symlinkSync(NodePath.join(dangling, "missing.jsonl"), logFile(dangling)); + expect(catchLedgerError(() => open(dangling))._tag).toBe("LedgerIoError"); + expect(NodeFS.existsSync(NodePath.join(dangling, "missing.jsonl"))).toBe(false); + + const directory = tempDir(); + NodeFS.mkdirSync(logFile(directory)); + expect(catchLedgerError(() => open(directory))._tag).toBe("LedgerIoError"); + }); + + it("open refuses a pipe at events.jsonl without waiting on it", () => { + const dataDir = tempDir(); + const made = NodeChildProcess.spawnSync("mkfifo", [logFile(dataDir)]); + expect(made.status).toBe(0); + expect(catchLedgerError(() => open(dataDir))._tag).toBe("LedgerIoError"); + }); +}); + +describe("append", () => { + it("append writes a numeric issue key as a JSON number and an identifier as a string", () => { + const dataDir = tempDir(); + const log = open(dataDir, ticking(T0, 1000)); + log.append({ event: "claim_skipped", data: { repo: "a/b", issue: "8", reason: "limit" } }); + log.append({ + event: "claim_repaired", + data: { repo: "a/b", issue: "ENG-42", outcome: "released" }, + }); + const lines = readText(logFile(dataDir)).split("\n"); + expect(lines[0]).toContain(`"data":{"repo":"a/b","issue":8,"reason":"limit"}`); + expect(lines[1]).toContain(`"data":{"repo":"a/b","issue":"ENG-42","outcome":"released"}`); + }); + + it("append writes an issue number above 2^53 digit for digit", () => { + const dataDir = tempDir(); + const log = open(dataDir, ticking(T0, 1000)); + for (const issue of ["9007199254740993", "18446744073709551615"]) { + log.append({ event: "claim_skipped", data: { repo: "a/b", issue, reason: "limit" } }); + } + const lines = readText(logFile(dataDir)).split("\n"); + expect(lines[0]).toContain(`"issue":9007199254740993,`); + expect(lines[1]).toContain(`"issue":18446744073709551615,`); + }); + + it("append writes the five example lines of the doc as exact bytes, with no cause", () => { + const dataDir = tempDir(); + const log = open( + dataDir, + scripted([1791108796123, 1791108797123, 1791108798123, 1791108799123, 1791108800123]), + ); + log.append({ event: "pass_started", data: {} }); + log.append({ event: "pass_finished", data: { runs: 3, claimed: 1, errors: 2, attention: 0 } }); + log.append({ + event: "claim_skipped", + data: { repo: "rawphp/softfac", issue: "12", reason: "limit" }, + }); + log.append({ event: "attention", data: { run: "rawphp__softfac__8__1", reason: "abandoned" } }); + log.append({ + event: "error", + data: { + run: "rawphp__softfac__8__1", + step: "pass", + kind: "ledger", + message: + "run rawphp__softfac__8__1: its ledger file cannot be read, so nothing was done for it and it counts as active", + }, + }); + expect(readText(logFile(dataDir))).toBe( + [ + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"pass_started","data":{}}`, + `{"ts":"2026-10-04T10:13:17.123Z","ms":1791108797123,"event":"pass_finished","data":{"runs":3,"claimed":1,"errors":2,"attention":0}}`, + `{"ts":"2026-10-04T10:13:18.123Z","ms":1791108798123,"event":"claim_skipped","data":{"repo":"rawphp/softfac","issue":12,"reason":"limit"}}`, + `{"ts":"2026-10-04T10:13:19.123Z","ms":1791108799123,"event":"attention","data":{"run":"rawphp__softfac__8__1","reason":"abandoned"}}`, + `{"ts":"2026-10-04T10:13:20.123Z","ms":1791108800123,"event":"error","data":{"run":"rawphp__softfac__8__1","step":"pass","kind":"ledger","message":"run rawphp__softfac__8__1: its ledger file cannot be read, so nothing was done for it and it counts as active"}}`, + "", + ].join("\n"), + ); + }); + + it("append writes one exact line per machine event and variant", () => { + const dataDir = tempDir(); + const log = open(dataDir, ticking(T0, 0)); + let expected = ""; + const seen = new Set(); + for (const { event, line } of samples) { + log.append(event); + expected += line; + seen.add(event.event); + expect(readText(logFile(dataDir)), event.event).toBe(expected); + } + // Every one of the 17 event types has at least one exact line. + expect([...seen].sort()).toEqual(Object.keys(MACHINE_EVENT_FIELDS).sort()); + expect(seen.size).toBe(17); + }); + + it("the claims paused line has a reason and nothing else", () => { + const dataDir = tempDir(); + open(dataDir, ticking(T0, 0)).append({ event: "claim_skipped", data: { reason: "paused" } }); + const text = readText(logFile(dataDir)); + expect(text).toBe( + `{"ts":"2026-10-04T10:13:16.123Z","ms":1791108796123,"event":"claim_skipped","data":{"reason":"paused"}}\n`, + ); + expect(text).toContain(`"event":"claim_skipped","data":{"reason":"paused"}}`); + expect(Object.keys((JSON.parse(text) as { data: object }).data)).toEqual(["reason"]); + }); + + it("append writes the fields in the doc order whatever order the caller built the object in", () => { + const dataDir = tempDir(); + open(dataDir, ticking(T0, 0)).append({ + event: "retro_finished", + data: { duration_ms: 5, result: "stopped", thread_id: "t", n: 2 }, + }); + expect(readText(logFile(dataDir))).toBe( + lineAtT0("retro_finished", `{"n":2,"thread_id":"t","result":"stopped","duration_ms":5}`), + ); + }); + + it("append refuses an event name the doc does not list and writes nothing", () => { + const dataDir = tempDir(); + const log = open(dataDir, ticking(T0, 0)); + for (const name of ["merged", "toString", "__proto__", "claimed"]) { + const error = catchLedgerError( + () => log.append({ event: name, data: {} } as unknown as MachineEvent), + LedgerUnknownEventError, + ); + expect(error._tag).toBe("LedgerUnknownEventError"); + expect(error.event).toBe(name); + } + expect(readText(logFile(dataDir))).toBe(""); + }); + + it("two handles on one log interleave appends without overwriting", () => { + const dataDir = tempDir(); + const first = open(dataDir, ticking(T0, 0)); + const second = open(dataDir, ticking(T0, 0)); + first.append({ event: "pass_started", data: {} }); + second.append({ + event: "pass_finished", + data: { runs: 0, claimed: 0, errors: 0, attention: 0 }, + }); + first.append({ event: "pass_started", data: {} }); + expect(readText(logFile(dataDir)).split("\n")).toHaveLength(4); + }); + + it("a failed write gets a line break before the next event", () => { + const dataDir = tempDir(); + let failAfter: number | null = null; + const io: AppendIo = { + writeSync(fd, buffer) { + if (failAfter !== null) { + const keep = failAfter; + failAfter = null; + NodeFS.writeSync(fd, buffer.subarray(0, keep)); + throw new Error("injected disk error"); + } + return NodeFS.writeSync(fd, buffer); + }, + fsyncSync: (fd) => NodeFS.fsyncSync(fd), + }; + const log = open(dataDir, ticking(T0, 0), { io }); + log.append(passStarted); + failAfter = 10; + const error = catchLedgerError(() => log.append(passStarted), LedgerIoError); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(logFile(dataDir)); + log.append(passStarted); + log.append(passStarted); + const line = lineAtT0("pass_started", `{}`); + expect(readText(logFile(dataDir))).toBe(`${line}{"ts":"202\n${line}${line}`); + // The reader skips the fragment and gets the three whole lines. + expect(scanMachineLog(dataDir)).toHaveLength(3); + }); + + it("a line another handle's failed write cut off gets a line break before this handle's event", () => { + const dataDir = tempDir(); + let failAfter: number | null = null; + const io: AppendIo = { + writeSync(fd, buffer) { + if (failAfter !== null) { + const keep = failAfter; + failAfter = null; + NodeFS.writeSync(fd, buffer.subarray(0, keep)); + throw new Error("injected disk error"); + } + return NodeFS.writeSync(fd, buffer); + }, + fsyncSync: (fd) => NodeFS.fsyncSync(fd), + }; + const first = open(dataDir, ticking(T0, 0), { io }); + const second = open(dataDir, ticking(T0, 0)); + failAfter = 10; + expect(catchLedgerError(() => first.append(passStarted))._tag).toBe("LedgerIoError"); + second.append(passStarted); + const line = lineAtT0("pass_started", `{}`); + expect(readText(logFile(dataDir))).toBe(`{"ts":"202\n${line}`); + // The first handle still adds its own break after its failure, as softfac does: a blank line. + first.append(passStarted); + expect(readText(logFile(dataDir))).toBe(`{"ts":"202\n${line}\n${line}`); + expect(scanMachineLog(dataDir)).toHaveLength(2); + }); + + it("a failed sync gets a line break before the next event", () => { + const dataDir = tempDir(); + let failSync = true; + const io: AppendIo = { + writeSync: (fd, buffer) => NodeFS.writeSync(fd, buffer), + fsyncSync(fd) { + if (failSync) { + failSync = false; + throw new Error("injected disk error"); + } + NodeFS.fsyncSync(fd); + }, + }; + const log = open(dataDir, ticking(T0, 0), { io }); + expect(catchLedgerError(() => log.append(passStarted))._tag).toBe("LedgerIoError"); + log.append(passStarted); + const line = lineAtT0("pass_started", `{}`); + expect(readText(logFile(dataDir))).toBe(`${line}\n${line}`); + }); + + it("open without a clock stamps events with the system clock", () => { + const dataDir = tempDir(); + const before = Date.now(); + const log = MachineLog.open(dataDir); + handles.push(log); + log.append(passStarted); + const after = Date.now(); + const line = JSON.parse(readText(logFile(dataDir))) as { ms: number }; + expect(line.ms).toBeGreaterThanOrEqual(before); + expect(line.ms).toBeLessThanOrEqual(after); + }); +}); + +describe("scan machine log", () => { + const machineLine = (ms: number, event: string, note: string) => + `{"ts":"2026-10-04T10:13:16.123Z","ms":${ms},"event":"${event}","data":{"note":"${note}"}}\n`; + + it("scanMachineLog gives the lines oldest first", () => { + const dataDir = tempDir(); + const log = open(dataDir, ticking(T0, 1000)); + log.append(passStarted); + log.append({ event: "pass_finished", data: { runs: 1, claimed: 0, errors: 0, attention: 0 } }); + expect(scanMachineLog(dataDir)).toEqual([ + { ts: "2026-10-04T10:13:16.123Z", ms: T0, event: "pass_started", data: {} }, + { + ts: "2026-10-04T10:13:17.123Z", + ms: T0 + 1000, + event: "pass_finished", + data: { runs: 1, claimed: 0, errors: 0, attention: 0 }, + }, + ]); + }); + + it("scanMachineLog skips blank and unparsable lines and keeps a last line with no newline that parses", () => { + const dataDir = tempDir(); + NodeFS.writeFileSync( + logFile(dataDir), + [ + "\n\r\n", + machineLine(10, "pass_started", "a"), + "this is not json\n", + `{"ts":"x"}\n`, + "[]\n", + `{"ts":"t","ms":"1","event":"e"}\n`, + "\n", + machineLine(20, "pass_finished", "b"), + "\n\n", + // A line from a newer build with extra keys, and one with no data at all. + `{"ts":"t","ms":25,"event":"newer","data":{"k":1},"cause":{"step":"pass","reason":"x"},"extra":true}\n`, + `{"ts":"t","ms":26,"event":"bare"}\n`, + machineLine(30, "error", "c").trimEnd(), + ].join(""), + ); + expect(scanMachineLog(dataDir).map((entry) => [entry.ms, entry.event, entry.data])).toEqual([ + [10, "pass_started", { note: "a" }], + [20, "pass_finished", { note: "b" }], + [25, "newer", { k: 1 }], + [26, "bare", null], + [30, "error", { note: "c" }], + ]); + }); + + it("scanMachineLog skips a line cut off by a crash", () => { + const dataDir = tempDir(); + NodeFS.writeFileSync( + logFile(dataDir), + `${machineLine(10, "pass_started", "a")}${machineLine(40, "attention", "d").slice(0, 30)}`, + ); + expect(scanMachineLog(dataDir).map((entry) => entry.ms)).toEqual([10]); + }); + + it("scanMachineLog skips a line that is not UTF-8", () => { + const dataDir = tempDir(); + NodeFS.writeFileSync( + logFile(dataDir), + Buffer.concat([ + Buffer.from(machineLine(10, "pass_started", "a")), + Buffer.from([0xff, 0xfe, 0x0a]), + Buffer.from(machineLine(20, "pass_started", "b")), + ]), + ); + expect(scanMachineLog(dataDir).map((entry) => entry.ms)).toEqual([10, 20]); + }); + + it("scanMachineLog returns no lines for a missing or empty log", () => { + const dataDir = tempDir(); + expect(scanMachineLog(dataDir)).toEqual([]); + expect(scanMachineLog(NodePath.join(dataDir, "no", "such", "dir"))).toEqual([]); + NodeFS.writeFileSync(logFile(dataDir), ""); + expect(scanMachineLog(dataDir)).toEqual([]); + }); + + it("scanMachineLog refuses a symlink and a directory", () => { + const linked = tempDir(); + NodeFS.writeFileSync(NodePath.join(linked, "real"), machineLine(1, "pass_started", "a")); + NodeFS.symlinkSync(NodePath.join(linked, "real"), logFile(linked)); + const error = catchLedgerError(() => scanMachineLog(linked), LedgerIoError); + expect(error._tag).toBe("LedgerIoError"); + expect(error.path).toBe(logFile(linked)); + + const directory = tempDir(); + NodeFS.mkdirSync(logFile(directory)); + expect(catchLedgerError(() => scanMachineLog(directory))._tag).toBe("LedgerIoError"); + }); + + it("scanMachineLog refuses a pipe without waiting on it", () => { + const dataDir = tempDir(); + expect(NodeChildProcess.spawnSync("mkfifo", [logFile(dataDir)]).status).toBe(0); + expect(catchLedgerError(() => scanMachineLog(dataDir))._tag).toBe("LedgerIoError"); + }); +}); diff --git a/apps/server/src/factory/ledger/machineLog.ts b/apps/server/src/factory/ledger/machineLog.ts new file mode 100644 index 0000000000000..4c63bcea40ce9 --- /dev/null +++ b/apps/server/src/factory/ledger/machineLog.ts @@ -0,0 +1,190 @@ +// @effect-diagnostics nodeBuiltinImport:off - softfac's write rules need raw fds: O_NOFOLLOW, O_NONBLOCK and fsync. +// @effect-diagnostics globalDate:off - the default clock is the wall clock softfac stamps lines with. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + AppendFile, + ensureDir, + nodeIo, + openForAppend, + parentOf, + syncAt, + syncDir, + type AppendIo, + type SyncDir, +} from "./appendFile.ts"; +import { LedgerUnknownEventError, LedgerIoError, errnoCode } from "./errors.ts"; +import { MACHINE_EVENT_FIELDS, buildData, issueKeyJson, type MachineEvent } from "./events.ts"; +import { NEWLINE } from "./limits.ts"; +import { decodeLine, isBlankLine, lineHead } from "./read.ts"; +import { formatTs } from "./time.ts"; + +/** The file in the data dir that takes the events that belong to no run. */ +export const MACHINE_LOG = "events.jsonl"; + +const NOT_REGULAR = "not a regular file, so it is not followed"; + +export type MachineLogOptions = { + /** Returns the current time as Unix epoch milliseconds. Defaults to the system clock. */ + readonly clock?: () => number; + /** The write and fsync an append performs. Defaults to `node:fs`; a test injects failures. */ + readonly io?: AppendIo; + /** Flushes a directory to disk after the log is made. Defaults to an fsync. */ + readonly syncDir?: SyncDir; +}; + +/** + * The machine log: `/events.jsonl`, one line per event in the ledger's line format + * without a cause, and with the ledger's write rules: the file and a data dir made for it are + * private to the owner, each event is one write of the whole line followed by a sync, a line cut + * off by a crash or a failed write is ended before the next one, and a symlink or anything else + * that is not a regular file in its place is refused. It is appended to and never rewritten. + */ +export class MachineLog { + private readonly out: AppendFile; + private readonly clock: () => number; + + private constructor(out: AppendFile, clock: () => number) { + this.out = out; + this.clock = clock; + } + + /** + * Open the log for append, making it (and the data dir) if they are missing. The data dir and + * its parent are synced on every open, not only when this call made them, so an open that + * made them and then failed to sync leaves them for the retry to sync. + */ + static open(dataDir: string, options: MachineLogOptions = {}): MachineLog { + const path = NodePath.join(dataDir, MACHINE_LOG); + const sync = options.syncDir ?? syncDir; + try { + ensureDir(dataDir); + } catch (error) { + throw new LedgerIoError({ path: dataDir, cause: error }); + } + const io = options.io ?? nodeIo; + const file = existingRegularFile(path) ? openForAppend(path, io) : createLog(path, io); + try { + syncAt(sync, dataDir); + syncAt(sync, parentOf(dataDir)); + } catch (error) { + file.close(); + throw error; + } + return new MachineLog(file, options.clock ?? Date.now); + } + + /** + * Append one event as one line: a single write of the whole line, then a sync. Throws + * `UnknownEvent`, writing nothing, for an event name that ledger.md does not list. + */ + append(event: MachineEvent): void { + if (!Object.hasOwn(MACHINE_EVENT_FIELDS, event.event)) { + throw new LedgerUnknownEventError({ event: String(event.event) }); + } + const ms = this.clock(); + let line: string; + try { + // An issue key is written the way softfac's `IssueKey` serialises: a number or a string. + const issue = "issue" in event.data ? { issue: issueKeyJson(event.data.issue) } : {}; + const data = buildData(MACHINE_EVENT_FIELDS[event.event], event.data, issue); + line = `${JSON.stringify({ ts: formatTs(ms), ms, event: event.event, data })}\n`; + } catch (error) { + throw new LedgerIoError({ path: this.out.path, cause: error }); + } + this.out.writeLine(line); + } + + /** Release the file handle. Appending after this fails with `Io`. */ + close(): void { + this.out.close(); + } +} + +/** Make the log, owner-only. Read access lets an append check how the file ends. */ +const createLog = (path: string, io: AppendIo): AppendFile => { + try { + // The check before this and the open are two calls, so the open refuses a symlink itself. + const fd = NodeFS.openSync( + path, + NodeFS.constants.O_RDWR | + NodeFS.constants.O_APPEND | + NodeFS.constants.O_CREAT | + NodeFS.constants.O_NOFOLLOW, + 0o600, + ); + return new AppendFile(path, fd, io); + } catch (error) { + throw new LedgerIoError({ path, cause: error }); + } +}; + +/** True when `path` is a regular file, false when it is missing; anything else is refused. */ +const existingRegularFile = (path: string): boolean => { + let isFile: boolean; + try { + isFile = NodeFS.lstatSync(path).isFile(); + } catch (error) { + if (errnoCode(error) === "ENOENT") return false; + throw new LedgerIoError({ path, cause: error }); + } + if (!isFile) throw new LedgerIoError({ path, cause: new Error(NOT_REGULAR) }); + return true; +}; + +/** One line of the machine log as read back. The event and its data are as written. */ +export type MachineEntry = { + /** RFC 3339 UTC, as written in the file. */ + readonly ts: string; + /** Unix epoch milliseconds. */ + readonly ms: number; + readonly event: string; + /** `null` when the line has no `data`. */ + readonly data: unknown; +}; + +/** + * Every line of the machine log, oldest first. A line that is not valid (such as the last one, + * when a crash cut it off) is skipped, and a last line with no newline is still read if it + * parses. A missing file is an empty log. A symlink or anything else that is not a regular file + * is refused, as the log refuses to append to one: the file is opened without following a + * symlink and without blocking, and checked on the open handle, so a swap after the check is + * not followed. + */ +export const scanMachineLog = (dataDir: string): Array => { + const path = NodePath.join(dataDir, MACHINE_LOG); + let fd: number; + try { + fd = NodeFS.openSync( + path, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + ); + } catch (error) { + if (errnoCode(error) === "ENOENT") return []; + throw new LedgerIoError({ path, cause: error }); + } + let bytes: Buffer; + try { + if (!NodeFS.fstatSync(fd).isFile()) throw new Error(NOT_REGULAR); + bytes = NodeFS.readFileSync(fd); + } catch (error) { + throw new LedgerIoError({ path, cause: error }); + } finally { + NodeFS.closeSync(fd); + } + + const entries: Array = []; + let start = 0; + while (start < bytes.length) { + const newline = bytes.indexOf(NEWLINE, start); + const end = newline === -1 ? bytes.length : newline; + const line = bytes.subarray(start, end); + start = end + 1; + if (isBlankLine(line)) continue; + const object = decodeLine(line); + const head = object === null ? null : lineHead(object); + if (head !== null) entries.push(head); + } + return entries; +}; diff --git a/apps/server/src/factory/ledger/read.ts b/apps/server/src/factory/ledger/read.ts new file mode 100644 index 0000000000000..d92e8a0221b05 --- /dev/null +++ b/apps/server/src/factory/ledger/read.ts @@ -0,0 +1,154 @@ +// @effect-diagnostics nodeBuiltinImport:off - a run file is read the way softfac reads it: lstat first, then the bytes. +import * as NodeFS from "node:fs"; + +import { LedgerNotFoundError, LedgerIoError, errnoCode } from "./errors.ts"; +import { NEWLINE, isU32 } from "./limits.ts"; +import { runPath, type RunId } from "./runId.ts"; + +/** + * A cause as read back. Its step and reason are the words in the file, not the ones this build + * knows, so a cause from a newer build is read as it was written, extra keys included. + */ +export type ReadCause = { step: string; reason: string; [key: string]: unknown }; + +/** One line of a run, as read back: the event and its data are as written. */ +export type Entry = { + /** RFC 3339 UTC, as written in the file. */ + readonly ts: string; + /** Unix epoch milliseconds. */ + readonly ms: number; + readonly event: string; + /** `null` when the line has no `data`. */ + readonly data: unknown; + /** `null` for a line from before causes existed, and for a cause that is not an object with a step and a reason. */ + readonly cause: ReadCause | null; +}; + +/** A run read back from its file. */ +export type RunRecord = { + readonly id: RunId; + readonly entries: ReadonlyArray; + /** The last line was incomplete or not valid JSON, and was dropped. */ + readonly truncated: boolean; + /** Invalid lines that were not the last, and so were skipped. */ + readonly skipped: number; +}; + +const CARRIAGE_RETURN = 0x0d; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * An empty line: nothing, or just the `\r` of a CRLF ending. The repair after a failed write + * leaves one, and nothing was lost, so it is neither an entry nor damage. + */ +export const isBlankLine = (line: Uint8Array): boolean => + line.length === 0 || (line.length === 1 && line[0] === CARRIAGE_RETURN); + +// Strict UTF-8, and a byte order mark is a syntax error as it is for any JSON reader. +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** One line as a JSON object, or `null` when it is not UTF-8, not JSON or not an object. */ +export const decodeLine = (line: Uint8Array): Record | null => { + try { + const value: unknown = JSON.parse(decoder.decode(line)); + return isRecord(value) ? value : null; + } catch { + return null; + } +}; + +/** + * The keys every line has: a string `ts`, a whole non-negative number `ms` and a string `event`. + * A missing `data` reads as `null`. + */ +export const lineHead = ( + object: Record, +): { ts: string; ms: number; event: string; data: unknown } | null => { + const { ts, ms, event, data } = object; + if (typeof ts !== "string" || typeof event !== "string") return null; + if (typeof ms !== "number" || !Number.isSafeInteger(ms) || ms < 0) return null; + return { ts, ms, event, data: data === undefined ? null : data }; +}; + +const readCause = (value: unknown): ReadCause | null => { + if (!isRecord(value)) return null; + const { step, reason, actor, command, attempt, label } = value; + if (typeof step !== "string" || typeof reason !== "string") return null; + for (const text of [actor, command, label]) { + if (text !== undefined && text !== null && typeof text !== "string") return null; + } + if (attempt !== undefined && attempt !== null && !isU32(attempt)) return null; + return value as ReadCause; +}; + +const parseLine = (line: Uint8Array): Entry | null => { + const object = decodeLine(line); + const head = object === null ? null : lineHead(object); + if (object === null || head === null) return null; + return { ...head, cause: readCause(object.cause) }; +}; + +/** + * Read a run file's bytes into entries. Never fails on a bad line: an incomplete or invalid last + * line is dropped and flagged, and any other invalid line is skipped and counted. Empty lines + * are ignored. + */ +export const parseLines = ( + bytes: Uint8Array, +): { entries: Array; truncated: boolean; skipped: number } => { + const completeLength = bytes.lastIndexOf(NEWLINE) + 1; + const lines: Array = []; + let start = 0; + while (start < completeLength) { + const end = bytes.indexOf(NEWLINE, start); + const line = bytes.subarray(start, end); + if (!isBlankLine(line)) lines.push(parseLine(line)); + start = end + 1; + } + + let truncated = false; + if (!isBlankLine(bytes.subarray(completeLength))) { + truncated = true; + } else if (lines.length > 0 && lines[lines.length - 1] === null) { + truncated = true; + lines.pop(); + } + const skipped = lines.filter((line) => line === null).length; + return { entries: lines.filter((line) => line !== null), truncated, skipped }; +}; + +/** + * Refuse anything at a run's path that is not a regular file: a symlink or directory named like + * a run is reported as not found, so nothing ever reads (or appends to) some other file. The + * check and what follows are separate calls, so a swap in between is not caught; the data + * directory is private to its owner, which is who could make that swap. + */ +export const requireRegularFile = (path: string): void => { + let isFile: boolean; + try { + isFile = NodeFS.lstatSync(path).isFile(); + } catch (error) { + if (errnoCode(error) === "ENOENT") throw new LedgerNotFoundError({ path }); + throw new LedgerIoError({ path, cause: error }); + } + if (!isFile) throw new LedgerNotFoundError({ path }); +}; + +/** Read a run file's bytes, once `requireRegularFile` accepts it. */ +const readRunBytes = (path: string): Buffer => { + requireRegularFile(path); + try { + return NodeFS.readFileSync(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") throw new LedgerNotFoundError({ path }); + throw new LedgerIoError({ path, cause: error }); + } +}; + +/** Read a run back. A damaged line never fails the read; see `RunRecord`. */ +export const readRun = (dataDir: string, id: RunId): RunRecord => { + const bytes = readRunBytes(runPath(dataDir, id)); + return { id, ...parseLines(bytes) }; +}; diff --git a/apps/server/src/factory/ledger/runId.test.ts b/apps/server/src/factory/ledger/runId.test.ts new file mode 100644 index 0000000000000..76fe4b4cc4c0e --- /dev/null +++ b/apps/server/src/factory/ledger/runId.test.ts @@ -0,0 +1,352 @@ +// @effect-diagnostics nodeBuiltinImport:off - the tests lay out real run files to list. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { InvalidRunIdError } from "./errors.ts"; +import { + issueRunPart, + newRunId, + nextAttempt, + parseIssueKey, + parseRunId, + runIdFileName, + runIdFromFileName, + runIdToString, + validateRunId, + type RunId, +} from "./runId.ts"; +import { catchLedgerError, useTempDirs } from "./testSupport.ts"; + +const tempDir = useTempDirs(); + +const runId = (repo: string, issue: string, attempt: number): RunId => ({ repo, issue, attempt }); + +const expectInvalid = (run: () => unknown, input: string) => { + const error = catchLedgerError(run, InvalidRunIdError); + expect(error._tag).toBe("InvalidRunIdError"); + expect(error.input).toBe(input); +}; + +describe("run id", () => { + it("run id converts to and from its string", () => { + const id = runId("rawphp/softfac", "8", 1); + expect(runIdToString(id)).toBe("rawphp__softfac__8__1"); + expect(runIdFileName(id)).toBe("rawphp__softfac__8__1.jsonl"); + expect(parseRunId("rawphp__softfac__8__1")).toEqual(id); + expect(newRunId("rawphp/softfac", "8", 1)).toEqual(id); + }); + + it("run id round trips dots, dashes and large numbers", () => { + const id = runId("my-org/my.repo-2", "18446744073709551615", 4294967295); + expect(runIdToString(id)).toBe("my-org__my.repo-2__18446744073709551615__4294967295"); + expect(parseRunId(runIdToString(id))).toEqual(id); + }); + + it("run id new rejects bad repos", () => { + for (const repo of [ + "", + "softfac", + "rawphp/", + "/softfac", + "a/b/c", + "a\\b/c", + "a/b c", + "a/b\\c", + "a/b\0", + "a/b\u001b", + "a/bé", + "a/b~", + "a/b:c", + "a/b*", + "../x", + "x/..", + "./x", + "../../etc/passwd", + "a b/c", + "_tom/x", + "tom_/x", + "to__m/x", + "tom_tc_/x", + "my.org/x", + "a/.", + "a/b\n", + "a\0/b", + "raé/b", + ]) { + expectInvalid(() => newRunId(repo, "1", 1), repo); + } + }); + + it("run id new rejects a bad issue or attempt", () => { + expectInvalid(() => newRunId("a/b", "08", 1), "08"); + expectInvalid(() => newRunId("a/b", "18446744073709551616", 1), "18446744073709551616"); + expect(catchLedgerError(() => newRunId("a/b", "1", 4294967296))._tag).toBe("InvalidRunIdError"); + expect(catchLedgerError(() => newRunId("a/b", "1", -1))._tag).toBe("InvalidRunIdError"); + expect(catchLedgerError(() => newRunId("a/b", "1", 1.5))._tag).toBe("InvalidRunIdError"); + expect(newRunId("a/b", "eng-42", 0)).toEqual(runId("a/b", "ENG-42", 0)); + }); + + it("run id validate rejects a hand built bad id", () => { + expectInvalid(() => validateRunId(runId("../x", "1", 1)), "../x"); + expect(() => validateRunId(runId("a/b", "1", 1))).not.toThrow(); + // A hand-built id must already be in the lowercase form `newRunId` produces. + expectInvalid(() => validateRunId(runId("Rawphp/softfac", "1", 1)), "Rawphp/softfac"); + // The issue and attempt end up in a file name too. + expectInvalid(() => validateRunId(runId("a/b", "../x", 1)), "../x"); + expect(catchLedgerError(() => validateRunId(runId("a/b", "1", -1)))._tag).toBe( + "InvalidRunIdError", + ); + }); + + it("run id round trips an owner with an interior underscore", () => { + // GitHub Enterprise Managed User logins look like `handle_shortcode`. + for (const [repo, file] of [ + ["tom_tc/softfac", "tom_tc__softfac__8__1.jsonl"], + ["tom_tc/a__b", "tom_tc__a__b__8__1.jsonl"], + ["a_b_c/_x_", "a_b_c___x___8__1.jsonl"], + ] as const) { + const id = newRunId(repo, "8", 1); + expect(id.repo).toBe(repo); + expect(runIdFileName(id)).toBe(file); + expect(runIdFromFileName(file)).toEqual(id); + expect(parseRunId(runIdToString(id))).toEqual(id); + } + }); + + it("run ids for awkward owners and repos are distinct and parse back", () => { + const combos: ReadonlyArray = [ + ["tom_tc", "softfac", "8", 1], + ["tom_tc", "a__b", "8", 1], + ["tom", "_x", "8", 1], + ["tom", "x_", "8", 1], + ["tom", "__", "8", 1], + ["tom", "_", "8", 1], + ["tom", "8__1", "8", 1], + ["tom", "8", "1", 8], + ["tom", "8__1", "1", 8], + ["tom_8", "1__2", "3", 4], + ["tom", "1__2", "3", 4], + ["a_b_c", "d_e__f_", "12", 34], + ["a", "b__c", "1", 1], + ["a_b", "c", "1", 1], + ["a-b", "c.d-e_f", "0", 0], + ["x", "y", "18446744073709551615", 4294967295], + ]; + const seen = new Set(); + for (const [owner, name, issue, attempt] of combos) { + const id = newRunId(`${owner}/${name}`, issue, attempt); + const text = runIdToString(id); + expect(parseRunId(text), text).toEqual(id); + expect(runIdFromFileName(runIdFileName(id)), text).toEqual(id); + expect(seen.has(text), `${text} is not distinct`).toBe(false); + seen.add(text); + } + expect(seen.size).toBe(combos.length); + }); + + it("run id round trips underscores in the repo name", () => { + for (const [repo, file] of [ + ["rawphp/my_app", "rawphp__my_app__8__1.jsonl"], + ["rawphp/_private", "rawphp___private__8__1.jsonl"], + ["rawphp/trailing_", "rawphp__trailing___8__1.jsonl"], + ["rawphp/a__b", "rawphp__a__b__8__1.jsonl"], + ["rawphp/a__8", "rawphp__a__8__8__1.jsonl"], + ["rawphp/__", "rawphp______8__1.jsonl"], + ["rawphp/softfac__8__1", "rawphp__softfac__8__1__8__1.jsonl"], + ] as const) { + const id = newRunId(repo, "8", 1); + expect(id.repo).toBe(repo); + expect(runIdFileName(id), repo).toBe(file); + expect(runIdFromFileName(file), repo).toEqual(id); + expect(parseRunId(runIdToString(id)), repo).toEqual(id); + } + }); + + it("run id lowercases so one repo is one id and one file", () => { + const mixed = newRunId("Rawphp/SoftFac", "8", 1); + const lower = newRunId("rawphp/softfac", "8", 1); + expect(mixed).toEqual(lower); + expect(mixed.repo).toBe("rawphp/softfac"); + expect(runIdFileName(mixed)).toBe("rawphp__softfac__8__1.jsonl"); + expect(parseRunId("Rawphp__SoftFac__8__1")).toEqual(lower); + expect(runIdFileName(newRunId("Rawphp/My__App", "8", 1))).toBe("rawphp__my__app__8__1.jsonl"); + expect(parseRunId("RAWPHP__My__App__8__1")).toEqual(newRunId("rawphp/my__app", "8", 1)); + }); + + it("run id lowercases ASCII only, so a lookalike capital is not folded into a legal name", () => { + // U+212A KELVIN SIGN lowercases to an ASCII `k` under Unicode rules. + expectInvalid(() => newRunId("Kelvin/x", "1", 1), "Kelvin/x"); + }); + + it("run id from file name ignores names that are not the canonical file name", () => { + expect(runIdFromFileName("Rawphp__SoftFac__8__1.jsonl")).toBeNull(); + expect(runIdFromFileName("rawphp__softfac__8__1.JSONL")).toBeNull(); + }); + + it("a linear run id round trips and an old numeric one still parses", () => { + const id = newRunId("Acme/Widgets", "ENG-42", 1); + expect(runIdToString(id)).toBe("acme__widgets__eng-42__1"); + expect(parseRunId("acme__widgets__eng-42__1")).toEqual(id); + expect(parseRunId("rawphp__softfac__8__1").issue).toBe("8"); + }); + + it("a run file with a capital in the issue part is not one of ours", () => { + expect(runIdFromFileName("acme__widgets__ENG-42__1.jsonl")).toBeNull(); + expect(runIdFromFileName("acme__widgets__Eng-42__1.jsonl")).toBeNull(); + // The same guard when the id comes from the command line, not a file name. + expect(() => parseRunId("acme__widgets__ENG-42__1")).toThrow(); + expect(() => parseRunId("acme__widgets__Eng-42__1")).toThrow(); + expect(() => parseRunId("acme__widgets__eng-42__1")).not.toThrow(); + }); + + it("run id parse rejects anything else", () => { + for (const input of [ + "", + "rawphp__softfac__8", + "_tom__x__8__1", + "tom___x__8", + "my.org__softfac__8__1", + "rawphp__softfac__8__1__x", + "rawphp__soft__fac__8", + "rawphp__.__8__1", + "rawphp____8", + "rawphp__softfac__x__1", + "rawphp__softfac__8__y", + "rawphp__softfac__8__1x", + "rawphp__softfac__+8__1", + "rawphp__softfac__-8__1", + "rawphp__softfac__08__1", + "rawphp__softfac__8__01", + "rawphp__softfac__8__", + "rawphp__softfac____1", + "__softfac__8__1", + "rawphp____8__1", + "..__softfac__8__1", + "rawphp__..__8__1", + "rawphp/softfac__8__1", + "rawphp__softfac__8__1.jsonl", + "rawphp__softfac__8__1 ", + "rawphp__softfac__8__1\n", + "rawphp__softfac__99999999999999999999__1", + "rawphp__softfac__8__4294967296", + "../../etc/passwd", + "/etc/passwd", + "..\\..\\x__y__1__1", + "rawphp__b c__8__1", + "rawphp__b\\c__8__1", + "rawphp__b\0__8__1", + "rawphp__b\u001b__8__1", + "rawphp__bé__8__1", + "rawphp__b~__8__1", + "rawphp__b:c__8__1", + "rawphp__b*__8__1", + ]) { + expectInvalid(() => parseRunId(input), input); + } + }); + + it("run id from file name needs the jsonl extension", () => { + expect(runIdFromFileName("rawphp__softfac__8__1.jsonl")).toEqual( + runId("rawphp/softfac", "8", 1), + ); + expect(runIdFromFileName("rawphp__softfac__8__1")).toBeNull(); + expect(runIdFromFileName("rawphp__softfac__8__1.txt")).toBeNull(); + expect(runIdFromFileName("notes.jsonl")).toBeNull(); + expect(runIdFromFileName(".jsonl")).toBeNull(); + }); + + it("invalid run id error names the input and the reason", () => { + const error = catchLedgerError(() => parseRunId("nope")); + expect(error.message).toBe( + "invalid run id `nope`: expected ______", + ); + }); +}); + +describe("issue key", () => { + it("a github number parses and prints as itself", () => { + expect(parseIssueKey("8")).toBe("8"); + expect(issueRunPart("8")).toBe("8"); + expect(parseIssueKey("0")).toBe("0"); + expect(parseIssueKey("18446744073709551615")).toBe("18446744073709551615"); + }); + + it("a linear identifier is kept in capitals and lowercased only for the run id", () => { + for (const input of ["ENG-42", "eng-42", "Eng-42"]) { + expect(parseIssueKey(input), input).toBe("ENG-42"); + } + expect(issueRunPart("ENG-42")).toBe("eng-42"); + }); + + it("what is neither a number nor an identifier is refused", () => { + for (const input of [ + "", + "08", + "+8", + "-1", + "ENG", + "ENG-", + "ENG-08", + "a__b-1", + "ENG/1", + "ENG-1-2", + "8 ", + "8\n", + // A team key starts with a letter. + "8-1", + "1A-2", + // One past the largest number. + "18446744073709551616", + "ENG-18446744073709551616", + ]) { + expectInvalid(() => parseIssueKey(input), input); + } + }); +}); + +describe("next attempt", () => { + it("next attempt is one when there are no runs", () => { + expect(nextAttempt(tempDir(), "rawphp/softfac", "8")).toBe(1); + }); + + it("next attempt is one past the highest for that repo and issue", () => { + const dataDir = tempDir(); + const runs = NodePath.join(dataDir, "runs"); + NodeFS.mkdirSync(runs); + for (const name of [ + "rawphp__softfac__8__1.jsonl", + "rawphp__softfac__8__2.jsonl", + "rawphp__softfac__8__4.jsonl", + "rawphp__softfac__9__7.jsonl", + "rawphp__my_app__8__3.jsonl", + "rawphp__my__app__8__6.jsonl", + "rawphp__other__8__9.jsonl", + "someone__softfac__8__9.jsonl", + "notes.txt", + "rawphp__softfac__8__x.jsonl", + ]) { + NodeFS.writeFileSync(NodePath.join(runs, name), ""); + } + expect(nextAttempt(dataDir, "rawphp/softfac", "8")).toBe(5); + expect(nextAttempt(dataDir, "rawphp/softfac", "9")).toBe(8); + expect(nextAttempt(dataDir, "rawphp/softfac", "10")).toBe(1); + expect(nextAttempt(dataDir, "rawphp/other", "8")).toBe(10); + expect(nextAttempt(dataDir, "rawphp/my_app", "8")).toBe(4); + expect(nextAttempt(dataDir, "Rawphp/My__App", "8")).toBe(7); + expect(nextAttempt(dataDir, "RAWPHP/SoftFac", "8")).toBe(5); + expect(nextAttempt(dataDir, "../x", "8")).toBe(1); + }); + + it("next attempt matches a linear identifier in any case and never passes the largest attempt", () => { + const dataDir = tempDir(); + const runs = NodePath.join(dataDir, "runs"); + NodeFS.mkdirSync(runs); + NodeFS.writeFileSync(NodePath.join(runs, "acme__widgets__eng-42__3.jsonl"), ""); + NodeFS.writeFileSync(NodePath.join(runs, "acme__widgets__8__4294967295.jsonl"), ""); + expect(nextAttempt(dataDir, "acme/widgets", "eng-42")).toBe(4); + expect(nextAttempt(dataDir, "acme/widgets", "ENG-42")).toBe(4); + expect(nextAttempt(dataDir, "acme/widgets", "8")).toBe(4294967295); + }); +}); diff --git a/apps/server/src/factory/ledger/runId.ts b/apps/server/src/factory/ledger/runId.ts new file mode 100644 index 0000000000000..155bf0985e582 --- /dev/null +++ b/apps/server/src/factory/ledger/runId.ts @@ -0,0 +1,207 @@ +// @effect-diagnostics nodeBuiltinImport:off - nextAttempt lists the runs directory synchronously, like the rest of the ledger. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { InvalidRunIdError } from "./errors.ts"; +import { U32_MAX } from "./limits.ts"; + +/** The directory under the data dir that holds the run files. */ +export const RUNS_DIR = "runs"; +const EXTENSION = "jsonl"; + +const SHAPE = "expected ______"; +const REPO_REASON = + "the repo must be /: the owner uses letters, digits and `-` plus single `_` between them, the name uses letters, digits, `.`, `_` and `-` and is not `.` or `..`"; +const ISSUE_REASON = + "the issue must be a number without a sign or leading zero (8), or a team key, a hyphen and such a number (ENG-42)"; +const ATTEMPT_REASON = + "the attempt must be a number without a sign or leading zero, up to 4294967295"; + +const U64_MAX = 18446744073709551615n; + +/** + * One run: a repo, an issue in it, and which attempt at that issue this is. + * `repo` is lowercase `owner/name`. GitHub names are case-insensitive, and so are macOS file + * systems, so one repo must be one run id and one file. `issue` is as softfac's IssueKey stores + * it: a canonical number (`"8"`) or a Linear identifier in capitals (`"ENG-42"`). + */ +export type RunId = { + readonly repo: string; + readonly issue: string; + readonly attempt: number; +}; + +/** Lowercase the ASCII letters only: `toLowerCase` would fold U+212A (Kelvin) into a `k`. */ +const asciiLower = (text: string) => text.replace(/[A-Z]/g, (c) => c.toLowerCase()); + +/** A number the way we write it: digits, no sign, no leading zeros, no padding. */ +const CANONICAL = /^(?:0|[1-9][0-9]*)$/; +const canonicalU64 = (text: string) => CANONICAL.test(text) && BigInt(text) <= U64_MAX; +const canonicalU32 = (text: string): number | null => + CANONICAL.test(text) && Number(text) <= U32_MAX ? Number(text) : null; + +/** + * `8`, `ENG-42` or `eng-42`. A number must be canonical and fit an unsigned 64-bit integer; an + * identifier is a team key (letters and digits, starting with a letter), a hyphen, then such a + * number, stored in capitals. + */ +export const parseIssueKey = (text: string): string => { + if (canonicalU64(text)) return text; + const hyphen = text.indexOf("-"); + if (hyphen !== -1) { + const team = text.slice(0, hyphen); + const number = text.slice(hyphen + 1); + if (/^[A-Za-z][A-Za-z0-9]*$/.test(team) && canonicalU64(number)) { + return `${asciiLower(team).toUpperCase()}-${number}`; + } + } + throw new InvalidRunIdError({ input: text, reason: ISSUE_REASON }); +}; + +/** `8` or `eng-42`: the run id field. Never holds `_`, `/` or a capital. */ +export const issueRunPart = (issue: string): string => asciiLower(issue); + +/** + * Lowercase `owner/name` after checking both parts. GitHub owners use `[A-Za-z0-9-]`, and + * Enterprise Managed User logins add a `_` (`handle_shortcode`), so an owner may hold single + * underscores between other characters: none first, none last, never `__`. That is what lets + * `parseRunId` end the owner at the first `__`. Repo names use `[A-Za-z0-9._-]`. + */ +const normaliseRepo = (input: string): string | null => { + const repo = asciiLower(input); + const slash = repo.indexOf("/"); + if (slash === -1) return null; + const owner = repo.slice(0, slash); + const name = repo.slice(slash + 1); + const ownerOk = + owner.length > 0 && + !owner.startsWith("_") && + !owner.endsWith("_") && + !owner.includes("__") && + /^[a-z0-9_-]+$/.test(owner); + const nameOk = name !== "." && name !== ".." && /^[a-z0-9._-]+$/.test(name); + return ownerOk && nameOk ? repo : null; +}; + +/** A run id for `owner/name`, lowercased. Throws for a repo that is not a legal GitHub one. */ +export const newRunId = (repo: string, issue: string, attempt: number): RunId => { + const normalised = normaliseRepo(repo); + if (normalised === null) throw new InvalidRunIdError({ input: repo, reason: REPO_REASON }); + if (!Number.isInteger(attempt) || attempt < 0 || attempt > U32_MAX) { + throw new InvalidRunIdError({ input: String(attempt), reason: ATTEMPT_REASON }); + } + return { repo: normalised, issue: parseIssueKey(issue), attempt }; +}; + +/** + * Check the id is already in the form `newRunId` produces: lowercase `owner/name`, the owner + * in `[a-z0-9-]` with single interior `_`, and the name in `[a-z0-9._-]` but not `.` or `..`, + * and an issue and attempt that are canonical. That is what keeps the run id from escaping the + * runs directory. + */ +export const validateRunId = (id: RunId): void => { + if (normaliseRepo(id.repo) !== id.repo) + throw new InvalidRunIdError({ input: id.repo, reason: REPO_REASON }); + let issue: string | null = null; + try { + issue = parseIssueKey(id.issue); + } catch { + // Reported below. + } + if (issue !== id.issue) throw new InvalidRunIdError({ input: id.issue, reason: ISSUE_REASON }); + if (!Number.isInteger(id.attempt) || id.attempt < 0 || id.attempt > U32_MAX) { + throw new InvalidRunIdError({ input: String(id.attempt), reason: ATTEMPT_REASON }); + } +}; + +/** + * Parse `______`, lowercasing the owner and repo. + * + * The owner never holds `__`, so it ends at the first `__`. The last two fields are canonical + * numbers, so they are taken from the right. Whatever is left in the middle is the repo name, + * which may itself contain `__`. That makes the split unambiguous. + */ +export const parseRunId = (text: string): RunId => { + const bad = (reason: string) => new InvalidRunIdError({ input: text, reason }); + const first = text.indexOf("__"); + if (first === -1) throw bad(SHAPE); + const owner = text.slice(0, first); + const afterOwner = text.slice(first + 2); + const lastA = afterOwner.lastIndexOf("__"); + if (lastA === -1) throw bad(SHAPE); + const beforeAttempt = afterOwner.slice(0, lastA); + const attempt = canonicalU32(afterOwner.slice(lastA + 2)); + const lastB = beforeAttempt.lastIndexOf("__"); + if (lastB === -1) throw bad(SHAPE); + const name = beforeAttempt.slice(0, lastB); + const issueText = beforeAttempt.slice(lastB + 2); + if (attempt === null) throw bad(SHAPE); + if (issueText !== asciiLower(issueText)) throw bad(SHAPE); + let issue: string; + try { + issue = parseIssueKey(issueText); + } catch { + throw bad(SHAPE); + } + const repo = normaliseRepo(`${owner}/${name}`); + if (repo === null) throw bad(REPO_REASON); + return { repo, issue, attempt }; +}; + +/** ``: `owner__name__issue__attempt`. */ +export const runIdToString = (id: RunId): string => { + const slash = id.repo.indexOf("/"); + const owner = slash === -1 ? id.repo : id.repo.slice(0, slash); + const name = slash === -1 ? "" : id.repo.slice(slash + 1); + return `${owner}__${name}__${issueRunPart(id.issue)}__${id.attempt}`; +}; + +/** `.jsonl`. */ +export const runIdFileName = (id: RunId): string => `${runIdToString(id)}.${EXTENSION}`; + +/** + * The run id for a file name, or `null` if the name is not exactly `.jsonl` in the + * form this code writes (so a file with capitals in its name is not one of ours). + */ +export const runIdFromFileName = (name: string): RunId | null => { + const suffix = `.${EXTENSION}`; + if (!name.endsWith(suffix)) return null; + let id: RunId; + try { + id = parseRunId(name.slice(0, -suffix.length)); + } catch { + return null; + } + return runIdFileName(id) === name ? id : null; +}; + +/** + * `/runs/.jsonl`, after checking the run id is safe to use as a file name. + */ +export const runPath = (dataDir: string, id: RunId): string => { + validateRunId(id); + return NodePath.join(dataDir, RUNS_DIR, runIdFileName(id)); +}; + +/** + * The attempt number for a new run of this issue: one past the highest existing attempt, or 1. + * The repo is matched case-insensitively. A runs directory that cannot be read counts as + * empty; `Ledger.create` refuses to overwrite. + */ +export const nextAttempt = (dataDir: string, repo: string, issue: string): number => { + const wanted = asciiLower(repo); + const wantedIssue = parseIssueKey(issue); + let names: Array; + try { + names = NodeFS.readdirSync(NodePath.join(dataDir, RUNS_DIR)); + } catch { + return 1; + } + let highest: number | null = null; + for (const name of names) { + const id = runIdFromFileName(name); + if (id === null || id.repo !== wanted || id.issue !== wantedIssue) continue; + if (highest === null || id.attempt > highest) highest = id.attempt; + } + return highest === null ? 1 : Math.min(highest + 1, U32_MAX); +}; diff --git a/apps/server/src/factory/ledger/testSupport.ts b/apps/server/src/factory/ledger/testSupport.ts new file mode 100644 index 0000000000000..05593a4611c16 --- /dev/null +++ b/apps/server/src/factory/ledger/testSupport.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off - the tests make and remove real temp directories. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach } from "vite-plus/test"; + +import { isLedgerError, type LedgerError } from "./errors.ts"; + +/** + * Temp directories for one test file: each call makes a fresh empty directory, and all of them + * are removed after every test. Call it once at the top of a `describe`. + */ +export const useTempDirs = () => { + const dirs: Array = []; + afterEach(() => { + for (const dir of dirs.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); + }); + return (name = "t"): string => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `factory-ledger-${name}-`)); + dirs.push(dir); + return dir; + }; +}; + +/** + * The ledger error that `run` throws, checked to be an `expected` when one is given; fails the + * test when it throws nothing or something else. + */ +export function catchLedgerError(run: () => unknown): LedgerError; +export function catchLedgerError(run: () => unknown, expected: new (...args: never) => E): E; +export function catchLedgerError( + run: () => unknown, + expected?: new (...args: never) => unknown, +): unknown { + try { + run(); + } catch (error) { + if (expected ? error instanceof expected : isLedgerError(error)) return error; + throw error; + } + throw new Error("expected a ledger error, but nothing was thrown"); +} + +/** A clock that starts at `start` and moves on by `step` ms every time it is read. */ +export const ticking = (start: number, step: number): (() => number) => { + let now = start; + return () => { + const value = now; + now += step; + return value; + }; +}; + +/** A clock that returns the given times in order, and throws if it is read more often. */ +export const scripted = (times: ReadonlyArray): (() => number) => { + const queue = [...times]; + return () => { + const next = queue.shift(); + if (next === undefined) throw new Error("clock read too often"); + return next; + }; +}; + +/** 2026-10-04T10:13:16.123Z */ +export const T0 = 1791108796123; diff --git a/apps/server/src/factory/ledger/time.test.ts b/apps/server/src/factory/ledger/time.test.ts new file mode 100644 index 0000000000000..126712cc67eef --- /dev/null +++ b/apps/server/src/factory/ledger/time.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatTs, parseTs } from "./time.ts"; + +describe("format ts", () => { + it("format ts epoch", () => { + expect(formatTs(0)).toBe("1970-01-01T00:00:00.000Z"); + }); + + it("format ts known instants", () => { + expect(formatTs(1791108796123)).toBe("2026-10-04T10:13:16.123Z"); + expect(formatTs(946684799999)).toBe("1999-12-31T23:59:59.999Z"); + expect(formatTs(4107542401005)).toBe("2100-03-01T00:00:01.005Z"); + }); + + it("format ts leap days", () => { + expect(formatTs(1709210096789)).toBe("2024-02-29T12:34:56.789Z"); + expect(formatTs(951782400000)).toBe("2000-02-29T00:00:00.000Z"); + }); +}); + +describe("parse ts", () => { + it("parse ts is the inverse of format ts", () => { + for (const ms of [0, 1791108796123, 946684799999, 4107542401005, 1709210096789, 951782400000]) { + expect(parseTs(formatTs(ms))).toBe(ms); + } + }); + + it("parse ts takes no fraction or a short one", () => { + expect(parseTs("1970-01-01T00:00:00Z")).toBe(0); + expect(parseTs("2026-10-04T10:13:16Z")).toBe(1791108796000); + expect(parseTs("2026-10-04T10:13:16.1Z")).toBe(1791108796100); + expect(parseTs("2026-10-04T10:13:16.12Z")).toBe(1791108796120); + // A leap day: 2000 is a leap year, being divisible by 400. + expect(formatTs(951782400000)).toBe("2000-02-29T00:00:00.000Z"); + expect(parseTs("2000-02-29T00:00:00Z")).toBe(951782400000); + }); + + it("parse ts refuses what format ts never writes", () => { + for (const text of [ + "", + "garbage", + "2026-10-04T10:13:16+10:00", + "2026-10-04T10:13:16.123+00:00", + "2026-10-04T10:13:16", + "2026-10-04 10:13:16Z", + "2026-10-04T10:13:16.Z", + "2026-10-04T10:13:16.1234Z", + "2026-13-04T10:13:16Z", + "2026-10-32T10:13:16Z", + "2026-10-04T24:13:16Z", + "2026-10-04T10:60:16Z", + "2026-10-04T10:13:16Z\n", + // 2100 is a century year not divisible by 400, and 2026 is a common year. + "2100-02-29T00:00:00Z", + "2026-02-29T00:00:00Z", + // Not ASCII digits, or a sign where a digit belongs. + "2026-10-04T10:13:1٦Z", + "+026-10-04T10:13:16Z", + // Before the epoch is not a Unix millisecond count. + "1969-12-31T23:59:59Z", + ]) { + expect(parseTs(text), JSON.stringify(text)).toBeNull(); + } + }); +}); diff --git a/apps/server/src/factory/ledger/time.ts b/apps/server/src/factory/ledger/time.ts new file mode 100644 index 0000000000000..490aefec186b1 --- /dev/null +++ b/apps/server/src/factory/ledger/time.ts @@ -0,0 +1,76 @@ +/** Floor division, so dates before the epoch come out right too. */ +const floorDiv = (a: number, b: number) => Math.floor(a / b); + +const pad = (value: number, width: number) => String(value).padStart(width, "0"); + +/** Format Unix epoch milliseconds as RFC 3339 UTC with millisecond precision. */ +export const formatTs = (ms: number): string => { + const secs = floorDiv(ms, 1000); + const millis = ms - secs * 1000; + const dayNumber = floorDiv(secs, 86_400); + const secsOfDay = secs - dayNumber * 86_400; + const hour = floorDiv(secsOfDay, 3_600); + const minute = floorDiv(secsOfDay % 3_600, 60); + const second = secsOfDay % 60; + // Days since 1970-01-01 to a civil date (Howard Hinnant's `civil_from_days`). + const z = dayNumber + 719_468; + const era = floorDiv(z, 146_097); + const dayOfEra = z - era * 146_097; + const yearOfEra = floorDiv( + dayOfEra - floorDiv(dayOfEra, 1_460) + floorDiv(dayOfEra, 36_524) - floorDiv(dayOfEra, 146_096), + 365, + ); + const dayOfYear = + dayOfEra - (365 * yearOfEra + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100)); + const mp = floorDiv(5 * dayOfYear + 2, 153); + const day = dayOfYear - floorDiv(153 * mp + 2, 5) + 1; + const month = mp < 10 ? mp + 3 : mp - 9; + const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0); + return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}T${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}.${pad(millis, 3)}Z`; +}; + +const TIMESTAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})$/; + +/** + * Unix epoch milliseconds from RFC 3339 UTC as `formatTs` writes it: `YYYY-MM-DDTHH:MM:SSZ`, + * with a fraction of one to three digits before the `Z` when there is one. Anything else, such + * as an offset in place of the `Z`, a date that is not on the calendar, a trailing newline or a + * time before the epoch, is `null`. + */ +export const parseTs = (text: string): number | null => { + if (!text.endsWith("Z")) return null; + const body = text.slice(0, -1); + const dot = body.indexOf("."); + const whole = dot === -1 ? body : body.slice(0, dot); + const fraction = dot === -1 ? null : body.slice(dot + 1); + const match = TIMESTAMP.exec(whole); + if (match === null) return null; + const [year, month, day, hour, minute, second] = match.slice(1).map(Number) as [ + number, + number, + number, + number, + number, + number, + ]; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + if (monthDays === undefined) return null; + if (day < 1 || day > monthDays || hour > 23 || minute > 59 || second > 59) return null; + let millis = 0; + if (fraction !== null) { + if (!/^[0-9]{1,3}$/.test(fraction)) return null; + // `.1` is a tenth of a second: pad on the right to three digits. + millis = Number(fraction.padEnd(3, "0")); + } + // A civil date to days since 1970-01-01 (Howard Hinnant's `days_from_civil`). + const y = year - (month <= 2 ? 1 : 0); + const era = floorDiv(y, 400); + const yearOfEra = y - era * 400; + const mp = (month + 9) % 12; + const dayOfYear = floorDiv(153 * mp + 2, 5) + day - 1; + const dayOfEra = yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear; + const days = era * 146_097 + dayOfEra - 719_468; + const ms = (days * 86_400 + hour * 3_600 + minute * 60 + second) * 1000 + millis; + return ms >= 0 ? ms : null; +}; diff --git a/apps/server/src/factory/process.test.ts b/apps/server/src/factory/process.test.ts new file mode 100644 index 0000000000000..4614119f39d80 --- /dev/null +++ b/apps/server/src/factory/process.test.ts @@ -0,0 +1,369 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests write pid files to a temp dir and probe process liveness with kill(pid, 0). +import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { FactoryProcess, type RunInput, RunSpawnError, layer } from "./process.ts"; + +// Every test goes through the service, the way a caller does. +const run = (input: RunInput) => + Effect.gen(function* () { + const factoryProcess = yield* FactoryProcess; + return yield* factoryProcess.run(input); + }).pipe(Effect.provide(layer)); + +const withEnv = ( + vars: Record, + body: Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const saved = Object.keys(vars).map((k) => [k, globalThis.process.env[k]] as const); + for (const [k, v] of Object.entries(vars)) globalThis.process.env[k] = v; + return saved; + }), + () => body, + (saved) => + Effect.sync(() => { + for (const [k, v] of saved) { + if (v === undefined) delete globalThis.process.env[k]; + else globalThis.process.env[k] = v; + } + }), + ); + +const isAlive = (pid: number): boolean => { + try { + globalThis.process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// A temp dir that is removed however the test ends. +const withTempDir = (body: (dir: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "factory-process-"))), + body, + (dir) => Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })), + ); + +const readPids = (file: string): ReadonlyArray => + NodeFS.readFileSync(file, "utf8").trim().split(/\s+/).map(Number); + +const waitForFile = (file: string) => + Effect.gen(function* () { + for (let i = 0; i < 100 && !NodeFS.existsSync(file); i++) yield* Effect.sleep("20 millis"); + assert.isTrue(NodeFS.existsSync(file)); + }); + +const waitForDeath = (pid: number) => + Effect.gen(function* () { + for (let i = 0; i < 40 && isAlive(pid); i++) yield* Effect.sleep("50 millis"); + assert.strictEqual(isAlive(pid), false); + }); + +// 16384 lines of 65 bytes each, about 1 MiB, with a counter so the tail is checkable. +const COUNTER_SCRIPT = `i=0; while [ $i -lt 16384 ]; do printf '%064d\\n' $i; i=$((i+1)); done`; +const counterOutput = (): string => { + let out = ""; + for (let i = 0; i < 16384; i++) out += `${String(i).padStart(64, "0")}\n`; + return out; +}; + +it.live("does not pass variables outside the allowlist", () => + withEnv( + { FACTORY_PROCESS_TEST_SECRET: "leak" }, + Effect.gen(function* () { + const result = yield* run({ + command: `printf %s "\${FACTORY_PROCESS_TEST_SECRET-unset}"`, + shell: true, + }); + assert.strictEqual(result.tail, "unset"); + }), + ), +); + +it.live("passes PATH, LC_* and the env argument through", () => + withEnv( + { LC_FACTORY_TEST: "lc" }, + Effect.gen(function* () { + const path = yield* run({ command: `printf %s "$PATH"`, shell: true }); + assert.strictEqual(path.tail, globalThis.process.env.PATH ?? ""); + const lc = yield* run({ command: `printf %s "$LC_FACTORY_TEST"`, shell: true }); + assert.strictEqual(lc.tail, "lc"); + const extra = yield* run({ + command: `printf %s "$FACTORY_PROCESS_TEST_EXTRA"`, + shell: true, + env: { FACTORY_PROCESS_TEST_EXTRA: "yes" }, + }); + assert.strictEqual(extra.tail, "yes"); + const home = yield* run({ + command: `printf %s "$HOME"`, + shell: true, + env: { HOME: "/factory-test-home" }, + }); + assert.strictEqual(home.tail, "/factory-test-home"); + }), + ), +); + +it.live("a timeout kills the whole process group, grandchild included", () => + withTempDir((dir) => + Effect.gen(function* () { + const pidFile = NodePath.join(dir, "pid"); + const started = yield* Clock.currentTimeMillis; + const result = yield* run({ + command: `sleep 100 & echo $! > "$0"; sleep 100`, + args: [pidFile], + shell: true, + timeoutMs: 300, + }); + assert.strictEqual(result.timedOut, true); + assert.strictEqual(result.exitCode, null); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 5000); + + const [pid] = readPids(pidFile); + assert.isAbove(pid!, 0); + yield* waitForDeath(pid!); + }), + ), +); + +it.live("kills what a finished command left behind, without waiting", () => + withTempDir((dir) => + Effect.gen(function* () { + const pidFile = NodePath.join(dir, "pid"); + const started = yield* Clock.currentTimeMillis; + const result = yield* run({ + command: `sleep 100 & echo $! > "$0"; echo done`, + args: [pidFile], + shell: true, + }); + assert.strictEqual(result.tail, "done\n"); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.timedOut, false); + // Well under the 10 s grace a timeout would give; waitForDeath proves the kill. + assert.isBelow((yield* Clock.currentTimeMillis) - started, 5000); + + const [pid] = readPids(pidFile); + assert.isAbove(pid!, 0); + yield* waitForDeath(pid!); + }), + ), +); + +it.live("escalates to SIGKILL when SIGTERM is ignored", () => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + const result = yield* run({ + command: `trap '' TERM; sleep 100`, + shell: true, + timeoutMs: 200, + termGraceMs: 300, + }); + assert.strictEqual(result.timedOut, true); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 5000); + }), +); + +it.live("keeps only the last tailBytes of a 1 MB output", () => + Effect.gen(function* () { + const expected = counterOutput(); + const small = yield* run({ command: COUNTER_SCRIPT, shell: true, tailBytes: 4096 }); + assert.strictEqual(small.tail, expected.slice(-4096)); + assert.strictEqual(small.tail.length, 4096); + + const dflt = yield* run({ command: COUNTER_SCRIPT, shell: true }); + assert.strictEqual(dflt.tail, expected.slice(-16384)); + assert.strictEqual(dflt.tail.length, 16384); + }), +); + +it.live("passes the exit code through", () => + Effect.gen(function* () { + const failed = yield* run({ command: "exit 3", shell: true }); + assert.strictEqual(failed.exitCode, 3); + assert.strictEqual(failed.timedOut, false); + const ok = yield* run({ command: "true" }); + assert.strictEqual(ok.exitCode, 0); + }), +); + +it.live("interleaves stdout and stderr in order", () => + Effect.gen(function* () { + const result = yield* run({ command: "echo out1; echo err1 >&2; echo out2", shell: true }); + assert.strictEqual(result.tail, "out1\nerr1\nout2\n"); + }), +); + +it.live("runs argv without a shell", () => + Effect.gen(function* () { + const result = yield* run({ command: "printf", args: ["%s-%s", "a", "b"] }); + assert.strictEqual(result.tail, "a-b"); + }), +); + +it.live("merges stderr into the tail without a shell", () => + Effect.gen(function* () { + const result = yield* run({ command: "sh", args: ["-c", "echo e >&2"] }); + assert.strictEqual(result.tail, "e\n"); + }), +); + +it.live("fails with RunSpawnError when the cwd does not exist", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + run({ command: "true", cwd: "/nonexistent/factory-process-test" }), + ); + assert.instanceOf(error, RunSpawnError); + }), +); + +it.live("gives a TERM-trapping grandchild its grace before SIGKILL", () => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + // The wrapper sh dies on SIGTERM at once; the inner sh keeps the group + // alive while its trap runs, and the marker only appears if SIGKILL waits. + const result = yield* run({ + command: `sh -c 'trap "sleep 0.3; printf caught; exit 0" TERM; sleep 100 & wait $!'; printf %s leader`, + shell: true, + timeoutMs: 300, + termGraceMs: 3000, + }); + assert.strictEqual(result.timedOut, true); + assert.include(result.tail, "caught"); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 5000); + }), +); + +it.live("fails with RunSpawnError when spawn throws synchronously", () => + Effect.gen(function* () { + const error = yield* Effect.flip(run({ command: "true", env: { BAD: "a\u0000b" } })); + assert.instanceOf(error, RunSpawnError); + }), +); + +it.live("an interrupted run kills the whole process group", () => + withTempDir((dir) => + Effect.gen(function* () { + const pidFile = NodePath.join(dir, "pids"); + const ready = NodePath.join(dir, "ready"); + const fiber = yield* run({ + command: `sleep 100 & echo $$ $! > "$0"; mv "$0" "$1"; sleep 100`, + args: [pidFile, ready], + shell: true, + }).pipe(Effect.forkChild); + yield* waitForFile(ready); + const [leader, grandchild] = readPids(ready); + yield* Fiber.interrupt(fiber); + yield* waitForDeath(grandchild!); + yield* waitForDeath(leader!); + }), + ), +); + +it.live("does not wait long for a process that left the group and holds the pipe", () => + withTempDir((dir) => { + const pidFile = NodePath.join(dir, "pid"); + return Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + // The stray writes its pid only after leaving the group, so the group kill cannot reach it. + const result = yield* run({ + command: `perl -e 'setpgrp(0, 0); open my $f, ">", "$ARGV[0].tmp"; print $f $$; close $f; rename "$ARGV[0].tmp", $ARGV[0]; sleep 100' "$0" & while [ ! -s "$0" ]; do sleep 0.01; done; echo done`, + args: [pidFile], + shell: true, + }); + assert.strictEqual(result.tail, "done\n"); + assert.strictEqual(result.exitCode, 0); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 4000); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + const [pid] = NodeFS.existsSync(pidFile) ? readPids(pidFile) : []; + if (pid !== undefined && pid > 0) { + try { + globalThis.process.kill(pid, "SIGKILL"); + } catch { + // Already gone. + } + } + }), + ), + ); + }), +); + +it.live("a tail cut inside a UTF-8 character drops the partial character", () => + Effect.gen(function* () { + // 20 000 two-byte characters; an odd cap lands the cut on a continuation byte. + const result = yield* run({ + command: `perl -e 'print "\\xc3\\xa9" x 20000'`, + shell: true, + tailBytes: 4097, + }); + assert.strictEqual(result.tail, "é".repeat(2048)); + }), +); + +it.live("separateStderr keeps stderr out of tail and puts it in stderrTail", () => + Effect.gen(function* () { + const result = yield* run({ + command: "echo out1; echo err1 >&2; echo out2", + shell: true, + separateStderr: true, + }); + assert.strictEqual(result.tail, "out1\nout2\n"); + assert.strictEqual(result.stderrTail, "err1\n"); + assert.strictEqual(result.exitCode, 0); + }), +); + +it.live("separateStderr works without a shell", () => + Effect.gen(function* () { + const result = yield* run({ + command: "sh", + args: ["-c", "echo o; echo e >&2; exit 2"], + separateStderr: true, + }); + assert.strictEqual(result.tail, "o\n"); + assert.strictEqual(result.stderrTail, "e\n"); + assert.strictEqual(result.exitCode, 2); + }), +); + +it.live("merged mode leaves stderrTail empty", () => + Effect.gen(function* () { + const result = yield* run({ command: "echo out; echo err >&2", shell: true }); + assert.strictEqual(result.tail, "out\nerr\n"); + assert.strictEqual(result.stderrTail, ""); + assert.strictEqual(result.truncated, false); + }), +); + +it.live("truncated is true only when a tail dropped bytes", () => + Effect.gen(function* () { + const within = yield* run({ command: "printf abcd", shell: true, tailBytes: 4 }); + assert.strictEqual(within.tail, "abcd"); + assert.strictEqual(within.truncated, false); + + const stdoutCut = yield* run({ command: "printf abcdefgh", shell: true, tailBytes: 4 }); + assert.strictEqual(stdoutCut.tail, "efgh"); + assert.strictEqual(stdoutCut.truncated, true); + + const stderrCut = yield* run({ + command: "echo ok; printf abcdefgh >&2", + shell: true, + tailBytes: 4, + separateStderr: true, + }); + assert.strictEqual(stderrCut.tail, "ok\n"); + assert.strictEqual(stderrCut.stderrTail, "efgh"); + assert.strictEqual(stderrCut.truncated, true); + }), +); diff --git a/apps/server/src/factory/process.ts b/apps/server/src/factory/process.ts new file mode 100644 index 0000000000000..b30d766169b3e --- /dev/null +++ b/apps/server/src/factory/process.ts @@ -0,0 +1,285 @@ +// @effect-diagnostics nodeBuiltinImport:off - The factory runs commands as detached process groups, which Node's child_process exposes and ProcessRunner does not. +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as NodeChildProcess from "node:child_process"; + +/** + * Runs commands the way softfac does: a known environment, a hard time limit + * that kills the whole process tree, and only the end of the output kept. + * + * POSIX only: it relies on /bin/sh, process groups and negative-pid kill. + * `ProcessRunner` cannot do this (see factory/docs/design.md, open question 2), + * so this module uses `node:child_process` directly and imports no T3 code. + */ + +/** + * Names copied from the server's environment, besides `LC_*`. This is defence + * in depth, not a sandbox: code running as the same user can still read the + * token file and ~/.ssh. + */ +const INHERITED_ENV: ReadonlySet = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "TMPDIR", + "TERM", +]); + +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; +const DEFAULT_TAIL_BYTES = 16 * 1024; +const DEFAULT_TERM_GRACE_MS = 10_000; +/** A process that left the group may still hold the pipe; do not wait long for it. */ +const DRAIN_WAIT_MS = 2_000; + +/** The command itself is only in `cause`: in shell mode it is a whole script. */ +export class RunSpawnError extends Schema.TaggedError()("RunSpawnError", { + shell: Schema.Boolean, + cwd: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return this.shell ? "Failed to spawn a shell command." : "Failed to spawn a command."; + } +} + +export interface RunInput { + readonly command: string; + readonly args?: ReadonlyArray; + /** Run `command` as a /bin/sh script; `args` become its positional parameters ($0, $1, …). */ + readonly shell?: boolean; + readonly cwd?: string; + /** Added after the inherited allowlist; may override an inherited name. */ + readonly env?: Readonly>; + /** Default 30 minutes (softfac's gate default). */ + readonly timeoutMs?: number; + /** Default 16 KiB. */ + readonly tailBytes?: number; + /** How long after SIGTERM to send SIGKILL. Default 10_000. Exists so a test can be fast. */ + readonly termGraceMs?: number; + /** + * Keep stderr apart from stdout: stdout alone is `tail`, stderr is `stderrTail`. + * Default false, which merges both into `tail` in order. For commands whose + * stdout is data (gh's JSON) that stderr text must not end up in. + */ + readonly separateStderr?: boolean; +} + +export interface RunResult { + /** + * Node's exit code; null when the process ended by a signal. A command that + * handles the timeout's SIGTERM can still exit with a code, even 0, so check + * `timedOut` first. + */ + readonly exitCode: number | null; + readonly timedOut: boolean; + /** The last `tailBytes` of stdout and stderr interleaved, decoded as UTF-8. */ + readonly tail: string; + /** The last `tailBytes` of stderr; "" unless `separateStderr` was set. */ + readonly stderrTail: string; + /** True when a tail dropped bytes, i.e. the output was longer than `tailBytes`. */ + readonly truncated: boolean; +} + +/** + * Keeps the end of a byte stream. Memory stays under about twice the cap: + * the front is dropped down to the cap only once the buffer passes twice it. + */ +class Tail { + private buf = Buffer.alloc(0); + private dropped = false; + + private readonly cap: number; + + constructor(cap: number) { + this.cap = cap; + } + + push(chunk: Buffer): void { + this.buf = Buffer.concat([this.buf, chunk]); + if (this.buf.length > 2 * this.cap) this.trim(); + } + + get truncated(): boolean { + return this.dropped || this.buf.length > this.cap; + } + + finish(): string { + this.trim(); + let start = 0; + // A cut inside a character loses that character instead of becoming U+FFFD. + if (this.dropped) { + while (start < this.buf.length && (this.buf[start]! & 0xc0) === 0x80) start++; + } + return new TextDecoder().decode(this.buf.subarray(start)); + } + + private trim(): void { + if (this.buf.length > this.cap) { + this.buf = this.buf.subarray(this.buf.length - this.cap); + this.dropped = true; + } + } +} + +const buildEnv = (extra: Readonly> | undefined): Record => { + const env: Record = {}; + for (const [name, value] of Object.entries(globalThis.process.env)) { + if (value === undefined) continue; + if (INHERITED_ENV.has(name) || name.startsWith("LC_")) env[name] = value; + } + return { ...env, ...extra }; +}; + +const run = (input: RunInput): Effect.Effect => + Effect.gen(function* () { + const args = input.args ?? []; + const separate = input.separateStderr === true; + const tail = new Tail(input.tailBytes ?? DEFAULT_TAIL_BYTES); + const stderrTail = new Tail(input.tailBytes ?? DEFAULT_TAIL_BYTES); + + // By default one pipe for both streams: Node cannot hand one pipe to two + // fds, so the wrapper shell merges stderr into stdout. With separateStderr + // there are two pipes and no merge. /bin/sh is absolute because the caller + // may override PATH. + const merge = separate ? "" : "exec 2>&1\n"; + const script = input.shell + ? ["-c", `${merge}${input.command}`, ...args] + : ["-c", `${merge}exec "$0" "$@"`, input.command, ...args]; + + const exited = Deferred.makeUnsafe< + readonly [number | null, NodeJS.Signals | null], + RunSpawnError + >(); + const closed = Deferred.makeUnsafe(); + const stderrClosed = Deferred.makeUnsafe(); + + const spawnError = (cause: unknown) => + new RunSpawnError({ shell: input.shell === true, cwd: input.cwd, cause }); + + // Acquire is uninterruptible, so an interrupt can never land between the + // spawn and the cleanup that kills the group. Spawn reports some failures + // by throwing (a NUL byte in env or args) and others by an 'error' event. + const acquire = Effect.try({ + try: () => { + const child = NodeChildProcess.spawn("/bin/sh", script, { + cwd: input.cwd, + env: buildEnv(input.env), + stdio: ["ignore", "pipe", separate ? "pipe" : "ignore"], + detached: true, + }); + child.once("error", (cause) => Deferred.doneUnsafe(exited, Exit.fail(spawnError(cause)))); + child.once("exit", (code, signal) => + Deferred.doneUnsafe(exited, Exit.succeed([code, signal] as const)), + ); + child.stdout?.on("data", (chunk: Buffer) => tail.push(chunk)); + child.stdout?.once("close", () => Deferred.doneUnsafe(closed, Exit.void)); + // No stdout (spawn failed): nothing to drain. + if (!child.stdout) Deferred.doneUnsafe(closed, Exit.void); + child.stderr?.on("data", (chunk: Buffer) => stderrTail.push(chunk)); + child.stderr?.once("close", () => Deferred.doneUnsafe(stderrClosed, Exit.void)); + if (!child.stderr) Deferred.doneUnsafe(stderrClosed, Exit.void); + return child; + }, + catch: spawnError, + }); + + // Signals the group, and the leader too in case it left the group (softfac's kill_all). + const killGroup = (child: NodeChildProcess.ChildProcess, signal: NodeJS.Signals): void => { + const pid = child.pid; + if (pid === undefined) return; + try { + globalThis.process.kill(-pid, signal); + } catch { + // ESRCH: the group is already gone. + } + try { + child.kill(signal); + } catch { + // The leader is already gone. + } + }; + + // Runs on every exit path; after a normal run both calls are no-ops. + const release = (child: NodeChildProcess.ChildProcess) => + Effect.sync(() => { + killGroup(child, "SIGKILL"); + child.stdout?.destroy(); + child.stderr?.destroy(); + }); + + const use = (child: NodeChildProcess.ChildProcess) => + Effect.gen(function* () { + const pid = child.pid; + // Like softfac's group_exists: signal 0 throws ESRCH once the group is empty. + const groupExists = (): boolean => { + if (pid === undefined) return false; + try { + globalThis.process.kill(-pid, 0); + return true; + } catch { + return false; + } + }; + // In shell mode the leader is the wrapper sh, which dies on SIGTERM + // before the real command; the grace lasts until the group is empty. + const groupGone = Effect.gen(function* () { + while (!(yield* Deferred.isDone(exited)) || groupExists()) { + yield* Effect.sleep("50 millis"); + } + }); + + let timedOut = false; + const outcome = yield* Deferred.await(exited).pipe( + Effect.timeoutOption(input.timeoutMs ?? DEFAULT_TIMEOUT_MS), + ); + if (outcome._tag === "None") { + timedOut = true; + killGroup(child, "SIGTERM"); + yield* groupGone.pipe(Effect.timeoutOption(input.termGraceMs ?? DEFAULT_TERM_GRACE_MS)); + killGroup(child, "SIGKILL"); + } + const [exitCode] = yield* Deferred.await(exited); + + // Kill whatever the command left in its group. This also closes pipe + // ends a stray in the group still held; a process that left the group + // (setsid, setpgid) escapes, and the drain wait below bounds it. + killGroup(child, "SIGKILL"); + const drained = yield* Deferred.await(closed).pipe( + Effect.andThen(Deferred.await(stderrClosed)), + Effect.timeoutOption(DRAIN_WAIT_MS), + ); + if (drained._tag === "None") { + child.stdout?.destroy(); + child.stderr?.destroy(); + } + + const out = tail.finish(); + const err = stderrTail.finish(); + return { + exitCode, + timedOut, + tail: out, + stderrTail: err, + truncated: tail.truncated || stderrTail.truncated, + } satisfies RunResult; + }); + + return yield* Effect.acquireUseRelease(acquire, use, release); + }); + +/** Callers depend on this tag, so their tests can swap in a fake that starts no process. */ +export class FactoryProcess extends Context.Service< + FactoryProcess, + { + readonly run: (input: RunInput) => Effect.Effect; + } +>()("t3/factory/process/FactoryProcess") {} + +export const layer = Layer.succeed(FactoryProcess, FactoryProcess.of({ run })); diff --git a/apps/server/src/factory/t3/fixtures/t3_thread_snapshot.json b/apps/server/src/factory/t3/fixtures/t3_thread_snapshot.json new file mode 100644 index 0000000000000..3ec1c0e7ebca0 --- /dev/null +++ b/apps/server/src/factory/t3/fixtures/t3_thread_snapshot.json @@ -0,0 +1,153 @@ +{ + "snapshotSequence": 38, + "projection": { + "thread": { + "createdBy": "user", + "creationSource": "web", + "id": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "projectId": "e87a2ca6-3572-4958-bb1d-d91ca88c9070", + "title": "factory spike", + "providerInstanceId": "claudeAgent", + "modelSelection": { + "instanceId": "claudeAgent", + "model": "claude-sonnet-5-5" + }, + "runtimeMode": "approval-required", + "interactionMode": "default", + "branch": "t3code/618ff943", + "worktreePath": "/tmp/t3-spike-home/worktrees/t3-spike-repo/t3code-618ff943", + "activeProviderThreadId": "provider-thread:provider:claudeAgent:native-thread:pending%3Arun%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1", + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511" + }, + "forkedFrom": null, + "createdAt": "2026-10-04T09:36:15.108Z", + "updatedAt": "2026-10-04T09:36:19.470Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "lastVisitedAt": null, + "deletedAt": null + }, + "runs": [ + { + "id": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "threadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "ordinal": 1, + "providerInstanceId": "claudeAgent", + "modelSelection": { + "instanceId": "claudeAgent", + "model": "claude-sonnet-5-5" + }, + "providerThreadId": "provider-thread:provider:claudeAgent:native-thread:pending%3Arun%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1", + "userMessageId": "message:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1:e61e49ab-97fb-4e08-a54f-8c5409ae7982", + "rootNodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "activeAttemptId": "run-attempt:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:attempt:1", + "status": "completed", + "queuePosition": null, + "requestedAt": "2026-10-04T09:36:15.113Z", + "startedAt": "2026-10-04T09:36:15.242Z", + "completedAt": "2026-10-04T09:36:19.470Z", + "checkpointId": "checkpoint:scope:checkpoint-scope%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aname%3Aroot:name:1", + "contextHandoffId": null, + "workspacePreparation": { + "type": "worktree", + "baseRef": "main", + "startFromOrigin": false + } + } + ], + "attempts": [ + { + "id": "run-attempt:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:attempt:1", + "nativeThreadId": "913ad7b4-ea27-4a7c-957e-de45ae9e96ff", + "runId": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "attemptOrdinal": 1, + "rootNodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "providerInstanceId": "claudeAgent", + "providerThreadId": "provider-thread:provider:claudeAgent:native-thread:pending%3Arun%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1", + "providerTurnId": null, + "reason": "initial", + "status": "completed", + "startedAt": "2026-10-04T09:36:15.242Z", + "completedAt": "2026-10-04T09:36:19.466Z" + } + ], + "nodes": [ + { + "id": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "threadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "runId": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "parentNodeId": null, + "rootNodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "kind": "root_turn", + "status": "completed", + "countsForRun": true, + "providerThreadId": "provider-thread:provider:claudeAgent:native-thread:pending%3Arun%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1", + "providerTurnId": null, + "nativeItemRef": null, + "runtimeRequestId": null, + "checkpointScopeId": "checkpoint-scope:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:name:root", + "startedAt": "2026-10-04T09:36:15.242Z", + "completedAt": "2026-10-04T09:36:19.470Z" + }, + { + "id": "node:provider:claudeAgent:native-item:32c394dd-48e9-4e54-9fa5-26c8c2789600", + "threadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "runId": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "parentNodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "rootNodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "kind": "assistant_message", + "status": "completed", + "countsForRun": false, + "providerThreadId": "provider-thread:provider:claudeAgent:native-thread:pending%3Arun%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1", + "providerTurnId": "provider-turn:provider:claudeAgent:native-turn:turn%3Arun-attempt%3Arun%3Arun%253Athread%253Athread%25253Aproject%25253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%25253A79e52860-a72a-4aa1-8042-6ed913b9a511%253Aordinal%253A1%3Aattempt%3A1", + "nativeItemRef": { + "driver": "claudeAgent", + "nativeId": "32c394dd-48e9-4e54-9fa5-26c8c2789600", + "strength": "strong" + }, + "runtimeRequestId": null, + "checkpointScopeId": null, + "startedAt": "2026-10-04T09:36:19.429Z", + "completedAt": "2026-10-04T09:36:19.429Z" + } + ], + "messages": [ + { + "createdBy": "user", + "creationSource": "web", + "id": "message:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1:e61e49ab-97fb-4e08-a54f-8c5409ae7982", + "threadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "runId": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "nodeId": "node:run:run%3Athread%3Athread%253Aproject%253Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%253A79e52860-a72a-4aa1-8042-6ed913b9a511%3Aordinal%3A1:root", + "role": "user", + "text": "Reply with the single word READY. Do not use any tools.", + "attachments": [], + "streaming": false, + "createdAt": "2026-10-04T09:36:15.113Z", + "updatedAt": "2026-10-04T09:36:15.113Z" + }, + { + "createdBy": "agent", + "creationSource": "provider", + "id": "message:provider:claudeAgent:native-item:32c394dd-48e9-4e54-9fa5-26c8c2789600", + "threadId": "thread:project:e87a2ca6-3572-4958-bb1d-d91ca88c9070:79e52860-a72a-4aa1-8042-6ed913b9a511", + "runId": "run:thread:thread%3Aproject%3Ae87a2ca6-3572-4958-bb1d-d91ca88c9070%3A79e52860-a72a-4aa1-8042-6ed913b9a511:ordinal:1", + "nodeId": "node:provider:claudeAgent:native-item:32c394dd-48e9-4e54-9fa5-26c8c2789600", + "role": "assistant", + "text": "READY", + "attachments": [], + "streaming": false, + "createdAt": "2026-10-04T09:36:19.429Z", + "updatedAt": "2026-10-04T09:36:19.429Z" + } + ], + "plans": [], + "runtimeRequests": [] + } +} diff --git a/apps/server/src/factory/t3/paths.test.ts b/apps/server/src/factory/t3/paths.test.ts new file mode 100644 index 0000000000000..72a6606234193 --- /dev/null +++ b/apps/server/src/factory/t3/paths.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../../config.ts"; +import * as Paths from "./paths.ts"; + +it.effect( + "takes the base dir and the worktrees dir from ServerConfig and puts factory data under /factory", + () => + Effect.gen(function* () { + const paths = yield* Paths.FactoryPaths; + assert.strictEqual(paths.baseDir, "/srv/t3fac"); + assert.strictEqual(paths.worktreesDir, "/srv/t3fac/worktrees"); + assert.strictEqual(paths.factoryDir, "/srv/t3fac/factory"); + assert.strictEqual(paths.machineConfigPath, "/srv/t3fac/factory/config.yml"); + }).pipe( + Effect.provide( + Paths.layer.pipe( + Layer.provide( + Layer.succeed(ServerConfig.ServerConfig, { + baseDir: "/srv/t3fac", + worktreesDir: "/srv/t3fac/worktrees", + } as ServerConfig.ServerConfig["Service"]), + ), + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/factory/t3/paths.ts b/apps/server/src/factory/t3/paths.ts new file mode 100644 index 0000000000000..3a552fd7fe32e --- /dev/null +++ b/apps/server/src/factory/t3/paths.ts @@ -0,0 +1,37 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../../config.ts"; + +/** + * Where the factory keeps and finds things on this machine, from the server's + * base dir: the worktrees T3 makes for agent threads, and the factory's own + * data under `/factory` (the same layout as softfac's data dir, so its + * retro can read both), including its machine config, `/factory/config.yml`. + */ +export class FactoryPaths extends Context.Service< + FactoryPaths, + { + readonly baseDir: string; + readonly worktreesDir: string; + readonly factoryDir: string; + readonly machineConfigPath: string; + } +>()("t3/factory/t3/paths/FactoryPaths") {} + +export const layer = Layer.effect( + FactoryPaths, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const factoryDir = path.join(config.baseDir, "factory"); + return { + baseDir: config.baseDir, + worktreesDir: config.worktreesDir, + factoryDir, + machineConfigPath: path.join(factoryDir, "config.yml"), + }; + }), +); diff --git a/apps/server/src/factory/t3/sessions.contract.test.ts b/apps/server/src/factory/t3/sessions.contract.test.ts new file mode 100644 index 0000000000000..46c3f7ad265c5 --- /dev/null +++ b/apps/server/src/factory/t3/sessions.contract.test.ts @@ -0,0 +1,290 @@ +/** + * Runs the adapter against T3's real orchestration layers: the orchestrator, + * its stores, the effect worker, the launch service and a real git worktree, + * with the Claude replay transcript from provider-testing standing in for the + * provider. It answers one question on every upstream sync: does the adapter + * still work against this upstream? + */ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EventId, ProjectId, ThreadId, type Project } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../../config.ts"; +import * as GitWorkflow from "../../git/GitWorkflowService.ts"; +import { ClaudeOrchestratorReplayHarness } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts"; +import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; +import * as EventSink from "../../orchestration-v2/EventSink.ts"; +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; +import * as ManagedProjectFolders from "../../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../../project/ProjectCloneTracker.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../../project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "../../project/WorktreeSetupTracker.ts"; +import * as ProviderRegistryMock from "../../provider/testUtils/providerRegistryMock.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import * as TextGeneration from "../../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as Sessions from "./sessions.ts"; + +const SIMPLE_TRANSCRIPT = new URL( + "../../orchestration-v2/testkit/fixtures/simple/claude_transcript.ndjson", + import.meta.url, +); +/** The prompt the transcript was recorded with; the reply is "fixture simple ok". */ +const SIMPLE_PROMPT = "Respond with the following text: fixture simple ok"; + +const projectId = ProjectId.make("project:factory-sessions-contract"); +const createdAt = "2026-10-15T00:00:00.000Z"; + +const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +]); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "factory.sessions.contract.git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = (cwd: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "factory@test.invalid"]); + yield* git(cwd, ["config", "user.name", "Factory"]); + yield* fs.writeFileString(path.join(cwd, "README.md"), "# factory contract\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + return yield* git(cwd, ["branch", "--show-current"]); + }); + +/** The real orchestration runtime plus the launch service over a real repo. */ +const layerRuntime = (input: { readonly repo: string; readonly worktreesDir: string }) => + Layer.unwrap( + Effect.gen(function* () { + const path = yield* Path.Path; + const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript( + yield* readProviderReplayTranscript(SIMPLE_TRANSCRIPT), + ); + const project: Project = { + id: projectId, + title: "Factory contract", + workspaceRoot: input.repo, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt, + updatedAt: createdAt, + deletedAt: null, + }; + + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "factory-sessions-contract" }, + ClaudeOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript), + { databaseLayer: layerDatabase }, + ); + const layerServerSettings = ServerSettings.layerTest(); + const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerServerSettings), + Layer.provide(layerReceipts), + ); + // Real worktrees through the git driver; the rest of the workflow + // service is not on the launch path. + const layerGitWorkflow = Layer.unwrap( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return Layer.mock(GitWorkflow.GitWorkflowService)({ + createWorktree: (worktree, options) => + driver.createWorktree(worktree, { + ...options, + worktreesDirectory: input.worktreesDir, + }), + hasCommit: () => Effect.succeed(false), + remoteExists: (remote) => driver.remoteExists(remote), + removeWorktree: (worktree) => driver.removeWorktree(worktree), + }); + }), + ); + const layerExternalServices = Layer.mergeAll( + WorktreeSetupTracker.layer, + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), + Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none()), + }), + layerGitWorkflow, + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }), + // No branch names in this test: the temporary worktree branch sticks. + Layer.mock(TextGeneration.TextGeneration)({ + generateBranchName: () => Effect.die("branch names are not generated here"), + }), + layerServerSettings, + ProviderRegistryMock.layer(), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: path.join(input.worktreesDir, "..", "projects"), + folderForThread: () => Effect.succeedNone, + }), + ); + const layerLaunch = ThreadLaunch.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerExternalServices, + layerThreadManagement, + layerReceipts, + IdAllocator.layer, + ), + ), + ); + const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe( + Layer.provide(layerDatabase), + ); + return Layer.mergeAll( + Sessions.layer.pipe(Layer.provide(Layer.merge(layerLaunch, layerStores))), + layerStores, + layerOrchestrator, + ); + }), + ); + +const layerGit = GitVcsDriver.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-factory-sessions-config-" })), + Layer.provide(NodeServices.layer), +); + +it.live( + "launches a thread in a new worktree, reads its status to completion, and starts one thread per command id", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-factory-sessions-" }); + const repo = path.join(base, "repo"); + const worktreesDir = path.join(base, "worktrees"); + yield* fs.makeDirectory(repo); + yield* fs.makeDirectory(worktreesDir); + const baseRef = yield* initRepoWithCommit(repo); + + yield* Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + yield* projects.apply({ + sequence: 1, + eventId: EventId.make("event:factory-sessions-contract:project-created"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: createdAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId, + title: "Factory contract", + workspaceRoot: repo, + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual(yield* sessions.findProject(repo), projectId); + assert.strictEqual(yield* sessions.findProject(`${repo}/`), null); + + const request: Sessions.LaunchRequest = { + commandId: "softfac:factory-sessions-contract:attempt-1", + projectId, + title: "Factory contract", + baseRef, + startFromOrigin: false, + message: SIMPLE_PROMPT, + model: { instanceId: "claudeAgent", model: "claude-sonnet-4-6" }, + }; + const launched = yield* sessions.launch(request); + const threadId = ThreadId.make(launched.threadId); + + // The worktree is prepared and the provider turn run after launch + // returns; the run's own events say when it is over. + const sink = yield* EventSink.EventSinkV2; + const terminal = yield* sink.stream({ threadId, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + TERMINAL_RUN_STATUSES.has(stored.event.payload.status), + ), + Stream.map((stored) => stored.event.payload as { readonly status: Sessions.RunStatus }), + Stream.take(1), + Stream.runCollect, + ); + assert.strictEqual(terminal[0]?.status, "completed"); + + const status = yield* sessions.status(launched.threadId); + assert.strictEqual(status.run, "completed"); + assert.strictEqual(status.runOrdinal, 1); + assert.include(status.lastAssistantMessage ?? "", "fixture simple ok"); + assert.isFalse(status.archived); + assert.isNotNull(status.branch); + assert.isNotNull(status.worktreePath); + const worktreePath = status.worktreePath!; + assert.isTrue(yield* fs.exists(path.join(worktreePath, ".git"))); + assert.strictEqual(path.dirname(path.dirname(worktreePath)), worktreesDir); + assert.strictEqual(yield* git(worktreePath, ["branch", "--show-current"]), status.branch); + assert.strictEqual( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(repo, ["rev-parse", baseRef]), + ); + + assert.deepStrictEqual(yield* sessions.thread(launched.threadId), { + worktreePath, + branch: status.branch, + archived: false, + }); + + // The same command id again is the same launch: the thread it made, + // and no second run. + const again = yield* sessions.launch(request); + assert.strictEqual(again.threadId, launched.threadId); + const after = yield* sessions.status(launched.threadId); + assert.strictEqual(after.runOrdinal, 1); + assert.strictEqual(after.run, "completed"); + }).pipe(Effect.provide(layerRuntime({ repo, worktreesDir }))); + }), + ).pipe(Effect.provide(Layer.merge(layerGit, NodeServices.layer))), +); diff --git a/apps/server/src/factory/t3/sessions.test.ts b/apps/server/src/factory/t3/sessions.test.ts new file mode 100644 index 0000000000000..f38dd836af1a3 --- /dev/null +++ b/apps/server/src/factory/t3/sessions.test.ts @@ -0,0 +1,654 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - the recorded snapshot is read as the wire bytes softfac saw. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import { + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2AppThread, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; +import * as Sessions from "./sessions.ts"; + +// ---- status: the run, its message and the thread fields, read from a projection ---- + +const thread = ( + branch: string | null = null, + worktreePath: string | null = null, + archivedAt: string | null = null, +) => ({ branch, worktreePath, archivedAt }); + +const run = (ordinal: number, status: Sessions.RunStatus) => ({ + id: `run-${ordinal}`, + ordinal, + status, + startedAt: "2026-10-05T06:39:38.376Z", +}); + +/** A run that was queued and never started: T3 gives it no `startedAt`. */ +const unstartedRun = (ordinal: number, status: Sessions.RunStatus) => ({ + id: `run-${ordinal}`, + ordinal, + status, + startedAt: null, +}); + +/** A message of the run with `ordinal`, which T3 references by the run's `id`. */ +const message = (ordinal: number | null, role: string, text: string) => ({ + role, + text, + runId: ordinal === null ? null : `run-${ordinal}`, +}); + +const statusOf = ( + runs: ReadonlyArray, + messages: ReadonlyArray = [], + fields = thread(), +) => Sessions.threadStatusOf({ thread: fields, runs, messages }); + +describe("threadStatusOf", () => { + it("reads a real recorded snapshot and ignores unknown fields", () => { + const snapshot = JSON.parse( + NodeFS.readFileSync( + NodePath.join(import.meta.dirname, "fixtures", "t3_thread_snapshot.json"), + "utf8", + ), + ) as { readonly projection: Sessions.ThreadSnapshot }; + assert.deepStrictEqual(Sessions.threadStatusOf(snapshot.projection), { + run: "completed", + runOrdinal: 1, + lastAssistantMessage: "READY", + worktreePath: "/tmp/t3-spike-home/worktrees/t3-spike-repo/t3code-618ff943", + branch: "t3code/618ff943", + archived: false, + }); + }); + + it("takes the run with the highest ordinal whatever the order", () => { + // The highest ordinal is in the middle, so first, last and lowest all get it wrong. + const status = statusOf([run(1, "completed"), run(3, "running"), run(2, "failed")]); + assert.strictEqual(status.run, "running"); + }); + + it("skips a cancelled run that never started", () => { + // Run 1 is working; the person's queued message became run 2 and was cancelled. + const active: ReadonlyArray = [ + "preparing", + "queued", + "starting", + "running", + "waiting", + ]; + for (const expected of active) { + // The cancelled run comes first, so "last in the array" gets it wrong too. + const status = statusOf([unstartedRun(2, "cancelled"), run(1, expected)]); + assert.strictEqual(status.run, expected, expected); + assert.strictEqual(status.runOrdinal, 1, expected); + } + }); + + it("keeps the finished run and its message behind a cancelled queued run", () => { + const status = statusOf( + [run(1, "completed"), unstartedRun(2, "cancelled")], + [ + message(1, "user", "do the work"), + message(1, "assistant", "all done. FACTORY-DONE: ready"), + message(2, "user", "continue the work"), + ], + ); + assert.strictEqual(status.run, "completed"); + assert.strictEqual(status.runOrdinal, 1); + assert.strictEqual(status.lastAssistantMessage, "all done. FACTORY-DONE: ready"); + }); + + it("of a sole cancelled run that never started is cancelled", () => { + const status = statusOf([unstartedRun(1, "cancelled")]); + assert.strictEqual(status.run, "cancelled"); + assert.strictEqual(status.runOrdinal, 1); + }); + + it("of a sole cancelled run that started is cancelled", () => { + const status = statusOf([run(1, "cancelled")]); + assert.strictEqual(status.run, "cancelled"); + assert.strictEqual(status.runOrdinal, 1); + }); + + it("lets a cancelled run that started decide", () => { + const status = statusOf([run(1, "completed"), run(2, "cancelled")]); + assert.strictEqual(status.run, "cancelled"); + assert.strictEqual(status.runOrdinal, 2); + }); + + it("skips every cancelled run that never started", () => { + const status = statusOf([ + unstartedRun(3, "cancelled"), + run(1, "running"), + unstartedRun(2, "cancelled"), + ]); + assert.strictEqual(status.run, "running"); + assert.strictEqual(status.runOrdinal, 1); + }); + + it("takes the highest ordinal when every run is cancelled and never started", () => { + // The highest ordinal comes first, so "last in the array" gets it wrong too. + const status = statusOf([unstartedRun(2, "cancelled"), unstartedRun(1, "cancelled")]); + assert.strictEqual(status.run, "cancelled"); + assert.strictEqual(status.runOrdinal, 2); + }); + + it("keeps a queued run that has not started yet", () => { + // Only a cancelled run is left out: a message still waiting in the queue is the + // thread's run. + const status = statusOf([unstartedRun(2, "queued"), run(1, "completed")]); + assert.strictEqual(status.run, "queued"); + assert.strictEqual(status.runOrdinal, 2); + }); + + it("treats a cancelled run without startedAt as never started", () => { + const status = statusOf([{ id: "run-2", ordinal: 2, status: "cancelled" }, run(1, "running")]); + assert.strictEqual(status.run, "running"); + assert.strictEqual(status.runOrdinal, 1); + }); + + it("reports the ordinal of the run it describes", () => { + // Out of order, with the highest in the middle: the status and the ordinal are of the + // same run. + const status = statusOf([run(4, "completed"), run(9, "running"), run(2, "failed")]); + assert.strictEqual(status.run, "running"); + assert.strictEqual(status.runOrdinal, 9); + }); + + it("without runs has no run and no run ordinal", () => { + const status = statusOf([]); + assert.strictEqual(status.run, null); + assert.strictEqual(status.runOrdinal, null); + assert.strictEqual(status.lastAssistantMessage, null); + }); + + it("maps every known run status", () => { + for (const status of Sessions.RUN_STATUSES) { + assert.strictEqual(statusOf([run(1, status)]).run, status, status); + } + }); + + it("reports only the reported run's own last assistant message", () => { + // A run that ended quietly must not be read as ending with an older run's result + // line, and a message with no run reference is no run's. + const status = statusOf( + [run(1, "completed"), run(2, "completed")], + [ + message(1, "assistant", "FACTORY-DONE: old"), + message(2, "user", "go again"), + message(null, "assistant", "orphan"), + ], + ); + assert.strictEqual(status.runOrdinal, 2); + assert.strictEqual(status.lastAssistantMessage, null); + }); + + it("reports the last of the run's assistant messages, not the first", () => { + // A real run says something on the way ("looking") before its result line. + const status = statusOf( + [run(1, "completed")], + [ + message(1, "assistant", "looking"), + message(1, "user", "carry on"), + message(1, "assistant", "FACTORY-DONE: ready"), + ], + ); + assert.strictEqual(status.lastAssistantMessage, "FACTORY-DONE: ready"); + }); + + it("reports the thread's branch, worktree path and whether it is archived", () => { + const status = statusOf([], [], thread("t3/abc", "/tmp/wt", "2026-10-05T06:39:38.376Z")); + assert.strictEqual(status.branch, "t3/abc"); + assert.strictEqual(status.worktreePath, "/tmp/wt"); + assert.strictEqual(status.archived, true); + assert.strictEqual(statusOf([], [], thread()).archived, false); + }); +}); + +// ---- the service over T3's stores ---- + +const projectId = ProjectId.make("project:factory-sessions-test"); +const threadId = ThreadId.make("thread:project:factory-sessions-test:1"); + +const projectCreated = (id: string, workspaceRoot: string, sequence: number) => + ({ + sequence, + eventId: EventId.make(`event:${id}:created`), + aggregateKind: "project", + aggregateId: ProjectId.make(id), + occurredAt: "2026-10-03T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId: ProjectId.make(id), + title: id, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-03T00:00:00.000Z", + updatedAt: "2026-10-03T00:00:00.000Z", + }, + }) as const; + +const projectDeleted = (id: string, sequence: number) => + ({ + sequence, + eventId: EventId.make(`event:${id}:deleted`), + aggregateKind: "project", + aggregateId: ProjectId.make(id), + occurredAt: "2026-10-03T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.deleted", + payload: { projectId: ProjectId.make(id), deletedAt: "2026-10-03T00:00:00.000Z" }, + }) as const; + +const layerProjects = ProjectStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)); + +const layerSessions = ( + projections: Partial = {}, + launches: Partial = {}, +) => + Layer.mergeAll( + Sessions.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjects, + Layer.mock(ProjectionStore.ProjectionStoreV2)(projections), + Layer.mock(ThreadLaunch.ThreadLaunchService)(launches), + ), + ), + ), + layerProjects, + ); + +describe("FactorySessions.findProject", () => { + it.effect("returns the id of the matching workspace root", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + yield* projects.apply(projectCreated("p-other", "/tmp/other", 1)); + yield* projects.apply(projectCreated("p-spike", "/tmp/t3-spike-repo", 2)); + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual(yield* sessions.findProject("/tmp/t3-spike-repo"), "p-spike"); + }).pipe(Effect.provide(layerSessions())), + ); + + it.effect("without a match is null", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + yield* projects.apply(projectCreated("p-other", "/tmp/other", 1)); + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual(yield* sessions.findProject("/tmp/t3-spike-repo"), null); + }).pipe(Effect.provide(layerSessions())), + ); + + it.effect("ignores deleted projects", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + yield* projects.apply(projectCreated("p-old", "/tmp/t3-spike-repo", 1)); + yield* projects.apply(projectDeleted("p-old", 2)); + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual(yield* sessions.findProject("/tmp/t3-spike-repo"), null); + yield* projects.apply(projectCreated("p-new", "/tmp/t3-spike-repo", 3)); + assert.strictEqual(yield* sessions.findProject("/tmp/t3-spike-repo"), "p-new"); + }).pipe(Effect.provide(layerSessions())), + ); + + it.effect("matches the whole root, not a prefix or a trailing slash", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + yield* projects.apply(projectCreated("p-a", "/tmp/t3-spike-repo-2", 1)); + yield* projects.apply(projectCreated("p-b", "/tmp/t3-spike-repo/", 2)); + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual(yield* sessions.findProject("/tmp/t3-spike-repo"), null); + }).pipe(Effect.provide(layerSessions())), + ); + + it.effect("fails as a sessions error when the project store fails, not as no project", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const error = yield* Effect.flip(sessions.findProject("/tmp/t3-spike-repo")); + assert.strictEqual(error._tag, "FactorySessionsError"); + assert.strictEqual(error.operation, "find-project"); + }).pipe( + Effect.provide( + Sessions.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectStore.ProjectStoreV2)({ + findActiveByWorkspaceRoot: () => + Effect.fail( + new ProjectStore.ProjectStoreV2Error({ + operation: "findActiveByWorkspaceRoot", + cause: new Error("database is locked"), + }), + ), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + Layer.mock(ThreadLaunch.ThreadLaunchService)({}), + ), + ), + ), + ), + ), + ); +}); + +describe("FactorySessions.launch", () => { + const request: Sessions.LaunchRequest = { + commandId: "softfac:issue-5:attempt-1", + projectId, + title: "Build issue 5", + baseRef: "main", + startFromOrigin: true, + message: "/factory 5", + model: { instanceId: "claudeAgent", model: "claude-sonnet-5-5" }, + }; + + it.effect( + "starts a thread in a new worktree, full-access, default interaction, under the caller's command id", + () => + Effect.gen(function* () { + const inputs: Array = []; + const launched = yield* Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + return yield* sessions.launch(request); + }).pipe( + Effect.provide( + layerSessions( + {}, + { + launch: (input) => + Effect.sync(() => { + inputs.push(input); + return { + threadId, + resumed: false, + projection: { + thread: { branch: null, worktreePath: null }, + } as OrchestrationV2ThreadProjection, + }; + }), + }, + ), + ), + ); + assert.deepStrictEqual(launched, { threadId, worktreePath: null, branch: null }); + assert.strictEqual(inputs.length, 1); + const input = inputs[0]!; + assert.strictEqual(input.commandId, "softfac:issue-5:attempt-1"); + assert.strictEqual(input.projectId, projectId); + assert.strictEqual(input.title, "Build issue 5"); + assert.deepStrictEqual(input.modelSelection, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-5-5", + }); + assert.strictEqual(input.runtimeMode, "full-access"); + assert.strictEqual(input.interactionMode, "default"); + assert.deepStrictEqual(input.workspaceStrategy, { + type: "worktree", + baseRef: "main", + startFromOrigin: true, + }); + assert.strictEqual(input.initialMessage?.text, "/factory 5"); + assert.deepStrictEqual(input.initialMessage?.attachments, []); + assert.strictEqual(input.threadId, undefined); + assert.strictEqual(input.reuseExistingThread, undefined); + }), + ); + + it.effect("reports the branch and worktree path once T3 has them", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const launched = yield* sessions.launch(request); + assert.deepStrictEqual(launched, { + threadId, + worktreePath: "/tmp/wt/t3-abc", + branch: "t3/abc", + }); + }).pipe( + Effect.provide( + layerSessions( + {}, + { + launch: () => + Effect.succeed({ + threadId, + resumed: true, + projection: { + thread: { branch: "t3/abc", worktreePath: "/tmp/wt/t3-abc" }, + } as OrchestrationV2ThreadProjection, + }), + }, + ), + ), + ), + ); + + const rejectsWithoutLaunching = (blank: Sessions.LaunchRequest) => + Effect.gen(function* () { + let launches = 0; + const error = yield* Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + return yield* Effect.flip(sessions.launch(blank)); + }).pipe( + Effect.provide( + layerSessions( + {}, + { + launch: () => + Effect.sync(() => { + launches += 1; + return { + threadId, + resumed: false, + projection: { + thread: { branch: null, worktreePath: null }, + } as OrchestrationV2ThreadProjection, + }; + }), + }, + ), + ), + ); + assert.strictEqual(error._tag, "FactorySessionsError"); + assert.strictEqual(error.operation, "launch"); + assert.strictEqual(launches, 0); + }); + + it.effect.each([ + { name: "command id", blank: { ...request, commandId: " " } }, + { name: "project id", blank: { ...request, projectId: " " } }, + { name: "provider instance id", blank: { ...request, model: { instanceId: "", model: "m" } } }, + ])("rejects a blank $name as a sessions error without reaching T3", ({ blank }) => + rejectsWithoutLaunching(blank), + ); + + it.effect("fails as a sessions error when T3 rejects the launch", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const error = yield* Effect.flip(sessions.launch(request)); + assert.strictEqual(error._tag, "FactorySessionsError"); + assert.strictEqual(error.operation, "launch"); + }).pipe( + Effect.provide( + layerSessions( + {}, + { + launch: (input) => + Effect.fail( + new ThreadLaunch.ThreadLaunchError({ + operation: "resolve-project", + commandId: input.commandId, + projectId: input.projectId, + cause: "Project not found.", + }), + ), + }, + ), + ), + ), + ); +}); + +describe("FactorySessions.status and thread", () => { + const appThread = (fields: ReturnType) => + ({ + ...fields, + archivedAt: fields.archivedAt === null ? null : DateTime.makeUnsafe(fields.archivedAt), + }) as unknown as OrchestrationV2AppThread; + + it.effect("status reads the run, its last assistant message and the thread fields", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + assert.deepStrictEqual(yield* sessions.status(threadId), { + run: "completed", + runOrdinal: 2, + lastAssistantMessage: "FACTORY-DONE: https://example.test/pr/1", + worktreePath: "/tmp/wt/t3-abc", + branch: "t3/abc", + archived: false, + }); + }).pipe( + Effect.provide( + layerSessions({ + getThreadRecords: (id) => + Effect.succeed({ + thread: appThread(thread("t3/abc", "/tmp/wt/t3-abc")), + runs: [run(1, "completed"), run(2, "completed")].map((r) => ({ + ...r, + threadId: id, + })), + messages: [ + message(1, "assistant", "first"), + message(2, "assistant", "FACTORY-DONE: https://example.test/pr/1"), + ], + } as never), + }), + ), + ), + ); + + it.effect("status of a thread T3 does not know is not found", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const error = yield* Effect.flip(sessions.status(threadId)); + assert.strictEqual(error._tag, "FactoryThreadNotFoundError"); + if (error._tag === "FactoryThreadNotFoundError") { + assert.strictEqual(error.threadId, threadId); + } + }).pipe( + Effect.provide( + layerSessions({ + getThreadRecords: (id) => + Effect.fail(new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id })), + }), + ), + ), + ); + + it.effect("thread reads the thread's own fields and works when the runs cannot be read", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + assert.deepStrictEqual(yield* sessions.thread(threadId), { + worktreePath: "/tmp/wt/t3-abc", + branch: "t3/abc", + archived: true, + }); + const error = yield* Effect.flip(sessions.status(threadId)); + assert.strictEqual(error._tag, "FactorySessionsError"); + }).pipe( + Effect.provide( + layerSessions({ + getThread: () => + Effect.succeed(appThread(thread("t3/abc", "/tmp/wt/t3-abc", "2026-10-05T00:00:00Z"))), + getThreadRecords: (id) => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: id, + cause: new Error("a run row did not decode"), + }), + ), + }), + ), + ), + ); + + it.effect("a blank thread id names no thread", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + assert.strictEqual( + (yield* Effect.flip(sessions.status("")))._tag, + "FactoryThreadNotFoundError", + ); + assert.strictEqual( + (yield* Effect.flip(sessions.thread(" ")))._tag, + "FactoryThreadNotFoundError", + ); + }).pipe( + Effect.provide( + layerSessions({ + getThread: () => Effect.die("a blank id must not reach the store"), + getThreadRecords: () => Effect.die("a blank id must not reach the store"), + }), + ), + ), + ); + + it.effect("thread fails as a sessions error when the thread cannot be read", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const error = yield* Effect.flip(sessions.thread(threadId)); + assert.strictEqual(error._tag, "FactorySessionsError"); + if (error._tag === "FactorySessionsError") { + assert.strictEqual(error.operation, "thread"); + } + }).pipe( + Effect.provide( + layerSessions({ + getThread: (id) => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: id, + cause: new Error("the thread row did not decode"), + }), + ), + }), + ), + ), + ); + + it.effect("thread of a thread T3 does not know is not found", () => + Effect.gen(function* () { + const sessions = yield* Sessions.FactorySessions; + const error = yield* Effect.flip(sessions.thread(threadId)); + assert.strictEqual(error._tag, "FactoryThreadNotFoundError"); + }).pipe( + Effect.provide( + layerSessions({ + getThread: (id) => + Effect.fail(new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id })), + }), + ), + ), + ); +}); diff --git a/apps/server/src/factory/t3/sessions.ts b/apps/server/src/factory/t3/sessions.ts new file mode 100644 index 0000000000000..4d9747edbb29e --- /dev/null +++ b/apps/server/src/factory/t3/sessions.ts @@ -0,0 +1,287 @@ +import { CommandId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts"; + +/** + * Agent sessions as the factory sees them: softfac's `Sessions` trait + * (`src/t3.rs`) over T3's services, called in-process instead of over the + * wire. Everything above this file uses these types, never T3's. + */ + +/** Where a run is in its life, as T3 Code names it. */ +export const RUN_STATUSES = [ + "preparing", + "queued", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +] as const; +export type RunStatus = (typeof RUN_STATUSES)[number]; + +/** The model a thread runs on, as T3 Code names it. */ +export interface Model { + /** The configured provider instance, e.g. `claudeAgent`. */ + readonly instanceId: string; + /** The model id, e.g. `claude-sonnet-5-5`. */ + readonly model: string; +} + +/** Everything `launch` needs. */ +export interface LaunchRequest { + /** + * Names this command. Sent again after a failure, the same id lets T3 tell a + * retry from a new launch, so callers derive it from what they are launching + * (issue and attempt). + */ + readonly commandId: string; + /** A T3 project id, from `findProject`. */ + readonly projectId: string; + readonly title: string; + /** The ref the thread's worktree starts from. */ + readonly baseRef: string; + /** + * Fetch `origin` first and start from `baseRef`'s tip there, not from the + * clone's local ref: for a resume from a branch an earlier session pushed. + */ + readonly startFromOrigin: boolean; + /** The first message, which starts the first run. */ + readonly message: string; + readonly model: Model; +} + +/** What `launch` returns. */ +export interface Launched { + readonly threadId: string; + /** `null` until T3 Code has prepared the worktree. */ + readonly worktreePath: string | null; + /** `null` until T3 Code has prepared the worktree. */ + readonly branch: string | null; +} + +/** Where a thread works and whether it is archived. */ +export interface Thread { + /** `null` until T3 Code has prepared the worktree. */ + readonly worktreePath: string | null; + /** `null` until T3 Code has prepared the worktree. */ + readonly branch: string | null; + readonly archived: boolean; +} + +/** What `status` reports about a thread. */ +export interface ThreadStatus extends Thread { + /** + * The run with the highest ordinal, if the thread has any run yet. A + * cancelled run that never started is left out, unless every run is one. + */ + readonly run: RunStatus | null; + /** The `ordinal` of that same run; `null` when the thread has no run. */ + readonly runOrdinal: number | null; + /** The text of the last assistant message of that same run; `null` when the run has none. */ + readonly lastAssistantMessage: string | null; +} + +export class ThreadNotFoundError extends Schema.TaggedError()( + "FactoryThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `T3 does not know thread ${this.threadId}.`; + } +} + +export class SessionsError extends Schema.TaggedError()("FactorySessionsError", { + operation: Schema.Literals(["find-project", "launch", "status", "thread"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Factory sessions ${this.operation} failed.`; + } +} + +/** The fields `status` and `thread` read from a thread projection. */ +export interface ThreadSnapshot { + readonly thread: ThreadFields; + readonly runs: ReadonlyArray<{ + /** What each of the run's messages carries as its `runId`. */ + readonly id: string; + readonly ordinal: number; + readonly status: RunStatus; + /** Absent for a run that never started. */ + readonly startedAt?: unknown; + }>; + readonly messages: ReadonlyArray<{ + readonly role: string; + readonly text: string; + /** The `id` of the run the message belongs to. */ + readonly runId: string | null; + }>; +} + +export interface ThreadFields { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly archivedAt: unknown; +} + +export const threadOf = (thread: ThreadFields): Thread => ({ + worktreePath: thread.worktreePath, + branch: thread.branch, + archived: thread.archivedAt !== null && thread.archivedAt !== undefined, +}); + +/** A message queued behind a working run and cancelled before it started. */ +const isCancelledUnstarted = (run: ThreadSnapshot["runs"][number]) => + run.status === "cancelled" && (run.startedAt === null || run.startedAt === undefined); + +const highestOrdinal = (runs: ReadonlyArray) => + runs.reduce( + (best, run) => (best === undefined || run.ordinal >= best.ordinal ? run : best), + undefined, + ); + +export const threadStatusOf = (snapshot: ThreadSnapshot): ThreadStatus => { + // A cancelled run that never started is not the thread's run. When every run + // is one, the highest ordinal among them decides. + const latest = + highestOrdinal(snapshot.runs.filter((run) => !isCancelledUnstarted(run))) ?? + highestOrdinal(snapshot.runs); + // Only the reported run's own message: a run that ended quietly must not be + // read as ending with an older run's result line. A message with no run + // reference is no run's. + const lastAssistantMessage = + latest === undefined + ? null + : (snapshot.messages.findLast( + (message) => message.role === "assistant" && message.runId === latest.id, + )?.text ?? null); + return { + run: latest?.status ?? null, + runOrdinal: latest?.ordinal ?? null, + lastAssistantMessage, + ...threadOf(snapshot.thread), + }; +}; + +export class FactorySessions extends Context.Service< + FactorySessions, + { + /** The id of the live T3 project whose workspace root is exactly `workspaceRoot`. */ + readonly findProject: (workspaceRoot: string) => Effect.Effect; + /** + * Starts a thread in its own worktree and runs `message` in it. The + * worktree is prepared asynchronously, so the answer may not carry a + * branch or path yet; `status` will. The same `commandId` again is the + * same launch: T3 answers with the thread it made and starts nothing. + */ + readonly launch: (request: LaunchRequest) => Effect.Effect; + /** The status of the thread's run, the last assistant message and where the thread works. */ + readonly status: ( + threadId: string, + ) => Effect.Effect; + /** + * Where the thread works and whether it is archived. Only the thread's own + * fields are read, so this answers for a thread whose runs or messages + * `status` cannot read: the stop path uses it, so a session can always be + * stopped. + */ + readonly thread: ( + threadId: string, + ) => Effect.Effect; + } +>()("t3/factory/t3/sessions/FactorySessions") {} + +const isThreadNotFound = Schema.is(ProjectionStore.ProjectionStoreThreadNotFoundError); + +const mapReadError = + (operation: "status" | "thread", threadId: string) => + (cause: ProjectionStore.ProjectionStoreV2Error) => + isThreadNotFound(cause) + ? new ThreadNotFoundError({ threadId }) + : new SessionsError({ operation, cause }); + +// Ids are T3's branded non-blank strings. A blank one is a caller's mistake +// and fails as the declared error, never as a thrown schema defect. +const decodeCommandId = Schema.decodeUnknownEffect(CommandId); +const decodeProjectId = Schema.decodeUnknownEffect(ProjectId); +const decodeInstanceId = Schema.decodeUnknownEffect(ProviderInstanceId); +const decodeThreadId = Schema.decodeUnknownEffect(ThreadId); + +/** A blank thread id names no thread. */ +const requireThreadId = (threadId: string) => + decodeThreadId(threadId).pipe(Effect.mapError(() => new ThreadNotFoundError({ threadId }))); + +export const make = Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const projects = yield* ProjectStore.ProjectStoreV2; + + const findProject: FactorySessions["Service"]["findProject"] = (workspaceRoot) => + projects.findActiveByWorkspaceRoot(workspaceRoot).pipe( + Effect.map(Option.match({ onNone: () => null, onSome: (row) => row.projectId as string })), + Effect.mapError((cause) => new SessionsError({ operation: "find-project", cause })), + ); + + const launch: FactorySessions["Service"]["launch"] = (request) => + Effect.gen(function* () { + const commandId = yield* decodeCommandId(request.commandId); + const projectId = yield* decodeProjectId(request.projectId); + const instanceId = yield* decodeInstanceId(request.model.instanceId); + const result = yield* launches.launch({ + commandId, + projectId, + title: request.title, + modelSelection: { instanceId, model: request.model.model }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { + type: "worktree", + baseRef: request.baseRef, + startFromOrigin: request.startFromOrigin, + }, + initialMessage: { text: request.message, attachments: [] }, + createdBy: "user", + creationSource: "server", + }); + return { + threadId: result.threadId as string, + worktreePath: result.projection.thread.worktreePath, + branch: result.projection.thread.branch, + }; + }).pipe(Effect.mapError((cause) => new SessionsError({ operation: "launch", cause }))); + + // The factory polls this per session, so it reads only the rows + // `threadStatusOf` uses, not the whole projection of a long thread. + const status: FactorySessions["Service"]["status"] = (threadId) => + requireThreadId(threadId).pipe( + Effect.flatMap((id) => + projections + .getThreadRecords(id, ["runs", "messages"], { messageRoles: ["assistant"] }) + .pipe(Effect.map(threadStatusOf), Effect.mapError(mapReadError("status", threadId))), + ), + ); + + const thread: FactorySessions["Service"]["thread"] = (threadId) => + requireThreadId(threadId).pipe( + Effect.flatMap((id) => + projections + .getThread(id) + .pipe(Effect.map(threadOf), Effect.mapError(mapReadError("thread", threadId))), + ), + ); + + return FactorySessions.of({ findProject, launch, status, thread }); +}); + +export const layer = Layer.effect(FactorySessions, make); diff --git a/apps/server/src/factory/tracker/github.test.ts b/apps/server/src/factory/tracker/github.test.ts new file mode 100644 index 0000000000000..8c9442ce1fef8 --- /dev/null +++ b/apps/server/src/factory/tracker/github.test.ts @@ -0,0 +1,1311 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { PROJECT_CONFIG_PATH } from "../config.ts"; +import { FactoryProcess, type RunInput, type RunResult, RunSpawnError } from "../process.ts"; +import { + GitHubFailed, + GitHubOutputTooLarge, + GitHubParse, + GitHubRateLimited, + GitHubSpawnError, + GitHubTimedOut, + LIST_LIMIT, + MARKER, + MAX_FILE_BYTES, + STATUS_MARKER, + type GitHubError, + isNotFound, + makeGitHub, +} from "./github.ts"; + +// ---- the fake FactoryProcess: records every call, answers from a queue ---- + +type Answer = + | { readonly kind: "result"; readonly result: Partial } + | { readonly kind: "spawn" }; + +const reply = (stdout: string, stderr = ""): Answer => ({ + kind: "result", + result: { exitCode: 0, tail: stdout, stderrTail: stderr }, +}); +const fail = (status: number, stderr: string): Answer => ({ + kind: "result", + result: { exitCode: status, stderrTail: stderr }, +}); +const signalKilled = (): Answer => ({ kind: "result", result: { exitCode: null } }); +const spawnError = (): Answer => ({ kind: "spawn" }); +const timedOut = (): Answer => ({ kind: "result", result: { exitCode: null, timedOut: true } }); +const truncated = (): Answer => ({ kind: "result", result: { exitCode: 0, truncated: true } }); + +const REPO = "rawphp/softfac"; +const GH_CONFIG_DIR = "/factory-test/gh-config"; + +const setup = (...answers: ReadonlyArray) => { + const queue = [...answers]; + const inputs: Array = []; + const layer = Layer.succeed( + FactoryProcess, + FactoryProcess.of({ + run: (input) => { + inputs.push(input); + const answer = queue.shift(); + if (answer === undefined || answer.kind === "spawn") { + return Effect.fail( + new RunSpawnError({ + shell: false, + cause: new Error(answer === undefined ? "no queued output" : "gh not found"), + }), + ); + } + return Effect.succeed({ + exitCode: 0, + timedOut: false, + tail: "", + stderrTail: "", + truncated: false, + ...answer.result, + } satisfies RunResult); + }, + }), + ); + return { + github: makeGitHub({ repo: REPO, ghConfigDir: GH_CONFIG_DIR }), + inputs, + calls: (): ReadonlyArray> => inputs.map((input) => input.args ?? []), + ok: (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer)), + err: (effect: Effect.Effect) => + Effect.flip(effect).pipe(Effect.provide(layer)), + }; +}; + +// ---- recorded gh output ---- + +const BASE_SHA = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"; + +const refReply = (sha: string) => + `{"ref":"refs/heads/main","node_id":"REF_x","url":"https://api.github.com/repos/rawphp/softfac/git/refs/heads/main","object":{"sha":"${sha}","type":"commit","url":"https://api.github.com/repos/rawphp/softfac/git/commits/${sha}"}}`; + +const FILE_SHA = BASE_SHA; +const CONFIG_TEXT = "factory:\n instructors: [tomkaczocha]\n base: main\n"; +const CONFIG_B64 = "ZmFjdG9yeToKICBpbnN0cnVjdG9yczogW3RvbWthY3pvY2hhXQogIGJhc2U6IG1haW4K\n"; + +const fileReply = (kind: string, encoding: string, size: number, content: string) => + `{"name":"config.yml","path":".dw/config.yml","sha":"abc","size":${size},"url":"https://api.github.com/repos/rawphp/softfac/contents/.dw/config.yml?ref=main","type":"${kind}","content":${JSON.stringify(content)},"encoding":"${encoding}","_links":{"self":"x"}}`; + +const LIST_ONE = + '[{"assignees":[{"id":"MDQ6VXNlcjE=","login":"rawphp","name":"Tom"}],"number":6,"title":"GitHub client"}]'; +const LIST_MANY = + '[{"assignees":[],"number":6,"title":"GitHub client"},{"assignees":[{"login":"a"},{"login":"b"}],"number":9,"title":"Daemon loop"}]'; + +const listingOf = (count: number) => + `[${Array.from({ length: count }, (_, i) => count - i) + .map((n) => `{"assignees":[],"number":${n},"title":"issue ${n}"}`) + .join(",")}]`; + +const labelsReply = (names: ReadonlyArray) => + `{"labels":[${names + .map((n) => `{"id":"LA_1","name":"${n}","description":"d","color":"0e8a16"}`) + .join(",")}]}`; + +const editCall = (...rest: ReadonlyArray) => [ + "issue", + "edit", + "6", + "--repo", + REPO, + ...rest, +]; +const viewLabelsCall = ["issue", "view", "6", "--repo", REPO, "--json", "labels"]; +const commentCall = (issue: string, body: string) => [ + "issue", + "comment", + issue, + "--repo", + REPO, + "--body", + body, +]; +const listCommentsCall = ["api", "repos/rawphp/softfac/issues/6/comments", "--paginate"]; +const patchCall = (id: string, body: string) => [ + "api", + "-X", + "PATCH", + `repos/rawphp/softfac/issues/comments/${id}`, + "-f", + `body=${body}`, +]; + +const ISSUE_VIEW = + '{"assignees":[{"login":"rawphp"}],"author":{"id":"MDQ6VXNlcjE=","is_bot":false,"login":"tomkaczocha","name":"Tom"},"body":"Build the runner.\\n\\n- one\\n- two","labels":[{"name":"factory:ready"},{"name":"phase:1"}],"number":10,"state":"OPEN","title":"Build step"}'; +const issueViewCall = [ + "issue", + "view", + "10", + "--repo", + REPO, + "--json", + "number,title,body,labels,assignees,state,author", +]; +const issueJson = (labels: ReadonlyArray, assignees: ReadonlyArray, body: string) => + `{"assignees":[${assignees.map((n) => `{"login":"${n}"}`).join(",")}],"author":null,"body":${body},"labels":[${labels.map((n) => `{"name":"${n}"}`).join(",")}],"number":10,"state":"OPEN","title":"Build step"}`; + +// ---- every call goes through gh with the same settings ---- + +it.effect("runs gh with the config dir, a 60 s limit and separate stderr on every call", () => + Effect.gen(function* () { + const t = setup(reply("[]"), reply("[]"), reply(ISSUE_VIEW), reply("")); + yield* t.ok(t.github.listIssues("ready")); + yield* t.ok(t.github.listIssues("ready")); + yield* t.ok(t.github.issue(10)); + yield* t.ok(t.github.assign(6, "rawphp")); + assert.strictEqual(t.inputs.length, 4); + for (const input of t.inputs) { + assert.strictEqual(input.command, "gh"); + assert.strictEqual(input.env?.GH_CONFIG_DIR, GH_CONFIG_DIR); + assert.strictEqual(input.separateStderr, true); + assert.strictEqual(input.timeoutMs, 60_000); + assert.strictEqual(input.tailBytes, 8 * 1024 * 1024); + assert.notStrictEqual(input.shell, true); + } + }), +); + +// ---- branchTip ---- + +it.effect("branchTip reads the full commit id of the branch with the exact call", () => + Effect.gen(function* () { + const t = setup(reply(refReply(BASE_SHA))); + assert.strictEqual(yield* t.ok(t.github.branchTip("main")), BASE_SHA); + assert.deepStrictEqual(t.calls(), [["api", "repos/rawphp/softfac/git/ref/heads/main"]]); + }), +); + +it.effect("branchTip keeps slashes in the branch and encodes everything else", () => + Effect.gen(function* () { + const t = setup(reply(refReply(BASE_SHA)), reply(refReply(BASE_SHA))); + yield* t.ok(t.github.branchTip("release/1.0")); + yield* t.ok(t.github.branchTip("a b?c#d%e")); + const calls = t.calls(); + assert.strictEqual(calls[0]![1], "repos/rawphp/softfac/git/ref/heads/release/1.0"); + assert.strictEqual(calls[1]![1], "repos/rawphp/softfac/git/ref/heads/a%20b%3Fc%23d%25e"); + }), +); + +it.effect("branchTip refuses an answer that is not a commit id or not a commit", () => + Effect.gen(function* () { + for (const answer of [ + refReply("main"), + refReply("9f8e7d6c5b4a3928"), + refReply(BASE_SHA.toUpperCase()).replace("commit", "tag"), + '{"object":{"type":"commit"}}', + "[]", + "not json", + ]) { + const t = setup(reply(answer)); + const error = yield* t.err(t.github.branchTip("main")); + assert.strictEqual(error._tag, "GitHubParse", answer); + } + }), +); + +it.effect("branchTip accepts a 64 digit id", () => + Effect.gen(function* () { + const sha = "a".repeat(64); + const t = setup(reply(refReply(sha))); + assert.strictEqual(yield* t.ok(t.github.branchTip("main")), sha); + }), +); + +it.effect("branchTip refuses a branch name that could leave the refs path, with no call", () => + Effect.gen(function* () { + for (const branch of ["", "..", "../x", "a/../b", "./x", "/x", "x//y", "x/"]) { + const t = setup(reply(refReply(BASE_SHA))); + const error = yield* t.err(t.github.branchTip(branch)); + assert.strictEqual(error._tag, "GitHubParse", branch); + assert.deepStrictEqual(t.calls(), [], branch); + } + }), +); + +it.effect("branchTip of a branch that is not there is the gh failure", () => + Effect.gen(function* () { + const t = setup(fail(1, "gh: Not Found (HTTP 404)")); + const error = yield* t.err(t.github.branchTip("gone")); + assert.strictEqual(error._tag, "GitHubFailed"); + assert.isTrue(isNotFound(error)); + }), +); + +// ---- defaultBranch ---- + +it.effect("defaultBranch reads the repo from the API with the exact call", () => + Effect.gen(function* () { + const t = setup( + reply( + '{"id":1,"name":"softfac","full_name":"rawphp/softfac","default_branch":"main","private":true}', + ), + ); + assert.strictEqual(yield* t.ok(t.github.defaultBranch()), "main"); + assert.deepStrictEqual(t.calls(), [["api", "repos/rawphp/softfac"]]); + }), +); + +it.effect("defaultBranch refuses an answer without one", () => + Effect.gen(function* () { + for (const answer of [ + '{"name":"softfac"}', + '{"default_branch":""}', + '{"default_branch":null}', + "[]", + "no", + ]) { + const t = setup(reply(answer)); + const error = yield* t.err(t.github.defaultBranch()); + assert.strictEqual(error._tag, "GitHubParse", answer); + } + }), +); + +// ---- fileAt ---- + +it.effect("fileAt reads a file at a commit with the exact call and decodes it", () => + Effect.gen(function* () { + const t = setup(reply(fileReply("file", "base64", 51, CONFIG_B64))); + assert.strictEqual(yield* t.ok(t.github.fileAt(".dw/config.yml", FILE_SHA)), CONFIG_TEXT); + assert.deepStrictEqual(t.calls(), [ + ["api", `repos/rawphp/softfac/contents/.dw/config.yml?ref=${FILE_SHA}`], + ]); + }), +); + +it.effect("fileAt decodes content wrapped over several lines and padded", () => + Effect.gen(function* () { + const b64 = + "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh\nYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQo=\n"; + const t = setup(reply(fileReply("file", "base64", 101, b64))); + assert.strictEqual( + yield* t.ok(t.github.fileAt(".dw/config.yml", FILE_SHA)), + `${"a".repeat(100)}\n`, + ); + }), +); + +it.effect("fileAt refuses a file over the size cap even when its content is whole", () => + Effect.gen(function* () { + // The size check must refuse it on its own: the content matches the stated size. + const size = MAX_FILE_BYTES + 1; + const t = setup(reply(fileReply("file", "base64", size, btoa("a".repeat(size))))); + const error = yield* t.err(t.github.fileAt(".dw/config.yml", FILE_SHA)); + assert.strictEqual(error._tag, "GitHubParse"); + assert.ok( + error._tag === "GitHubParse" && error.expected.includes("it is too big"), + error.message, + ); + }), +); + +it.effect("fileAt refuses what is not a plain complete file", () => + Effect.gen(function* () { + for (const answer of [ + // A directory is a JSON array. + '[{"type":"file","name":"config.yml"}]', + fileReply("symlink", "base64", 51, CONFIG_B64), + fileReply("submodule", "base64", 51, CONFIG_B64), + // A file over 1 MB comes with no content and the encoding `none`. + fileReply("file", "none", 2_000_000, ""), + // The size says more than the content holds: truncated. + fileReply("file", "base64", 5_000, CONFIG_B64), + fileReply("file", "base64", 10, CONFIG_B64), + // Over the size this reader accepts, whatever the content says. + fileReply("file", "base64", MAX_FILE_BYTES + 1, CONFIG_B64), + // Not base64, not UTF-8. + fileReply("file", "base64", 51, "!!!not base64!!!"), + fileReply("file", "base64", 3, "//4K\n"), + "not json", + ]) { + const t = setup(reply(answer)); + const error = yield* t.err(t.github.fileAt(".dw/config.yml", FILE_SHA)); + assert.strictEqual(error._tag, "GitHubParse", answer); + } + }), +); + +it.effect("fileAt refuses base64 whose padding does not fit its length", () => + Effect.gen(function* () { + // `YQ==` is "a" and `YWI=` is "ab". One `=` after two characters, or two after three, + // is not base64. + for (const [b64, size] of [ + ["YQ=", 1], + ["YWI==", 2], + ["YQ", 1], + ["YQ==YQ==", 2], + ] as const) { + const t = setup(reply(fileReply("file", "base64", size, b64))); + const error = yield* t.err(t.github.fileAt(".dw/config.yml", FILE_SHA)); + assert.strictEqual(error._tag, "GitHubParse", b64); + } + for (const [b64, want] of [ + ["YQ==", "a"], + ["YWI=", "ab"], + ] as const) { + const t = setup(reply(fileReply("file", "base64", want.length, b64))); + assert.strictEqual(yield* t.ok(t.github.fileAt(".dw/config.yml", FILE_SHA)), want); + } + }), +); + +it.effect( + "fileAt makes no call for a ref that is not a full commit id or a path that could leave", + () => + Effect.gen(function* () { + for (const [path, sha] of [ + [".dw/config.yml", "main"], + [".dw/config.yml", "9f8e7d6c"], + [".dw/config.yml", "9f8e7d6c5b4a39281706f5e4d3c2b1a09876543g"], + ["../x", FILE_SHA], + ["a//b", FILE_SHA], + ["", FILE_SHA], + ["a b", FILE_SHA], + ["a?ref=main", FILE_SHA], + ] as const) { + const t = setup(reply(fileReply("file", "base64", 51, CONFIG_B64))); + const error = yield* t.err(t.github.fileAt(path, sha)); + assert.strictEqual(error._tag, "GitHubParse", `${path} ${sha}`); + assert.deepStrictEqual(t.calls(), [], `${path} ${sha}`); + } + }), +); + +it.effect("fileAt of a file that is not there is the gh failure", () => + Effect.gen(function* () { + const t = setup(fail(1, "gh: Not Found (HTTP 404)")); + const error = yield* t.err(t.github.fileAt(".dw/config.yml", FILE_SHA)); + assert.strictEqual(error._tag, "GitHubFailed"); + }), +); + +// ---- labelNames, createLabel, ensureLabels ---- + +/** Literal copies of softfac's lifecycle.rs table, so drift in either module is caught. */ +const LABEL_TABLE = [ + [ + "proposed", + "c5def5", + "A bot filed it. The factory ignores it until a person sets `factory:ready`.", + ], + ["ready", "0e8a16", "Approved for the factory."], + ["triage", "fbca04", "Claimed. The session is being launched."], + [ + "needs-info", + "d93f0b", + "The factory asked questions on the issue. Answer them, then set `factory:ready`.", + ], + ["building", "1d76db", "A session is building it."], + ["pr-open", "5319e7", "The PR is open and has passed the gate."], + ["stuck", "b60205", "The factory gave up and said why in a comment."], +] as const; +const ALL_LABEL_NAMES = LABEL_TABLE.map(([state]) => `factory:${state}`); +const createCall = (state: string, color: string, description: string) => [ + "label", + "create", + `factory:${state}`, + "--repo", + REPO, + "--color", + color, + "--description", + description, +]; + +it.effect("ensureLabels on an empty repo creates all seven labels exactly", () => + Effect.gen(function* () { + const t = setup(reply("[]"), ...LABEL_TABLE.map(() => reply(""))); + const created = yield* t.ok(t.github.ensureLabels()); + assert.deepStrictEqual( + created, + LABEL_TABLE.map(([state]) => state), + ); + assert.deepStrictEqual(t.calls().slice(1), [ + createCall( + "proposed", + "c5def5", + "A bot filed it. The factory ignores it until a person sets `factory:ready`.", + ), + createCall("ready", "0e8a16", "Approved for the factory."), + createCall("triage", "fbca04", "Claimed. The session is being launched."), + createCall( + "needs-info", + "d93f0b", + "The factory asked questions on the issue. Answer them, then set `factory:ready`.", + ), + createCall("building", "1d76db", "A session is building it."), + createCall("pr-open", "5319e7", "The PR is open and has passed the gate."), + createCall("stuck", "b60205", "The factory gave up and said why in a comment."), + ]); + }), +); + +it.effect("labelNames lists every page with the exact call", () => + Effect.gen(function* () { + const t = setup( + reply('[{"name":"bug","color":"d73a4a"}]'), + reply("[]"), + reply('[{"name":"a"}][{"name":"b"}]'), + ); + assert.deepStrictEqual(yield* t.ok(t.github.labelNames()), ["bug"]); + assert.deepStrictEqual(t.calls()[0], [ + "api", + "repos/rawphp/softfac/labels?per_page=100", + "--paginate", + ]); + // One array per page reads the same as one merged array. + yield* t.ok(t.github.labelNames()); + assert.deepStrictEqual(yield* t.ok(t.github.labelNames()), ["a", "b"]); + }), +); + +it.effect("labelNames of a repo gh cannot read is the gh failure", () => + Effect.gen(function* () { + const t = setup(fail(1, "gh: Not Found (HTTP 404)")); + assert.strictEqual((yield* t.err(t.github.labelNames()))._tag, "GitHubFailed"); + }), +); + +it.effect("createLabel sends the name, colour and description as arguments", () => + Effect.gen(function* () { + const t = setup(reply("")); + yield* t.ok(t.github.createLabel("factory:ready", "0e8a16", "Approved for the factory")); + assert.deepStrictEqual(t.calls(), [ + [ + "label", + "create", + "factory:ready", + "--repo", + REPO, + "--color", + "0e8a16", + "--description", + "Approved for the factory", + ], + ]); + }), +); + +it.effect("a refused createLabel is the gh failure", () => + Effect.gen(function* () { + const t = setup(fail(1, "HTTP 403: Resource not accessible")); + const error = yield* t.err(t.github.createLabel("factory:ready", "0e8a16", "d")); + assert.strictEqual(error._tag, "GitHubFailed"); + }), +); + +it.effect("ensureLabels creates only the missing factory labels, in table order", () => + Effect.gen(function* () { + const t = setup( + reply('[{"name":"bug"},{"name":"factory:ready"}][{"name":"factory:stuck"}]'), + reply(""), + reply(""), + reply(""), + reply(""), + reply(""), + ); + const created = yield* t.ok(t.github.ensureLabels()); + assert.deepStrictEqual(created, ["proposed", "triage", "needs-info", "building", "pr-open"]); + const calls = t.calls(); + assert.strictEqual(calls.length, 6); + assert.deepStrictEqual(calls[1], [ + "label", + "create", + "factory:proposed", + "--repo", + REPO, + "--color", + "c5def5", + "--description", + "A bot filed it. The factory ignores it until a person sets `factory:ready`.", + ]); + assert.deepStrictEqual( + calls.slice(2).map((call) => call[2]), + ["factory:triage", "factory:needs-info", "factory:building", "factory:pr-open"], + ); + }), +); + +it.effect("ensureLabels creates nothing when every label exists", () => + Effect.gen(function* () { + const t = setup(reply(JSON.stringify(ALL_LABEL_NAMES.map((name) => ({ name }))))); + assert.deepStrictEqual(yield* t.ok(t.github.ensureLabels()), []); + assert.strictEqual(t.calls().length, 1); + }), +); + +it.effect("ensureLabels stops at the first failure", () => + Effect.gen(function* () { + const t = setup(reply("[]"), reply(""), fail(1, "HTTP 403"), reply("")); + const error = yield* t.err(t.github.ensureLabels()); + assert.strictEqual(error._tag, "GitHubFailed"); + assert.strictEqual(t.calls().length, 3); + }), +); + +it.effect( + "a NUL byte in an argument is refused before gh starts and never reaches the message", + () => + Effect.gen(function* () { + const t = setup(reply("")); + const error = yield* t.err(t.github.postComment(6, "secret\u0000tail")); + assert.strictEqual(error._tag, "GitHubSpawnError"); + const bytes = new TextEncoder().encode("secret\u0000tail\n\n").length; + assert.strictEqual( + error.message, + `cannot start \`gh issue comment 6 --repo rawphp/softfac --body <${bytes} bytes>\`: argument contains a NUL byte`, + ); + assert.notInclude(error.message, "secret"); + assert.strictEqual(t.calls().length, 0); + }), +); + +// ---- baseConfig ---- + +it.effect("baseConfig reads the branch, its tip and the config file at that tip", () => + Effect.gen(function* () { + const t = setup( + reply('{"default_branch":"main"}'), + reply(refReply(BASE_SHA)), + reply(fileReply("file", "base64", 51, CONFIG_B64)), + ); + const base = yield* t.ok(t.github.baseConfig()); + assert.deepStrictEqual(base, { branch: "main", tip: BASE_SHA, text: CONFIG_TEXT }); + assert.deepStrictEqual(t.calls(), [ + ["api", "repos/rawphp/softfac"], + ["api", "repos/rawphp/softfac/git/ref/heads/main"], + ["api", `repos/rawphp/softfac/contents/${PROJECT_CONFIG_PATH}?ref=${BASE_SHA}`], + ]); + }), +); + +it.effect("baseConfig passes on a missing file and any other failure", () => + Effect.gen(function* () { + const missing = setup( + reply('{"default_branch":"main"}'), + reply(refReply(BASE_SHA)), + fail(1, "gh: Not Found (HTTP 404)"), + ); + const notFound = yield* missing.err(missing.github.baseConfig()); + assert.isTrue(isNotFound(notFound)); + + const server = setup( + reply('{"default_branch":"main"}'), + reply(refReply(BASE_SHA)), + fail(1, "gh: Server Error (HTTP 502)"), + ); + const failed = yield* server.err(server.github.baseConfig()); + assert.strictEqual(failed._tag, "GitHubFailed"); + assert.isFalse(isNotFound(failed)); + + const first = setup(fail(1, "gh: Not Found (HTTP 404)")); + assert.strictEqual((yield* first.err(first.github.baseConfig()))._tag, "GitHubFailed"); + assert.strictEqual(first.calls().length, 1); + }), +); + +// ---- listIssues ---- + +it.effect("listIssues with none", () => + Effect.gen(function* () { + const t = setup(reply("[]")); + const issues = yield* t.ok(t.github.listIssues("ready")); + assert.deepStrictEqual(issues, { items: [], cut: false }); + assert.deepStrictEqual(t.calls(), [ + [ + "issue", + "list", + "--repo", + REPO, + "--state", + "open", + "--label", + "factory:ready", + "--limit", + "201", + "--json", + "number,title,assignees", + ], + ]); + }), +); + +it.effect("listIssues with one ignores unknown fields", () => + Effect.gen(function* () { + const t = setup(reply(LIST_ONE)); + const issues = yield* t.ok(t.github.listIssues("needs-info")); + assert.deepStrictEqual(issues.items, [ + { number: 6, title: "GitHub client", assignees: ["rawphp"] }, + ]); + assert.strictEqual(t.calls()[0]![7], "factory:needs-info"); + }), +); + +it.effect("listIssues with several", () => + Effect.gen(function* () { + const t = setup(reply(LIST_MANY)); + const issues = yield* t.ok(t.github.listIssues("ready")); + assert.deepStrictEqual(issues.items, [ + { number: 6, title: "GitHub client", assignees: [] }, + { number: 9, title: "Daemon loop", assignees: ["a", "b"] }, + ]); + }), +); + +it.effect("listIssues past the limit returns the first 200 and says it was cut", () => + Effect.gen(function* () { + assert.strictEqual(LIST_LIMIT, 200); + const t = setup(reply(listingOf(201))); + const issues = yield* t.ok(t.github.listIssues("ready")); + assert.isTrue(issues.cut); + assert.strictEqual(issues.items.length, 200); + assert.deepStrictEqual( + issues.items.map((item) => item.number), + Array.from({ length: 200 }, (_, i) => 201 - i), + ); + assert.strictEqual(t.calls()[0]![9], "201"); + }), +); + +it.effect("listIssues at the limit returns them all and says nothing", () => + Effect.gen(function* () { + const t = setup(reply(listingOf(200))); + const issues = yield* t.ok(t.github.listIssues("ready")); + assert.isFalse(issues.cut); + assert.strictEqual(issues.items.length, 200); + }), +); + +// ---- issue ---- + +it.effect("issue reads the real shape with the exact call", () => + Effect.gen(function* () { + const t = setup(reply(ISSUE_VIEW)); + const detail = yield* t.ok(t.github.issue(10)); + assert.deepStrictEqual(t.calls(), [issueViewCall]); + assert.deepStrictEqual(detail, { + number: 10, + title: "Build step", + body: "Build the runner.\n\n- one\n- two", + labels: ["factory:ready", "phase:1"], + assignees: ["rawphp"], + author: "tomkaczocha", + state: "ready", + closed: false, + }); + }), +); + +it.effect("issue says when GitHub has it closed and keeps its factory label", () => + Effect.gen(function* () { + const t = setup(reply(ISSUE_VIEW.replace('"state":"OPEN"', '"state":"CLOSED"'))); + const detail = yield* t.ok(t.github.issue(10)); + assert.isTrue(detail.closed); + assert.strictEqual(detail.state, "ready"); + }), +); + +it.effect("issue author is null when gh gives null or nothing", () => + Effect.gen(function* () { + const t = setup( + reply(issueJson([], [], '"b"')), + reply(ISSUE_VIEW.replace(/"author":\{[^}]*\},/, "")), + ); + assert.strictEqual((yield* t.ok(t.github.issue(10))).author, null); + assert.strictEqual((yield* t.ok(t.github.issue(10))).author, null); + }), +); + +it.effect("issue state is null with no factory label", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["phase:1", "bug"], [], '"b"'))); + const detail = yield* t.ok(t.github.issue(10)); + assert.strictEqual(detail.state, null); + assert.deepStrictEqual(detail.assignees, []); + }), +); + +it.effect("issue state is the one factory label", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["bug", "factory:needs-info"], [], '"b"'))); + assert.strictEqual((yield* t.ok(t.github.issue(10))).state, "needs-info"); + }), +); + +it.effect("issue state is null with two factory labels", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["factory:ready", "factory:building"], [], '"b"'))); + assert.strictEqual((yield* t.ok(t.github.issue(10))).state, null); + }), +); + +it.effect("issue state is null for an unknown factory label", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["factory:bogus"], [], '"b"'))); + assert.strictEqual((yield* t.ok(t.github.issue(10))).state, null); + }), +); + +it.effect("issue state is null with a bogus and a real factory label in either order", () => + Effect.gen(function* () { + for (const labels of [ + ["factory:bogus", "factory:ready"], + ["factory:ready", "factory:bogus"], + ]) { + const t = setup(reply(issueJson(labels, [], '"b"'))); + assert.strictEqual((yield* t.ok(t.github.issue(10))).state, null, labels.join()); + } + }), +); + +it.effect("issue with a null body has an empty body and keeps every assignee", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["factory:ready"], ["a", "b"], "null"))); + const detail = yield* t.ok(t.github.issue(10)); + assert.strictEqual(detail.body, ""); + assert.deepStrictEqual(detail.assignees, ["a", "b"]); + }), +); + +it.effect("issue errors are typed", () => + Effect.gen(function* () { + const failing = setup(fail(1, "not found")); + const failed = yield* failing.err(failing.github.issue(10)); + assert.strictEqual(failed._tag, "GitHubFailed"); + assert.strictEqual(failed._tag === "GitHubFailed" ? failed.status : 0, 1); + + const wrong = setup(reply("[]")); + assert.strictEqual((yield* wrong.err(wrong.github.issue(10)))._tag, "GitHubParse"); + + const nullAssignees = setup(reply('{"assignees":null}')); + assert.strictEqual( + (yield* nullAssignees.err(nullAssignees.github.issue(10)))._tag, + "GitHubParse", + ); + }), +); + +it.effect("issue uses the number it is given", () => + Effect.gen(function* () { + const t = setup(reply(issueJson(["factory:ready"], [], '"b"'))); + yield* t.ok(t.github.issue(7)); + assert.deepStrictEqual(t.calls()[0]!.slice(0, 3), ["issue", "view", "7"]); + }), +); + +// ---- setState ---- + +it.effect("setState from one wrong label", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["factory:ready"])), reply("")); + yield* t.ok(t.github.setState(6, "triage")); + assert.deepStrictEqual(t.calls(), [ + viewLabelsCall, + editCall("--add-label", "factory:triage", "--remove-label", "factory:ready"), + ]); + }), +); + +it.effect("setState from two factory labels", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["factory:ready", "factory:stuck"])), reply("")); + yield* t.ok(t.github.setState(6, "building")); + assert.deepStrictEqual(t.calls(), [ + viewLabelsCall, + editCall( + "--add-label", + "factory:building", + "--remove-label", + "factory:ready", + "--remove-label", + "factory:stuck", + ), + ]); + }), +); + +it.effect("setState removes extras when the target is already present", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["factory:triage", "factory:ready"])), reply("")); + yield* t.ok(t.github.setState(6, "triage")); + assert.deepStrictEqual(t.calls(), [ + viewLabelsCall, + editCall("--remove-label", "factory:ready"), + ]); + }), +); + +it.effect("setState adds when there is no factory label", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["phase:1"])), reply("")); + yield* t.ok(t.github.setState(6, "ready")); + assert.deepStrictEqual(t.calls(), [viewLabelsCall, editCall("--add-label", "factory:ready")]); + }), +); + +it.effect("setState already correct makes no edit", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["phase:1", "factory:building"]))); + yield* t.ok(t.github.setState(6, "building")); + assert.deepStrictEqual(t.calls(), [viewLabelsCall]); + }), +); + +it.effect("setState leaves non-factory labels alone", () => + Effect.gen(function* () { + const t = setup( + reply(labelsReply(["phase:1", "factory:ready", "bug", "factory-ish"])), + reply(""), + ); + yield* t.ok(t.github.setState(6, "triage")); + assert.deepStrictEqual( + t.calls()[1], + editCall("--add-label", "factory:triage", "--remove-label", "factory:ready"), + ); + }), +); + +it.effect("setState reads the real view shape", () => + Effect.gen(function* () { + const real = + '{"assignees":[{"login":"rawphp"}],"labels":[{"id":"x","name":"factory:ready","description":"d","color":"0e8a16"},{"name":"phase:1"}]}'; + const t = setup(reply(real), reply("")); + yield* t.ok(t.github.setState(6, "triage")); + assert.strictEqual(t.calls().length, 2); + }), +); + +it.effect("setState surfaces a failing edit", () => + Effect.gen(function* () { + const t = setup(reply(labelsReply(["factory:ready"])), fail(1, "label does not exist")); + const error = yield* t.err(t.github.setState(6, "triage")); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.strictEqual(error.stderr, "label does not exist"); + assert.deepStrictEqual(error.args.slice(0, 3), ["issue", "edit", "6"]); + assert.strictEqual(t.calls().length, 2); + }), +); + +// ---- assign, unassign ---- + +it.effect("unassign removes the user", () => + Effect.gen(function* () { + const t = setup(reply("")); + yield* t.ok(t.github.unassign(6, "rawphp")); + assert.deepStrictEqual(t.calls(), [editCall("--remove-assignee", "rawphp")]); + }), +); + +it.effect("assign adds the user and a failure is an error", () => + Effect.gen(function* () { + const t = setup(reply("")); + yield* t.ok(t.github.assign(6, "rawphp")); + assert.deepStrictEqual(t.calls(), [editCall("--add-assignee", "rawphp")]); + + const failing = setup(fail(1, "HTTP 403\n")); + assert.strictEqual( + (yield* failing.err(failing.github.assign(6, "rawphp")))._tag, + "GitHubFailed", + ); + }), +); + +// ---- error typing ---- + +it.effect("a non-zero exit carries args, status and stderr", () => + Effect.gen(function* () { + const t = setup(fail(4, "HTTP 404: Not Found\n")); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.deepStrictEqual(error.args.slice(0, 2), ["issue", "list"]); + assert.strictEqual(error.status, 4); + assert.strictEqual(error.stderr, "HTTP 404: Not Found\n"); + assert.include(error.message, "gh issue list --repo rawphp/softfac"); + assert.include(error.message, "status 4"); + assert.include(error.message, "HTTP 404: Not Found"); + }), +); + +it("only a failure with HTTP 404 in stderr is not found", () => { + const args = ["api", "repos/rawphp/softfac/branches/x"]; + const failed = (stderr: string) => new GitHubFailed({ args, status: 1, stderr }); + assert.isTrue(isNotFound(failed("gh: Not Found (HTTP 404)"))); + assert.isTrue(isNotFound(failed("HTTP 404: Not Found\n"))); + const others: ReadonlyArray = [ + failed("gh: Server Error (HTTP 500)"), + failed("gh: Forbidden (HTTP 403)"), + new GitHubTimedOut({ args, seconds: 30 }), + new GitHubSpawnError({ args, cause: new Error("HTTP 404") }), + new GitHubOutputTooLarge({ args, limit: 1024 }), + new GitHubParse({ args, expected: "HTTP 404" }), + new GitHubRateLimited({ args, stderr: "HTTP 404 API rate limit" }), + ]; + for (const other of others) assert.isFalse(isNotFound(other), other._tag); +}); + +it.effect("a signal-killed gh is a failure with status -1", () => + Effect.gen(function* () { + const t = setup(signalKilled()); + const error = yield* t.err(t.github.unassign(6, "rawphp")); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.strictEqual(error.status, -1); + assert.include(error.message, "status -1"); + }), +); + +it.effect("malformed JSON is a parse error carrying the args", () => + Effect.gen(function* () { + const t = setup(reply("not json")); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubParse"); + if (error._tag !== "GitHubParse") return; + assert.deepStrictEqual(error.args, t.calls()[0]); + assert.include(error.expected, "JSON array of issues"); + assert.include(error.message, "unexpected output"); + assert.include(error.message, "gh issue list --repo rawphp/softfac"); + + const labels = setup(reply('{"labels":"nope"}')); + const labelsError = yield* labels.err(labels.github.setState(6, "ready")); + assert.strictEqual(labelsError._tag, "GitHubParse"); + if (labelsError._tag !== "GitHubParse") return; + assert.deepStrictEqual(labelsError.args, viewLabelsCall); + assert.strictEqual(labels.calls().length, 1); + }), +); + +it.effect("a spawn failure is a spawn error carrying the args", () => + Effect.gen(function* () { + const t = setup(spawnError()); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubSpawnError"); + if (error._tag !== "GitHubSpawnError") return; + assert.deepStrictEqual(error.args, t.calls()[0]); + assert.include(error.message, "gh issue list --repo rawphp/softfac"); + assert.include(error.message, "gh not found"); + + const unassign = setup(spawnError()); + const unassignError = yield* unassign.err(unassign.github.unassign(6, "rawphp")); + assert.strictEqual(unassignError._tag, "GitHubSpawnError"); + if (unassignError._tag !== "GitHubSpawnError") return; + assert.deepStrictEqual(unassignError.args, editCall("--remove-assignee", "rawphp")); + }), +); + +it.effect("a timed out gh is a timeout error", () => + Effect.gen(function* () { + const t = setup(timedOut()); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubTimedOut"); + if (error._tag !== "GitHubTimedOut") return; + assert.strictEqual(error.seconds, 60); + assert.include(error.message, "timed out after 60 s"); + }), +); + +it.effect("output past the cap is an error, never a truncation", () => + Effect.gen(function* () { + const t = setup(truncated()); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubOutputTooLarge"); + if (error._tag !== "GitHubOutputTooLarge") return; + assert.strictEqual(error.limit, 8 * 1024 * 1024); + assert.include(error.message, `printed more than ${8 * 1024 * 1024} bytes`); + }), +); + +// ---- rate limit ---- + +it.effect("only the rate limit is read as a rate limit", () => + Effect.gen(function* () { + for (const stderr of [ + "GraphQL: API rate limit already exceeded for user ID 1.", + "gh: API rate limit exceeded (HTTP 403)", + ]) { + const t = setup(fail(1, stderr)); + const error = yield* t.err(t.github.listIssues("ready")); + assert.strictEqual(error._tag, "GitHubRateLimited"); + if (error._tag !== "GitHubRateLimited") return; + assert.strictEqual(error.stderr, stderr); + assert.include(error.args, "list"); + assert.include(error.message, "gh issue list --repo rawphp/softfac"); + assert.include(error.message, `hit GitHub's rate limit: ${stderr}`); + } + const other = setup(fail(1, "HTTP 404: Not Found")); + assert.strictEqual((yield* other.err(other.github.listIssues("ready")))._tag, "GitHubFailed"); + }), +); + +it.effect("a success that mentions the rate limit is not an error", () => + Effect.gen(function* () { + const t = setup(reply("[]", "API rate limit")); + const issues = yield* t.ok(t.github.listIssues("ready")); + assert.deepStrictEqual(issues, { items: [], cut: false }); + }), +); + +// ---- postComment ---- + +it.effect("postComment appends the marker", () => + Effect.gen(function* () { + const t = setup(reply("https://github.com/rawphp/softfac/issues/6#issuecomment-1\n")); + yield* t.ok(t.github.postComment(6, "hello")); + assert.deepStrictEqual(t.calls(), [commentCall("6", "hello\n\n")]); + assert.strictEqual(MARKER, ""); + assert.strictEqual(STATUS_MARKER, ""); + }), +); + +it.effect("untrusted bodies arrive as one unmodified argument", () => + Effect.gen(function* () { + const nasty = "$(rm -rf ~) `id` \"q\" 'q' ; | && > /tmp/x\nsecond line\n--add-label evil"; + const expected = `${nasty}\n\n${MARKER}`; + const status = `${nasty}\n\n${STATUS_MARKER}`; + + const post = setup(reply("")); + yield* post.ok(post.github.postComment(6, nasty)); + assert.deepStrictEqual(post.calls(), [commentCall("6", expected)]); + + const create = setup(reply("[]"), reply("")); + yield* create.ok(create.github.upsertStatusComment(6, "rawphp", nasty)); + assert.deepStrictEqual(create.calls()[1], commentCall("6", status)); + + const patch = setup( + reply('[{"id":77,"user":{"login":"rawphp"},"body":"old\\n\\n"}]'), + reply(""), + ); + yield* patch.ok(patch.github.upsertStatusComment(6, "rawphp", nasty)); + assert.deepStrictEqual(patch.calls()[1], patchCall("77", status)); + }), +); + +it.effect("postComment errors carry the status and stderr", () => + Effect.gen(function* () { + const t = setup(fail(1, "issue is locked")); + const error = yield* t.err(t.github.postComment(6, "x")); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.strictEqual(error.status, 1); + assert.strictEqual(error.stderr, "issue is locked"); + assert.include(error.message, "issue is locked"); + }), +); + +// ---- upsertStatusComment ---- + +it.effect("the status comment is created when absent", () => + Effect.gen(function* () { + const listing = + '[{"id":1,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:13:16Z","body":"a person\'s comment"},{"id":2,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:14:16Z","body":"questions\\n\\n"}]'; + const t = setup(reply(listing), reply("")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "state: building")); + assert.deepStrictEqual(t.calls(), [ + listCommentsCall, + commentCall("6", "state: building\n\n"), + ]); + }), +); + +it.effect("the status comment is patched by id when present", () => + Effect.gen(function* () { + const listing = + '[{"id":1,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:13:16Z","body":"hi"},{"id":5978910929,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:14:16Z","body":"state: triage\\n\\n"}]'; + const t = setup(reply(listing), reply("{}")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "state: building")); + assert.deepStrictEqual(t.calls(), [ + listCommentsCall, + patchCall("5978910929", "state: building\n\n"), + ]); + }), +); + +it.effect("the status comment is never created twice", () => + Effect.gen(function* () { + const created = `[{"id":42,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:13:16Z","body":"one\\n\\n${STATUS_MARKER}"}]`; + const t = setup(reply("[]"), reply(""), reply(created), reply("{}")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "one")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "two")); + const calls = t.calls(); + assert.strictEqual(calls.length, 4); + assert.strictEqual( + calls.filter((call) => call[0] === "issue" && call[1] === "comment").length, + 1, + ); + assert.deepStrictEqual(calls[3]!.slice(0, 4), [ + "api", + "-X", + "PATCH", + "repos/rawphp/softfac/issues/comments/42", + ]); + }), +); + +it.effect("a status marker posted by someone else is not adopted", () => + Effect.gen(function* () { + const listing = + '[{"id":10,"user":{"login":"stranger"},"created_at":"2026-10-04T10:13:16Z","body":"gotcha\\n\\n"},{"id":11,"user":null,"created_at":"2026-10-04T10:14:16Z","body":"ghost\\n\\n"}]'; + const t = setup(reply(listing), reply("")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "real status")); + assert.deepStrictEqual(t.calls(), [ + listCommentsCall, + commentCall("6", "real status\n\n"), + ]); + }), +); + +it.effect("a status marker in the middle of the factory's comment is not adopted", () => + Effect.gen(function* () { + const listing = + '[{"id":10,"user":{"login":"rawphp"},"created_at":"2026-10-04T10:13:16Z","body":"quoting and more text"}]'; + const t = setup(reply(listing), reply("")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "s")); + assert.deepStrictEqual(t.calls(), [ + listCommentsCall, + commentCall("6", "s\n\n"), + ]); + }), +); + +it.effect("the factory's own status comment is found despite case and trailing whitespace", () => + Effect.gen(function* () { + const listing = + '[{"id":10,"user":{"login":"stranger"},"body":"fake\\n\\n"},{"id":12,"user":{"login":"RawPhp"},"body":"state: triage\\n\\n\\n\\n"}]'; + const t = setup(reply(listing), reply("{}")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "s")); + assert.deepStrictEqual(t.calls()[1], patchCall("12", "s\n\n")); + }), +); + +it.effect("the status comment user match is exact, not a prefix", () => + Effect.gen(function* () { + const listing = + '[{"id":10,"user":{"login":"rawphp-bot"},"body":"a\\n\\n"},{"id":11,"user":{"login":"raw"},"body":"b\\n\\n"}]'; + const t = setup(reply(listing), reply("")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "s")); + assert.deepStrictEqual(t.calls(), [ + listCommentsCall, + commentCall("6", "s\n\n"), + ]); + }), +); + +it.effect("the status comment is found on a later page", () => + Effect.gen(function* () { + // `gh api --paginate` may print each page as its own array. + const pages = + '[{"id":1,"body":"a"}][{"id":2,"body":"b"},{"id":3,"user":{"login":"rawphp"},"body":"state\\n\\n"}]'; + const t = setup(reply(pages), reply("{}")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "x")); + assert.strictEqual(t.calls()[1]![3], "repos/rawphp/softfac/issues/comments/3"); + }), +); + +it.effect("pages split correctly around brackets and quotes inside strings", () => + Effect.gen(function* () { + const pages = + '[{"id":1,"user":{"login":"x"},"body":"] [ } \\" {"}]\n[{"id":3,"user":{"login":"rawphp"},"body":"s\\n\\n"}]'; + const t = setup(reply(pages), reply("{}")); + yield* t.ok(t.github.upsertStatusComment(6, "rawphp", "x")); + assert.strictEqual(t.calls()[1]![3], "repos/rawphp/softfac/issues/comments/3"); + }), +); + +it.effect("status comment errors", () => + Effect.gen(function* () { + const spawn = setup(spawnError()); + assert.strictEqual( + (yield* spawn.err(spawn.github.upsertStatusComment(6, "rawphp", "x")))._tag, + "GitHubSpawnError", + ); + + const oops = setup(reply("oops")); + assert.strictEqual( + (yield* oops.err(oops.github.upsertStatusComment(6, "rawphp", "x")))._tag, + "GitHubParse", + ); + assert.strictEqual(oops.calls().length, 1); + + const empty = setup(reply("")); + const emptyError = yield* empty.err(empty.github.upsertStatusComment(6, "rawphp", "x")); + assert.strictEqual(emptyError._tag, "GitHubParse"); + if (emptyError._tag !== "GitHubParse") return; + assert.deepStrictEqual(emptyError.args, listCommentsCall); + assert.include(emptyError.expected, "output was empty"); + + const failing = setup(reply("[]"), fail(1, "rate limited")); + const failed = yield* failing.err(failing.github.upsertStatusComment(6, "rawphp", "x")); + assert.strictEqual(failed._tag, "GitHubFailed"); + if (failed._tag !== "GitHubFailed") return; + assert.strictEqual(failed.stderr, "rate limited"); + }), +); + +it.effect("the status comment surfaces a failing patch", () => + Effect.gen(function* () { + const listing = + '[{"id":12,"user":{"login":"rawphp"},"body":"state: triage\\n\\n"}]'; + const t = setup(reply(listing), fail(1, "validation failed")); + const error = yield* t.err(t.github.upsertStatusComment(6, "rawphp", "s")); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.strictEqual(error.stderr, "validation failed"); + assert.deepStrictEqual(error.args.slice(0, 3), ["api", "-X", "PATCH"]); + assert.strictEqual(t.calls().length, 2); + }), +); + +// ---- error text ---- + +it.effect("error text hides comment bodies but the error keeps them", () => + Effect.gen(function* () { + // "hello\nworld" + "\n\n" + the 16-byte marker = 29 bytes. + const t = setup(fail(1, "boom")); + const error = yield* t.err(t.github.postComment(6, "hello\nworld")); + assert.strictEqual( + error.message, + "`gh issue comment 6 --repo rawphp/softfac --body <29 bytes>` exited with status 1: boom", + ); + assert.strictEqual(error._tag, "GitHubFailed"); + if (error._tag !== "GitHubFailed") return; + assert.strictEqual(error.args.at(-1), "hello\nworld\n\n"); + + // Counted in bytes, not characters: "héllo ✓" is 7 chars and 10 bytes, plus 18. + const utf8 = setup(fail(1, "boom")); + const shown = (yield* utf8.err(utf8.github.postComment(6, "héllo ✓"))).message; + assert.include(shown, "--body <28 bytes>"); + assert.notInclude(shown, "llo"); + }), +); + +it.effect("error text hides raw field bodies", () => + Effect.gen(function* () { + const listing = + '[{"id":77,"user":{"login":"rawphp"},"body":"old\\n\\n"}]'; + const t = setup(reply(listing), fail(1, "gone")); + const shown = (yield* t.err(t.github.upsertStatusComment(6, "rawphp", "private status"))) + .message; + assert.include(shown, "repos/rawphp/softfac/issues/comments/77 -f body=<39 bytes>"); + assert.notInclude(shown, "private"); + }), +); + +it("error text escapes newlines in other arguments", () => { + assert.strictEqual( + new GitHubParse({ args: ["issue", "edit", "a\nb\r\nc"], expected: "x" }).message, + "`gh issue edit a\\nb\\r\\nc` returned unexpected output: x", + ); + assert.strictEqual( + new GitHubSpawnError({ args: ["--body"], cause: new Error("nope") }).message, + "cannot start `gh --body`: nope", + ); +}); diff --git a/apps/server/src/factory/tracker/github.ts b/apps/server/src/factory/tracker/github.ts new file mode 100644 index 0000000000000..d701147365582 --- /dev/null +++ b/apps/server/src/factory/tracker/github.ts @@ -0,0 +1,649 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { PROJECT_CONFIG_PATH } from "../config.ts"; +import { + STATES, + stateColor, + stateDescription, + stateFromLabel, + stateLabel, + type State, +} from "../domain/lifecycle.ts"; +import { FactoryProcess } from "../process.ts"; + +/** + * GitHub issues, labels, comments and files through the `gh` CLI, ported from + * softfac's github.rs. Every call runs `gh` through `FactoryProcess`, so a test + * swaps in a fake that records argv and answers from a queue. + */ + +/** How many issues one listing of a label reads; past this the oldest are left out. */ +export const LIST_LIMIT = 200; +/** Ends every comment the factory posts, so it can tell its own comments from instructors'. */ +export const MARKER = ""; +/** Ends the single status comment instead of {@link MARKER}. */ +export const STATUS_MARKER = ""; +/** The most a file read through the contents API may be. A project config is a few lines. */ +export const MAX_FILE_BYTES = 64 * 1024; + +const GH_TIMEOUT_MS = 60_000; +const GH_OUTPUT_LIMIT = 8 * 1024 * 1024; + +// ---- errors ---- + +const utf8Bytes = (text: string): number => new TextEncoder().encode(text).length; + +/** + * The command for error messages. Comment bodies (the value after `--body`, or a + * `body=` raw field) are replaced by their size, and newlines are escaped, so one + * error stays one line. The error's `args` keep everything. + */ +const command = (args: ReadonlyArray): string => { + const shown = ["gh"]; + let afterBodyFlag = false; + for (const arg of args) { + if (afterBodyFlag) shown.push(`<${utf8Bytes(arg)} bytes>`); + else if (arg.startsWith("body=")) shown.push(`body=<${utf8Bytes(arg.slice(5))} bytes>`); + else shown.push(arg.replaceAll("\r", "\\r").replaceAll("\n", "\\n")); + afterBodyFlag = arg === "--body"; + } + return shown.join(" "); +}; + +const causeText = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +/** `gh` could not be started (not installed, not on PATH, ...). */ +export class GitHubSpawnError extends Schema.TaggedError()("GitHubSpawnError", { + args: Schema.Array(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return `cannot start \`${command(this.args)}\`: ${causeText(this.cause)}`; + } +} + +/** `gh` ran and exited non-zero (status -1 when a signal ended it). */ +export class GitHubFailed extends Schema.TaggedError()("GitHubFailed", { + args: Schema.Array(Schema.String), + status: Schema.Number, + stderr: Schema.String, +}) { + override get message(): string { + return `\`${command(this.args)}\` exited with status ${this.status}: ${this.stderr.trimEnd()}`; + } +} + +/** `gh` was still running at the time limit and was killed with everything it started. */ +export class GitHubTimedOut extends Schema.TaggedError()("GitHubTimedOut", { + args: Schema.Array(Schema.String), + seconds: Schema.Number, +}) { + override get message(): string { + return `${command(this.args)} timed out after ${this.seconds} s`; + } +} + +/** `gh` printed more than `limit` bytes. Output is never cut short: stdout is parsed whole. */ +export class GitHubOutputTooLarge extends Schema.TaggedError()( + "GitHubOutputTooLarge", + { args: Schema.Array(Schema.String), limit: Schema.Number }, +) { + override get message(): string { + return `${command(this.args)} printed more than ${this.limit} bytes`; + } +} + +/** `gh` succeeded but its output was not what the call expected. */ +export class GitHubParse extends Schema.TaggedError()("GitHubParse", { + args: Schema.Array(Schema.String), + expected: Schema.String, +}) { + override get message(): string { + return `\`${command(this.args)}\` returned unexpected output: ${this.expected}`; + } +} + +/** `gh` failed because the GitHub rate limit for its login is used up. Retry later. */ +export class GitHubRateLimited extends Schema.TaggedError()( + "GitHubRateLimited", + { args: Schema.Array(Schema.String), stderr: Schema.String }, +) { + override get message(): string { + return `\`${command(this.args)}\` hit GitHub's rate limit: ${this.stderr.trim()}`; + } +} + +export type GitHubError = + | GitHubSpawnError + | GitHubFailed + | GitHubTimedOut + | GitHubOutputTooLarge + | GitHubParse + | GitHubRateLimited; + +/** GitHub saying the thing asked for is not there: `gh` failed with a 404. */ +export const isNotFound = (error: GitHubError): boolean => + error._tag === "GitHubFailed" && error.stderr.includes("HTTP 404"); + +// ---- gh's JSON ---- + +const Login = Schema.Struct({ login: Schema.String }); +const OptionalText = Schema.optional(Schema.NullOr(Schema.String)); + +const IssueSummaries = Schema.Array( + Schema.Struct({ + number: Schema.Number, + title: Schema.String, + assignees: Schema.Array(Login), + }), +); + +const IssueView = Schema.Struct({ + number: Schema.Number, + title: Schema.String, + body: OptionalText, + labels: Schema.Array(Schema.Struct({ name: Schema.String })), + assignees: Schema.Array(Login), + state: Schema.Literals(["OPEN", "CLOSED"]), + author: Schema.optional(Schema.NullOr(Login)), +}); + +const LabelsView = Schema.Struct({ labels: Schema.Array(Schema.Struct({ name: Schema.String })) }); +const LabelRows = Schema.Array(Schema.Struct({ name: Schema.String })); + +const CommentRows = Schema.Array( + Schema.Struct({ + id: Schema.Number, + user: Schema.optional(Schema.NullOr(Login)), + body: OptionalText, + }), +); + +const RepoView = Schema.Struct({ default_branch: OptionalText }); + +const RefView = Schema.Struct({ + object: Schema.Struct({ sha: Schema.String, type: Schema.String }), +}); + +const FileView = Schema.Struct({ + type: Schema.String, + encoding: OptionalText, + size: Schema.Number, + content: OptionalText, +}); + +export interface IssueSummary { + readonly number: number; + readonly title: string; + readonly assignees: ReadonlyArray; +} + +/** `cut` is set when the listing had more than {@link LIST_LIMIT} issues. */ +export interface Listing { + readonly items: ReadonlyArray; + readonly cut: boolean; +} + +export interface IssueDetail { + readonly number: number; + readonly title: string; + /** "" for an issue with no description. */ + readonly body: string; + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; + /** The author's login; null when GitHub gives none (a deleted account). */ + readonly author: string | null; + /** The lifecycle state of the one `factory:*` label; null for none, several or an unknown one. */ + readonly state: State | null; + /** GitHub has the issue closed. */ + readonly closed: boolean; +} + +export interface BaseConfig { + readonly branch: string; + readonly tip: string; + readonly text: string; +} + +// ---- parsing helpers ---- + +const isJsonSpace = (char: string): boolean => + char === " " || char === "\n" || char === "\r" || char === "\t"; + +/** + * Splits text holding several top-level JSON values (`gh --paginate` prints one + * array per page) into the text of each. Brackets and quotes inside strings do + * not count. A value that is cut short comes back as is, for the decoder to refuse. + */ +const splitJsonValues = (text: string): Array => { + const values: Array = []; + let i = 0; + while (true) { + while (i < text.length && isJsonSpace(text[i]!)) i++; + if (i >= text.length) return values; + const start = i; + let depth = 0; + let inString = false; + for (; i < text.length; i++) { + const char = text[i]!; + if (inString) { + if (char === "\\") i++; + else if (char === '"') { + inString = false; + if (depth === 0) { + i++; + break; + } + } + } else if (char === '"') inString = true; + else if (char === "[" || char === "{") depth++; + else if (char === "]" || char === "}") { + depth--; + if (depth <= 0) { + i++; + break; + } + } else if (depth === 0 && isJsonSpace(char)) break; + } + values.push(text.slice(start, i)); + } +}; + +/** + * Standard base64 as GitHub's contents API writes it: wrapped with newlines, + * padded with `=`. Null for anything else, including data after padding and + * padding that does not fit the length. + */ +const decodeBase64 = (text: string): Uint8Array | null => { + const out: Array = []; + let bits = 0; + let have = 0; + let padding = 0; + for (const char of text) { + let value: number; + if (char >= "A" && char <= "Z") value = char.charCodeAt(0) - 65; + else if (char >= "a" && char <= "z") value = char.charCodeAt(0) - 97 + 26; + else if (char >= "0" && char <= "9") value = char.charCodeAt(0) - 48 + 52; + else if (char === "+") value = 62; + else if (char === "/") value = 63; + else if (char === "=") { + padding++; + continue; + } else if (char === "\n" || char === "\r") continue; + else return null; + // Nothing may follow padding. + if (padding > 0) return null; + bits = (bits << 6) | value; + have += 6; + if (have >= 8) { + have -= 8; + out.push((bits >> have) & 0xff); + bits &= (1 << have) - 1; + } + } + // What is left over is padding bits, which must be zero, and the padding must fit. + const fits = + (have === 0 && padding === 0) || (have === 4 && padding === 2) || (have === 2 && padding === 1); + return fits && bits === 0 ? Uint8Array.from(out) : null; +}; + +const decodeUtf8 = (bytes: Uint8Array): string | null => { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return null; + } +}; + +/** A branch name as a path under `refs/heads/` in the API; null for a name that could leave it. */ +const encodeBranch = (branch: string): string | null => { + if (branch.split("/").some((part) => part === "" || part === "." || part === "..")) return null; + let out = ""; + for (const byte of new TextEncoder().encode(branch)) { + const char = String.fromCharCode(byte); + out += /[A-Za-z0-9\-._~/]/.test(char) + ? char + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return out; +}; + +const isPlainSegment = (part: string): boolean => + part !== "." && part !== ".." && /^[A-Za-z0-9._-]+$/.test(part); + +const isCommitId = (text: string): boolean => /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(text); + +/** For a value shown in an error message. */ +const quoted = (text: string): string => `"${text}"`; + +const withMarker = (body: string, marker: string): string => `${body}\n\n${marker}`; + +// ---- the client ---- + +export interface GitHubOptions { + /** `owner/name`. */ + readonly repo: string; + /** The `gh` login directory (`GH_CONFIG_DIR`) the calls run as. */ + readonly ghConfigDir: string; +} + +type GitHubEffect = Effect.Effect; + +export const makeGitHub = (options: GitHubOptions) => { + const { repo } = options; + + /** Runs `gh` and returns stdout, or the typed error. */ + const gh = (args: ReadonlyArray): GitHubEffect => + Effect.gen(function* () { + // Node's own spawn error quotes the offending argument, which could be a hidden comment body. + if (args.some((arg) => arg.includes("\u0000"))) { + return yield* new GitHubSpawnError({ + args, + cause: new Error("argument contains a NUL byte"), + }); + } + const factoryProcess = yield* FactoryProcess; + const result = yield* factoryProcess + .run({ + command: "gh", + args, + env: { GH_CONFIG_DIR: options.ghConfigDir }, + timeoutMs: GH_TIMEOUT_MS, + tailBytes: GH_OUTPUT_LIMIT, + separateStderr: true, + }) + .pipe(Effect.mapError((error) => new GitHubSpawnError({ args, cause: error.cause }))); + if (result.timedOut) { + return yield* new GitHubTimedOut({ args, seconds: GH_TIMEOUT_MS / 1000 }); + } + if (result.truncated) { + return yield* new GitHubOutputTooLarge({ args, limit: GH_OUTPUT_LIMIT }); + } + if (result.exitCode !== 0) { + if (result.stderrTail.includes("API rate limit")) { + return yield* new GitHubRateLimited({ args, stderr: result.stderrTail }); + } + return yield* new GitHubFailed({ + args, + status: result.exitCode ?? -1, + stderr: result.stderrTail, + }); + } + return result.tail; + }); + + const parseError = (args: ReadonlyArray, expected: string) => + new GitHubParse({ args, expected }); + + /** Decodes `text` as JSON of the shape `schema`; the schema's complaint joins `expected`. */ + const decode = ( + schema: Schema.Codec, + args: ReadonlyArray, + expected: string, + text: string, + ): GitHubEffect => + Schema.decodeEffect(Schema.fromJsonString(schema))(text).pipe( + Effect.mapError((error) => + parseError(args, `${expected} (${String(error.message).replaceAll(/\s+/g, " ")})`), + ), + ); + + /** Runs `gh` and decodes its stdout as JSON of the shape `schema`. */ + const ghJson = ( + args: ReadonlyArray, + schema: Schema.Codec, + expected: string, + ): GitHubEffect => + gh(args).pipe(Effect.flatMap((stdout) => decode(schema, args, expected, stdout))); + + /** + * Runs `gh api --paginate` and collects the rows of every JSON array in stdout: + * gh prints either one merged array or one array per page. + */ + const ghPages = ( + args: ReadonlyArray, + schema: Schema.Codec, E>, + expected: string, + ): GitHubEffect> => + Effect.gen(function* () { + const pages = splitJsonValues(yield* gh(args)); + if (pages.length === 0) { + return yield* parseError(args, `${expected} (output was empty)`); + } + const rows: Array = []; + for (const page of pages) rows.push(...(yield* decode(schema, args, expected, page))); + return rows; + }); + + const repoArgs = (parts: ReadonlyArray): Array => [...parts, "--repo", repo]; + + const listIssues = (state: State): GitHubEffect> => + Effect.gen(function* () { + const args = [ + ...repoArgs(["issue", "list"]), + "--state", + "open", + "--label", + stateLabel(state), + "--limit", + String(LIST_LIMIT + 1), + "--json", + "number,title,assignees", + ]; + const raw = yield* ghJson(args, IssueSummaries, "a JSON array of issues"); + return { + items: raw.slice(0, LIST_LIMIT).map((item) => ({ + number: item.number, + title: item.title, + assignees: item.assignees.map((assignee) => assignee.login), + })), + cut: raw.length > LIST_LIMIT, + }; + }); + + const issue = (number: number): GitHubEffect => + Effect.gen(function* () { + const args = [ + ...repoArgs(["issue", "view", String(number)]), + "--json", + "number,title,body,labels,assignees,state,author", + ]; + const raw = yield* ghJson(args, IssueView, "an object describing an issue"); + const labels = raw.labels.map((label) => label.name); + const factory = labels.filter((name) => name.startsWith("factory:")); + return { + number: raw.number, + title: raw.title, + body: raw.body ?? "", + labels, + assignees: raw.assignees.map((assignee) => assignee.login), + author: raw.author?.login ?? null, + state: factory.length === 1 ? (stateFromLabel(factory[0]!) ?? null) : null, + closed: raw.state === "CLOSED", + }; + }); + + /** Moves the issue to `state`: adds its label, removes every other `factory:*` label, touches nothing else. */ + const setState = (number: number, state: State): GitHubEffect => + Effect.gen(function* () { + const label = stateLabel(state); + const view = [...repoArgs(["issue", "view", String(number)]), "--json", "labels"]; + const current = yield* ghJson(view, LabelsView, "an object with `labels`"); + const names = current.labels.map((l) => l.name); + const present = names.includes(label); + const stale = names.filter((name) => name.startsWith("factory:") && name !== label); + if (present && stale.length === 0) return; + const edit = repoArgs(["issue", "edit", String(number)]); + if (!present) edit.push("--add-label", label); + for (const name of stale) edit.push("--remove-label", name); + yield* gh(edit); + }); + + const assign = (number: number, user: string): GitHubEffect => + gh([...repoArgs(["issue", "edit", String(number)]), "--add-assignee", user]).pipe( + Effect.asVoid, + ); + + const unassign = (number: number, user: string): GitHubEffect => + gh([...repoArgs(["issue", "edit", String(number)]), "--remove-assignee", user]).pipe( + Effect.asVoid, + ); + + /** Posts a comment on the issue, ending with the factory marker. */ + const postComment = (number: number, body: string): GitHubEffect => + gh([ + ...repoArgs(["issue", "comment", String(number)]), + "--body", + withMarker(body, MARKER), + ]).pipe(Effect.asVoid); + + /** + * Writes the issue's single status comment: edits `user`'s existing one in place, + * or creates it. Only a comment by `user` ending with the status marker counts, so + * nobody else can take it over. A list-then-write, so concurrent callers could + * still create two. + */ + const upsertStatusComment = (number: number, user: string, body: string): GitHubEffect => + Effect.gen(function* () { + const list = ["api", `repos/${repo}/issues/${number}/comments`, "--paginate"]; + const existing = yield* ghPages(list, CommentRows, "JSON arrays of comments"); + const status = existing.find( + (comment) => + comment.user?.login.toLowerCase() === user.toLowerCase() && + (comment.body ?? "").trimEnd().endsWith(STATUS_MARKER), + ); + const text = withMarker(body, STATUS_MARKER); + if (status === undefined) { + yield* gh([...repoArgs(["issue", "comment", String(number)]), "--body", text]); + } else { + yield* gh([ + "api", + "-X", + "PATCH", + `repos/${repo}/issues/comments/${status.id}`, + "-f", + `body=${text}`, + ]); + } + }); + + const labelNames = (): GitHubEffect> => + ghPages( + ["api", `repos/${repo}/labels?per_page=100`, "--paginate"], + LabelRows, + "JSON arrays of labels", + ).pipe(Effect.map((rows) => rows.map((row) => row.name))); + + /** `color` is six hex digits without the `#`. Creating a label that exists is an error. */ + const createLabel = (name: string, color: string, description: string): GitHubEffect => + gh([ + ...repoArgs(["label", "create", name]), + "--color", + color, + "--description", + description, + ]).pipe(Effect.asVoid); + + /** Creates the factory labels the repo lacks, in lifecycle order; returns their states. */ + const ensureLabels = (): GitHubEffect> => + Effect.gen(function* () { + const existing = new Set(yield* labelNames()); + const created: Array = []; + for (const state of STATES) { + if (existing.has(stateLabel(state))) continue; + yield* createLabel(stateLabel(state), stateColor(state), stateDescription(state)); + created.push(state); + } + return created; + }); + + const defaultBranch = (): GitHubEffect => + Effect.gen(function* () { + const args = ["api", `repos/${repo}`]; + const view = yield* ghJson(args, RepoView, "an object with the repo's default branch"); + if ( + view.default_branch === undefined || + view.default_branch === null || + view.default_branch === "" + ) { + return yield* parseError(args, "an object with the repo's default branch (it had none)"); + } + return view.default_branch; + }); + + /** The full commit id at the tip of `branch`. */ + const branchTip = (branch: string): GitHubEffect => + Effect.gen(function* () { + const encoded = encodeBranch(branch); + if (encoded === null) { + return yield* parseError( + ["api"], + `a branch name without empty or dot segments, got ${quoted(branch)}`, + ); + } + const args = ["api", `repos/${repo}/git/ref/heads/${encoded}`]; + const view = yield* ghJson(args, RefView, "an object with the commit the ref points at"); + if (view.object.type !== "commit" || !isCommitId(view.object.sha)) { + return yield* parseError(args, "a commit and its full 40 or 64 digit id"); + } + return view.object.sha; + }); + + /** + * The text of the file at `path` in the commit `sha` (a full 40 or 64 digit id, never + * a ref name), read through the contents API. Only a regular file of at most 64 KiB + * whose content arrived whole is accepted; every refusal is a Parse error. `path` is + * plain `/`-separated segments of letters, digits, `.`, `_` and `-`. + */ + const fileAt = (path: string, sha: string): GitHubEffect => + Effect.gen(function* () { + if (!path.split("/").every(isPlainSegment)) { + return yield* parseError(["api"], `a plain file path, got ${quoted(path)}`); + } + if (!isCommitId(sha)) return yield* parseError(["api"], "a full commit id"); + const args = ["api", `repos/${repo}/contents/${path}?ref=${sha}`]; + const file = yield* ghJson(args, FileView, "an object describing a file"); + const bad = (why: string) => parseError(args, `a regular file read whole (${why})`); + if (file.type !== "file") return yield* bad("it is not a regular file"); + if (file.size > MAX_FILE_BYTES) return yield* bad("it is too big"); + if (file.encoding !== "base64") return yield* bad("its content did not come as base64"); + const bytes = decodeBase64(file.content ?? ""); + if (bytes === null) return yield* bad("its content is not valid base64"); + if (bytes.length !== file.size) + return yield* bad("its content is not as long as its size says"); + const text = decodeUtf8(bytes); + if (text === null) return yield* bad("it is not UTF-8"); + return text; + }); + + /** + * The project config on the default branch: the branch, its tip and the file. The + * file is read at that tip, so the three agree. A missing file is the 404 failure. + */ + const baseConfig = (): GitHubEffect => + Effect.gen(function* () { + const branch = yield* defaultBranch(); + const tip = yield* branchTip(branch); + const text = yield* fileAt(PROJECT_CONFIG_PATH, tip); + return { branch, tip, text }; + }); + + return { + listIssues, + issue, + setState, + assign, + unassign, + postComment, + upsertStatusComment, + labelNames, + createLabel, + ensureLabels, + defaultBranch, + branchTip, + fileAt, + baseConfig, + }; +}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23981a932e052..35e3b9f55037d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -7,6 +7,7 @@ import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; +import { FactoryLayer } from "./factory/index.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; @@ -550,6 +551,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // Asks T3 Connect to deliver webhooks it held while this environment was offline. HeldHooksWaker.layer, layerThreadSettlementWorker, + FactoryLayer, StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), layerThreadPullRequestWorker, Layer.effectDiscard( diff --git a/factory/README.md b/factory/README.md new file mode 100644 index 0000000000000..411db6bd7717d --- /dev/null +++ b/factory/README.md @@ -0,0 +1,27 @@ +# Factory + +This is `original-solutions/t3code-factory`, a fork of `pingdotgg/t3code` that runs the software factory inside the T3 server. The design is in [docs/design.md](docs/design.md). + +## Seam rule + +The only upstream files this fork edits are the ones listed in [SEAMS](SEAMS). [UPSTREAM](UPSTREAM) names the upstream ref and commit the fork is based on. `scripts/check-seams.sh` diffs the committed HEAD against that commit (commit first; staged, unstaged and untracked changes are not checked) and fails on any changed path that is not factory-owned (`apps/server/src/factory/`, `apps/web/src/factory/`, `factory/`, `.dw/`, `apps/web/src/routes/factory*`, `.github/workflows/factory-*`) and not listed in `SEAMS`. + +## Boundary rule + +Only `apps/server/src/factory/t3/` may import T3 internals. Other files under `apps/server/src/factory/` import relative paths only from inside that folder, and no package that `apps/server/package.json` takes from the workspace (`@t3tools/*`, `effect-acp`, ...). Nothing outside `apps/server/src/factory/` imports the factory, except `apps/server/src/server.ts`. `apps/server/src/factory/boundary.test.ts` enforces both. + +## Checks + +`factory/scripts/check.sh` runs these steps in order, printing each name first, and stops at the first failure with that step's exit code: + +1. `factory/scripts/check-seams.sh` +2. `vp test run apps/server/src/factory` +3. `vp run --filter t3 typecheck` +4. `vp run --filter @t3tools/web typecheck` + +The same script is the factory gate (`gate` in `.dw/config.yml`) and the `factory-ci` GitHub workflow. To run it locally from the repo root: + +```sh +vp i +factory/scripts/check.sh +``` diff --git a/factory/SEAMS b/factory/SEAMS new file mode 100644 index 0000000000000..c8f6dc05b4c4f --- /dev/null +++ b/factory/SEAMS @@ -0,0 +1 @@ +apps/server/src/server.ts diff --git a/factory/UPSTREAM b/factory/UPSTREAM new file mode 100644 index 0000000000000..3323b4b3bd273 --- /dev/null +++ b/factory/UPSTREAM @@ -0,0 +1 @@ +v0.0.46-nightly.20261015.3145 339179f50a84dc599cce373dbb48f172f182f44e diff --git a/factory/docs/design.md b/factory/docs/design.md new file mode 100644 index 0000000000000..36106624fa6b2 --- /dev/null +++ b/factory/docs/design.md @@ -0,0 +1,237 @@ +# Factory inside a T3 Code fork: experiment design + +Status: draft for Tom's review. Date: 2026-10-15. + +## Why + +softfac and T3 Code are two apps today. softfac is a Rust daemon that drives a separate `t3 serve` over an undocumented wire format. That means two installs, two LaunchAgents, two update paths, two logs, and an orchestration layer that copies thread state into its own ledger so it can survive crashes between "send" and "record". + +The experiment puts the factory inside a fork of T3 Code as one product. The factory code knows T3's internals. T3's code never imports the factory. The fork keeps taking upstream changes. + +This is an experiment. The Rust softfac keeps running and keeps building its repos. The fork replaces it only if the numbers below say it should. + +## What we know about T3 Code (main at 339179f, 2026-10-15) + +- pnpm monorepo on Node 24, Effect v4, vitest, oxlint, all wrapped by the `vp` (Vite+) toolchain. MIT licence. +- `apps/server` is the `t3` CLI. `apps/web` is React 19 with TanStack Router and file-based routes. `apps/desktop` is Electron. +- The server is one Effect `Layer` graph in `apps/server/src/server.ts`. Background workers sit in `layerRuntimeCoreDependenciesBase` as `Layer.effectDiscard(...)` entries. `PullRequestSyncReactor` is a worker that reads projections and dispatches commands, which is the same shape the factory needs. +- Orchestration v2 is event-sourced on SQLite (`/userdata/statev2.sqlite`). In-process entry points: + - `OrchestratorV2.dispatch(command)` for every thread command, with command receipts, so a command id is idempotent. + - `EventSinkV2.stream({ threadId?, afterSequence?, eventType? })` replays from a sequence and then goes live. + - `ProjectionStoreV2` is the read model. + - `ThreadLaunchService` starts a thread in its own worktree. + - `ProcessRunner.run({ command, args, cwd, env, timeout, maxOutputBytes })` returns stdout, stderr, exit code and a timed-out flag. +- There is no plugin mechanism. Upstream's `AGENTS.md` says many users run forks. +- Upstream moves fast: about 350 commits a week across the repo, about 24 a week in `orchestration-v2/`, about 10 in `server.ts` and about 14 in `ws.ts`. +- Release: `vp pack` builds the server bundle, and `T3CODE_PACK_EXE=1` wraps it as a Node single-executable app (`t3`). `scripts/build-cli-archive.ts` puts `t3`, `client/` (the web build), `resource-monitor/` (a small Rust binary) and the native `node_modules/` into one archive. Nightly versions are `-nightly..`, released every 30 minutes when main has changed. + +## Shape + +``` +┌──────────────────────── t3 serve (the fork, one process) ────────────────────────┐ +│ │ +│ upstream T3 (untouched) factory (new directories only) │ +│ ───────────────────────── ────────────────────────────── │ +│ OrchestratorV2 ◄──── dispatch ───────────── factory/t3/sessions.ts │ +│ EventSinkV2 ─────── stream(run.updated) ──► factory/t3/events.ts ─┐ │ +│ ProjectionStoreV2 ◄─ read thread/run ─────── factory/t3/sessions.ts│ │ +│ ThreadLaunchService ◄ launch ─────────────── factory/t3/sessions.ts│ │ +│ node:child_process ◄ gh, git, gate cmds ──── factory/process.ts │ │ +│ ServerConfig ─────── base dir, worktrees ──► factory/t3/paths.ts │ │ +│ ▼ │ +│ server.ts +1 line: FactoryLayer ──────────► factory/index.ts ─► pass loop │ +│ server.ts +1 line: FactoryRoutes ─────────► factory/http.ts (60s + wakes) │ +│ │ +│ web: routes/factory.tsx, routes/factory.$runId.tsx ◄── /api/factory/* │ +└──────────────────────────────────────────────────────────────────────────────────┘ + │ gh CLI (GH_CONFIG_DIR) │ JSONL ledger + ▼ ▼ + GitHub issues, labels, PRs /factory/runs/*.jsonl +``` + +One process, one binary, one LaunchAgent, one log. Agent sessions are ordinary T3 threads, so the T3 UI shows them next to a Factory page. + +## Code layout in the fork + +``` +apps/server/src/factory/ + index.ts FactoryLayer: the only symbol server.ts imports + process.ts node:child_process with env allowlist, process group kill and a bounded output tail + t3/ the adapter: the only factory code that imports T3 internals + sessions.ts launch, send, archive, status over ThreadLaunchService, OrchestratorV2, ProjectionStoreV2 + events.ts EventSinkV2.stream → wake the run that owns the thread + paths.ts ServerConfig → base dir, worktrees dir, factory dir + domain/ pure: lifecycle states, result-line parsing, approval, limits, fairness + ledger/ JSONL ledger, same format as softfac docs/reference/ledger.md + tracker/github.ts gh CLI calls (the same argv softfac uses today) + runner/ start, building, gating, feedback, interruptions + gate.ts checkout + provision + gate commands + pass.ts one pass: claims, ordering, slots, then advance each run + config.ts /factory/config.yml and the repo's .dw/config.yml factory block + http.ts /api/factory/status, /runs, /runs/:id, /runs/:id/stop, /claims/pause +apps/web/src/routes/factory.tsx, factory.$runId.tsx +apps/web/src/factory/ page components +factory/ repo root: SEAMS, sync/build/install scripts, docs +.github/workflows/factory-*.yml the fork's own CI, sync and release +``` + +### Seams: the only upstream files we edit + +`factory/SEAMS` lists them, and CI fails any change to an upstream file not on the list: + +1. `apps/server/src/server.ts`: one line adding `FactoryLayer` to `layerRuntimeCoreDependenciesBase`, and one adding the factory route layer to `layerMakeRoutes`. +2. One nav link in the web sidebar. +3. `apps/web/src/routeTree.gen.ts`: generated, so a merge regenerates it instead of resolving it by hand. + +Three entries. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. + +### Boundary rules, checked by a test + +- Only `apps/server/src/factory/t3/**` may import T3 code: files outside `apps/server/src/factory/`, or the workspace packages (`@t3tools/*` and the rest) the server depends on. +- No file outside `factory/` may import from it, except the two lines in `server.ts`. +- The checks run as a vitest test in `apps/server/src/factory/boundary.test.ts`, so they run with the factory suite. + +When upstream changes an internal API, the break lands as a type error in `factory/t3/`. Everything above the adapter uses softfac-shaped types (`Launched`, `ThreadStatus`, `Thread`) that we own. + +## How the factory runs inside T3 + +The behaviour is softfac's, as written down in `docs/lifecycle.md` and pinned by `src/runner/tests.rs`. What changes is what sits underneath it. + +| softfac today | In the fork | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `Sessions` trait over HTTP and WebSocket, protocol header, bearer token, websocket ticket | `factory/t3/sessions.ts` calls services in-process. No token, no ticket, no protocol version. | +| Poll every 60s for thread status | Still a 60s pass for GitHub, plus `EventSinkV2.stream` wakes a run as soon as its thread's run settles | +| Ledger copies thread id, branch, worktree path and run ordinals | The ledger records factory events. Thread facts are read from `ProjectionStoreV2` when needed. | +| `forward-wait`, `gate-sending` and similar steps survive a crash between "send" and "record" | Kept, but simpler: T3's command receipts dedupe a retried `softfac::…` command id in the same process | +| Gate runs as a detached `softfac gate` child that outlives the daemon | Gate runs through `process.ts` inside the server. A server restart kills it, and the next pass re-runs the gate from a fresh checkout. | +| `t3 install`, `rollback`, pairing, Open T3 deadline, two LaunchAgents | Gone. One binary, versioned as a whole. | +| `status`, `runs list/show/stop`, menubar | The Factory page and `/api/factory/*`. A small CLI can come later if needed. | + +Kept exactly: + +- The `/factory` skill and its last-line protocol (`FACTORY-DONE`, `FACTORY-STOP`). The skill does not know which factory launched it. +- The `factory:*` labels, instructors, `.dw/config.yml` factory block, claims by assignee and label, and comment wording. +- The ledger format. `softfac retro --data-dir /factory` then reads the fork's runs with the same code that reads the Rust factory's runs, so the comparison is like for like. + +Persistence: JSONL files under `/factory/`, the same layout as `~/.local/share/softfac`. No changes to T3's SQLite and no migrations. If a later version wants factory events in T3's store, that is a separate decision. + +## Scope of the experiment (v1) + +In: + +- One machine (this Mac), one factory identity (the `rawphp` gh login), GitHub issues only. +- Claim, start, building, result line, gate, gate retries, `pr-open` re-gating, feedback relaunch, interruptions, time limits, `needs-info` and `stuck`. +- Factory page: runs, state, current step, last gate output, stop, pause claims. + +Out, until the experiment earns them: Linear, multiple identities, fleet API, self-update, menubar, retro step, `repo add` and `user add` wizards, `doctor`. Rust softfac keeps all of these. + +## Running both factories side by side + +The rule is the one softfac already has: one factory per repo. + +- The fork watches only repos that are removed from the Rust factory's `repos` list. Start with one low-stakes repo. Both factories use the same labels, assignee and comment markers, so sharing a repo would make them claim each other's issues. +- Separate everything else: + +| | Rust softfac | Fork | +| ------------ | --------------------------------------------------- | -------------------------------------- | +| T3 base dir | `~/.config/softfac/t3` | `~/.config/t3fac` | +| Port | 3874 | 3876 | +| LaunchAgent | `io.github.rawphp.softfac`, `com.rawphp.softfac.t3` | `io.github.rawphp.t3fac` | +| Factory data | `~/.local/share/softfac` | `~/.config/t3fac/factory` | +| Repo clones | `repos[].path` | separate clones, never the same `path` | +| Logs | `~/Library/Logs/softfac` | `~/Library/Logs/t3fac` | + +`t3fac` is a working name. + +## Building the fork ourselves + +### Repo and branches + +- `original-solutions/t3code-factory`, a public GitHub fork of `pingdotgg/t3code`. `main` is upstream plus our commits. On 2026-10-15 it was identical to upstream `main`, and the factory's `rawphp` login has admin on it. +- Issues and auto-merge are on (set 2026-10-15). Forks start with issues off, the factory works from issues, and green sync PRs need auto-merge. +- Actions are off for the whole repo (set 2026-10-15). GitHub had not registered upstream's 19 workflows yet, so `gh workflow disable` had nothing to act on. Step 1 adds the `factory-*.yml` workflows. In that step, turn Actions back on and run `gh workflow disable` on every upstream workflow straight away, without editing their files, so they stay off the seam list. Upstream's `ci.yml` may run once on that first push. + +### Syncing with upstream + +We sync to upstream's newest nightly tag, not to the tip of main. A nightly has passed upstream's own quality, test and build jobs. + +`factory-sync.yml` runs daily: + +1. Find the newest `v*-nightly.*` tag upstream. +2. Merge it into a `sync/` branch. Use merge, not rebase: our history stays intact and conflicts only show up in the seam files. +3. Regenerate `routeTree.gen.ts`, run the seam check, typecheck `apps/server` and `apps/web`, then run the factory suite and the contract suite. +4. Green: open the PR and auto-merge it. +5. Red: open the PR as a draft and file a `factory:ready` issue on the fork saying what broke. The Rust factory builds it, because the fork is one of the Rust factory's repos (below). + +The fork never runs a build that failed its own gates. The installed version only moves when a sync merges and a release builds. + +### Contract tests + +`apps/server/src/factory/t3/*.contract.test.ts` boot the real orchestration layers with `packages/provider-testing` as the provider. They launch a thread, send a message, wait for `run.updated` to settle, read the last assistant message, archive, and check the worktree is gone. These tests cover only what the adapter uses. They answer one question on every sync: does the adapter still work against this upstream? + +### Release build + +Local on this Mac. No CI macOS runners, so no cost to the `rawphp` budget. Signing is ad hoc, which is fine on the machine that built it. + +`factory/scripts/build.sh`: + +1. `vp i` +2. `vp run --filter @t3tools/web build` +3. `cargo build --locked --release` in `native/resource-monitor` +4. `node apps/server/scripts/cli.ts build-exe` with the SEA Node version upstream pins +5. `node scripts/build-cli-archive.ts --platform mac --arch arm64 --version …` +6. Smoke test: `t3 serve` on a throwaway base dir, wait for `/.well-known/t3/environment`, then hit `/api/factory/status`. + +Version: `+fac.`, for example `0.0.46-nightly.20261014.2950+fac.1a2b3c4`. + +`factory/scripts/install.sh` unpacks the archive into `~/.local/share/t3fac/versions/`, points a `current` symlink at it, and restarts the LaunchAgent. `rollback.sh` points `current` at the previous version. That is the same idea as `softfac t3 install` and `rollback`, as two short scripts. + +Not built: npm packages, the desktop app, other platforms, notarisation. A second machine can install from a GitHub Release asset later. + +### Fork CI on pull requests + +`factory-ci.yml` on an Ubuntu runner: seam check, boundary test, factory unit tests, contract tests, typecheck of server and web. Upstream's own `AGENTS.md` asks agents not to run repo-wide checks, and we don't need them, because the sync job already pins us to tags upstream tested. + +## Who builds the fork + +The Rust factory builds the fork until the fork's own factory can build its own repo. After that the fork builds itself. Issues `original-solutions/t3code-factory#1` to `#21` take it up to the handover (#21). The handover is a config switch a person makes: the repo leaves the Rust factory's `repos` list and enters the fork's. Issues #22 to #28 (forwarding, re-gating, feedback, interruptions, required checks, the API and the page) stay `factory:proposed` until that switch, and then the fork's factory builds them. + +To begin with, `original-solutions/t3code-factory` becomes one of the Rust factory's repos, with a `.dw/config.yml` factory block whose gate is the fork CI commands above. `.dw/` is a new path, so it is not a seam. The work goes in as issues in this order, each one testable on its own: + +1. Fork, seams, boundary test, sync job, build and install scripts, an empty `FactoryLayer` that logs "factory started". This proves we can sync and ship before porting anything. +2. Adapter and contract tests. +3. Ledger (format tests ported from `ledger.rs`) and the pure domain (lifecycle, result line, approval, limits). +4. GitHub tracker over `gh`, with argv-matching tests ported from `github.rs`. +5. Pass and claim, then start and building up to the result line. +6. Gate and gate retries, then `pr-open` re-gating and feedback. +7. Interruptions and time limits. +8. Factory page. + +Port behaviour, not code. `docs/lifecycle.md` and `src/runner/tests.rs` (836 tests) are the spec. Port the test cases first in each step, then make them pass. + +## How we decide + +Run the two factories side by side for at least 4 weeks or 30 fork runs, whichever comes later. + +Factory output, from `softfac retro` on both data dirs: + +- share of runs that reach `pr-open` without a human +- stuck rate, by reason +- time from claim to `pr-open` +- PRs merged without change requests + +Cost of the fork: + +- sync PRs that went red, and hours spent fixing them +- commits to `factory/t3/` caused by upstream changes +- seam conflicts per month + +The fork replaces Rust softfac if its output is at least as good and the sync cost stays under an agreed bound, for example one red sync a week fixed by the factory without Tom. If the sync cost is the problem, the fallback is the cheaper design from the brainstorm: Rust softfac bundled with T3 as one install, still talking over the wire. + +## Open questions to settle in step 1 or 2 + +1. Do routes added through `layerMakeRoutes` get T3's auth? The Factory page needs it. If they don't, the page has to use T3's RPC, which means more seams. +2. Answered (#8): `ProcessRunner` spawns through Effect's Node spawner, which does put the child in its own process group (`detached: true` on POSIX) and sends the group `SIGTERM` when the effect is interrupted. It cannot do the rest: it always extends the parent environment (`extendEnv` is true whenever `env` is set), it reads stdout and stderr on separate pipes, and on timeout it drops the partial output and never escalates to `SIGKILL`. `apps/server/src/factory/process.ts` therefore uses `node:child_process` directly, outside `factory/t3/`, and needs no seam. +3. Is a YAML parser already a server dependency? If not, adding one touches `package.json` and the lockfile, so they join the seam list. +4. How does T3 handle a thread's worktree on archive? softfac removes worktrees with git today and may still need to. +5. Is the installed 0.0.46 nightly already on orchestration v2? The Rust factory works against it with protocol 2, so probably yes. It only affects which upstream tag the first sync starts from. diff --git a/factory/scripts/check-seams.sh b/factory/scripts/check-seams.sh new file mode 100755 index 0000000000000..bff29cfc25af2 --- /dev/null +++ b/factory/scripts/check-seams.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Fails when HEAD differs from the upstream commit named in factory/UPSTREAM in a +# file that is neither factory-owned nor listed in factory/SEAMS. Only committed +# changes count; staged, unstaged and untracked files are not checked. +set -euo pipefail + +root=$(git rev-parse --show-toplevel) +read -r _ref sha < "$root/factory/UPSTREAM" + +if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + git fetch --quiet https://github.com/pingdotgg/t3code "$sha" +fi + +# Captured first so set -e stops the script when the diff fails. +changed=$(git diff --name-only --no-renames "$sha" HEAD) + +bad=0 +while IFS= read -r path; do + [[ -n $path ]] || continue + case "$path" in + apps/server/src/factory/*|apps/web/src/factory/*|factory/*|.dw/*|apps/web/src/routes/factory*|.github/workflows/factory-*) + continue ;; + esac + if grep -qxF -- "$path" "$root/factory/SEAMS"; then + continue + fi + echo "check-seams: not a listed seam: $path" + bad=1 +done <<< "$changed" + +exit "$bad" diff --git a/factory/scripts/check.sh b/factory/scripts/check.sh new file mode 100755 index 0000000000000..89f3efde4da1c --- /dev/null +++ b/factory/scripts/check.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# The one check for this fork. Runs the seam check, the factory tests and the two +# typechecks in order, printing each step first, and stops at the first failure +# with that step's exit code. Run by people, by the factory gate and by factory-ci. +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +cd "$root" + +step() { + echo "check: $*" + "$@" || exit $? +} + +step factory/scripts/check-seams.sh +step vp test run apps/server/src/factory +step vp run --filter t3 typecheck +step vp run --filter @t3tools/web typecheck