refactor(core): stateful sense types — remove Signal, add initialState

Phase 1 of RFC #308: Stateful Sense refactor.

- SenseComputeFn<S> now takes state and returns { state, workflow }
- SenseModule<S> exports compute + initialState (no more table)
- Removed: Signal type, ComputeResult, RoutedSenseOutput,
  routeSenseComputeOutput, retention/DEFAULT_SENSE_SIGNAL_RETENTION
- Updated isSenseInfo (removed lastSignalTimestamp)

Refs #308, closes #309
This commit is contained in:
2026-05-01 09:43:13 +00:00
parent 06b91c2e63
commit e789c7bb34
6 changed files with 12 additions and 190 deletions
@@ -34,7 +34,6 @@ describe("parseNerveConfig", () => {
throttle: 5000, throttle: 5000,
timeout: null, timeout: null,
gracePeriod: null, gracePeriod: null,
retention: 10_000,
interval: 30_000, interval: 30_000,
on: [], on: [],
}); });
@@ -43,7 +42,6 @@ describe("parseNerveConfig", () => {
throttle: null, throttle: null,
timeout: 10_000, timeout: 10_000,
gracePeriod: 3000, gracePeriod: 3000,
retention: 10_000,
interval: null, interval: null,
on: ["high_usage"], on: ["high_usage"],
}); });
@@ -83,25 +81,11 @@ senses:
throttle: null, throttle: null,
timeout: null, timeout: null,
gracePeriod: null, gracePeriod: null,
retention: 10_000,
interval: null, interval: null,
on: [], on: [],
}); });
}); });
it("parses optional retention as a positive integer", () => {
const yaml = `
senses:
cpu:
group: system
retention: 5000
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.senses.cpu.retention).toBe(5000);
});
it("accepts all valid duration suffixes (s, m, h)", () => { it("accepts all valid duration suffixes (s, m, h)", () => {
const yaml = ` const yaml = `
senses: senses:
@@ -343,45 +327,6 @@ api:
expect(result.error.message).toMatch(/senses/); expect(result.error.message).toMatch(/senses/);
}); });
it("returns error when retention is zero", () => {
const yaml = `
senses:
cpu:
group: system
retention: 0
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error when retention is not an integer", () => {
const yaml = `
senses:
cpu:
group: system
retention: 1.5
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error when retention is not a number", () => {
const yaml = `
senses:
cpu:
group: system
retention: "5000"
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/retention.*positive integer/);
});
it("returns error for invalid throttle format", () => { it("returns error for invalid throttle format", () => {
const yaml = ` const yaml = `
senses: senses:
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js"; import { parseWorkflowTrigger } from "../sense.js";
describe("parseWorkflowTrigger", () => { describe("parseWorkflowTrigger", () => {
it("accepts a valid trigger object", () => { it("accepts a valid trigger object", () => {
@@ -57,60 +57,3 @@ describe("parseWorkflowTrigger", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });
describe("routeSenseComputeOutput", () => {
it("wraps non-record values as signal-only", () => {
const r = routeSenseComputeOutput(99);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ signal: 99, workflow: null });
});
it("wraps plain objects without signal key as signal-only", () => {
const r = routeSenseComputeOutput({ count: 2 });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ signal: { count: 2 }, workflow: null });
});
it("parses explicit signal with null workflow", () => {
const r = routeSenseComputeOutput({ signal: { a: 1 }, workflow: null });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ signal: { a: 1 }, workflow: null });
});
it("parses explicit signal with workflow trigger", () => {
const r = routeSenseComputeOutput({
signal: { x: true },
workflow: { name: "wf", maxRounds: 2, prompt: "p", dryRun: false },
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.signal).toEqual({ x: true });
expect(r.value.workflow).toEqual({
name: "wf",
maxRounds: 2,
prompt: "p",
dryRun: false,
});
});
it("defaults missing workflow key to null", () => {
const r = routeSenseComputeOutput({ signal: 7 });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ signal: 7, workflow: null });
});
it("degrades to signal-only when workflow object is invalid", () => {
const r = routeSenseComputeOutput({
signal: { v: 1 },
workflow: { name: "w", maxRounds: 0, prompt: "", dryRun: false },
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.signal).toEqual({ v: 1 });
expect(r.value.workflow).toBeNull();
});
});
-25
View File
@@ -8,8 +8,6 @@ export type SenseConfig = {
throttle: number | null; throttle: number | null;
timeout: number | null; timeout: number | null;
gracePeriod: number | null; gracePeriod: number | null;
/** Max rows to retain in `_signals`; older rows are pruned periodically after inserts. */
retention: number;
/** Polling interval (ms). When set, the sense is triggered periodically. */ /** Polling interval (ms). When set, the sense is triggered periodically. */
interval: number | null; interval: number | null;
/** Other sense names whose signals trigger this sense. */ /** Other sense names whose signals trigger this sense. */
@@ -62,12 +60,6 @@ export type WorkflowTrigger = {
dryRun: boolean; dryRun: boolean;
}; };
/**
* Sense `compute()` return: silence, or a signal payload with an optional workflow to start.
* `workflow: null` means signal only; signal is always emitted first when non-null.
*/
export type ComputeResult<T> = null | { signal: T; workflow: WorkflowTrigger | null };
export type NerveConfig = { export type NerveConfig = {
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */ /** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
maxRounds: number; maxRounds: number;
@@ -83,23 +75,10 @@ export type KnowledgeConfig = {
exclude: ReadonlyArray<string>; exclude: ReadonlyArray<string>;
}; };
/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */
export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000;
function isValidGroupName(value: string): boolean { function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value); return /^[a-zA-Z0-9_-]+$/.test(value);
} }
function parseRetentionField(name: string, field: unknown): Result<number> {
if (field === undefined || field === null) {
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
}
if (typeof field !== "number" || !Number.isInteger(field) || field < 1) {
return err(new Error(`senses.${name}.retention: must be a positive integer`));
}
return ok(field);
}
function parseDurationField(field: unknown, label: string): Result<number | null> { function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null); if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") { if (typeof field !== "string") {
@@ -142,9 +121,6 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`); const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult; if (!graceResult.ok) return graceResult;
const retentionResult = parseRetentionField(name, obj.retention);
if (!retentionResult.ok) return retentionResult;
const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`); const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`);
if (!intervalResult.ok) return intervalResult; if (!intervalResult.ok) return intervalResult;
@@ -164,7 +140,6 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
throttle: throttleResult.value, throttle: throttleResult.value,
timeout: timeoutResult.value, timeout: timeoutResult.value,
gracePeriod: graceResult.value, gracePeriod: graceResult.value,
retention: retentionResult.value,
interval: intervalResult.value, interval: intervalResult.value,
on, on,
}); });
+1 -2
View File
@@ -186,8 +186,7 @@ export function isSenseInfo(value: unknown): value is SenseInfo {
(value.throttle === null || typeof value.throttle === "number") && (value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") && (value.timeout === null || typeof value.timeout === "number") &&
Array.isArray(value.triggers) && Array.isArray(value.triggers) &&
value.triggers.every((t: unknown) => typeof t === "string") && value.triggers.every((t: unknown) => typeof t === "string")
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
); );
} }
+2 -5
View File
@@ -1,4 +1,3 @@
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
export type { export type {
SenseConfig, SenseConfig,
DropOverflowConfig, DropOverflowConfig,
@@ -9,9 +8,8 @@ export type {
ExtractConfig, ExtractConfig,
NerveConfig, NerveConfig,
WorkflowTrigger, WorkflowTrigger,
ComputeResult,
} from "./config.js"; } from "./config.js";
export type { Signal, SenseInfo } from "./sense.js"; export type { SenseInfo } from "./sense.js";
export type { SenseComputeFn, SenseModule } from "./sense.js"; export type { SenseComputeFn, SenseModule } from "./sense.js";
export { senseTriggerLabels } from "./sense.js"; export { senseTriggerLabels } from "./sense.js";
export type { export type {
@@ -46,8 +44,7 @@ export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js"; export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.js"; export { isPlainRecord } from "./util.js";
export type { RoutedSenseOutput } from "./sense.js"; export { parseWorkflowTrigger } from "./sense.js";
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon.js"; export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
export type { export type {
+8 -45
View File
@@ -1,15 +1,6 @@
import type { SQLiteTable } from "drizzle-orm/sqlite-core"; import type { SenseConfig, WorkflowTrigger } from "./config.js";
import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js";
import { type Result, err, isPlainRecord, ok } from "./util.js"; import { type Result, err, isPlainRecord, ok } from "./util.js";
export type Signal = {
id: number;
senseId: string;
payload: unknown;
timestamp: number;
};
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */ /** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
export type SenseInfo = { export type SenseInfo = {
name: string; name: string;
@@ -18,7 +9,6 @@ export type SenseInfo = {
timeout: number | null; timeout: number | null;
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */ /** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
triggers: string[]; triggers: string[];
lastSignalTimestamp: number | null;
}; };
/** /**
@@ -26,25 +16,18 @@ export type SenseInfo = {
* `compute` export. * `compute` export.
* *
* Pure: no DB, no peers. * Pure: no DB, no peers.
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal * Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
* (and optionally trigger a Workflow).
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
*/ */
export type SenseComputeFn<T = unknown> = () => Promise<ComputeResult<T>>; export type SenseComputeFn<S = unknown> = (
state: S,
) => Promise<{ state: S; workflow: WorkflowTrigger | null }>;
/** /**
* The full shape a sense module (`src/index.ts`) must export. * The full shape a sense module (`src/index.ts`) must export.
* `compute` provides the data; `table` tells the runtime where to persist it.
*/ */
export type SenseModule<T = unknown> = { export type SenseModule<S = unknown> = {
compute: SenseComputeFn<T>; compute: SenseComputeFn<S>;
table: SQLiteTable; initialState: S;
};
/** Normalized non-null compute output for the kernel (unknown signal payload). */
export type RoutedSenseOutput = {
signal: unknown;
workflow: WorkflowTrigger | null;
}; };
function formatIntervalMs(ms: number): string { function formatIntervalMs(ms: number): string {
@@ -111,23 +94,3 @@ export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
} }
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun }); return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
} }
/**
* Interprets a Sense compute non-null return value for the engine.
* - Explicit `{ signal, workflow }` (workflow may be null): validates `workflow` when non-null.
* - Any other value: treated as `{ signal: payload, workflow: null }` (shorthand).
*/
export function routeSenseComputeOutput(payload: unknown): Result<RoutedSenseOutput> {
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) {
const wfRaw = Object.hasOwn(payload, "workflow") ? payload.workflow : null;
if (wfRaw === null) {
return ok({ signal: payload.signal, workflow: null });
}
const parsed = parseWorkflowTrigger(wfRaw);
if (!parsed.ok) {
return ok({ signal: payload.signal, workflow: null });
}
return ok({ signal: payload.signal, workflow: parsed.value });
}
return ok({ signal: payload, workflow: null });
}