import type { CandidateSetInput, ConfigSnapshot, LoadOptions, ValidatedSource, WriteResult } from "./types"; import { loadConfig, readSources, type ReadSource } from "./loader"; import { ConfigValidationError, freezeConfig, mergeSources, parseModelKey, validateSource } from "./schema"; import { MAX_EPISODE_PROBES } from "../recovery/limits"; const empty = (): ConfigSnapshot => freezeConfig({}, { probe: { earlyDiscriminationProbe: true, mode: "fixed", intervalMin: 60, maxProbes: MAX_EPISODE_PROBES, silent: true, resetMarginSec: 60, outageIntervalMin: 10, maxOutageProbes: 6 }, review: { flagShellActivity: true }, }); export class ConfigStore { private current = empty(); constructor(private readonly options: LoadOptions) {} snapshot(): ConfigSnapshot { return this.current; } async reload() { const result = await loadConfig(this.options); this.current = result.config; return result; } load() { return this.reload(); } private fail(error: unknown): WriteResult { const message = error instanceof Error ? error.message : String(error); this.options.ui?.notify(message); return { ok: false, config: this.current, error: message }; } private async mutate(primary: string | undefined, candidate: CandidateSetInput | undefined, global: boolean, all: boolean): Promise { try { if (primary !== undefined && !parseModelKey(primary)) throw new ConfigValidationError("command", "invalid model key"); const [globalSource, projectSource] = await readSources(this.options); const sources: ReadSource[] = all ? [globalSource, projectSource] : [global ? globalSource : projectSource]; const invalid = sources.find((source) => source.error); if (invalid?.error) throw new Error(invalid.error.message); const changed = sources.map((source) => { const raw = { ...(source.raw ?? {}), version: 1, fallbacks: { ...(source.value?.fallbacks ?? {}) } } as Record; // Route the constructed raw source through the same `validateSource` the load path uses (R13: // symmetric rejection): the local candidate-set checks — self edge, within-set duplicate — and // the other schema/policy rules run BEFORE anything is written; never a bespoke scalar // comparison here. Cross-set references (even mutual cycles) pass like any accepted shape (R20). const edges = raw.fallbacks as Record; if (all && primary === undefined) Object.keys(edges).forEach((key) => delete edges[key]); else if (primary !== undefined && candidate === undefined) delete edges[primary]; else if (primary !== undefined) edges[primary] = candidate as CandidateSetInput; return { source, raw, value: validateSource(raw, source.path) }; }); const valueOf = (source: ReadSource): ValidatedSource | undefined => changed.find((item) => item.source === source)?.value ?? source.value; const config = mergeSources(valueOf(globalSource), valueOf(projectSource)); const outputs: [string, string][] = changed.map(({ source, raw }) => [source.path, `${JSON.stringify(raw, null, 2)}\n`]); for (const [path, text] of outputs) await this.options.fs.writeAtomic(path, text); this.current = config; return { ok: true, config }; } catch (error) { return this.fail(error); } } set(primary: string, candidate: CandidateSetInput, options: { global?: boolean } = {}) { return this.mutate(primary, candidate, options.global === true, false); } remove(primary: string | undefined, options: { global?: boolean; all?: boolean } = {}) { return this.mutate(primary, undefined, options.global === true, options.all === true); } }