/** * On-disk configuration for omp-conductor. * * The dispatcher runs unattended, so a malformed config must fail loudly at * load time rather than surface hours later as `undefined.inProgress` inside a * worker. `loadConfig` therefore validates and normalises in one pass and * reports *every* problem it found, because fixing a hand-written config one * error per run is miserable. * * The whole config tree lives under `$OMP_CONDUCTOR_HOME` (default * `~/.omp/conductor`), read on every call so a test — or a second fleet on the * same machine — can redirect it without reloading the module. */ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { backupTimestamp, copyToUniqueBackup } from "./backups.ts"; import { ARM_PROOFS, AUTHORITY_HOLDERS, BASE_FRESHNESS, BEHIND_BASE_ACTIONS, CONFIG_VERSION, DEFAULT_ARM_PROOF, DEFAULT_AUTHORITY, DEFAULT_CAPS, DEFAULT_GROOM_ROLE, DEFAULT_PROJECT_POLICY, DEFAULT_REPORT_POLICY, DEFAULT_REPORT_SCOPE, DEFAULT_REVIEW_ADJUDICATOR_ROLE, DEFAULT_REVIEW_MAX_ROUNDS, DEFAULT_REVIEW_POLICY, DEFAULT_REVIEW_STRICTNESS, DENIED_RELEASE_GRANTS, DRAFT_POLICIES, LEGACY_RELEASE_POLICIES, OPERATOR_BRIEF_GRANTS, ORCHESTRATOR_MODES, READABLE_CONFIG_VERSIONS, RELEASE_REQUIREMENTS, RELEASE_SHAPES, REPORT_SCOPES, REVIEW_MAX_ROUNDS_MAX, REVIEW_MAX_ROUNDS_MIN, REVIEW_STRICTNESS, INTERRUPT_CATEGORIES, DIGEST_CADENCES, WEEKDAYS, type Caps, type ConductorConfig, type DigestCadence, type HostConstraints, type InterruptCategory, type MergePreconditions, type PlanUsageCap, type ProjectConfig, type RecoveryMergeAuthorization, type ProjectPolicy, type ReleasePreconditions, type ReleaseRequirement, type ReportScope, type ReportingPolicy, type ReviewPolicy, type ReviewStrictness, type Weekday, type WeeklyAvailability, type RepoTarget, type ResolvedGrants, type ArmProof, } from "./types.ts"; import { AUTHORITY_HOLDER_LIST, BASE_FRESHNESS_LIST, BEHIND_BASE_ACTION_LIST, ConfigSchema, DIGEST_CADENCE_LIST, DRAFT_POLICY_LIST, INTERRUPT_CATEGORY_LIST, LEGACY_RELEASE_POLICY_LIST, ORCHESTRATOR_MODE_LIST, RELEASE_REQUIREMENT_LIST, RELEASE_SHAPE_LIST, REVIEW_STRICTNESS_LIST, REPORT_SCOPE_LIST, WEEKDAY_LIST, quoteList, } from "./config-schema.ts"; /** * A JSON node whose fields are all still unproven. Reading a field off a * non-object (string, number, null) yields `undefined` at runtime, so every * field read below is safe. * * ponytail: this is no longer the validator — `ConfigSchema` in * `config-schema.ts` owns every shape/type/enum/required-optional decision, and * the normalisers below read already-validated values and apply only what zod * cannot express. */ type Raw = { readonly [key: string]: unknown }; /** Derived from the data so a new `Caps` field cannot be silently ignored. */ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[]; /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; /** * Used when a project omits `stateLabels`. Namespaced so a human scanning the * tracker can tell dispatcher-written labels from their own. */ export const DEFAULT_STATE_LABELS: ProjectConfig["stateLabels"] = { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed", // The operator's park gesture (#507): unnamed by design — it is theirs, and // the operator adds it by hand until setup grows a confirm step (slice 2). backlog: "backlog", }; /** * Default routing prefix. Namespaced rather than empty: a bare `api` label * would collide with ordinary topic labels and route work by accident. */ const DEFAULT_LABEL_PREFIX = "repo:"; /** Absolute path of the config file, honouring `$OMP_CONDUCTOR_HOME`. */ export function configPath(): string { const override = process.env["OMP_CONDUCTOR_HOME"]; const home = override !== undefined && override.length > 0 ? override : join(homedir(), ".omp", "conductor"); return join(home, "config.json"); } /** Directory holding the config, the sqlite store, and the pause sentinel. */ export function stateDir(): string { return dirname(configPath()); } /** State-root directory for durable copies of every pre-write config.json. */ export function configBackupDir(): string { return join(stateDir(), "backups", "config"); } /** * Where restorable `conductor.db` snapshots land: the configured `dbBackupDir`, * or the state-root default `/backups/db` beside the config * backups when the field is absent. A `~/`-prefixed configured value is * expanded like every other hand-written path field. */ export function dbBackupDirFor(cfg: Pick | undefined): string { const configured = cfg?.dbBackupDir; const dir = configured !== undefined && configured.trim() !== "" ? expandHome(configured) : join(stateDir(), "backups", "db"); return dir; } /** * Where per-run worktrees and bare mirrors live when a project names neither. * * Exported because two readers need the same answer: `normalizeProject` below, * and the orchestrator jail (`confinement.ts`, #127), which must deny the * worker checkouts even when the config was too broken to load and say where * they are. Two copies of this default would mean a jail guarding the wrong * directory on exactly the day the config is broken. */ export function defaultWorkspaceRoot(base: string = stateDir()): string { return join(base, "worktrees"); } export function defaultMirrorRoot(base: string = stateDir()): string { return join(base, "mirrors"); } /** * Reads, validates and normalises the config. Throws an `Error` naming the * path and the fix; never returns a partially-shaped `ConductorConfig`. */ export function loadConfig(): ConductorConfig { const path = configPath(); if (!existsSync(path)) { throw new Error(`No conductor config at ${path} — run \`omp-conductor setup\` to create one.`); } let raw: string; try { raw = readFileSync(path, "utf8"); } catch (err) { throw new Error(`Cannot read conductor config at ${path}: ${err instanceof Error ? err.message : String(err)}`); } let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch (err) { throw new Error( `Conductor config at ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, ); } return validate(parsed, path); } /** * Persists the config atomically: a temp file in the same directory, then * `rename`, so a crash mid-write leaves the previous config intact instead of * a truncated one. Mode 0600 because a config carries chat ids and clone URLs. */ export function saveConfig(c: ConductorConfig): void { writeConfigFile(c); } /** * Atomic write of whatever object is handed in. * * Takes `unknown` rather than `ConductorConfig` so the credentials migration * can persist the operator's own parsed file with one key added, instead of a * value that has been through the loader and back out. That round-trip is what * rewrote a live fleet's config into a dialect the previous release rejected. */ export function writeConfigFile(c: unknown): void { const withSchema = typeof c === "object" && c !== null && !Array.isArray(c) && "version" in c && !("$schema" in c) ? { ...(c as Record), $schema: SCHEMA_URI } : c; writeConfigRaw(`${JSON.stringify(withSchema, null, 2)}\n`); } /** * Where the shipped `config.schema.json` is installed, referenced from the top * of every config `saveConfig` writes. It is derived from this module's own * location (`import.meta.dir` is the installed package's `src/`), so the URI * points at the actual installed copy rather than a dangling relative path — * the config lives at `$OMP_CONDUCTOR_HOME/config.json`, which has no * `node_modules` beside it. */ const SCHEMA_URI = join(dirname(import.meta.dir), "schema", "config.schema.json"); /** * Atomic write of exact bytes. * * The rollback path needs this rather than {@link writeConfigFile}: it captured * a snapshot as text, and putting it back through `JSON.parse` and * `JSON.stringify` returns canonically formatted output, not the operator's * file. Keys and values would survive; their formatting would not — and a * rollback that reformats the thing it is restoring is not a restore. */ export function writeConfigRaw(text: string): void { const dir = stateDir(); const created = mkdirSync(dir, { recursive: true, mode: 0o700 }); // mkdir's mode is masked by umask; chmod only what we just created so an // existing directory keeps whatever the user chose for it. if (created !== undefined) chmodSync(dir, 0o700); const target = configPath(); // A durable pre-write copy under state keeps the previous bytes recoverable // even when the process dies between this moment and the rename, or when a // later caller discovers it needs the old file back. Retention is unbounded, // matching the brief backups. if (existsSync(target)) { copyToUniqueBackup(target, configBackupDir(), `config.json.bak-${backupTimestamp()}`); } const tmp = join(dir, `.config.json.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`); try { writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, target); } catch (err) { rmSync(tmp, { force: true }); throw err; } chmodSync(target, 0o600); } /** * Layers a project's overrides on the global defaults, field by field. `??` * not `||`: a deliberate `dailySpendUsd: 0` is a hard stop, and `null` is a * deliberate "no spend gate". Spelled out so a new cap fails compilation here. */ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps { const o: Partial = p.caps ?? {}; const workerMaxTurns = o.workerMaxTurns ?? defaults.workerMaxTurns; return { maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers, maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo, dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd, // Same `!== undefined` reading as `dailySpendUsd`: an explicit `null` is a // deliberate "derive the per-run allowance from the daily cap" (#851), not // an omission that should inherit a global override. maxRunSpendUsd: o.maxRunSpendUsd !== undefined ? o.maxRunSpendUsd : defaults.maxRunSpendUsd, planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage, workerMaxTurns, workerMaxTurnsCeiling: o.workerMaxTurnsCeiling ?? (o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2), workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs, // Same `!== undefined` reading as `maxRunSpendUsd`: an explicit `null` // means "derive from workerWallClockMs", not "inherit a global override" // (#1086). workerStallSilenceMs: o.workerStallSilenceMs !== undefined ? o.workerStallSilenceMs : defaults.workerStallSilenceMs, maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue, maxContinuationsPerIssue: o.maxContinuationsPerIssue ?? defaults.maxContinuationsPerIssue, }; } /** * The effective grant for every shape. Old configs, an omitted key and a * hand-written partial map are all fail-closed here: a shape the map does not * name is denied, never inherited from a neighbour and never opened. * * `loadConfig` has already migrated what it read, so for a loaded project this * is a copy. It stays because a `ProjectConfig` also reaches the gate from * `buildProject` and from tests, and #122's point is that "absent means denied" * has exactly one spelling in the package. */ export function resolveReleaseGrants(p: ProjectConfig): ResolvedGrants { const policy = p.releasePolicy; if (policy === undefined || policy === "none") return { ...DENIED_RELEASE_GRANTS }; const grants = { ...DENIED_RELEASE_GRANTS }; for (const shape of RELEASE_SHAPES) { const holder = policy[shape]; if (holder !== undefined) grants[shape] = holder; } return grants; } /** The one install holder shared by every project on this host, or the * per-project evidence explaining why no shared holder exists (#1018). */ export function resolveSharedInstallAuthority(projects: readonly ProjectConfig[]): { holder?: ResolvedGrants["install"]; entries: readonly { project: string; holder: ResolvedGrants["install"] }[]; } { const entries = projects.map((project) => ({ project: project.name, holder: resolveReleaseGrants(project).install, })); const holders = new Set(entries.map((entry) => entry.holder)); return { ...(holders.size === 1 ? { holder: entries[0]!.holder } : {}), entries, }; } /** * The gating conditions for one project, complete (#129). * * The loader always writes a complete `policy`, so this is only ever the * fallback for a `ProjectConfig` that never went through it — a hand-built one * in a test, or a config written before the key existed. Spelled once here so a * caller cannot invent a different answer for the absent case, which is exactly * how "conditions live in typed config" degrades back into "conditions live * wherever the reader looked". */ export function resolvePolicy(p: ProjectConfig): ProjectPolicy { return clonePolicy(p.policy ?? DEFAULT_PROJECT_POLICY); } /** * How this project's `arm` proves a human just approved arming (#613), * complete. * * The loader always materialises `arm` (finalizeProject), so this is only ever * the fallback for a `ProjectConfig` that never went through it — a hand-built * one in a test, or a config written before the key existed. Absent resolves * to {@link DEFAULT_ARM_PROOF} (`challenge`): the authenticated round-trip, * which is the behaviour every existing install already has. */ export function resolveArmProof(p: ProjectConfig): ArmProof { return p.arm?.proof === "claim-only" ? "claim-only" : DEFAULT_ARM_PROOF; } /** * The project's review policy (#678), complete. * * The loader always materialises `review` (finalizeProject), so this is only * ever the fallback for a `ProjectConfig` that never went through it — a * hand-built one in a test, or a config written before the key existed. * Absent (or a legacy config that stopped at strictness and maxRounds) * resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration default * every existing install deterministically upgrades to — the adjudicator role * included, so no consumer ever reads a policy without one (#875). A fresh * object each call, so mutating the result cannot reach the config it came * from. */ export function resolveReview(p: ProjectConfig): ReviewPolicy { const base = p.review ?? DEFAULT_REVIEW_POLICY; return { strictness: base.strictness ?? DEFAULT_REVIEW_STRICTNESS, maxRounds: base.maxRounds ?? DEFAULT_REVIEW_MAX_ROUNDS, adjudicator: base.adjudicator ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE, }; } /** * The OMP model role this project's daemon-owned grooming scouts run under * (#1041). * * One reader for the same reason {@link resolveReview} is one: the daemon * launches these sessions unattended, so "which role did it spend under" must * have exactly one answer, and a project that never chose gets * {@link DEFAULT_GROOM_ROLE} — `task`, the general session role every install * can launch without pinning a provider. Deliberately not folded into * `review.adjudicator`: grooming judgement and review adjudication are * separate operator choices, and sharing the key would silently move one when * the other is retuned. */ export function resolveGroomRole(p: ProjectConfig): string { const configured = p.groomRole; return configured !== undefined && configured.trim() !== "" ? configured.trim() : DEFAULT_GROOM_ROLE; } /** * A policy with no array shared with its source. * * {@link DEFAULT_PROJECT_POLICY} is one object for the whole process, and every * consumer of a resolved policy holds something it may hand to the wizard and * back. A shared `requiredChecks` array would let one project's amend push a * check onto every project that had never answered the question. */ export function clonePolicy(policy: ProjectPolicy): ProjectPolicy { return { merge: { ...policy.merge, requiredChecks: [...policy.merge.requiredChecks] }, release: { ...policy.release, requires: [...policy.release.requires], requiredChecks: [...policy.release.requiredChecks], artefacts: [...policy.release.artefacts], environments: [...policy.release.environments], }, }; } /** * The runtime twin of {@link pickLiteral}, for a value a *model* supplied rather * than a value an operator wrote (#129). * * Throws rather than returning a flag: a verb's `reason` is the field the ledger * records the act under, and a caller that forgets to check a boolean has * written an unexplained act into the audit trail instead of refusing one. The * message names every accepted value, because the reader is a session that can * only correct itself from what the refusal tells it — "invalid reason" costs a * turn and teaches nothing. * * Free-form rationale is not this function's business. It rides beside the * reason as a `rationale` field, is logged verbatim, and is never matched. */ export function requireReason(value: unknown, allowed: readonly R[], field: string): R { const hit = allowed.find((a) => a === value); if (hit === undefined) { throw new Error(`${field} must be ${quoteList(allowed)}, found ${JSON.stringify(value)}`); } return hit; } /** * Resolves a project by name, or the only project when the name is omitted. * Refuses to guess between several: picking one silently would spend the wrong * project's budget. */ export function findProject(c: ConductorConfig, name?: string): ProjectConfig { const names = c.projects.map((p) => p.name); if (name === undefined) { const only = c.projects[0]; if (c.projects.length !== 1 || only === undefined) { // #514: every operator hitting this has to guess the spelling of the // flag that names a project — say it outright. Doctor's project finding // quotes this text, so keep the "name one explicitly" phrase. throw new Error( `Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly with --project NAME.`, ); } return only; } const hit = c.projects.find((p) => p.name === name); if (hit === undefined) { throw new Error(`Unknown project "${name}" — configured projects: ${names.join(", ") || "none"}.`); } return hit; } // --------------------------------------------------------------------------- // load boundary: zod parse + residue normalisation // --------------------------------------------------------------------------- /** * Validates and normalises an on-disk config into a `ConductorConfig`. * * Per-field shape, type, enum, numeric-bound and required/optional validity is * enforced entirely by `ConfigSchema` (zod) at this boundary — the hand-rolled * field guards that used to live below are gone. What zod cannot express is * done here as a thin residue layer, in today's exact wording: * * - cross-field coherence (reporting scope-vs-explicit, digest/availability * times, `start !== end`); * - the version-keyed cap migration (v1 drops a retired key, v2 rejects it); * - the clone-URL credential rejection; and * - legacy-key / path normalisation (defaults, presets, `~` and default roots). */ function validate(parsed: unknown, path: string): ConductorConfig { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error(`Conductor config at ${path} must be a JSON object — run \`omp-conductor setup\` to recreate it.`); } const shape = ConfigSchema.safeParse(parsed); if (!shape.success) { // Shape faults from zod, plus the cross-field/credential complaints that // only the residue layer can voice. For the nested objects below, zod stops // collecting member faults once any sibling fails, so zod's issues for // those objects are folded into a single accumulated pass that reports // every faulty member (matching the envelope's "whole list" promise). const expanded = shape.error.issues.flatMap(expandIssue); const problems = [ ...expanded.filter((issue) => !inAccumulatedObject(issue.path)).map((issue) => issueToProblem(issue, parsed)), ...crossFieldProblems(parsed), ...nestedObjectProblems(parsed), ]; throw new Error(problemEnvelope(path, problems)); } return finalize(shape.data as unknown, path); } /** * True when a zod issue path lives inside one of the objects whose member * faults must all be reported together (availability, digest, caps, authority, * per-repo gates). zod skips those once a sibling fails, so the failure path * drops zod's issues for them and lets {@link nestedObjectProblems} own them. */ function inAccumulatedObject(path: readonly PropertyKey[]): boolean { if (path[0] !== "projects" || typeof path[1] !== "number") return false; const rel = path.slice(2).map(String); if (rel[0] === "caps" || rel[0] === "authority") return true; if (rel[0] === "reporting" && (rel[1] === "digest" || rel[1] === "availability")) return true; if (rel[0] === "routing" && rel[1] === "repos" && rel[3] === "gates") return true; return false; } /** * Reports every faulty member of the nested objects in one pass, in today's * wording, so an operator hears the whole list the first time. zod cannot do * this (it abandons an object's remaining members once one fails), so the * failure path substitutes this for zod's issues inside those objects. * Cross-field items (scope-vs-explicit, at-only-with-daily, timezone match, * start != end, clone URLs) are {@link crossFieldProblems}' job, not this one's. */ function nestedObjectProblems(parsed: unknown): string[] { const problems: string[] = []; const root = parsed as Raw | undefined; const list = root?.["projects"]; if (!Array.isArray(list)) return problems; const legacyCaps = root?.["version"] === 1; list.forEach((p: unknown, i) => { const proj = p as Raw; if (typeof proj !== "object" || proj === null) return; const name = proj["name"]; const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`; // availability — every member, even when days already failed. const reporting = typeof proj["reporting"] === "object" && proj["reporting"] !== null && !Array.isArray(proj["reporting"]) ? (proj["reporting"] as Raw) : undefined; const availability = reporting !== undefined && typeof reporting["availability"] === "object" && reporting["availability"] !== null && !Array.isArray(reporting["availability"]) ? (reporting["availability"] as Raw) : undefined; if (availability !== undefined) checkAvailability(availability, label, problems); // digest — every member even when cadence already failed. if (reporting !== undefined && typeof reporting["digest"] === "object" && reporting["digest"] !== null && !Array.isArray(reporting["digest"])) { checkDigest(reporting["digest"] as Raw, label, problems); } // authority const authority = proj["authority"]; if (typeof authority === "object" && authority !== null && !Array.isArray(authority)) { checkAuthority(authority as Raw, label, problems); } // caps reconcileCaps(proj["caps"], `${label}: caps`, problems, legacyCaps); // per-repo gates (two bad gates must both surface) const routing = typeof proj["routing"] === "object" && proj["routing"] !== null && !Array.isArray(proj["routing"]) ? (proj["routing"] as Raw) : undefined; const repos = routing !== undefined && typeof routing["repos"] === "object" && routing["repos"] !== null && !Array.isArray(routing["repos"]) ? (routing["repos"] as Record) : undefined; if (repos !== undefined) { for (const [key, entry] of Object.entries(repos)) { const e = entry as Raw | undefined; if (e === undefined || typeof e !== "object") continue; const gates = e["gates"]; if (gates === undefined) continue; const base = `${label}: routing.repos.${key}`; if (!Array.isArray(gates)) { problems.push(`${base}.gates must be an array of { cmd, cwd }`); continue; } gates.forEach((g: unknown, gi: number) => { const cmd = (g as Raw | undefined)?.["cmd"]; if (typeof cmd !== "string" || cmd.trim() === "") { problems.push(`${base}.gates[${gi}] must be { cmd, cwd } with a non-empty cmd`); } }); } } }); return problems; } function checkAvailability(a: Raw, label: string, problems: string[]): void { if (typeof a["timezone"] !== "string" || a["timezone"].trim() === "") { problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`); } else { try { new Intl.DateTimeFormat("en-GB", { timeZone: a["timezone"] }); } catch { problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`); } } const days = a["days"]; if (!Array.isArray(days) || days.length === 0) { problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`); } else { for (const d of days) { if (typeof d !== "string" || !(WEEKDAYS as readonly string[]).includes(d)) { problems.push(`${label}: reporting.availability.days has unknown day ${JSON.stringify(d)} — one of ${WEEKDAY_LIST}`); } } } if (typeof a["start"] !== "string" || !DIGEST_AT.test(a["start"])) { problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`); } if (typeof a["end"] !== "string" || !DIGEST_AT.test(a["end"])) { problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`); } if (!Array.isArray(a["bypass"])) { problems.push(`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`); } else { for (const b of a["bypass"]) { if (typeof b !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(b)) { problems.push(`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(b)} — one of ${INTERRUPT_CATEGORY_LIST}`); } } } } function checkDigest(d: Raw, label: string, problems: string[]): void { if (typeof d["cadence"] !== "string" || !(DIGEST_CADENCES as readonly string[]).includes(d["cadence"])) { problems.push(`${label}: reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`); } if (d["at"] !== undefined && (typeof d["at"] !== "string" || !DIGEST_AT.test(d["at"]))) { problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`); } if (d["timezone"] !== undefined) { if (typeof d["timezone"] !== "string") { problems.push(`${label}: reporting.digest.timezone must be a string`); } else { try { new Intl.DateTimeFormat("en-GB", { timeZone: d["timezone"] }); } catch { problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`); } } } } function checkAuthority(a: Raw, label: string, problems: string[]): void { const merge = a["merge"]; const release = a["release"]; const promotion = a["promotion"]; if (merge !== undefined && typeof merge === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(merge)) { problems.push(`${label}: authority.merge must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(merge)}`); } if (release !== undefined && typeof release === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(release)) { problems.push(`${label}: authority.release must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(release)}`); } if (promotion !== undefined && typeof promotion === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(promotion)) { problems.push(`${label}: authority.promotion must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(promotion)}`); } const unknown = Object.keys(a).filter((k) => k !== "merge" && k !== "release" && k !== "promotion"); if (unknown.length > 0) problems.push(`${label}: authority has unknown key(s): ${unknown.join(", ")}`); } /** * The cross-field and credential complaints zod cannot express, stated against * the raw document so they are reported even when a sibling key fails its * shape. On the success path the same checks run inside `finalize`; this is the * failure-path twin, keeping the multi-problem envelope cumulative. */ function crossFieldProblems(parsed: unknown): string[] { const problems: string[] = []; const list = (parsed as Raw | undefined)?.["projects"]; if (!Array.isArray(list)) return problems; list.forEach((p: unknown, i) => { const proj = p as Raw; if (typeof proj !== "object" || proj === null) return; const name = proj["name"]; const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`; const reporting = proj["reporting"]; if (typeof reporting === "object" && reporting !== null && !Array.isArray(reporting)) { const r = reporting as Raw; const hasScope = r["scope"] !== undefined; const hasExplicit = r["interruptOn"] !== undefined || r["digest"] !== undefined || r["availability"] !== undefined; if (hasScope && hasExplicit) { problems.push( `${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`, ); } const digest = typeof r["digest"] === "object" && r["digest"] !== null && !Array.isArray(r["digest"]) ? (r["digest"] as Raw) : undefined; if (digest !== undefined && digest["cadence"] !== undefined && digest["cadence"] !== "daily") { if (digest["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`); if (digest["timezone"] !== undefined) { problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`); } } const availability = typeof r["availability"] === "object" && r["availability"] !== null && !Array.isArray(r["availability"]) ? (r["availability"] as Raw) : undefined; if (availability !== undefined) { const start = availability["start"]; const end = availability["end"]; if (typeof start === "string" && typeof end === "string" && start === end) { problems.push(`${label}: reporting.availability.start and end must differ`); } if ( digest !== undefined && digest["cadence"] === "daily" && digest["timezone"] !== undefined && availability["timezone"] !== undefined && digest["timezone"] !== availability["timezone"] ) { problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`); } } } const routing = proj["routing"]; const repos = typeof routing === "object" && routing !== null && !Array.isArray(routing) ? (routing as Raw)["repos"] : undefined; if (typeof repos === "object" && repos !== null && !Array.isArray(repos)) { for (const [key, entry] of Object.entries(repos as Record)) { const cloneUrl = (entry as Raw | undefined)?.["cloneUrl"]; if (typeof cloneUrl === "string") { const credential = cloneUrlCredentialProblem(cloneUrl); if (credential !== undefined) { problems.push( `${label}: routing.repos.${key}.cloneUrl ${credential}. Use an SSH URL, or an https URL ` + `backed by the daemon's own credential helper.`, ); } } } } }); return problems; } /** * Expands a zod `invalid_union` into its most specific branch — the one with * the deepest path — so `releasePolicy: { deploy: "worker" }` reports the * offending holder (and its field) rather than a bare "Invalid input". The * legacy-literal branch of the same union always errors for an object and is * the one we discard. */ type LooseIssue = { path: PropertyKey[]; code: string; message: string; keys?: readonly string[]; values?: readonly unknown[]; received?: unknown; }; function expandIssue(issue: unknown): LooseIssue[] { const raw = issue as { code?: string; path?: PropertyKey[]; errors?: unknown; message?: string }; const base = { ...(issue as object) } as LooseIssue; if (raw.code !== "invalid_union" || !Array.isArray(raw.errors)) { return [{ ...base, path: raw.path ?? [], code: raw.code ?? "invalid_type", message: raw.message ?? "Invalid input" }]; } let best: { path?: PropertyKey[]; code?: string; message?: string } | undefined; let bestDepth = -1; for (const branch of raw.errors) { for (const e of Array.isArray(branch) ? branch : [branch]) { const er = e as { path?: PropertyKey[] }; const depth = (raw.path?.length ?? 0) + (er.path?.length ?? 0); if (depth > bestDepth) { bestDepth = depth; best = { path: er.path, code: (e as { code?: string }).code, message: (e as { message?: string }).message }; } } } if (best === undefined) { return [{ ...base, path: raw.path ?? [], code: raw.code, message: raw.message ?? "Invalid input" }]; } return [ { ...base, path: [...(raw.path ?? []), ...(best.path ?? [])], code: best.code ?? "invalid_type", message: best.message ?? "Invalid input" }, ]; } function problemEnvelope(path: string, problems: string[]): string { return ( `Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` + `Fix the file or run \`omp-conductor setup\`.` ); } /** The operator-facing label of a project from its issue path, or undefined. */ function projectLabel(root: unknown, index: number): string | undefined { const list = (root as Raw | undefined)?.["projects"]; if (!Array.isArray(list)) return undefined; const p = list[index] as Raw | undefined; const name = p?.["name"]; return typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${index}]`; } /** Joins zod's issue path segments into the dotted config path an operator sees. */ function dottedPath(segments: readonly PropertyKey[]): string { return segments .map((seg) => (typeof seg === "number" ? `[${seg}]` : `.${String(seg)}`)) .join("") .replace(/^\./, ""); } /** Reads a value out of the original parsed document at a zod issue path. */ function rawAt(root: unknown, path: readonly PropertyKey[]): unknown { let cur: unknown = root; for (const seg of path) { if (cur === null || typeof cur !== "object") return undefined; cur = (cur as Record)[String(seg)]; } return cur; } const FOUND = (v: unknown): string => (v === "" ? '""' : JSON.stringify(v)); /** * Renders a zod issue the way the loader has always worded faults — the dotted * config path, the exact alternatives, the offending value — so moving the * validation to zod changes how a problem is *found*, never how it reads. */ function issueToProblem(issue: { path: readonly PropertyKey[]; code: string; message: string; keys?: readonly string[]; values?: readonly unknown[]; }, root: unknown): string { const path = issue.path; // Top-level version / projects faults carry no project label. if (path.length === 1 && (path[0] === "version" || path[0] === "projects")) { if (path[0] === "version") { return `"version" must be 1 or 2, found ${JSON.stringify(rawAt(root, path))} — this config was written by a different conductor`; } return `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`; } if (path[0] === "projects" && typeof path[1] === "number") { const rel = path.slice(2); // The project's own name is invalid precisely when *its* field fails, and // that fault has always been voiced as `projects[N].name …`. if (rel.length === 1 && rel[0] === "name") { return `projects[${path[1]}].name must be a non-empty string`; } const label = projectLabel(root, path[1]) ?? `projects[${path[1]}]`; return `${label}: ${clauseFor(rel, issue, root, path)}`; } return clauseFor(path, issue, root, path); } /** The clause after the project label — ` `. */ function clauseFor(rel: readonly PropertyKey[], issue: { code: string; message: string; keys?: readonly string[]; values?: readonly unknown[]; }, root: unknown, fullPath: readonly PropertyKey[]): string { const pathStr = dottedPath(rel); const found = (at?: readonly PropertyKey[]): string => FOUND(rawAt(root, at ?? fullPath)); if (rel.length === 1 && rel[0] === "groomBelow") { return `groomBelow must be an integer ≥ 1 or the literal "always", found ${found()}`; } if (rel.length === 1 && rel[0] === "releasePolicy") { return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`; } if (rel[0] === "routing" && (rel.length === 1 || (rel.length === 2 && rel[1] === "repos"))) { return `routing.repos needs at least one repo entry, or no issue can be routed`; } if (rel.length >= 2 && rel[rel.length - 1] === "dir" && rel.includes("migrations")) { return `${pathStr} must be a non-empty string`; } const NAME_LIST_FIELDS = new Set(["requiredChecks", "artefacts", "environments"]); // A whole name-list field of the wrong shape. if ( rel.length >= 2 && NAME_LIST_FIELDS.has(String(rel[rel.length - 1])) && (rel[rel.length - 2] === "merge" || rel[rel.length - 2] === "release") ) { return `${pathStr} must be an array of non-empty strings`; } // One malformed element of a name-list. if ( typeof rel[rel.length - 1] === "number" && rel.length >= 3 && NAME_LIST_FIELDS.has(String(rel[rel.length - 2])) ) { return `${pathStr} must be a non-empty string, found ${found()}`; } // Schema-authored findings (`.superRefine` / `.message` carried on the node // with a `custom` code) assemble as ` `. if (issue.code === "custom") return `${pathStr} ${issue.message}`; // Presence of an object-looking field where a singular scalar belongs, or a // whole object/array of the wrong type — the "must be an object" family. if (issue.code === "invalid_type") { const relStr = (rel[0] as string) ?? ""; if (rel.length === 1 && relStr === "reporting") { return `reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`; } if (rel.length === 2 && relStr === "reporting" && rel[1] === "digest") { return `reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`; } if (rel.length === 2 && relStr === "reporting" && rel[1] === "availability") { return `reporting.availability must be an object`; } if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`; if (rel.length === 1 && relStr === "arm") { return `arm must be an object with a "proof" of ${ARM_PROOF_LIST}`; } if (rel.length === 1 && relStr === "authority") { return `authority must be an object with "merge", "release" and "promotion" of ${AUTHORITY_HOLDER_LIST}`; } if (rel.length === 1 && relStr === "policy") { return `policy must be an object with "merge" and "release" sections`; } if (rel.length === 1 && relStr === "review") { return `review must be an object with "strictness", "maxRounds" and "adjudicator"`; } if (rel.length === 2 && rel[0] === "review" && rel[1] === "maxRounds") { return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`; } if (rel.length === 2 && rel[0] === "review" && rel[1] === "adjudicator") { return `review.adjudicator must be a string naming one OMP model role, found ${found()}`; } if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`; if (rel.length === 1 && relStr === "caps") return `caps must be an object`; if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") { return `caps.planUsage must be { windowId, maxUsedFraction } or null (unmetered), found ${found()}`; } if (rel.length === 1 && relStr === "releasePolicy") { return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`; } if (rel.length === 3 && rel[0] === "routing" && rel[1] === "repos") { return `routing.repos needs at least one repo entry, or no issue can be routed`; } if (rel.length === 1 && relStr === "tracker") { return `tracker.repo must look like "owner/repo", found undefined`; } // A numeric cap field of the wrong shape (zod union over number|null). if (rel.length === 2 && relStr === "caps") { return capProblem(rel[1] as string, found()); } // A missing/wrong name-list element. if (typeof rel[rel.length - 1] === "number") { return `${pathStr} must be a non-empty string, found ${found()}`; } // Every remaining require/missing-property case is authored here; never // fall through to zod's own rendering. if (rel[0] === "tracker") return `tracker.repo must look like "owner/repo", found undefined`; if (pathStr === "reporting.digest.cadence") return `reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`; if (pathStr === "reporting.digest.at") return `reporting.digest.at must be a 24h HH:MM time`; if (pathStr === "reporting.digest.timezone") return `reporting.digest.timezone must be a string`; if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`; if (pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") { return `${pathStr} must be a 24h HH:MM time`; } if (pathStr === "reporting.availability.days") { return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`; } if (pathStr === "reporting.availability.bypass") { return `reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`; } if (pathStr === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`; if (rel.length >= 2 && String(rel[rel.length - 1]).endsWith("graphProject")) { return `${pathStr} must be a non-empty absolute path, found ${found()}`; } if (rel.length >= 2 && String(rel[rel.length - 1]) === "gates") return `${pathStr} must be an array of { cmd, cwd }`; if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found()); return `${pathStr} is invalid`; } if (issue.code === "unrecognized_keys") { const keys = (issue.keys ?? []).join(", "); if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`; if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness", "maxRounds" and "adjudicator"`; if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`; if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`; if (pathStr === "caps.planUsage") { return `caps.planUsage has unknown key(s): ${keys} — expected windowId and maxUsedFraction`; } if (pathStr === "releasePolicy") { return `releasePolicy has unknown release shape(s): ${keys} — expected ${RELEASE_SHAPE_LIST}`; } return `${pathStr} has unknown key(s): ${keys}`; } // Enum membership (invalid_value). The wording depends on the field. if (issue.code === "invalid_value") { const tail = rel[rel.length - 1]; const parent = dottedPath(rel.slice(0, rel.length - 1)); // Array-element vocabularies stated with their own phrasing. if (parent === "reporting.interruptOn") { return `reporting.interruptOn has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`; } if (parent === "reporting.availability.bypass") { return `reporting.availability.bypass has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`; } if (parent === "reporting.availability.days") { return `reporting.availability.days has unknown day ${found()} — one of ${WEEKDAY_LIST}`; } if (parent === "policy.release.requires") { return `${pathStr} must be ${RELEASE_REQUIREMENT_LIST}, found ${found()}`; } if (rel.length >= 2 && rel[0] === "releasePolicy") { return `releasePolicy.${String(tail)} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`; } if (pathStr === "tracker.kind") return `tracker.kind must be "github", found ${found()}`; if (pathStr === "reporting.scope" || pathStr === "reporting.scopePreset") { return `${pathStr} must be ${REPORT_SCOPE_LIST}, found ${found()}`; } if (pathStr === "reporting.digest.cadence") return `${pathStr} must be ${DIGEST_CADENCE_LIST}, found ${found()}`; if (pathStr === "escalation.orchestrator") return `${pathStr} must be ${ORCHESTRATOR_MODE_LIST}, found ${found()}`; if (pathStr === "authority.merge" || pathStr === "authority.release" || pathStr === "authority.promotion") { return `${pathStr} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`; } if (pathStr === "policy.merge.baseFreshness") return `${pathStr} must be ${BASE_FRESHNESS_LIST}, found ${found()}`; if (pathStr === "policy.merge.drafts") return `${pathStr} must be ${DRAFT_POLICY_LIST}, found ${found()}`; if (pathStr === "policy.merge.whenBehindBase") return `${pathStr} must be ${BEHIND_BASE_ACTION_LIST}, found ${found()}`; const values = (issue.values ?? []).map(String); return `${pathStr} must be ${quoteList(values)}, found ${found()}`; } // Regex / custom-message failures carry the finding in issue.message. if (issue.code === "too_small") { if (pathStr === "review.maxRounds") { return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`; } // Empty array (reporting.interruptOn, availability.days) has its own line. if (parentOf(rel) === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`; if (pathStr === "reporting.availability.days") return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`; if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`; if (pathStr === "routing.repos" ) return `routing.repos needs at least one repo entry, or no issue can be routed`; if (cloneUrlPath(rel) ) return `${pathStr} must be a non-empty string`; if (gatesCmdPath(rel)) return `${pathStr} must be { cmd, cwd } with a non-empty cmd`; if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found()); if (pathStr === "caps.planUsage.maxUsedFraction") { return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`; } return `${pathStr} ${finding(issue.message)}`; } if (issue.code === "too_big") { if (pathStr === "review.maxRounds") { return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`; } if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found()); if (pathStr === "caps.planUsage.maxUsedFraction") { return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`; } return `${pathStr} ${finding(issue.message)}`; } // invalid_format (the repo / HH:MM regexes) and anything else. if (pathStr === "reporting.digest.at" || pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") { return `${pathStr} must be a 24h HH:MM time`; } if (pathStr === "tracker.repo") return `tracker.repo must look like "owner/repo", found ${found()}`; return `${pathStr} ${finding(issue.message)}`; } /** zod's own phrasing, which must never reach an operator-facing problem line. */ const LEAKY = /(Invalid input|expected |received |Unrecognized key|Too small|Too big)/; function finding(message: string): string { return LEAKY.test(message) ? "is not valid" : message; } function parentOf(rel: readonly PropertyKey[]): string { return dottedPath(rel.slice(0, rel.length - 1)); } function cloneUrlPath(rel: readonly PropertyKey[]): boolean { return rel.length >= 4 && rel[rel.length - 1] === "cloneUrl"; } function gatesCmdPath(rel: readonly PropertyKey[]): boolean { return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd"); } function capProblem(key: string, found: string): string { if (key === "maxRunSpendUsd") { return ( "caps.maxRunSpendUsd must be a positive finite number or null " + `(derive it from dailySpendUsd), found ${found}` ); } return key === "dailySpendUsd" ? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}` : key === "workerStallSilenceMs" ? `caps.workerStallSilenceMs must be a non-negative finite number or null (derive it from workerWallClockMs), found ${found}` : `caps.${key} must be a non-negative finite number, found ${found}`; } /** Quted fallback key lists for policy unknown-key errors. */ const POLICY_MERGE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).merge)); const POLICY_RELEASE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).release)); /** The closed `arm.proof` vocabulary, for the not-an-object wording. */ const ARM_PROOF_LIST = quoteList(ARM_PROOFS); // --------------------------------------------------------------------------- // residue: cross-field coherence, migrations, defaults, path expansion // --------------------------------------------------------------------------- function finalize(data: unknown, path: string): ConductorConfig { const root = data as Raw; const problems: string[] = []; const version = root["version"]; const legacyCaps = version === 1; if (!READABLE_CONFIG_VERSIONS.some((v) => v === version)) { problems.push( `"version" must be ${READABLE_CONFIG_VERSIONS.join(" or ")}, found ${JSON.stringify(version)} — this config was written by a different conductor`, ); } const configuredDefaults = reconcileCaps(root["defaults"], `"defaults"`, problems, legacyCaps); const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns; const defaults: Caps = { ...DEFAULT_CAPS, ...configuredDefaults, workerMaxTurnsCeiling: configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2, }; const rawProjects = root["projects"]; const projects: ProjectConfig[] = []; if (!Array.isArray(rawProjects) || rawProjects.length === 0) { problems.push(`"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`); } else { rawProjects.forEach((p: unknown, i) => { const project = finalizeProject(p, i, problems, legacyCaps); if (project !== undefined) projects.push(project); }); } // Cross-field, and checked on the EFFECTIVE caps each project will run // under (#851): a per-run reservation larger than the day's budget can never // be satisfied, so every candidate would be held forever by a gate the // operator believed was a ceiling. Checked after resolution because the two // values may come from different layers — a global `dailySpendUsd` and a // per-project `maxRunSpendUsd`. for (const project of projects) { const effective = resolveCaps(project, defaults); if ( effective.maxRunSpendUsd !== null && effective.dailySpendUsd !== null && effective.maxRunSpendUsd > effective.dailySpendUsd ) { problems.push( `project "${project.name}": caps.maxRunSpendUsd ($${String(effective.maxRunSpendUsd)}) must not exceed ` + `caps.dailySpendUsd ($${String(effective.dailySpendUsd)}) — a per-run reservation larger than the day's ` + "budget can never be admitted", ); } } const rawHost = root["host"] as Raw | undefined; const host = finalizeHost(rawHost); const rawDbBackupDir = root["dbBackupDir"]; const dbBackupDir = typeof rawDbBackupDir === "string" ? rawDbBackupDir : undefined; if (problems.length > 0) throw new Error(problemEnvelope(path, problems)); // `ConductorConfig` is rebuilt here, so every optional top-level key must be // spelled out or it silently vanishes from every loaded config. `dbBackupDir` // and `host` are the two today; a third needs its own line below (#773). return { version: CONFIG_VERSION, defaults, projects, ...(dbBackupDir === undefined ? {} : { dbBackupDir }), ...(host === undefined ? {} : { host }), }; } /** * The typed host-constraints block (#721), or `undefined` when nothing usable * was configured — matching the schema's "absent renders no section" promise. * Trimming and empty-drop mirror the other hand-edited string fields; an * object whose every key is empty loads as if the field were absent. */ function finalizeHost(parsed: Raw | undefined): HostConstraints | undefined { if (parsed === undefined) return undefined; const out: HostConstraints = {}; const description = parsed["description"]; if (typeof description === "string" && description.trim() !== "") out.description = description.trim(); const path = parsed["path"]; if (typeof path === "string" && path.trim() !== "") out.path = path.trim(); const conventions = parsed["conventions"]; if (typeof conventions === "object" && conventions !== null && !Array.isArray(conventions)) { const kept: Record = {}; for (const [slug, value] of Object.entries(conventions as Record)) { if (typeof value === "string" && value.trim() !== "" && slug.trim() !== "") { kept[slug.trim()] = value.trim(); } } if (Object.keys(kept).length > 0) out.conventions = kept; } return Object.keys(out).length === 0 ? undefined : out; } function finalizeProject( parsed: unknown, index: number, problems: string[], legacyCaps: boolean, ): ProjectConfig | undefined { const at = `projects[${index}]`; const before = problems.length; const p = parsed as Raw; // zod guarantees name/repo/queueLabel/kind are present and well-formed. const name = p["name"] as string; const label = `project "${name}"`; const trackerRepo = ((p["tracker"] as Raw | undefined)?.["repo"]) as string; // The zod union already enforced `integer ≥ 1 | "always"`; this only // narrows the type for the assembled ProjectConfig (#988). const rawGroomBelow = p["groomBelow"]; const groomBelow = rawGroomBelow === "always" ? "always" : typeof rawGroomBelow === "number" ? rawGroomBelow : undefined; const rawRouting = p["routing"] as Raw | undefined; const rawPrefix = rawRouting?.["labelPrefix"]; const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX; const repos = finalizeRepos(rawRouting?.["repos"], label, problems); const stateLabels = p["stateLabels"] as Raw | undefined; const escalation = finalizeEscalation(p["escalation"] as Raw | undefined); const arm = finalizeArm(p["arm"] as Raw | undefined); const authority = finalizeAuthority(p["authority"] as Raw | undefined); const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems); const strandedTagRepos = releasePolicy["git-tag"] === "orchestrator" && releasePolicy["version-bump-pr"] !== "orchestrator" ? Object.values(repos).filter((repo) => repo.release !== undefined) : []; if (strandedTagRepos.length > 0) { problems.push( `${label}: releasePolicy delegates git-tag, but ${strandedTagRepos .map((repo) => `${repo.name}.release.versionFile`) .join(", ")} requires a version-change PR and version-bump-pr is not delegated — ` + `grant "version-bump-pr": "orchestrator" or keep git-tag with the human`, ); } const policy = finalizePolicy(p["policy"], label, problems); const review = finalizeReview(p["review"] as Raw | undefined); const caps = reconcileCaps(p["caps"], `${label}: caps`, problems, legacyCaps); const reporting = finalizeReporting(p["reporting"], label, problems); const recoveryMerges = (p["recoveryMerges"] as RecoveryMergeAuthorization[] | undefined)?.map( (entry) => ({ ...entry }), ); if (recoveryMerges !== undefined) { const seen = new Set(); for (const entry of recoveryMerges) { if (seen.has(entry.prUrl)) { problems.push(`${label}: recoveryMerges contains duplicate prUrl ${JSON.stringify(entry.prUrl)}`); } seen.add(entry.prUrl); } } const rawWorkerModel = p["workerModel"]; const workerModel = typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined; // The daemon's grooming-scout role (#1041), a hint like `workerModel`: zod // already enforced the role-token grammar, so this only narrows the type and // drops a blank. Absent stays absent — the daemon reads it as // DEFAULT_GROOM_ROLE — so no config gains a key its operator never chose. const rawGroomRole = p["groomRole"]; const groomRole = typeof rawGroomRole === "string" && rawGroomRole !== "" ? rawGroomRole : undefined; // The failover chain is a hint like workerModel, not a ceiling: an unusable // entry or a malformed threshold is dropped rather than failing the load, // and absent/empty `modelFallbacks` keeps today's dispatch byte for byte. const rawFallbacks = p["modelFallbacks"]; const modelFallbacks = Array.isArray(rawFallbacks) ? rawFallbacks .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter((entry) => entry !== "") : []; const rawThreshold = p["modelFallbackThreshold"]; const modelFallbackThreshold = typeof rawThreshold === "number" && Number.isInteger(rawThreshold) && rawThreshold >= 1 ? rawThreshold : undefined; // The one-shot cap escalation target (#807): a hint like `workerModel`, and // trimmed here because it is handed to the harness verbatim as a selector. // Blank or the wrong type is dropped rather than failing the load, which is // exactly the "no escalation target configured" state settlement reports. const rawEscalationModel = p["workerEscalationModel"]; const workerEscalationModel = typeof rawEscalationModel === "string" && rawEscalationModel.trim() !== "" ? rawEscalationModel.trim() : undefined; // Providers whose routes must bill to a subscription credential (#852). // Trimmed and de-duplicated here because each entry is compared against the // provider id the harness itself reports, and a stray blank or a repeat would // spend a probe subprocess proving the same thing twice. A non-array, or an // entry of the wrong type, is dropped like an unusable `modelFallbacks` entry: // absent means today's dispatch, byte for byte. const rawRequireOauth = p["requireOauthProviders"]; const requireOauthProviders = Array.isArray(rawRequireOauth) ? [ ...new Set( rawRequireOauth .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter((entry) => entry !== ""), ), ] : []; // The fleet-owned omp settings overlay (#537): an opaque map omp's own schema // owns, so the loader validates YAML shape only — a non-mapping is dropped // like an unusable `modelFallbacks` entry rather than failing the load, and // an absent field keeps today's dispatch byte for byte. Everything inside the // map is omp's to interpret, never conductor's. const rawOmpSettings = p["ompSettings"]; const ompSettings = typeof rawOmpSettings === "object" && rawOmpSettings !== null && !Array.isArray(rawOmpSettings) ? (rawOmpSettings as Record) : undefined; // Opt-in omp advisor on this project's workers (#542). `workerAdvisor: true` // stages `advisor.enabled: true` into the omp settings overlay — the same // #537 channel everything else uses — so the advisor's own behaviour (roster, // model, tools) stays omp's to resolve from the staged or global config, and // conductor never names a model or grants a tool. Only the literal boolean // `true` opts in; anything else (absent, false, a string) is dropped like an // unusable `modelFallbacks` entry, keeping today's dispatch byte for byte. const workerAdvisor = p["workerAdvisor"] === true; const effectiveOmpSettings = workerAdvisor || ompSettings !== undefined ? { ...(ompSettings === undefined ? {} : ompSettings), ...(workerAdvisor ? { // Merge, not replace: an operator's own `ompSettings.advisor` // block (roster or model overrides) survives; only `enabled` is // forced on, because that is the point of the opt-in. advisor: { ...(typeof ompSettings?.advisor === "object" && ompSettings.advisor !== null && !Array.isArray(ompSettings.advisor) ? (ompSettings.advisor as Record) : {}), enabled: true, }, } : {}), } : undefined; // Marked critical-base/safety markers (commit SHAs or refs). A continuation // branch must contain every one before the dispatcher may reattach it; // unusable entries are dropped like `modelFallbacks` and an absent or empty // list keeps today's behaviour byte for byte. const rawCriticalBase = p["criticalBase"]; const criticalBase = Array.isArray(rawCriticalBase) ? rawCriticalBase .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter((entry) => entry !== "") : []; if (problems.length > before) return undefined; return { name, tracker: { kind: "github", repo: trackerRepo }, queueLabel: p["queueLabel"] as string, ...(groomBelow === undefined ? {} : { groomBelow }), ...(groomRole === undefined ? {} : { groomRole }), stateLabels: { inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress), blocked: pickString(stateLabels?.["blocked"], DEFAULT_STATE_LABELS.blocked), failed: pickString(stateLabels?.["failed"], DEFAULT_STATE_LABELS.failed), backlog: pickString(stateLabels?.["backlog"], DEFAULT_STATE_LABELS.backlog), }, routing: { labelPrefix, repos }, caps, ...(workerModel === undefined ? {} : { workerModel }), ...(modelFallbacks.length === 0 ? {} : { modelFallbacks }), ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }), ...(workerEscalationModel === undefined ? {} : { workerEscalationModel }), ...(requireOauthProviders.length === 0 ? {} : { requireOauthProviders }), ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }), escalation, arm, review, authority, releasePolicy, policy, ...(recoveryMerges === undefined ? {} : { recoveryMerges }), reporting, ...(criticalBase.length === 0 ? {} : { criticalBase }), workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())), mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())), }; } /** * The project's arming gate (#613), complete. A config written before the key * existed — or one that never answered — materialises as `challenge`, the * authenticated round-trip every existing install already runs, so an upgrade * changes no behaviour. zod has already rejected anything that is not a * `"challenge"` / `"claim-only"` string, so this only fills the absent case. */ function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] { if (parsed === undefined) return { proof: DEFAULT_ARM_PROOF }; return { proof: (parsed["proof"] as ArmProof | undefined) ?? DEFAULT_ARM_PROOF }; } /** * The project's review policy (#678), complete. A config written before the * key existed — or one that never answered — materialises as * {@link DEFAULT_REVIEW_POLICY}, the documented migration default, so an * upgrade changes no existing project's behaviour beyond what its operators * then choose in setup. zod has already rejected anything outside the * strictness vocabulary, the validated round range or the adjudicator role * token grammar, so this only fills the absent-case defaults — the * adjudicator included (#875), deterministically the same role a new * project's setup starts from. */ function finalizeReview(parsed: Raw | undefined): ProjectConfig["review"] { if (parsed === undefined) return { ...DEFAULT_REVIEW_POLICY }; return { strictness: (parsed["strictness"] as ReviewStrictness | undefined) ?? DEFAULT_REVIEW_STRICTNESS, maxRounds: (parsed["maxRounds"] as number | undefined) ?? DEFAULT_REVIEW_MAX_ROUNDS, adjudicator: (parsed["adjudicator"] as string | undefined) ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE, }; } function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] { if (parsed === undefined) { return { fallbackToIssueComment: true, orchestrator: "embedded" }; } const escalation = { fallbackToIssueComment: parsed["fallbackToIssueComment"] !== false, orchestrator: (parsed["orchestrator"] as ProjectConfig["escalation"]["orchestrator"]) ?? "embedded", } as ProjectConfig["escalation"]; const chatId = parsed["telegramChatId"]; if (typeof chatId === "string" && chatId.trim() !== "") escalation.telegramChatId = chatId; // Admit a finite integer topic id; numeric strings that parse cleanly count. // Non-integers are omitted rather than rejected so a hand-edit never bricks load. const rawTopic = parsed["telegramTopicId"]; if (typeof rawTopic === "number") { if (Number.isFinite(rawTopic) && Number.isSafeInteger(rawTopic)) { escalation.telegramTopicId = rawTopic; } } else if (typeof rawTopic === "string") { const trimmed = rawTopic.trim(); if (trimmed !== "") { const n = Number(trimmed); if (Number.isFinite(n) && Number.isSafeInteger(n) && String(n) === trimmed) { escalation.telegramTopicId = n; } } } return escalation; } function finalizeAuthority(parsed: Raw | undefined): ProjectConfig["authority"] { if (parsed === undefined) return { ...DEFAULT_AUTHORITY }; return { merge: (parsed["merge"] as ProjectConfig["authority"]["merge"]) ?? DEFAULT_AUTHORITY.merge, release: (parsed["release"] as ProjectConfig["authority"]["release"]) ?? DEFAULT_AUTHORITY.release, // Unstated defaults to the human: a config written before promotion was a // key must load as operator-owned, never as a silent delegation. promotion: (parsed["promotion"] as ProjectConfig["authority"]["promotion"] | undefined) ?? DEFAULT_AUTHORITY.promotion, }; } function finalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants { if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS }; if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS }; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { problems.push( `${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` + `${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`, ); return { ...DENIED_RELEASE_GRANTS }; } const raw = parsed as Raw; const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k)); if (unknownKeys.length > 0) { problems.push( `${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`, ); } const grants = { ...DENIED_RELEASE_GRANTS }; for (const shape of RELEASE_SHAPES) { grants[shape] = (AUTHORITY_HOLDERS as readonly string[]).includes(raw[shape] as string) ? (raw[shape] as ResolvedGrants[typeof shape]) : "human"; } return grants; } function finalizePolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy { if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { problems.push(`${label}: policy must be an object with "merge" and "release" sections`); return clonePolicy(DEFAULT_PROJECT_POLICY); } const raw = parsed as Raw; return { merge: finalizeMergePreconditions(raw["merge"], problems), release: finalizeReleasePreconditions(raw["release"], problems), }; } function finalizeMergePreconditions(parsed: unknown, problems: string[]): MergePreconditions { if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).merge; const raw = parsed as Raw; const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge; return { requiredChecks: trimNames(raw["requiredChecks"]), baseFreshness: (raw["baseFreshness"] as MergePreconditions["baseFreshness"]) ?? fallback.baseFreshness, drafts: (raw["drafts"] as MergePreconditions["drafts"]) ?? fallback.drafts, whenBehindBase: (raw["whenBehindBase"] as MergePreconditions["whenBehindBase"]) ?? fallback.whenBehindBase, }; } function finalizeReleasePreconditions(parsed: unknown, problems: string[]): ReleasePreconditions { if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).release; const raw = parsed as Raw; const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release; return { requires: canonicalRequirements(raw["requires"]), requiredChecks: trimNames(raw["requiredChecks"]), artefacts: trimNames(raw["artefacts"]), environments: trimNames(raw["environments"]), }; } /** The `requires` set in vocabulary order, deduplicated (#129's promise). */ function canonicalRequirements(parsed: unknown): ReleaseRequirement[] { const chosen = new Set(); if (!Array.isArray(parsed)) return []; for (const entry of parsed) chosen.add(entry as string); return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r)); } /** Trims name-list entries (validation is zod's; this only canonises). */ function trimNames(parsed: unknown): string[] { if (!Array.isArray(parsed)) return []; return parsed.map((n) => (n as string).trim()); } function finalizeRepos(parsed: unknown, label: string, problems: string[]): Record { const repos: Record = {}; const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined; const entries = raw === undefined ? [] : Object.entries(raw); if (entries.length === 0) { problems.push(`${label}: routing.repos needs at least one repo entry, or no issue can be routed`); return repos; } for (const [key, entry] of entries) { const value = entry as Raw | undefined; const cloneUrl = value?.["cloneUrl"] as string | undefined; if (typeof cloneUrl !== "string" || cloneUrl.trim() === "") { problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`); continue; } const credentialInUrl = cloneUrlCredentialProblem(cloneUrl); if (credentialInUrl !== undefined) { problems.push( `${label}: routing.repos.${key}.cloneUrl ${credentialInUrl}. Use an SSH URL, or an https URL ` + `backed by the daemon's own credential helper.`, ); continue; } const target: RepoTarget = { name: pickString(value?.["name"], key), cloneUrl, defaultBranch: pickString(value?.["defaultBranch"], "main"), gates: finalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems), }; const graph = finalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems); if (graph !== undefined) target.graphProject = graph; const migrations = finalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems); if (migrations !== undefined) target.migrations = { dir: migrations }; const versionFile = finalizeVersionFile(value?.["release"], `${label}: routing.repos.${key}`, problems); if (versionFile !== undefined) target.release = { versionFile }; repos[key] = target; } return repos; } function finalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] { if (parsed === undefined) return []; if (!Array.isArray(parsed)) { problems.push(`${label}.gates must be an array of { cmd, cwd }`); return []; } const gates: { cmd: string; cwd: string }[] = []; parsed.forEach((entry: unknown, i) => { const gate = entry as Raw | undefined; const cmd = gate?.["cmd"]; if (typeof cmd !== "string" || cmd.trim() === "") { problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`); return; } gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") }); }); return gates; } function finalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined { if (parsed === undefined) return undefined; const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined; const dir = raw?.["dir"]; if (typeof dir !== "string" || dir.trim() === "") { problems.push(`${label}.migrations.dir must be a non-empty string`); return undefined; } if (dir.startsWith("/") || dir.split("/").includes("..")) { problems.push(`${label}.migrations.dir must be a repo-relative directory`); return undefined; } return dir; } function finalizeVersionFile(parsed: unknown, label: string, problems: string[]): string | undefined { if (parsed === undefined) return undefined; const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined; const file = raw?.["versionFile"]; if (typeof file !== "string" || file.trim() === "") { problems.push(`${label}.release.versionFile must be a non-empty string`); return undefined; } if (file.startsWith("/") || file.split("/").includes("..")) { problems.push(`${label}.release.versionFile must be a repo-relative file`); return undefined; } return file; } function finalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined { if (parsed === undefined) return undefined; if (typeof parsed !== "string" || parsed.trim() === "") { problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`); return undefined; } const path = expandHome(parsed.trim()); if (isAbsolute(path)) return path; problems.push( `${label}.graphProject must be an absolute path — it is read by sessions whose cwd is their own ` + `worktree — found ${JSON.stringify(parsed)}`, ); return undefined; } /** * Reconciles a caps object against the active key set, version-aware. zod has * already typed the *known* cap values; this drop-versus-reject is the one * version-keyed decision that impossibly lives in a value schema. */ function reconcileCaps( parsed: unknown, label: string, problems: string[], legacy: boolean, ): Partial { const out: Partial = {}; if (parsed === undefined) return out; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { problems.push(`${label} must be an object`); return out; } const raw = parsed as Raw; for (const key of CAP_KEYS) { const v = raw[key]; if (v === undefined) continue; if (key === "planUsage") { const cap = coercePlanUsage(v, `${label}.planUsage`, problems); if (cap !== undefined) out.planUsage = cap; continue; } if (key === "maxRunSpendUsd") { // Positive or null, never 0 (#851): `dailySpendUsd: 0` is a meaningful // hard stop, but a per-run allowance of 0 reserves nothing and admits // nothing — it is a misconfiguration wearing a cap's clothes, so it is // named rather than obeyed. if (v === null) { out.maxRunSpendUsd = null; continue; } if (typeof v === "number" && Number.isFinite(v) && v > 0) { out.maxRunSpendUsd = v; continue; } problems.push( `${label}.maxRunSpendUsd must be a positive finite number or null (derive it from dailySpendUsd), ` + `found ${JSON.stringify(v)}`, ); continue; } if (typeof v === "number" && Number.isFinite(v) && v >= 0) { (out as Record)[key as string] = v; continue; } if (key === "dailySpendUsd" && v === null) { out.dailySpendUsd = null; continue; } if (key === "workerStallSilenceMs" && v === null) { // Null is the derive-it value (#1086): a third of the wall-clock ceiling, // computed where the window is read, so it passes through untouched. out.workerStallSilenceMs = null; continue; } problems.push( key === "dailySpendUsd" ? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}` : key === "workerStallSilenceMs" ? `${label}.${key} must be a non-negative finite number or null (derive it from workerWallClockMs), found ${JSON.stringify(v)}` : `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`, ); } if (!legacy) { const unknownKeys = Object.keys(raw).filter((k) => !(CAP_KEYS as string[]).includes(k)); if (unknownKeys.length > 0) { problems.push( `${label} has unknown key(s): ${unknownKeys.join(", ")} — remove them, or drop "version" to 1 if they are caps an older conductor wrote`, ); } } return out; } function coercePlanUsage( v: unknown, label: string, problems: string[], ): PlanUsageCap | null | undefined { if (v === null) return null; if (typeof v !== "object" || Array.isArray(v)) { problems.push( `${label} must be { windowId, maxUsedFraction } or null (unmetered), found ${JSON.stringify(v)}`, ); return undefined; } const raw = v as Raw; const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction"); const rawId = raw["windowId"]; const windowId = typeof rawId === "string" && rawId.trim() !== "" ? rawId.trim() : undefined; const rawFraction = raw["maxUsedFraction"]; const maxUsedFraction = typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1 ? rawFraction : undefined; if (windowId === undefined) { problems.push( `${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`, ); } if (maxUsedFraction === undefined) { problems.push( `${label}.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${JSON.stringify(rawFraction)}`, ); } if (unknownKeys.length > 0) { problems.push(`${label} has unknown key(s): ${unknownKeys.join(", ")} — expected windowId and maxUsedFraction`); } if (windowId === undefined || maxUsedFraction === undefined || unknownKeys.length > 0) return undefined; return { windowId, maxUsedFraction }; } /** * Reporting scope decides whether the orchestrator speaks up or stays quiet, so * a typo is rejected rather than folded to the default: a misspelt `"materal"` * that silently resolved to `"material"` would read as configured on the day the * operator meant to turn the volume down, and the config would keep lying. */ /** The legacy `reporting.scope` presets, materialised as explicit policies. * Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk" * default, which must stay `material`): each preset records `scopePreset` so * the tick prompt can keep saying the exact legacy words (#229). Exported so * the setup wizard and the config validator agree on one mapping. */ export const SCOPE_PRESETS: Record = { material: { interruptOn: [...INTERRUPT_CATEGORIES], digest: { cadence: "per-tick" }, scopePreset: "material", }, decisions: { interruptOn: ["tier2", "decision-needed", "fleet-stopped"], digest: { cadence: "per-tick" }, scopePreset: "decisions", }, escalations: { interruptOn: ["tier2", "fleet-stopped"], digest: { cadence: "daily" }, scopePreset: "escalations", }, }; function defaultReporting(): ReportingPolicy { return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] }; } /** The 24-hour `HH:MM` shape `digest.at` must take. */ const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/; /** * The reporting policy: a legacy `scope` preset, or the explicit * `interruptOn` + `digest` form. The two forms are mutually exclusive — a * preset IS a policy, so configuring alongside it says one thing and means * another (#229). zod validates the shapes; this enforces the mutual exclusion, * materialises a preset, and reconciles the availability/digest clocks. */ function finalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy { if (parsed === undefined) return defaultReporting(); const raw = parsed as Raw; const hasScope = raw["scope"] !== undefined; const hasExplicit = raw["interruptOn"] !== undefined || raw["digest"] !== undefined || raw["availability"] !== undefined; if (hasScope && hasExplicit) { problems.push( `${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`, ); return defaultReporting(); } if (hasScope || (!hasExplicit && Object.keys(raw).length === 0)) { const scope = (raw["scope"] as ReportScope) ?? DEFAULT_REPORT_SCOPE; const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]; return { interruptOn: [...preset.interruptOn], digest: { ...preset.digest }, scopePreset: preset.scopePreset, }; } const storedPreset = raw["scopePreset"]; const scopePreset = typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset) ? (storedPreset as ReportScope) : undefined; const interruptOn = finalizeInterruptOn(raw["interruptOn"], label, problems); const digest = finalizeDigest(raw["digest"], label, problems); const availability = finalizeAvailability(raw["availability"], label, problems); if (availability !== undefined && digest.cadence === "daily") { if (digest.timezone === undefined) digest.timezone = availability.timezone; else if (digest.timezone !== availability.timezone) { problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`); } } return { interruptOn, digest, ...(availability === undefined ? {} : { availability }), ...(scopePreset === undefined ? {} : { scopePreset }), }; } function finalizeInterruptOn( parsed: unknown, label: string, problems: string[], ): InterruptCategory[] { if (parsed === undefined) { problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`); return [...INTERRUPT_CATEGORIES]; } const out: InterruptCategory[] = []; for (const item of parsed as readonly unknown[]) { const category = item as InterruptCategory; if (!out.includes(category)) out.push(category); } if (out.length === 0) { problems.push(`${label}: reporting.interruptOn must name at least one category`); return [...INTERRUPT_CATEGORIES]; } return out; } function finalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] { const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" }; if (parsed === undefined) { problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`); return fallback; } const raw = parsed as Raw; const cadence = (raw["cadence"] as DigestCadence) ?? "per-tick"; const digest: ReportingPolicy["digest"] = { cadence }; if (cadence !== "daily") { if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`); if (raw["timezone"] !== undefined) { problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`); } return digest; } if (raw["at"] !== undefined) { if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) { problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`); } else { digest.at = raw["at"]; } } if (raw["timezone"] !== undefined) { const tz = raw["timezone"]; if (typeof tz !== "string") { problems.push(`${label}: reporting.digest.timezone must be a string`); } else { try { new Intl.DateTimeFormat("en-GB", { timeZone: tz }); digest.timezone = tz; } catch { problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`); } } } return digest; } function finalizeAvailability( parsed: unknown, label: string, problems: string[], ): WeeklyAvailability | undefined { if (parsed === undefined) return undefined; const raw = parsed as Raw; let timezone: string | undefined; if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") { problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`); } else { try { new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] }); timezone = raw["timezone"]; } catch { problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`); } } const days: Weekday[] = []; if (!Array.isArray(raw["days"]) || raw["days"].length === 0) { problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`); } else { for (const item of raw["days"] as readonly unknown[]) { const day = item as Weekday; if (!days.includes(day)) days.push(day); } } const start = raw["start"]; const end = raw["end"]; if (typeof start !== "string" || !DIGEST_AT.test(start)) { problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`); } if (typeof end !== "string" || !DIGEST_AT.test(end)) { problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`); } if (typeof start === "string" && typeof end === "string" && start === end) { problems.push(`${label}: reporting.availability.start and end must differ`); } const bypass: InterruptCategory[] = []; if (!Array.isArray(raw["bypass"])) { problems.push( `${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`, ); } else { for (const item of raw["bypass"] as readonly unknown[]) { const category = item as InterruptCategory; if (!bypass.includes(category)) bypass.push(category); } } return timezone === undefined || days.length === 0 || typeof start !== "string" || !DIGEST_AT.test(start) || typeof end !== "string" || !DIGEST_AT.test(end) || start === end || !Array.isArray(raw["bypass"]) ? undefined : { timezone, days, start, end, bypass }; } /** * A clone URL carrying a password or token is rejected at load. * * `git clone` persists whatever is in the URL into the mirror's config, and the * run repository inherits `origin` from it — so a `https://@github.com/…` * writes the operator's credential into a file on disk that every later run * reads, where nobody is looking for it. This used to be a `ponytail` note on * `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it * is now rejected at load with the field named. * * An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`) * is not a credential and is left alone — that is how nearly every fleet is * configured, and rejecting it would be a migration nobody asked for. */ export function cloneUrlCredentialProblem(url: string): string | undefined { const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url); if (m === null) return undefined; const scheme = (m[1] ?? "").toLowerCase(); const userinfo = m[2] ?? ""; if (userinfo.includes(":")) { return "embeds a user:password — git persists it into the mirror config, which hands every session the credential"; } if (scheme === "http" || scheme === "https") { return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config"; } return undefined; } function nonEmptyString(v: unknown): v is string { return typeof v === "string" && v.trim().length > 0; } /** One rule for "a usable string, else the documented default", used throughout. */ function pickString(v: unknown, fallback: string): string { return nonEmptyString(v) ? v : fallback; } /** * `~/x` in a hand-written config must not create a literal `~` directory. * * Exported because the wizard and `setup graph` derive paths the operator may * have typed with a `~` in them, and one spelling of this rule in the package * is the only way a path shown in a plan matches the path a validator accepts. */ export function expandHome(p: string): string { if (p === "~") return homedir(); return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; }