Merge pull request 'refactor: inline reflex config — sense-level trigger declarations' (#198) from refactor/189-inline-reflex-config into main

This commit was merged in pull request #198.
This commit is contained in:
2026-04-27 10:58:47 +00:00
24 changed files with 532 additions and 138 deletions
+2 -4
View File
@@ -65,8 +65,6 @@ const nerveYamlTemplate = `senses:
counter:
group: e2e
reflexes: []
workflows:
echo:
concurrency: 1
@@ -110,8 +108,6 @@ const nerveYamlWithNoopWorkflow = `senses:
counter:
group: e2e
reflexes: []
workflows:
noop:
concurrency: 1
@@ -187,6 +183,8 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
reflexes: [],
@@ -116,8 +116,6 @@ const VALID_NERVE_YAML = `senses:
counter:
group: e2e
reflexes: []
workflows: {}
max_rounds: 10
@@ -45,8 +45,6 @@ senses:
group: system
throttle: 5s
timeout: 3s
reflexes:
- sense: cpu-usage
interval: 5s
`.trim();
}
@@ -121,7 +119,7 @@ reflexes:
rmSync(sockDir, { recursive: true, force: true });
});
it("prints sense list from daemon path with name, group, throttle, triggers, and last signal time", async () => {
it("prints sense list from daemon path with name, group, throttle, trigger schedule, and last signal time", async () => {
// With a real daemon, we would wait for a compute cycle; the mock server
// returns SenseInfo as if one already produced lastSignalTimestamp.
await runCommand(senseCommand, { rawArgs: ["list"] });
@@ -134,7 +132,7 @@ reflexes:
expect(out).toContain("group: system");
expect(out).toContain("throttle: 5s");
expect(out).toContain("timeout: 3s");
expect(out).toContain("triggers: every 5s");
expect(out).toContain("trigger schedule: every 5s");
expect(out).not.toContain("(never)");
expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString());
});
+24 -62
View File
@@ -14,63 +14,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function intervalLabelFromReflexRecord(rec: Record<string, unknown>): string | null {
if (typeof rec.interval !== "number") return null;
const totalSeconds = Math.floor(rec.interval / 1000);
if (totalSeconds < 60) return `every ${totalSeconds}s`;
return `every ${Math.floor(totalSeconds / 60)}m`;
}
function onTriggerPartFromReflexRecord(rec: Record<string, unknown>): string | null {
if (!Array.isArray(rec.on) || rec.on.length === 0) return null;
const onLabels = rec.on.filter((v): v is string => typeof v === "string");
if (onLabels.length === 0) return null;
return `on: ${onLabels.join(", ")}`;
}
/** Returns a display line when this reflex targets `senseName`; otherwise null (skip). */
function reflexTriggerLineForSense(rec: Record<string, unknown>, senseName: string): string | null {
if (rec.kind !== "sense" || rec.sense !== senseName) return null;
const parts: string[] = [];
const intervalPart = intervalLabelFromReflexRecord(rec);
if (intervalPart !== null) parts.push(intervalPart);
const onPart = onTriggerPartFromReflexRecord(rec);
if (onPart !== null) parts.push(onPart);
return parts.length > 0 ? parts.join(" · ") : "reflex (no interval or on)";
}
function mockSenseTriggerLabels(senseName: string, reflexes: readonly unknown[]): string[] {
const out: string[] = [];
for (const reflex of reflexes) {
if (typeof reflex !== "object" || reflex === null) continue;
const rec = reflex as Record<string, unknown>;
const line = reflexTriggerLineForSense(rec, senseName);
if (line !== null) out.push(line);
}
return out;
}
vi.mock("@uncaged/nerve-core", async () => {
const actual = await vi.importActual<typeof import("@uncaged/nerve-core")>("@uncaged/nerve-core");
return {
...actual,
senseTriggerLabels: mockSenseTriggerLabels,
isSenseInfo: (value: unknown): value is SenseInfo => {
if (typeof value !== "object" || value === null) return false;
const rec = value as Record<string, unknown>;
return (
typeof rec.name === "string" &&
typeof rec.group === "string" &&
(typeof rec.throttle === "number" || rec.throttle === null) &&
(typeof rec.timeout === "number" || rec.timeout === null) &&
Array.isArray(rec.triggers) &&
(typeof rec.lastSignalTimestamp === "number" || rec.lastSignalTimestamp === null)
);
},
};
});
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js";
import { listSensesViaDaemon } from "../daemon-client.js";
@@ -171,9 +115,9 @@ describe("formatSenseList", () => {
expect(output).toContain("—");
});
it("shows triggers from sense metadata", () => {
it("shows trigger schedule from sense metadata", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("triggers:");
expect(output).toContain("trigger schedule:");
expect(output).toContain("every 30s");
expect(output).toContain("(none)");
});
@@ -230,7 +174,6 @@ senses:
disk-usage:
group: system
throttle: 30s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
@@ -255,7 +198,6 @@ reflexes: []
senses:
my-sense:
group: default
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
@@ -272,13 +214,33 @@ senses:
group: default
throttle: 10s
timeout: 5s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].throttle).toBe(10000);
expect(result[0].timeout).toBe(5000);
});
it("uses inline interval and on for trigger schedule labels", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
downstream:
group: default
interval: 15s
on: [upstream]
upstream:
group: default
`.trim(),
);
const result = sensesFromConfig(path);
const downstream = result.find((s) => s.name === "downstream");
const upstream = result.find((s) => s.name === "upstream");
expect(downstream?.triggers).toEqual(["every 15s · on: upstream"]);
expect(upstream?.triggers).toEqual([]);
});
});
// ---------------------------------------------------------------------------
-4
View File
@@ -14,10 +14,6 @@ senses:
throttle: 5s
timeout: 10s
grace_period: null
reflexes:
- kind: sense
sense: cpu-usage
interval: 10s
`;
+5 -3
View File
@@ -6,7 +6,7 @@ import {
type SenseInfo,
isPlainRecord,
parseNerveConfig,
senseTriggerLabels,
senseTriggerLabelsWithFallback,
} from "@uncaged/nerve-core";
import { defineCommand } from "citty";
@@ -49,7 +49,9 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
lines.push(` triggers: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`);
lines.push(
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
);
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
@@ -73,7 +75,7 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
triggers: senseTriggerLabels(name, reflexes),
triggers: senseTriggerLabelsWithFallback(name, senses, reflexes),
lastSignalTimestamp: null,
}));
}
+2 -2
View File
@@ -30,11 +30,11 @@ export const validateCommand = defineCommand({
const config = result.value;
const senseCount = Object.keys(config.senses).length;
const reflexCount = config.reflexes.length;
const triggerScheduleCount = config.reflexes.length;
const workflowCount = Object.keys(config.workflows).length;
process.stdout.write(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${triggerScheduleCount} sense trigger schedule(s), ${workflowCount} workflow(s)\n`,
);
},
});