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,
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user