diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 92ff355..4875d62 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseNerveConfig } from "../parse-nerve-config.js"; +function testConsole(): { warn: (...args: unknown[]) => void } { + return (globalThis as unknown as { console: { warn: (...args: unknown[]) => void } }).console; +} + const VALID_CONFIG = ` senses: cpu: @@ -27,11 +31,21 @@ workflows: `; describe("parseNerveConfig", () => { + beforeEach(() => { + vi.spyOn(testConsole(), "warn").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe("valid configs", () => { it("parses a full valid config", () => { const result = parseNerveConfig(VALID_CONFIG); expect(result.ok).toBe(true); if (!result.ok) return; + expect(testConsole().warn).toHaveBeenCalledWith( + expect.stringMatching(/top-level `reflexes`.*deprecated/i), + ); expect(result.value.senses.cpu).toEqual({ group: "system", @@ -39,6 +53,8 @@ describe("parseNerveConfig", () => { timeout: null, gracePeriod: null, retention: 10_000, + interval: 30_000, + on: [], }); expect(result.value.senses.memory).toEqual({ group: "system", @@ -46,6 +62,8 @@ describe("parseNerveConfig", () => { timeout: 10_000, gracePeriod: 3000, retention: 10_000, + interval: null, + on: ["high_usage"], }); expect(result.value.reflexes).toHaveLength(2); expect(result.value.reflexes[0]).toEqual({ @@ -79,6 +97,7 @@ reflexes: [] expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.reflexes).toEqual([]); + expect(testConsole().warn).not.toHaveBeenCalled(); }); it("parses config without workflows section", () => { @@ -112,6 +131,8 @@ reflexes: [] timeout: null, gracePeriod: null, retention: 10_000, + interval: null, + on: [], }); }); @@ -456,16 +477,62 @@ reflexes: [] expect(result.error.message).toMatch(/senses/); }); - it("returns error when reflexes is missing", () => { + it("accepts config without reflexes key (inline triggers only)", () => { const yaml = ` senses: cpu: group: system + interval: 5s + on: + - memory + memory: + group: system +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.senses.cpu.interval).toBe(5000); + expect(result.value.senses.cpu.on).toEqual(["memory"]); + expect(result.value.reflexes).toEqual([ + { kind: "sense", sense: "cpu", interval: 5000, on: ["memory"] }, + ]); + }); + + it("returns error when legacy reflex interval conflicts with sense interval", () => { + const yaml = ` +senses: + cpu: + group: system + interval: 10s +reflexes: + - sense: cpu + interval: 5s `; const result = parseNerveConfig(yaml); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.message).toMatch(/reflexes/); + expect(result.error.message).toMatch(/conflicting interval/); + }); + + it("merges legacy reflex on[] into sense on and dedupes", () => { + const yaml = ` +senses: + cpu: + group: system + on: + - a +reflexes: + - sense: cpu + interval: 1s + on: + - a + - b +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.senses.cpu.on).toEqual(["a", "b"]); + expect(result.value.senses.cpu.interval).toBe(1000); }); it("returns error for invalid group name", () => { diff --git a/packages/core/src/parse-nerve-config.ts b/packages/core/src/parse-nerve-config.ts index c24db8b..7196785 100644 --- a/packages/core/src/parse-nerve-config.ts +++ b/packages/core/src/parse-nerve-config.ts @@ -113,6 +113,102 @@ function validateSenseConfig(name: string, raw: unknown): Result { }); } +const LEGACY_REFLEXES_DEPRECATION = + "[nerve] Deprecated: top-level `reflexes` in nerve.yaml is deprecated. Move each reflex's `interval` and `on` fields onto the corresponding sense under `senses.`."; + +/** Uses `globalThis` so declaration emit works without DOM / Node `lib` typings for `console`. */ +function warnToConsole(message: string): void { + const c = (globalThis as unknown as { console: { warn: (m: string) => void } }).console; + c.warn(message); +} + +function mergeOnLists(existing: readonly string[], extra: readonly string[]): string[] { + const seen = new Set(existing); + const out = [...existing]; + for (const name of extra) { + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} + +function mergeSenseIntervalFromLegacyReflex( + senseName: string, + senseInterval: number | null, + reflexInterval: number | null, + reflexIndex: number, +): Result { + if (reflexInterval === null) return ok(senseInterval); + if (senseInterval === null) return ok(reflexInterval); + if (senseInterval === reflexInterval) return ok(senseInterval); + return err( + new Error( + `reflexes[${reflexIndex}].sense "${senseName}": conflicting interval (sense has ${senseInterval} ms, reflex has ${reflexInterval} ms); use a single interval source`, + ), + ); +} + +function buildReflexesFromSenses(senses: Record): ReflexConfig[] { + const reflexes: ReflexConfig[] = []; + for (const [senseName, sc] of Object.entries(senses)) { + if (sc.interval !== null || sc.on.length > 0) { + reflexes.push({ + kind: "sense" as const, + sense: senseName, + interval: sc.interval, + on: sc.on, + }); + } + } + return reflexes; +} + +/** + * If `reflexes` is present and non-empty, parse legacy entries and merge `interval` / `on` + * into the corresponding sense. Emits a deprecation warning once when any legacy row exists. + */ +function mergeLegacyReflexesIntoSenses( + obj: Record, + senses: Record, + senseNames: Set, +): Result> { + if (!Object.hasOwn(obj, "reflexes") || obj.reflexes === undefined || obj.reflexes === null) { + return ok(senses); + } + if (!Array.isArray(obj.reflexes)) { + return err(new Error("reflexes: must be an array if provided")); + } + + if (obj.reflexes.length > 0) { + warnToConsole(LEGACY_REFLEXES_DEPRECATION); + } + + let merged: Record = senses; + for (let i = 0; i < obj.reflexes.length; i++) { + const reflexResult = validateReflexConfig(i, obj.reflexes[i], senseNames); + if (!reflexResult.ok) return reflexResult; + const ref = reflexResult.value; + const senseName = ref.sense; + const sense = merged[senseName]; + const intervalResult = mergeSenseIntervalFromLegacyReflex( + senseName, + sense.interval, + ref.interval, + i, + ); + if (!intervalResult.ok) return intervalResult; + const next: SenseConfig = { + ...sense, + interval: intervalResult.value, + on: mergeOnLists(sense.on, ref.on), + }; + merged = { ...merged, [senseName]: next }; + } + + return ok(merged); +} + function parseOnField(index: number, obj: Record): Result { if (obj.on === undefined || obj.on === null) return ok([]); if (!Array.isArray(obj.on) || !obj.on.every((item): item is string => typeof item === "string")) { @@ -264,24 +360,6 @@ function parseSenses( return ok({ senses, senseNames }); } -function parseReflexes( - obj: Record, - senseNames: Set, -): Result { - if (!Array.isArray(obj.reflexes)) { - return err(new Error("reflexes: required array")); - } - - const reflexes: ReflexConfig[] = []; - for (let i = 0; i < obj.reflexes.length; i++) { - const result = validateReflexConfig(i, obj.reflexes[i], senseNames); - if (!result.ok) return result; - reflexes.push(result.value); - } - - return ok(reflexes); -} - const DEFAULT_API_BIND_HOST = "127.0.0.1"; /** Hosts that may bind the HTTP API without `api.token` (loopback-only). */ @@ -389,8 +467,10 @@ export function parseNerveConfig(raw: string): Result { if (!sensesResult.ok) return sensesResult; const { senses, senseNames } = sensesResult.value; - const reflexesResult = parseReflexes(obj, senseNames); - if (!reflexesResult.ok) return reflexesResult; + const mergedSensesResult = mergeLegacyReflexesIntoSenses(obj, senses, senseNames); + if (!mergedSensesResult.ok) return mergedSensesResult; + const mergedSenses = mergedSensesResult.value; + const reflexes = buildReflexesFromSenses(mergedSenses); const workflowsResult = parseWorkflows(obj); if (!workflowsResult.ok) return workflowsResult; @@ -403,8 +483,8 @@ export function parseNerveConfig(raw: string): Result { return ok({ maxRounds: maxRoundsResult.value, - senses, - reflexes: reflexesResult.value, + senses: mergedSenses, + reflexes, workflows: workflowsResult.value, api: apiResult.value, });