From aa0abf2366bf8f224288fb71b09a13ec3c8ffedb Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 21:06:01 +1000 Subject: [PATCH 1/3] test(factory): port config parsing tests and the config-driven start Red: config.test.ts, index.test.ts and t3/paths.test.ts fail because ./config.ts, ./paths.ts, t3/paths.ts and FactoryRuntime do not exist yet. Issue: #11 --- apps/server/src/factory/config.test.ts | 651 +++++++++++++++++++++++ apps/server/src/factory/index.test.ts | 67 ++- apps/server/src/factory/t3/paths.test.ts | 27 + 3 files changed, 735 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/factory/config.test.ts create mode 100644 apps/server/src/factory/t3/paths.test.ts diff --git a/apps/server/src/factory/config.test.ts b/apps/server/src/factory/config.test.ts new file mode 100644 index 000000000000..15060e080daf --- /dev/null +++ b/apps/server/src/factory/config.test.ts @@ -0,0 +1,651 @@ +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) => { + assertOne(machineErr(edit(MACHINE, (t) => remove(t, path))), path, 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 1.0 is fractional", () => { + const errs = projectErr(edit(PROJECT, (tree) => set(tree, "factory.limits.minutes", "1.0"))); + assertOne(errs, "factory.limits.minutes", PFILE); + }); + 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")); + 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", () => { + for (const key of ["model", "model_instance", "gh_config_dir"]) { + it(`machine ${key} empty`, () => { + machineBad(key, '""'); + expect(machineErr(edit(MACHINE, (t) => set(t, key, '""')))[0]!.message).toBe( + "must not be empty", + ); + }); + it(`machine ${key} not a string`, () => { + 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/index.test.ts b/apps/server/src/factory/index.test.ts index 56060d59bab4..e7a8f2d406bf 100644 --- a/apps/server/src/factory/index.test.ts +++ b/apps/server/src/factory/index.test.ts @@ -1,26 +1,73 @@ +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 { FactoryLayer } from "./index.ts"; +import { FactoryRuntime } from "./index.ts"; +import { FactoryPaths } from "./paths.ts"; const messageText = (message: unknown): string => { const first = Array.isArray(message) ? message[0] : message; return typeof first === "string" ? first : String(first); }; -it.effect("logs factory started exactly once when the layer is built", () => - Effect.gen(function* () { - const logs: Array = []; - const capture = Logger.make(({ message }) => { - logs.push(messageText(message)); - }); +const VALID = `gh_config_dir: /gh +max_concurrent_runs: 1 +model: claude-sonnet-5-5 +repos: + - repo: a/b + path: /b +`; - yield* Effect.scoped(Layer.build(FactoryLayer)).pipe( - Effect.provide(Logger.layer([capture], { mergeWithExisting: false })), - ); +/** Builds FactoryRuntime over a temp factory dir holding `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 factoryDir = yield* fs.makeTempDirectoryScoped({ prefix: "factory-index-" }); + 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(FactoryPaths, { 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/t3/paths.test.ts b/apps/server/src/factory/t3/paths.test.ts new file mode 100644 index 000000000000..abd5b5934108 --- /dev/null +++ b/apps/server/src/factory/t3/paths.test.ts @@ -0,0 +1,27 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../../config.ts"; +import { FactoryPaths } from "../paths.ts"; +import { FactoryPathsLive } from "./paths.ts"; + +it.effect("derives the factory paths from the server base directory", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const { baseDir } = yield* ServerConfig; + const paths = yield* FactoryPaths; + + assert.strictEqual(paths.factoryDir, join(baseDir, "factory")); + assert.strictEqual(paths.machineConfigPath, join(baseDir, "factory", "config.yml")); + }).pipe( + Effect.provide( + Layer.mergeAll( + FactoryPathsLive, + ServerConfig.layerTest(process.cwd(), { prefix: "factory-paths-" }), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), +); From 45a137e8f2f89310b45f29fdb63f0c8ffcb85688 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 21:07:10 +1000 Subject: [PATCH 2/3] feat(factory): load the machine config and start only when it is valid Adds config.ts (project and machine config parsing ported from softfac, plus loaders), the FactoryPaths service with its T3 layer, and makes the factory log no-config, config errors, or started. Also fixes test-helper slips in the ported tests (repos error path, 90.0 fixture, layer wiring). Issue: #11 --- apps/server/src/factory/config.test.ts | 44 +- apps/server/src/factory/config.ts | 489 +++++++++++++++++++++++ apps/server/src/factory/index.ts | 27 +- apps/server/src/factory/paths.ts | 7 + apps/server/src/factory/t3/paths.test.ts | 8 +- apps/server/src/factory/t3/paths.ts | 17 + 6 files changed, 567 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/factory/config.ts create mode 100644 apps/server/src/factory/paths.ts create mode 100644 apps/server/src/factory/t3/paths.ts diff --git a/apps/server/src/factory/config.test.ts b/apps/server/src/factory/config.test.ts index 15060e080daf..e4c6d58874fb 100644 --- a/apps/server/src/factory/config.test.ts +++ b/apps/server/src/factory/config.test.ts @@ -123,8 +123,8 @@ const projectBad = (path: string, yaml: string) => { assertOne(projectErr(edit(PROJECT, (t) => set(t, path, yaml))), path, PFILE); }; -const machineMissing = (path: string) => { - assertOne(machineErr(edit(MACHINE, (t) => remove(t, path))), path, MFILE); +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`). */ @@ -348,9 +348,10 @@ describe("project config", () => { 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 1.0 is fractional", () => { - const errs = projectErr(edit(PROJECT, (tree) => set(tree, "factory.limits.minutes", "1.0"))); + 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")); @@ -449,7 +450,7 @@ describe("machine config", () => { 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")); + 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); @@ -485,20 +486,19 @@ describe("machine config", () => { }); describe("strings", () => { - for (const key of ["model", "model_instance", "gh_config_dir"]) { - it(`machine ${key} empty`, () => { - machineBad(key, '""'); - expect(machineErr(edit(MACHINE, (t) => set(t, key, '""')))[0]!.message).toBe( - "must not be empty", - ); - }); - it(`machine ${key} not a string`, () => { - machineBad(key, "[x]"); - expect(machineErr(edit(MACHINE, (t) => set(t, key, "[x]")))[0]!.message).toBe( - "must be a string", - ); - }); - } + 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", () => { @@ -582,7 +582,11 @@ describe("machine config", () => { describe("loaders", () => { const withTempDir = ( - body: (dir: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + body: ( + dir: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect, ) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/factory/config.ts b/apps/server/src/factory/config.ts new file mode 100644 index 000000000000..aa21556db3dc --- /dev/null +++ b/apps/server/src/factory/config.ts @@ -0,0 +1,489 @@ +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 interface ConfigError { + readonly file: string; + readonly field?: string; + readonly message: string; +} + +/** `: : `, 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( + Schema.Struct({ + file: Schema.String, + field: Schema.optionalKey(Schema.String), + message: Schema.String, + }), + ), +}) { + 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/index.ts b/apps/server/src/factory/index.ts index 184c916a76f8..f0c3a6a507d7 100644 --- a/apps/server/src/factory/index.ts +++ b/apps/server/src/factory/index.ts @@ -1,5 +1,30 @@ 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 { FactoryPaths } from "./paths.ts"; +import { FactoryPathsLive } from "./t3/paths.ts"; + +const start = Effect.gen(function* () { + const { machineConfigPath } = yield* 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 = Layer.effectDiscard(Effect.logInfo("factory started")); +export const FactoryLayer = FactoryRuntime.pipe(Layer.provide(FactoryPathsLive)); diff --git a/apps/server/src/factory/paths.ts b/apps/server/src/factory/paths.ts new file mode 100644 index 000000000000..f357b4e9e95f --- /dev/null +++ b/apps/server/src/factory/paths.ts @@ -0,0 +1,7 @@ +import * as Context from "effect/Context"; + +/** Where the factory keeps its files. Provided by `t3/paths.ts` in the server, by tests elsewhere. */ +export class FactoryPaths extends Context.Service< + FactoryPaths, + { readonly factoryDir: string; readonly machineConfigPath: string } +>()("t3/factory/paths/FactoryPaths") {} diff --git a/apps/server/src/factory/t3/paths.test.ts b/apps/server/src/factory/t3/paths.test.ts index abd5b5934108..32f78ca67116 100644 --- a/apps/server/src/factory/t3/paths.test.ts +++ b/apps/server/src/factory/t3/paths.test.ts @@ -18,10 +18,10 @@ it.effect("derives the factory paths from the server base directory", () => assert.strictEqual(paths.machineConfigPath, join(baseDir, "factory", "config.yml")); }).pipe( Effect.provide( - Layer.mergeAll( - FactoryPathsLive, - ServerConfig.layerTest(process.cwd(), { prefix: "factory-paths-" }), - ).pipe(Layer.provideMerge(NodeServices.layer)), + FactoryPathsLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "factory-paths-" })), + Layer.provideMerge(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 000000000000..08ef6abe187e --- /dev/null +++ b/apps/server/src/factory/t3/paths.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../../config.ts"; +import { FactoryPaths } from "../paths.ts"; + +/** The factory's files live in `/factory`. */ +export const FactoryPathsLive = Layer.effect( + FactoryPaths, + Effect.gen(function* () { + const { baseDir } = yield* ServerConfig; + const { join } = yield* Path.Path; + const factoryDir = join(baseDir, "factory"); + return { factoryDir, machineConfigPath: join(factoryDir, "config.yml") }; + }), +); From 7265e29d6768876bfaf88f3a6ec691acd63bd74a Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 21:10:55 +1000 Subject: [PATCH 3/3] refactor(factory): define the config error shape once Issue: #11 --- apps/server/src/factory/config.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/server/src/factory/config.ts b/apps/server/src/factory/config.ts index aa21556db3dc..14721f638c81 100644 --- a/apps/server/src/factory/config.ts +++ b/apps/server/src/factory/config.ts @@ -5,11 +5,12 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { parse } from "yaml"; -export interface ConfigError { - readonly file: string; - readonly field?: string; - readonly message: string; -} +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 => @@ -20,13 +21,7 @@ export const formatConfigError = (error: ConfigError): string => /** 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( - Schema.Struct({ - file: Schema.String, - field: Schema.optionalKey(Schema.String), - message: Schema.String, - }), - ), + errors: Schema.Array(ConfigErrorSchema), }) { override get message(): string { return this.errors.map(formatConfigError).join("\n");