/** * The zod schema for the on-disk conductor config, and the JSON Schema it * renders. * * `ConductorConfig`'s vocabulary was validated field by field by hand-written * guards in `config.ts`. This file is the upgrade those guards always pointed * at (see the note on `Raw` in `config.ts`): the shape, the value types and * every closed enum are now a declarative schema built from the same exported * `as const` arrays `types.ts` publishes, so the on-disk vocabulary, the load * gate and the shipped `config.schema.json` cannot fork. * * This schema is the per-field authority: types, enums, numeric bounds, * required/optional, and defaults all live here. What it deliberately does * **not** express — because the loader must, and zod cannot cleanly — is left * to the residue normalisers in `config.ts`: cross-field coherence * (scope-vs-explicit reporting, digest/availability times, `start !== end`), * the version-keyed cap migration, the clone-URL credential rejection, and * `~`/default path expansion. Editing a schema node here changes both what * `loadConfig` accepts and the shipped schema; `bun test`'s freshness lock * fails if the checked-in JSON is not regenerated. */ import { z } from "zod"; import { ARM_PROOFS, AUTHORITY_HOLDERS, BASE_FRESHNESS, BEHIND_BASE_ACTIONS, CONFIG_VERSION, DEFAULT_ARM_PROOF, DEFAULT_AUTHORITY, DEFAULT_CAPS, DEFAULT_PROJECT_POLICY, DEFAULT_REVIEW_ADJUDICATOR_ROLE, DEFAULT_REVIEW_MAX_ROUNDS, DEFAULT_REVIEW_STRICTNESS, DRAFT_POLICIES, INTERRUPT_CATEGORIES, LEGACY_RELEASE_POLICIES, ORCHESTRATOR_MODES, READABLE_CONFIG_VERSIONS, RELEASE_REQUIREMENTS, RELEASE_SHAPES, REPORT_SCOPES, REVIEW_ADJUDICATOR_RE, REVIEW_MAX_ROUNDS_MAX, REVIEW_MAX_ROUNDS_MIN, REVIEW_STRICTNESS, WEEKDAYS, DIGEST_CADENCES, } from "./types.ts"; /** * The quoted-literal spelling the loader uses in error text (`"a" or "b"`), * so a message built from these vocabularies cannot fork from the values it * names. Kept here (not in `config.ts`) because `config.ts` imports this file * at the parse boundary. */ export function quoteList(values: readonly string[]): string { return values.map((v) => `"${v}"`).join(" or "); } const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES); const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES); const WEEKDAY_LIST = quoteList(WEEKDAYS); const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES); const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS); const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS); const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES); const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS); const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS); const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES); const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES); const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES); const REVIEW_STRICTNESS_LIST = quoteList(REVIEW_STRICTNESS); // --------------------------------------------------------------------------- // Closed vocabularies — every one built from the exported `as const` array in // types.ts so a new member is a schema change too, never a silent fork. // --------------------------------------------------------------------------- const reportScopeEnum = z.enum([...REPORT_SCOPES]); const interruptCategoryEnum = z.enum([...INTERRUPT_CATEGORIES]); const weekdayEnum = z.enum([...WEEKDAYS]); const digestCadenceEnum = z.enum([...DIGEST_CADENCES]); const authorityHolderEnum = z.enum([...AUTHORITY_HOLDERS]); const baseFreshnessEnum = z.enum([...BASE_FRESHNESS]); const draftPolicyEnum = z.enum([...DRAFT_POLICIES]); const behindBaseActionEnum = z.enum([...BEHIND_BASE_ACTIONS]); const releaseRequirementEnum = z.enum([...RELEASE_REQUIREMENTS]); const releaseShapeEnum = z.enum([...RELEASE_SHAPES]); const orchestratorModeEnum = z.enum([...ORCHESTRATOR_MODES]); const releasePolicyLegacyEnum = z.enum([...LEGACY_RELEASE_POLICIES]); const reviewStrictnessEnum = z.enum([...REVIEW_STRICTNESS]); /** The 24-hour `HH:MM` shape `digest.at` / `availability.start/end` take. */ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/; // --------------------------------------------------------------------------- // Caps // --------------------------------------------------------------------------- const planUsageCap = z .object({ windowId: z .string() .min(1, "must be a non-empty allowance id such as \"anthropic:7d\"") .describe("The allowance window this threshold guards"), maxUsedFraction: z .number() .min(0) .max(1) .describe("Consumed share, 0–1, at which new claims stop"), }) .strict() .describe("An allowance-window spend guard, or null for unmetered"); const capsSchema = z .object({ maxConcurrentWorkers: z.number().min(0).describe(`Parallel omp sessions (default ${DEFAULT_CAPS.maxConcurrentWorkers})`), maxConcurrentWorkersPerRepo: z.number().min(0).describe(`Max live workers per repository (default ${DEFAULT_CAPS.maxConcurrentWorkersPerRepo})`), dailySpendUsd: z .number() .min(0) .nullable() .describe(`Rolling-day spend ceiling; null means no spend gate (default ${DEFAULT_CAPS.dailySpendUsd})`), maxRunSpendUsd: z .number() .positive() .nullable() .describe( "Ceiling on one run, reserved from the daily budget before it launches; " + "null derives it from dailySpendUsd. Must not exceed dailySpendUsd", ), planUsage: z.union([planUsageCap, z.null()]).describe(`Plan-allowance guard, or null for unmetered`), workerMaxTurns: z.number().min(0).describe(`Turn ceiling for one worker (default ${DEFAULT_CAPS.workerMaxTurns})`), workerMaxTurnsCeiling: z.number().min(0).describe(`Maximum turn budget assignable to one issue's next attempt`), workerWallClockMs: z.number().min(0).describe(`Wall-clock ceiling for one worker (default ${DEFAULT_CAPS.workerWallClockMs})`), workerStallSilenceMs: z .number() .min(0) .nullable() .describe( "How long a live worker's transcript may stay unwritten before the daemon settles it as a " + "progress stall; null derives a third of workerWallClockMs", ), maxAttemptsPerIssue: z.number().min(0).describe(`Failed attempts allowed before escalation (default ${DEFAULT_CAPS.maxAttemptsPerIssue})`), maxContinuationsPerIssue: z.number().min(0).describe(`Operational continuations allowed before crash/resume escalation (default ${DEFAULT_CAPS.maxContinuationsPerIssue})`), }) // A cap object carries any subset (project overrides are partial) and may // carry retired keys (v1) that the load boundary drops or rejects by version, // so unknown keys are kept and reconciled in `config.ts`, never rejected here. .partial() .loose() .describe("Per-fleet or per-project hard limits"); // --------------------------------------------------------------------------- // Reporting policy // --------------------------------------------------------------------------- const availabilitySchema = z .object({ timezone: z.string().min(1).describe("IANA timezone of the window"), days: z.array(weekdayEnum).min(1).describe("Days the window is open"), start: z.string().regex(HHMM, "must be a 24h HH:MM time").describe("Window open time, operator-local"), end: z.string().regex(HHMM, "must be a 24h HH:MM time").describe("Window close time, operator-local"), bypass: z.array(interruptCategoryEnum).describe("Categories that may interrupt outside the window").optional(), }) .strict() .superRefine((a, ctx) => { // Presence is required on disk (unlike a defaultable field); report the // missing key in the loader's own voice rather than zod's. if (a.bypass === undefined) { ctx.addIssue({ code: "custom", path: ["bypass"], message: `must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`, }); } }); const digestSchema = z .object({ cadence: digestCadenceEnum.describe("When the daily/report rollup is due"), at: z.string().regex(HHMM, "must be a 24h HH:MM time").optional().describe("Daily rollup time"), timezone: z.string().describe("IANA timezone of the daily rollup").optional(), }) .strict() .describe("The reporting digest/rollup schedule"); const reportingSchema = z .object({ scope: reportScopeEnum.optional().describe("Legacy reporting preset; mutually exclusive with the explicit form"), scopePreset: reportScopeEnum.optional().describe("Back-annotation of the legacy preset this policy came from"), interruptOn: z.array(interruptCategoryEnum).optional().describe("Which categories interrupt the operator's phone"), digest: digestSchema.optional(), availability: availabilitySchema.optional(), }) .strict(); // --------------------------------------------------------------------------- // Merge / release preconditions // --------------------------------------------------------------------------- const nameListSchema = z.array(z.string().min(1)); const mergePreconditionsSchema = z .object({ requiredChecks: nameListSchema.default(DEFAULT_PROJECT_POLICY.merge.requiredChecks), baseFreshness: baseFreshnessEnum.default(DEFAULT_PROJECT_POLICY.merge.baseFreshness), drafts: draftPolicyEnum.default(DEFAULT_PROJECT_POLICY.merge.drafts), whenBehindBase: behindBaseActionEnum.default(DEFAULT_PROJECT_POLICY.merge.whenBehindBase), }) .strict(); const releasePreconditionsSchema = z .object({ requires: z.array(releaseRequirementEnum).default(DEFAULT_PROJECT_POLICY.release.requires), requiredChecks: nameListSchema.default(DEFAULT_PROJECT_POLICY.release.requiredChecks), artefacts: nameListSchema.default(DEFAULT_PROJECT_POLICY.release.artefacts), environments: nameListSchema.default(DEFAULT_PROJECT_POLICY.release.environments), }) .strict(); const projectPolicySchema = z .object({ merge: mergePreconditionsSchema.optional(), release: releasePreconditionsSchema.optional(), }) .strict(); const recoveryMergesSchema = z.array( z .object({ prUrl: z .string() .regex( /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/, "must be a full https://github.com///pull/ URL", ), headSha: z.string().regex(/^[0-9a-fA-F]{40}$/, "must be an exact 40-character commit SHA"), reason: z.literal("operator-instructed"), }) .strict(), ); // --------------------------------------------------------------------------- // Authority / escalation / release policy // --------------------------------------------------------------------------- const authoritySchema = z .object({ merge: authorityHolderEnum.default(DEFAULT_AUTHORITY.merge), release: authorityHolderEnum.default(DEFAULT_AUTHORITY.release), promotion: authorityHolderEnum.default(DEFAULT_AUTHORITY.promotion), }) .strict(); const escalationSchema = z .object({ telegramChatId: z.unknown().optional(), telegramTopicId: z.unknown().optional(), fallbackToIssueComment: z.unknown().default(true), orchestrator: orchestratorModeEnum.default("embedded"), }) .strict(); /** * The per-project arming gate (conductor #613): how `arm` proves a human just * approved dispatch. Absent or a legacy config without the key loads as * `challenge` — the authenticated round-trip, which is what existing installs * already run. `claim-only` arms on the shared live-plumbing verdict with no * Telegram send or wait, so an unattended recovery can re-arm a project that * opted in. */ const armSchema = z .object({ proof: z.enum([...ARM_PROOFS]).default(DEFAULT_ARM_PROOF), }) .strict() .describe("How `arm` proves a human just approved arming"); /** * The per-project review policy (#678): how strictly green PRs are reviewed * and returned, the hard ceiling on review rounds per PR lifecycle, and which * OMP model role carries the terminal review-ceiling adjudication (#875). * Absent or a legacy config without the keys loads as `medium` with * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds and * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE} as adjudicator — the recommended * defaults for a new project, materialised deterministically for every * existing one, with the schema bounds and the runtime defaults read from the * same constants. The adjudicator names a role only; a provider/model is * refused here, because OMP owns exact provider selection. */ const reviewSchema = z .object({ strictness: reviewStrictnessEnum.default(DEFAULT_REVIEW_STRICTNESS), maxRounds: z .number() .int() .min(REVIEW_MAX_ROUNDS_MIN) .max(REVIEW_MAX_ROUNDS_MAX) .default(DEFAULT_REVIEW_MAX_ROUNDS), adjudicator: z .string() .regex( REVIEW_ADJUDICATOR_RE, "must name one OMP model role — a single role token like \"task\" (never a provider/model, which omp owns)", ) .default(DEFAULT_REVIEW_ADJUDICATOR_ROLE), }) .strict() .describe("Review strictness, round ceiling and adjudicator role for green PRs"); const releasePolicySchema = z.union([ releasePolicyLegacyEnum, z.record(z.string(), authorityHolderEnum), ]); // --------------------------------------------------------------------------- // Repo targets // --------------------------------------------------------------------------- const gateSchema = z .object({ cmd: z.string().optional(), cwd: z.unknown().default("."), }) .superRefine((g, ctx) => { if (typeof g.cmd !== "string" || g.cmd.trim() === "") { ctx.addIssue({ code: "custom", message: `must be { cmd, cwd } with a non-empty cmd` }); } }); const gatesSchema = z.array(gateSchema); const repoTargetSchema = z .object({ name: z.unknown().optional(), cloneUrl: z.string().min(1, "must be a non-empty string"), defaultBranch: z.unknown().default("main").optional(), gates: gatesSchema.optional(), graphProject: z.string().optional(), migrations: z .object({ dir: z.string().min(1, "must be a non-empty string") }) .strict() .optional(), release: z .object({ versionFile: z.string().min(1, "must be a non-empty string") }) .strict() .optional(), }) .strict(); const stateLabelsSchema = z .object({ // The loader defaults each of these (pickString), so a malformed value is // tolerated rather than rejected — the schema admits any shape and lets the // normaliser decide what is usable. inProgress: z.unknown(), blocked: z.unknown(), failed: z.unknown(), backlog: z.unknown(), }) .partial(); // --------------------------------------------------------------------------- // Host constraints (#721) // --------------------------------------------------------------------------- const hostConstraintsSchema = z .object({ description: z.string().min(1).describe("What this host is and what else it runs"), path: z .string() .min(1) .describe('The non-interactive PATH a script or `ssh host ""` invocation must export'), conventions: z .record(z.string().min(1), z.string().min(1)) .describe("Per-repo command conventions, keyed by the brief's `owner/repo` slug"), }) .strict() .partial() .describe("Operator-authored host facts rendered into every worker brief"); // --------------------------------------------------------------------------- // Project / root // --------------------------------------------------------------------------- const routingSchema = z.object({ labelPrefix: z.unknown().optional(), repos: z .record(z.string(), repoTargetSchema) .refine((r) => Object.keys(r).length > 0, `routing.repos needs at least one repo entry, or no issue can be routed`), }); const projectSchema = z .object({ name: z.string().min(1, "must be a non-empty string"), tracker: z .object({ kind: z.literal("github").optional(), repo: z.string().regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, `must look like "owner/repo"`), }) .strict(), queueLabel: z.string().min(1, "must be a non-empty string — it is the human sign-off gate"), // The grooming trigger (#988): an integer ≥ 1, or the literal "always" — // groom on demand every tick while ungroomed candidates remain. Unlike the // opaque model selectors below, this field's usable vocabulary is closed, // so the schema states it and a wrong value fails the load with an // authored line instead of silently degrading the fleet's grooming duty // to the default. groomBelow: z.union([z.number().int().min(1), z.literal("always")]).optional(), // The daemon's own grooming-scout role (#1041). Validated with exactly the // regex and message the review adjudicator is: one role-name grammar for // every role token conductor stores, so an operator who mistypes a // provider/model here hears the same authored line either place. Optional // rather than `.default()`ed — a config that never answered stays // unwritten and reads as DEFAULT_GROOM_ROLE, exactly like `groomBelow`. groomRole: z .string() .regex( REVIEW_ADJUDICATOR_RE, "must name one OMP model role — a single role token like \"task\" (never a provider/model, which omp owns)", ) .optional(), stateLabels: stateLabelsSchema.optional(), routing: routingSchema.optional(), caps: capsSchema.optional(), workerModel: z.unknown().optional(), // The loader normalises both of these (trimmed entries / positive integer), // so the schema admits any shape and lets the normaliser decide what is // usable, exactly like `workerModel`. modelFallbacks: z.unknown().optional(), modelFallbackThreshold: z.unknown().optional(), // One stronger opaque omp selector the first spinning cap in a chain // retries on (#807). Normalised (trimmed, blank dropped) by the loader // like `workerModel`, and deliberately separate from `modelFallbacks`: // that chain answers provider faults only. workerEscalationModel: z.unknown().optional(), // Providers that must bill to a subscription credential (#852). Normalised // by the loader (trimmed, blanks and non-strings dropped) like // `modelFallbacks`, so a malformed entry weakens the fence's *scope* rather // than failing the whole config load — and an entry that survives is one the // fence will actually enforce. requireOauthProviders: z.unknown().optional(), // The fleet-owned omp settings overlay (#537): an opaque map omp's own // schema owns. Conductor validates YAML shape only — the loader keeps it // when it is a mapping and drops anything else, like `modelFallbacks`. ompSettings: z.unknown().optional(), // Opt-in omp advisor on workers (#542). Only the literal boolean `true` // opts in (it stages `advisor.enabled: true` into the omp settings // overlay); anything else is dropped by the loader, like `workerModel`. workerAdvisor: z.unknown().optional(), escalation: escalationSchema.optional(), // How `arm` proves a human approved arming (#613); absent loads as // `challenge`, preserving today's authenticated round-trip. arm: armSchema.optional(), // Review strictness and the round ceiling (#678); absent loads as the // documented migration default. review: reviewSchema.optional(), authority: authoritySchema.optional(), releasePolicy: releasePolicySchema.optional(), policy: projectPolicySchema.optional(), recoveryMerges: recoveryMergesSchema.optional(), reporting: reportingSchema.optional(), // Hand-edited safety markers (commit SHAs or refs) a preserved continuation // branch must contain before it may be reattached; the loader normalises // them like `modelFallbacks`. criticalBase: z.unknown().optional(), // Roots silently fall back to the default when unusable, so the schema // admits any shape and the normaliser picks the usable path. workspaceRoot: z.unknown().optional(), mirrorRoot: z.unknown().optional(), }) .loose() .describe("One product this conductor services"); const configSchema = z .object({ $schema: z.string().optional().describe("Path to the shipped config.schema.json"), version: z.number().describe(`The config format version (${READABLE_CONFIG_VERSIONS.join(" or ")})`), defaults: capsSchema.optional(), dbBackupDir: z .string() .min(1, "must name a directory") .optional() .describe("Absolute directory for restorable conductor.db snapshots; defaults to /backups/db"), // Cores and RAM are derived by host.ts at render time; only what the // operator must type belongs in this block. host: hostConstraintsSchema .optional() .describe("Host facts every worker brief renders; absent renders no section"), projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`), }) .loose() .describe("On-disk configuration for omp-conductor"); // --------------------------------------------------------------------------- // Emitted schema // --------------------------------------------------------------------------- /** The zod schema `loadConfig` parses the on-disk config with. */ export const ConfigSchema = configSchema; /** * The draft 2020-12 JSON Schema rendering of {@link ConfigSchema}, shipped to * `schema/config.schema.json` (see the `bun run schema` script) so editors can * validate a hand-written config against the same vocabulary the loader enforces. */ export function configJsonSchema(): object { return unrequireDefaultedKeys(z.toJSONSchema(ConfigSchema) as object); } /** True for a JSON object value (null and arrays excluded). */ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } /** * zod's JSON-Schema renderer lists a field carrying `.default(...)` as * `required` anyway — its required computation keys off optionality, while * the only trace a default leaves in the output is the `default` annotation. * The loader accepts a config that omits any defaulted key, so a schema that * demands one rejects a file the daemon reads cleanly (the pre-#469 * back-compat promise). Walk the rendered schema and drop every `required` * key whose property schema declares a `default`; a key with no default is * untouched. Works in place on the tree `z.toJSONSchema` just built. */ function unrequireDefaultedKeys(schema: object): object { if (Array.isArray(schema)) { for (const item of schema) if (isObject(item)) unrequireDefaultedKeys(item); return schema; } const node = schema as Record; const properties = node.properties; if (isObject(properties) && Array.isArray(node.required)) { const kept = (node.required as unknown[]).filter((key) => { const property = properties[String(key)]; return !isObject(property) || !("default" in property); }); if (kept.length === 0) delete node.required; else node.required = kept; } for (const value of Object.values(node)) { if (isObject(value)) unrequireDefaultedKeys(value); } return schema; } // Re-exports the renderer consumes without duplicating the lists. export { REPORT_SCOPE_LIST, INTERRUPT_CATEGORY_LIST, WEEKDAY_LIST, DIGEST_CADENCE_LIST, AUTHORITY_HOLDER_LIST, BASE_FRESHNESS_LIST, DRAFT_POLICY_LIST, BEHIND_BASE_ACTION_LIST, RELEASE_REQUIREMENT_LIST, RELEASE_SHAPE_LIST, ORCHESTRATOR_MODE_LIST, LEGACY_RELEASE_POLICY_LIST, REVIEW_STRICTNESS_LIST, };