From e789c7bb347dce7fa437dbde52fad799e10f5459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 1 May 2026 09:43:13 +0000 Subject: [PATCH] =?UTF-8?q?refactor(core):=20stateful=20sense=20types=20?= =?UTF-8?q?=E2=80=94=20remove=20Signal,=20add=20initialState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of RFC #308: Stateful Sense refactor. - SenseComputeFn now takes state and returns { state, workflow } - SenseModule 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 --- packages/core/src/__tests__/config.test.ts | 55 ----------------- .../sense-workflow-directive.test.ts | 59 +------------------ packages/core/src/config.ts | 25 -------- packages/core/src/daemon.ts | 3 +- packages/core/src/index.ts | 7 +-- packages/core/src/sense.ts | 53 +++-------------- 6 files changed, 12 insertions(+), 190 deletions(-) diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 9443f28..126b8e0 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -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: diff --git a/packages/core/src/__tests__/sense-workflow-directive.test.ts b/packages/core/src/__tests__/sense-workflow-directive.test.ts index 402dbea..316b8f3 100644 --- a/packages/core/src/__tests__/sense-workflow-directive.test.ts +++ b/packages/core/src/__tests__/sense-workflow-directive.test.ts @@ -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(); - }); -}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ce85a1b..d526f0d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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 = 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; }; -/** 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 { - 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 { if (field === undefined || field === null) return ok(null); if (typeof field !== "string") { @@ -142,9 +121,6 @@ function validateSenseConfig(name: string, raw: unknown): Result { 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 { throttle: throttleResult.value, timeout: timeoutResult.value, gracePeriod: graceResult.value, - retention: retentionResult.value, interval: intervalResult.value, on, }); diff --git a/packages/core/src/daemon.ts b/packages/core/src/daemon.ts index aeb2a74..532416e 100644 --- a/packages/core/src/daemon.ts +++ b/packages/core/src/daemon.ts @@ -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") ); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3076306..0a34531 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/sense.ts b/packages/core/src/sense.ts index 1981aa7..19ceb3e 100644 --- a/packages/core/src/sense.ts +++ b/packages/core/src/sense.ts @@ -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 = () => Promise>; +export type SenseComputeFn = ( + 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 = { - compute: SenseComputeFn; - table: SQLiteTable; -}; - -/** Normalized non-null compute output for the kernel (unknown signal payload). */ -export type RoutedSenseOutput = { - signal: unknown; - workflow: WorkflowTrigger | null; +export type SenseModule = { + compute: SenseComputeFn; + initialState: S; }; function formatIntervalMs(ms: number): string { @@ -111,23 +94,3 @@ export function parseWorkflowTrigger(value: unknown): Result { } 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 { - 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 }); -}