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:
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,10 +14,6 @@ senses:
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
grace_period: null
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
interval: 10s
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user