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:
@@ -34,7 +34,6 @@ describe("parseNerveConfig", () => {
|
||||
throttle: 5000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: 30_000,
|
||||
on: [],
|
||||
});
|
||||
@@ -43,7 +42,6 @@ describe("parseNerveConfig", () => {
|
||||
throttle: null,
|
||||
timeout: 10_000,
|
||||
gracePeriod: 3000,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["high_usage"],
|
||||
});
|
||||
@@ -83,25 +81,11 @@ senses:
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
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)", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
@@ -343,45 +327,6 @@ api:
|
||||
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", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js";
|
||||
import { parseWorkflowTrigger } from "../sense.js";
|
||||
|
||||
describe("parseWorkflowTrigger", () => {
|
||||
it("accepts a valid trigger object", () => {
|
||||
@@ -57,60 +57,3 @@ describe("parseWorkflowTrigger", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ export type SenseConfig = {
|
||||
throttle: number | null;
|
||||
timeout: 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. */
|
||||
interval: number | null;
|
||||
/** Other sense names whose signals trigger this sense. */
|
||||
@@ -62,12 +60,6 @@ export type WorkflowTrigger = {
|
||||
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 = {
|
||||
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
|
||||
maxRounds: number;
|
||||
@@ -83,23 +75,10 @@ export type KnowledgeConfig = {
|
||||
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 {
|
||||
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> {
|
||||
if (field === undefined || field === null) return ok(null);
|
||||
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`);
|
||||
if (!graceResult.ok) return graceResult;
|
||||
|
||||
const retentionResult = parseRetentionField(name, obj.retention);
|
||||
if (!retentionResult.ok) return retentionResult;
|
||||
|
||||
const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`);
|
||||
if (!intervalResult.ok) return intervalResult;
|
||||
|
||||
@@ -164,7 +140,6 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
|
||||
throttle: throttleResult.value,
|
||||
timeout: timeoutResult.value,
|
||||
gracePeriod: graceResult.value,
|
||||
retention: retentionResult.value,
|
||||
interval: intervalResult.value,
|
||||
on,
|
||||
});
|
||||
|
||||
@@ -186,8 +186,7 @@ export function isSenseInfo(value: unknown): value is SenseInfo {
|
||||
(value.throttle === null || typeof value.throttle === "number") &&
|
||||
(value.timeout === null || typeof value.timeout === "number") &&
|
||||
Array.isArray(value.triggers) &&
|
||||
value.triggers.every((t: unknown) => typeof t === "string") &&
|
||||
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
|
||||
value.triggers.every((t: unknown) => typeof t === "string")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
|
||||
export type {
|
||||
SenseConfig,
|
||||
DropOverflowConfig,
|
||||
@@ -9,9 +8,8 @@ export type {
|
||||
ExtractConfig,
|
||||
NerveConfig,
|
||||
WorkflowTrigger,
|
||||
ComputeResult,
|
||||
} from "./config.js";
|
||||
export type { Signal, SenseInfo } from "./sense.js";
|
||||
export type { SenseInfo } from "./sense.js";
|
||||
export type { SenseComputeFn, SenseModule } from "./sense.js";
|
||||
export { senseTriggerLabels } from "./sense.js";
|
||||
export type {
|
||||
@@ -46,8 +44,7 @@ export type { KnowledgeConfig } from "./config.js";
|
||||
export { parseKnowledgeYaml } from "./config.js";
|
||||
export { isPlainRecord } from "./util.js";
|
||||
|
||||
export type { RoutedSenseOutput } from "./sense.js";
|
||||
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js";
|
||||
export { parseWorkflowTrigger } from "./sense.js";
|
||||
|
||||
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
|
||||
export type {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js";
|
||||
import type { SenseConfig, WorkflowTrigger } from "./config.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). */
|
||||
export type SenseInfo = {
|
||||
name: string;
|
||||
@@ -18,7 +9,6 @@ export type SenseInfo = {
|
||||
timeout: number | null;
|
||||
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
|
||||
triggers: string[];
|
||||
lastSignalTimestamp: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,25 +16,18 @@ export type SenseInfo = {
|
||||
* `compute` export.
|
||||
*
|
||||
* Pure: no DB, no peers.
|
||||
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal
|
||||
* (and optionally trigger a Workflow).
|
||||
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
|
||||
* Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
|
||||
*/
|
||||
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.
|
||||
* `compute` provides the data; `table` tells the runtime where to persist it.
|
||||
*/
|
||||
export type SenseModule<T = unknown> = {
|
||||
compute: SenseComputeFn<T>;
|
||||
table: SQLiteTable;
|
||||
};
|
||||
|
||||
/** Normalized non-null compute output for the kernel (unknown signal payload). */
|
||||
export type RoutedSenseOutput = {
|
||||
signal: unknown;
|
||||
workflow: WorkflowTrigger | null;
|
||||
export type SenseModule<S = unknown> = {
|
||||
compute: SenseComputeFn<S>;
|
||||
initialState: S;
|
||||
};
|
||||
|
||||
function formatIntervalMs(ms: number): string {
|
||||
@@ -111,23 +94,3 @@ export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user