From 3eba156b6bc4767f327e0c4e792301e55b1af78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 07:16:49 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(core):=20Phase=201=20=E2=80=94=20core?= =?UTF-8?q?=20types=20&=20config=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define Signal, SenseConfig, ReflexConfig, WorkflowConfig, NerveConfig types - Implement Result with ok()/err() helpers - Implement parseNerveConfig() with full YAML validation - 14 unit tests covering normal and error paths - pnpm run check passes with 0 errors Closes #2 小橘 --- packages/core/package.json | 9 +- packages/core/src/__tests__/config.test.ts | 228 +++++++ packages/core/src/config.ts | 232 +++++++ packages/core/src/index.ts | 5 +- packages/core/src/result.ts | 9 + packages/core/src/types.ts | 30 + pnpm-lock.yaml | 704 ++++++++++++++++++++- 7 files changed, 1210 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/config.test.ts create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/result.ts create mode 100644 packages/core/src/types.ts diff --git a/packages/core/package.json b/packages/core/package.json index 44ade47..454ba74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,13 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsup" + "build": "tsup", + "test": "vitest run" + }, + "dependencies": { + "yaml": "^2.8.3" + }, + "devDependencies": { + "vitest": "^4.1.5" } } diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts new file mode 100644 index 0000000..73b1d4c --- /dev/null +++ b/packages/core/src/__tests__/config.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { parseNerveConfig } from "../config.js"; + +const VALID_CONFIG = ` +senses: + cpu: + group: system + throttle: 5s + memory: + group: system + timeout: 10s + +reflexes: + - sense: cpu + workflow: alert + interval: 30s + - sense: memory + on: + - high_usage + +workflows: + alert: + concurrency: 2 + overflow: drop + max_queue: 10 +`; + +describe("parseNerveConfig", () => { + describe("valid configs", () => { + it("parses a full valid config", () => { + const result = parseNerveConfig(VALID_CONFIG); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.senses.cpu).toEqual({ group: "system", throttle: "5s" }); + expect(result.value.senses.memory).toEqual({ group: "system", timeout: "10s" }); + expect(result.value.reflexes).toHaveLength(2); + expect(result.value.reflexes[0]).toEqual({ + sense: "cpu", + workflow: "alert", + interval: "30s", + }); + expect(result.value.reflexes[1]).toEqual({ sense: "memory", on: ["high_usage"] }); + expect(result.value.workflows?.alert).toEqual({ + concurrency: 2, + overflow: "drop", + max_queue: 10, + }); + }); + + it("parses config with empty reflexes array", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.reflexes).toEqual([]); + }); + + it("parses config without workflows section", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - sense: cpu +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.workflows).toBeUndefined(); + }); + + it("allows sense without throttle or timeout", () => { + const yaml = ` +senses: + net: + group: network +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.senses.net).toEqual({ group: "network" }); + }); + + it("accepts all valid duration suffixes (s, m, h)", () => { + const yaml = ` +senses: + a: + group: g + throttle: 5s + b: + group: g + throttle: 10m + c: + group: g + timeout: 1h +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + }); + }); + + describe("invalid configs", () => { + it("returns error on bad YAML syntax", () => { + const result = parseNerveConfig("senses: [\nunclosed"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/YAML parse error/); + }); + + it("returns error when reflex references a non-existent sense", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - sense: disk +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/disk.*not found in senses/); + }); + + it("returns error for invalid throttle format", () => { + const yaml = ` +senses: + cpu: + group: system + throttle: 5sec +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/throttle.*invalid duration/); + }); + + it("returns error for invalid timeout format", () => { + const yaml = ` +senses: + cpu: + group: system + timeout: two-minutes +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/timeout.*invalid duration/); + }); + + it("returns error for invalid interval format", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - sense: cpu + interval: 30seconds +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/interval.*invalid duration/); + }); + + it("returns error when senses is missing", () => { + const yaml = ` +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/senses/); + }); + + it("returns error when reflexes is missing", () => { + const yaml = ` +senses: + cpu: + group: system +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/reflexes/); + }); + + it("returns error for invalid group name", () => { + const yaml = ` +senses: + cpu: + group: "my group!" +reflexes: [] +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/group.*invalid name/); + }); + + it("returns error for invalid workflow overflow value", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: [] +workflows: + alert: + concurrency: 1 + overflow: skip +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/overflow.*"drop" or "queue"/); + }); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..1ec2dca --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,232 @@ +import { parse } from "yaml"; + +import type { Result } from "./result.js"; +import { err, ok } from "./result.js"; +import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js"; + +const DURATION_RE = /^\d+[smh]$/; + +function isValidDuration(value: string): boolean { + return DURATION_RE.test(value); +} + +function isValidGroupName(value: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(value); +} + +function validateDurationField(field: unknown, label: string): Result { + if (field === undefined) return ok(undefined); + if (typeof field !== "string" || !isValidDuration(field)) { + return err( + new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), + ); + } + return ok(field); +} + +function validateSenseConfig(name: string, raw: unknown): Result { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return err(new Error(`senses.${name}: must be an object`)); + } + + const obj = raw as Record; + + if (typeof obj.group !== "string" || obj.group.trim() === "") { + return err(new Error(`senses.${name}.group: required string`)); + } + + if (!isValidGroupName(obj.group)) { + return err( + new Error( + `senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`, + ), + ); + } + + const throttleResult = validateDurationField(obj.throttle, `senses.${name}.throttle`); + if (!throttleResult.ok) return throttleResult; + + const timeoutResult = validateDurationField(obj.timeout, `senses.${name}.timeout`); + if (!timeoutResult.ok) return timeoutResult; + + return ok({ + group: obj.group, + ...(throttleResult.value !== undefined && { throttle: throttleResult.value }), + ...(timeoutResult.value !== undefined && { timeout: timeoutResult.value }), + }); +} + +function validateReflexSense( + index: number, + obj: Record, + senseNames: Set, +): Result { + if (obj.sense === undefined) return ok(undefined); + if (typeof obj.sense !== "string") { + return err(new Error(`reflexes[${index}].sense: must be a string`)); + } + if (!senseNames.has(obj.sense)) { + return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`)); + } + return ok(undefined); +} + +function validateReflexConfig( + index: number, + raw: unknown, + senseNames: Set, +): Result { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return err(new Error(`reflexes[${index}]: must be an object`)); + } + + const obj = raw as Record; + + const senseResult = validateReflexSense(index, obj, senseNames); + if (!senseResult.ok) return senseResult; + + if (obj.workflow !== undefined && typeof obj.workflow !== "string") { + return err(new Error(`reflexes[${index}].workflow: must be a string`)); + } + + const intervalResult = validateDurationField(obj.interval, `reflexes[${index}].interval`); + if (!intervalResult.ok) return intervalResult; + + if (obj.on !== undefined) { + if (!Array.isArray(obj.on) || !obj.on.every((item) => typeof item === "string")) { + return err(new Error(`reflexes[${index}].on: must be an array of strings`)); + } + } + + return ok({ + ...(obj.sense !== undefined && { sense: obj.sense as string }), + ...(obj.workflow !== undefined && { workflow: obj.workflow as string }), + ...(intervalResult.value !== undefined && { interval: intervalResult.value }), + ...(obj.on !== undefined && { on: obj.on as string[] }), + }); +} + +function validateWorkflowConfig(name: string, raw: unknown): Result { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return err(new Error(`workflows.${name}: must be an object`)); + } + + const obj = raw as Record; + + if ( + typeof obj.concurrency !== "number" || + !Number.isInteger(obj.concurrency) || + obj.concurrency < 1 + ) { + return err(new Error(`workflows.${name}.concurrency: must be a positive integer`)); + } + + if (obj.overflow !== "drop" && obj.overflow !== "queue") { + return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`)); + } + + if ( + obj.max_queue !== undefined && + (typeof obj.max_queue !== "number" || !Number.isInteger(obj.max_queue) || obj.max_queue < 0) + ) { + return err(new Error(`workflows.${name}.max_queue: must be a non-negative integer`)); + } + + return ok({ + concurrency: obj.concurrency, + overflow: obj.overflow, + ...(obj.max_queue !== undefined && { max_queue: obj.max_queue as number }), + }); +} + +function parseSenses( + obj: Record, +): Result<{ senses: Record; senseNames: Set }> { + if (obj.senses === null || typeof obj.senses !== "object" || Array.isArray(obj.senses)) { + return err(new Error("senses: required object")); + } + + const sensesRaw = obj.senses as Record; + const senses: Record = {}; + const senseNames = new Set(Object.keys(sensesRaw)); + + for (const [name, senseRaw] of Object.entries(sensesRaw)) { + const result = validateSenseConfig(name, senseRaw); + if (!result.ok) return result; + senses[name] = result.value; + } + + return ok({ senses, senseNames }); +} + +function parseReflexes( + obj: Record, + senseNames: Set, +): Result { + if (!Array.isArray(obj.reflexes)) { + return err(new Error("reflexes: required array")); + } + + const reflexes: ReflexConfig[] = []; + for (let i = 0; i < obj.reflexes.length; i++) { + const result = validateReflexConfig(i, obj.reflexes[i], senseNames); + if (!result.ok) return result; + reflexes.push(result.value); + } + + return ok(reflexes); +} + +function parseWorkflows( + obj: Record, +): Result | undefined> { + if (obj.workflows === undefined) return ok(undefined); + + if (obj.workflows === null || typeof obj.workflows !== "object" || Array.isArray(obj.workflows)) { + return err(new Error("workflows: must be an object if provided")); + } + + const workflowsRaw = obj.workflows as Record; + const workflows: Record = {}; + + for (const [name, wfRaw] of Object.entries(workflowsRaw)) { + const result = validateWorkflowConfig(name, wfRaw); + if (!result.ok) return result; + workflows[name] = result.value; + } + + return ok(workflows); +} + +export function parseNerveConfig(raw: string): Result { + let parsed: unknown; + + try { + parsed = parse(raw); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(new Error(`YAML parse error: ${message}`)); + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return err(new Error("Config must be a YAML object")); + } + + const obj = parsed as Record; + + const sensesResult = parseSenses(obj); + if (!sensesResult.ok) return sensesResult; + const { senses, senseNames } = sensesResult.value; + + const reflexesResult = parseReflexes(obj, senseNames); + if (!reflexesResult.ok) return reflexesResult; + + const workflowsResult = parseWorkflows(obj); + if (!workflowsResult.ok) return workflowsResult; + + return ok({ + senses, + reflexes: reflexesResult.value, + ...(workflowsResult.value !== undefined && { workflows: workflowsResult.value }), + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1039d4c..f5c7f90 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,4 @@ -// TODO: implement +export type { Signal, SenseConfig, ReflexConfig, WorkflowConfig, NerveConfig } from "./types.js"; +export type { Result } from "./result.js"; +export { ok, err } from "./result.js"; +export { parseNerveConfig } from "./config.js"; diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 0000000..335788c --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,9 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..301d554 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,30 @@ +export type Signal = { + senseId: string; + payload: unknown; + ts: number; +}; + +export type SenseConfig = { + group: string; + throttle?: string; + timeout?: string; +}; + +export type ReflexConfig = { + sense?: string; + workflow?: string; + interval?: string; + on?: string[]; +}; + +export type WorkflowConfig = { + concurrency: number; + overflow: "drop" | "queue"; + max_queue?: number; +}; + +export type NerveConfig = { + senses: Record; + reflexes: ReflexConfig[]; + workflows?: Record; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09397b2..c20d475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,14 +13,22 @@ importers: version: 1.9.4 tsup: specifier: ^8.0.0 - version: 8.5.1(typescript@5.9.3) + version: 8.5.1(postcss@8.5.10)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.5.0 version: 5.9.3 packages/cli: {} - packages/core: {} + packages/core: + dependencies: + yaml: + specifier: ^2.8.3 + version: 2.8.3 + devDependencies: + vitest: + specifier: ^4.1.5 + version: 4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3)) packages/daemon: {} @@ -83,6 +91,15 @@ packages: cpu: [x64] os: [win32] + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -252,6 +269,113 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.126.0': + resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.16': + resolution: {integrity: sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + resolution: {integrity: sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + resolution: {integrity: sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': + resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + resolution: {integrity: sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + resolution: {integrity: sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + resolution: {integrity: sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.16': + resolution: {integrity: sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==} + '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] @@ -390,9 +514,50 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -401,6 +566,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -411,6 +580,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -426,6 +599,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -435,11 +611,25 @@ packages: supports-color: optional: true + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -461,6 +651,80 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -484,10 +748,18 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -523,6 +795,10 @@ packages: yaml: optional: true + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -531,15 +807,33 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.0.0-rc.16: + resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.60.2: resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -552,13 +846,24 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -566,6 +871,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -593,6 +901,100 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + vite@8.0.9: + resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: '@biomejs/biome@1.9.4': @@ -630,6 +1032,22 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -722,6 +1140,66 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.126.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.16': {} + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -797,12 +1275,69 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.9(esbuild@0.27.7)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn@8.16.0: {} any-promise@1.3.0: {} + assertion-error@2.0.1: {} + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -810,6 +1345,8 @@ snapshots: cac@6.7.14: {} + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -820,10 +1357,16 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 + detect-libc@2.1.2: {} + + es-module-lexer@2.0.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -853,6 +1396,12 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -868,6 +1417,55 @@ snapshots: joycon@3.1.1: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -893,8 +1491,12 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + object-assign@4.1.1: {} + obug@2.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -909,14 +1511,44 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1: + postcss-load-config@6.0.1(postcss@8.5.10)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.10 + yaml: 2.8.3 + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 readdirp@4.1.2: {} resolve-from@5.0.0: {} + rolldown@1.0.0-rc.16: + dependencies: + '@oxc-project/types': 0.126.0 + '@rolldown/pluginutils': 1.0.0-rc.16 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-x64': 1.0.0-rc.16 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.16 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.16 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.16 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.16 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.16 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.16 + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 @@ -948,8 +1580,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -968,18 +1608,27 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.1.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} - tsup@8.5.1(typescript@5.9.3): + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.10)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -990,7 +1639,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(postcss@8.5.10)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.2 source-map: 0.7.6 @@ -999,6 +1648,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.10 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -1009,3 +1659,47 @@ snapshots: typescript@5.9.3: {} ufo@1.6.3: {} + + vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.16 + tinyglobby: 0.2.16 + optionalDependencies: + esbuild: 0.27.7 + fsevents: 2.3.3 + yaml: 2.8.3 + + vitest@4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.9(esbuild@0.27.7)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.9(esbuild@0.27.7)(yaml@2.8.3) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml@2.8.3: {} -- 2.43.0 From 3159d3713d147c113dda0549708cb96bcec948bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 07:22:10 +0000 Subject: [PATCH 2/7] feat(core): add autoincrement id to Signal type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timestamp alone can't guarantee strict total ordering (multiple signals in the same millisecond). An autoincrement id provides a reliable sequence for ordering and cursor-based pagination. 小橘 --- packages/core/src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 301d554..035d1ab 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ export type Signal = { + id: number; senseId: string; payload: unknown; ts: number; -- 2.43.0 From 1949007c99fb86b3c966bccacdbad89e8737f94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 07:33:14 +0000 Subject: [PATCH 3/7] refactor: ban optional properties, use T|null + discriminated unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add coding convention: no '?:', use explicit 'T | null' - ReflexConfig → discriminated union (SenseReflexConfig | WorkflowReflexConfig) - All optional fields → explicit null (throttle, timeout, interval, on, maxQueue, workflows) - Add exactOptionalPropertyTypes to tsconfig - Add lib: ES2022 to tsconfig - Refactor validateReflexConfig to reduce cognitive complexity 小橘 --- docs/coding-conventions.md | 38 ++++++++ packages/core/src/__tests__/config.test.ts | 78 +++++++++++++--- packages/core/src/config.ts | 102 ++++++++++++++------- packages/core/src/index.ts | 10 +- packages/core/src/types.ts | 26 ++++-- tsconfig.json | 2 + 6 files changed, 197 insertions(+), 59 deletions(-) diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md index 9291923..ca3356c 100644 --- a/docs/coding-conventions.md +++ b/docs/coding-conventions.md @@ -40,6 +40,44 @@ class Signal implements ISignal { | 无继承 | 不用 `extends`、`implements`、`abstract` | | 组合优先 | 用函数组合代替继承层次 | | 不可变优先 | 用 `Readonly`、`as const`,避免 mutation | +| 禁用 optional properties | 不用 `?:`,用 `T \| null` 显式标记可空;多个互斥字段用 discriminated union | + +### 禁用 Optional Properties + +不使用 `?:`,所有可空字段显式标注 `T | null`。 + +```typescript +// ✅ Good — 明确表达"可以为空" +type SenseConfig = { + group: string; + throttle: string | null; + timeout: string | null; +} + +// ❌ Bad — optional 隐藏了"缺失"和"空值"的区别 +type SenseConfig = { + group: string; + throttle?: string; + timeout?: string; +} +``` + +当多个字段互斥时,用 discriminated union 代替一堆 optional: + +```typescript +// ✅ Good — 编译器保证 sense 和 workflow 不会同时出现 +type ReflexConfig = + | { kind: "sense"; sense: string; interval: string | null; on: string[] | null } + | { kind: "workflow"; workflow: string; on: string[] | null } + +// ❌ Bad — sense 和 workflow 都 optional,运行时才知道到底填了哪个 +type ReflexConfig = { + sense?: string; + workflow?: string; + interval?: string; + on?: string[]; +} +``` ### 例外 diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 73b1d4c..fccb604 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -13,11 +13,13 @@ senses: reflexes: - sense: cpu - workflow: alert interval: 30s - sense: memory on: - high_usage + - workflow: alert + on: + - cpu workflows: alert: @@ -33,19 +35,34 @@ describe("parseNerveConfig", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.senses.cpu).toEqual({ group: "system", throttle: "5s" }); - expect(result.value.senses.memory).toEqual({ group: "system", timeout: "10s" }); - expect(result.value.reflexes).toHaveLength(2); - expect(result.value.reflexes[0]).toEqual({ - sense: "cpu", - workflow: "alert", - interval: "30s", + expect(result.value.senses.cpu).toEqual({ group: "system", throttle: "5s", timeout: null }); + expect(result.value.senses.memory).toEqual({ + group: "system", + throttle: null, + timeout: "10s", + }); + expect(result.value.reflexes).toHaveLength(3); + expect(result.value.reflexes[0]).toEqual({ + kind: "sense", + sense: "cpu", + interval: "30s", + on: null, + }); + expect(result.value.reflexes[1]).toEqual({ + kind: "sense", + sense: "memory", + interval: null, + on: ["high_usage"], + }); + expect(result.value.reflexes[2]).toEqual({ + kind: "workflow", + workflow: "alert", + on: ["cpu"], }); - expect(result.value.reflexes[1]).toEqual({ sense: "memory", on: ["high_usage"] }); expect(result.value.workflows?.alert).toEqual({ concurrency: 2, overflow: "drop", - max_queue: 10, + maxQueue: 10, }); }); @@ -73,10 +90,10 @@ reflexes: const result = parseNerveConfig(yaml); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.workflows).toBeUndefined(); + expect(result.value.workflows).toBeNull(); }); - it("allows sense without throttle or timeout", () => { + it("sense config has null for omitted throttle/timeout", () => { const yaml = ` senses: net: @@ -86,7 +103,11 @@ reflexes: [] const result = parseNerveConfig(yaml); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.senses.net).toEqual({ group: "network" }); + expect(result.value.senses.net).toEqual({ + group: "network", + throttle: null, + timeout: null, + }); }); it("accepts all valid duration suffixes (s, m, h)", () => { @@ -110,7 +131,7 @@ reflexes: [] describe("invalid configs", () => { it("returns error on bad YAML syntax", () => { - const result = parseNerveConfig("senses: [\nunclosed"); + const result = parseNerveConfig("senses: [\\nunclosed"); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.message).toMatch(/YAML parse error/); @@ -224,5 +245,34 @@ workflows: if (result.ok) return; expect(result.error.message).toMatch(/overflow.*"drop" or "queue"/); }); + + it("returns error when reflex has both sense and workflow", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - sense: cpu + workflow: alert +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/cannot have both/); + }); + + it("returns error when reflex has neither sense nor workflow", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - interval: 10s +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/must have either/); + }); }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1ec2dca..5cb77bf 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -14,8 +14,8 @@ function isValidGroupName(value: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(value); } -function validateDurationField(field: unknown, label: string): Result { - if (field === undefined) return ok(undefined); +function parseDurationField(field: unknown, label: string): Result { + if (field === undefined || field === null) return ok(null); if (typeof field !== "string" || !isValidDuration(field)) { return err( new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), @@ -43,32 +43,69 @@ function validateSenseConfig(name: string, raw: unknown): Result { ); } - const throttleResult = validateDurationField(obj.throttle, `senses.${name}.throttle`); + const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`); if (!throttleResult.ok) return throttleResult; - const timeoutResult = validateDurationField(obj.timeout, `senses.${name}.timeout`); + const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`); if (!timeoutResult.ok) return timeoutResult; return ok({ group: obj.group, - ...(throttleResult.value !== undefined && { throttle: throttleResult.value }), - ...(timeoutResult.value !== undefined && { timeout: timeoutResult.value }), + throttle: throttleResult.value, + timeout: timeoutResult.value, }); } -function validateReflexSense( +function parseOnField(index: number, obj: Record): Result { + if (obj.on === undefined || obj.on === null) return ok(null); + if (!Array.isArray(obj.on) || !obj.on.every((item) => typeof item === "string")) { + return err(new Error(`reflexes[${index}].on: must be an array of strings`)); + } + return ok(obj.on as string[]); +} + +function parseSenseReflex( index: number, obj: Record, senseNames: Set, -): Result { - if (obj.sense === undefined) return ok(undefined); + on: string[] | null, +): Result { if (typeof obj.sense !== "string") { return err(new Error(`reflexes[${index}].sense: must be a string`)); } if (!senseNames.has(obj.sense)) { return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`)); } - return ok(undefined); + + const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`); + if (!intervalResult.ok) return intervalResult; + + return ok({ + kind: "sense" as const, + sense: obj.sense, + interval: intervalResult.value, + on, + }); +} + +function parseWorkflowReflex( + index: number, + obj: Record, + on: string[] | null, +): Result { + if (typeof obj.workflow !== "string") { + return err(new Error(`reflexes[${index}].workflow: must be a string`)); + } + if (obj.interval !== undefined) { + return err( + new Error(`reflexes[${index}]: workflow reflex does not support "interval" (use "on")`), + ); + } + return ok({ + kind: "workflow" as const, + workflow: obj.workflow, + on, + }); } function validateReflexConfig( @@ -81,29 +118,23 @@ function validateReflexConfig( } const obj = raw as Record; + const hasSense = obj.sense !== undefined; + const hasWorkflow = obj.workflow !== undefined; - const senseResult = validateReflexSense(index, obj, senseNames); - if (!senseResult.ok) return senseResult; - - if (obj.workflow !== undefined && typeof obj.workflow !== "string") { - return err(new Error(`reflexes[${index}].workflow: must be a string`)); + if (hasSense && hasWorkflow) { + return err(new Error(`reflexes[${index}]: cannot have both "sense" and "workflow"`)); + } + if (!hasSense && !hasWorkflow) { + return err(new Error(`reflexes[${index}]: must have either "sense" or "workflow"`)); } - const intervalResult = validateDurationField(obj.interval, `reflexes[${index}].interval`); - if (!intervalResult.ok) return intervalResult; + const onResult = parseOnField(index, obj); + if (!onResult.ok) return onResult; - if (obj.on !== undefined) { - if (!Array.isArray(obj.on) || !obj.on.every((item) => typeof item === "string")) { - return err(new Error(`reflexes[${index}].on: must be an array of strings`)); - } + if (hasSense) { + return parseSenseReflex(index, obj, senseNames, onResult.value); } - - return ok({ - ...(obj.sense !== undefined && { sense: obj.sense as string }), - ...(obj.workflow !== undefined && { workflow: obj.workflow as string }), - ...(intervalResult.value !== undefined && { interval: intervalResult.value }), - ...(obj.on !== undefined && { on: obj.on as string[] }), - }); + return parseWorkflowReflex(index, obj, onResult.value); } function validateWorkflowConfig(name: string, raw: unknown): Result { @@ -125,9 +156,10 @@ function validateWorkflowConfig(name: string, raw: unknown): Result, -): Result | undefined> { - if (obj.workflows === undefined) return ok(undefined); +): Result | null> { + if (obj.workflows === undefined || obj.workflows === null) return ok(null); - if (obj.workflows === null || typeof obj.workflows !== "object" || Array.isArray(obj.workflows)) { + if (typeof obj.workflows !== "object" || Array.isArray(obj.workflows)) { return err(new Error("workflows: must be an object if provided")); } @@ -227,6 +259,6 @@ export function parseNerveConfig(raw: string): Result { return ok({ senses, reflexes: reflexesResult.value, - ...(workflowsResult.value !== undefined && { workflows: workflowsResult.value }), + workflows: workflowsResult.value, }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5c7f90..0bc4c96 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,12 @@ -export type { Signal, SenseConfig, ReflexConfig, WorkflowConfig, NerveConfig } from "./types.js"; +export type { + Signal, + SenseConfig, + SenseReflexConfig, + WorkflowReflexConfig, + ReflexConfig, + WorkflowConfig, + NerveConfig, +} from "./types.js"; export type { Result } from "./result.js"; export { ok, err } from "./result.js"; export { parseNerveConfig } from "./config.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 035d1ab..e3b56fb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,25 +7,33 @@ export type Signal = { export type SenseConfig = { group: string; - throttle?: string; - timeout?: string; + throttle: string | null; + timeout: string | null; }; -export type ReflexConfig = { - sense?: string; - workflow?: string; - interval?: string; - on?: string[]; +export type SenseReflexConfig = { + kind: "sense"; + sense: string; + interval: string | null; + on: string[] | null; }; +export type WorkflowReflexConfig = { + kind: "workflow"; + workflow: string; + on: string[] | null; +}; + +export type ReflexConfig = SenseReflexConfig | WorkflowReflexConfig; + export type WorkflowConfig = { concurrency: number; overflow: "drop" | "queue"; - max_queue?: number; + maxQueue: number | null; }; export type NerveConfig = { senses: Record; reflexes: ReflexConfig[]; - workflows?: Record; + workflows: Record | null; }; diff --git a/tsconfig.json b/tsconfig.json index 4b89a78..b43e7c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, + "exactOptionalPropertyTypes": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, -- 2.43.0 From 8784a4edc40a133bcb24d58c4b9fddf70a2243b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 07:41:22 +0000 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20duration=20fields=20=E2=86=92?= =?UTF-8?q?=20number|null=20(milliseconds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - throttle, timeout, interval: string|null → number|null - parseDurationField now returns parsed ms (5s→5000, 10m→600000, 1h→3600000) - biome.json: ignore dist/** from checks 小橘 --- biome.json | 3 +++ packages/core/src/__tests__/config.test.ts | 6 ++--- packages/core/src/config.ts | 26 +++++++++++++++++----- packages/core/src/types.ts | 6 ++--- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index 7f611ea..a8648b3 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,8 @@ { "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", + "files": { + "ignore": ["**/dist/**"] + }, "organizeImports": { "enabled": true }, diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index fccb604..5520abf 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -35,17 +35,17 @@ describe("parseNerveConfig", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.senses.cpu).toEqual({ group: "system", throttle: "5s", timeout: null }); + expect(result.value.senses.cpu).toEqual({ group: "system", throttle: 5000, timeout: null }); expect(result.value.senses.memory).toEqual({ group: "system", throttle: null, - timeout: "10s", + timeout: 10_000, }); expect(result.value.reflexes).toHaveLength(3); expect(result.value.reflexes[0]).toEqual({ kind: "sense", sense: "cpu", - interval: "30s", + interval: 30_000, on: null, }); expect(result.value.reflexes[1]).toEqual({ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5cb77bf..64fedae 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,24 +4,38 @@ import type { Result } from "./result.js"; import { err, ok } from "./result.js"; import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js"; -const DURATION_RE = /^\d+[smh]$/; +const DURATION_RE = /^(\d+)([smh])$/; -function isValidDuration(value: string): boolean { - return DURATION_RE.test(value); +const DURATION_MULTIPLIERS: Record = { + s: 1_000, + m: 60_000, + h: 3_600_000, +}; + +function parseDurationToMs(value: string): number | null { + const match = DURATION_RE.exec(value); + if (!match) return null; + return Number(match[1]) * DURATION_MULTIPLIERS[match[2]]; } function isValidGroupName(value: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(value); } -function parseDurationField(field: unknown, label: string): Result { +function parseDurationField(field: unknown, label: string): Result { if (field === undefined || field === null) return ok(null); - if (typeof field !== "string" || !isValidDuration(field)) { + if (typeof field !== "string") { return err( new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), ); } - return ok(field); + const ms = parseDurationToMs(field); + if (ms === null) { + return err( + new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), + ); + } + return ok(ms); } function validateSenseConfig(name: string, raw: unknown): Result { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e3b56fb..9aea0c0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,14 +7,14 @@ export type Signal = { export type SenseConfig = { group: string; - throttle: string | null; - timeout: string | null; + throttle: number | null; + timeout: number | null; }; export type SenseReflexConfig = { kind: "sense"; sense: string; - interval: string | null; + interval: number | null; on: string[] | null; }; -- 2.43.0 From 636b00c0a391329a6986af0fd06ad02d3e69795d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 07:55:23 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix(core):=20address=20PR=20#8=20review=20?= =?UTF-8?q?=E2=80=94=20gracePeriod,=20maxQueue=20default,=20drop+maxQueue?= =?UTF-8?q?=20guard,=20workflow=20name=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SenseConfig: add gracePeriod field (RFC §5.3 two-tier timeout) - WorkflowConfig: discriminated union (DropOverflowConfig | QueueOverflowConfig) - overflow: queue defaults maxQueue to 100 - overflow: drop + max_queue now returns validation error - Cross-validate workflow reflex references against defined workflows - Update tests: 21 cases covering all new behaviors 小橘 --- packages/core/src/__tests__/config.test.ts | 112 ++++++++++++++++++++- packages/core/src/config.ts | 50 +++++++-- packages/core/src/index.ts | 2 + packages/core/src/types.ts | 14 ++- 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 5520abf..58114b3 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -10,6 +10,7 @@ senses: memory: group: system timeout: 10s + grace_period: 3s reflexes: - sense: cpu @@ -24,7 +25,7 @@ reflexes: workflows: alert: concurrency: 2 - overflow: drop + overflow: queue max_queue: 10 `; @@ -35,11 +36,17 @@ describe("parseNerveConfig", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.senses.cpu).toEqual({ group: "system", throttle: 5000, timeout: null }); + expect(result.value.senses.cpu).toEqual({ + group: "system", + throttle: 5000, + timeout: null, + gracePeriod: null, + }); expect(result.value.senses.memory).toEqual({ group: "system", throttle: null, timeout: 10_000, + gracePeriod: 3000, }); expect(result.value.reflexes).toHaveLength(3); expect(result.value.reflexes[0]).toEqual({ @@ -61,7 +68,7 @@ describe("parseNerveConfig", () => { }); expect(result.value.workflows?.alert).toEqual({ concurrency: 2, - overflow: "drop", + overflow: "queue", maxQueue: 10, }); }); @@ -93,7 +100,7 @@ reflexes: expect(result.value.workflows).toBeNull(); }); - it("sense config has null for omitted throttle/timeout", () => { + it("sense config has null for omitted throttle/timeout/gracePeriod", () => { const yaml = ` senses: net: @@ -107,6 +114,7 @@ reflexes: [] group: "network", throttle: null, timeout: null, + gracePeriod: null, }); }); @@ -127,6 +135,48 @@ reflexes: [] const result = parseNerveConfig(yaml); expect(result.ok).toBe(true); }); + + it("overflow: drop produces DropOverflowConfig (no maxQueue)", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: [] +workflows: + alert: + concurrency: 1 + overflow: drop +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.workflows?.alert).toEqual({ + concurrency: 1, + overflow: "drop", + }); + expect("maxQueue" in (result.value.workflows?.alert ?? {})).toBe(false); + }); + + it("overflow: queue defaults maxQueue to 100", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: [] +workflows: + alert: + concurrency: 1 + overflow: queue +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.workflows?.alert).toEqual({ + concurrency: 1, + overflow: "queue", + maxQueue: 100, + }); + }); }); describe("invalid configs", () => { @@ -151,6 +201,42 @@ reflexes: expect(result.error.message).toMatch(/disk.*not found in senses/); }); + it("returns error when workflow reflex references a non-existent workflow", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - workflow: missing_wf + on: + - cpu +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/missing_wf.*not found in workflows/); + }); + + it("returns error when workflow reflex references non-existent workflow (with workflows defined)", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: + - workflow: unknown + on: + - cpu +workflows: + alert: + concurrency: 1 + overflow: drop +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/unknown.*not found in workflows/); + }); + it("returns error for invalid throttle format", () => { const yaml = ` senses: @@ -246,6 +332,24 @@ workflows: expect(result.error.message).toMatch(/overflow.*"drop" or "queue"/); }); + it("returns error when max_queue used with overflow: drop", () => { + const yaml = ` +senses: + cpu: + group: system +reflexes: [] +workflows: + alert: + concurrency: 1 + overflow: drop + max_queue: 10 +`; + const result = parseNerveConfig(yaml); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toMatch(/max_queue.*not allowed.*drop/); + }); + it("returns error when reflex has both sense and workflow", () => { const yaml = ` senses: diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 64fedae..8a1b9e1 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -63,10 +63,14 @@ function validateSenseConfig(name: string, raw: unknown): Result { const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`); if (!timeoutResult.ok) return timeoutResult; + const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`); + if (!graceResult.ok) return graceResult; + return ok({ group: obj.group, throttle: throttleResult.value, timeout: timeoutResult.value, + gracePeriod: graceResult.value, }); } @@ -170,18 +174,33 @@ function validateWorkflowConfig(name: string, raw: unknown): Result { const workflowsResult = parseWorkflows(obj); if (!workflowsResult.ok) return workflowsResult; + // Cross-validate: workflow reflexes must reference defined workflows + const workflowNames = new Set( + workflowsResult.value ? Object.keys(workflowsResult.value) : [], + ); + for (let i = 0; i < reflexesResult.value.length; i++) { + const reflex = reflexesResult.value[i]; + if (reflex.kind === "workflow" && !workflowNames.has(reflex.workflow)) { + return err( + new Error( + `reflexes[${i}].workflow: "${reflex.workflow}" not found in workflows`, + ), + ); + } + } + return ok({ senses, reflexes: reflexesResult.value, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0bc4c96..2972b7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,8 @@ export type { SenseReflexConfig, WorkflowReflexConfig, ReflexConfig, + DropOverflowConfig, + QueueOverflowConfig, WorkflowConfig, NerveConfig, } from "./types.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9aea0c0..67993eb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,6 +9,7 @@ export type SenseConfig = { group: string; throttle: number | null; timeout: number | null; + gracePeriod: number | null; }; export type SenseReflexConfig = { @@ -26,12 +27,19 @@ export type WorkflowReflexConfig = { export type ReflexConfig = SenseReflexConfig | WorkflowReflexConfig; -export type WorkflowConfig = { +export type DropOverflowConfig = { concurrency: number; - overflow: "drop" | "queue"; - maxQueue: number | null; + overflow: "drop"; }; +export type QueueOverflowConfig = { + concurrency: number; + overflow: "queue"; + maxQueue: number; +}; + +export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig; + export type NerveConfig = { senses: Record; reflexes: ReflexConfig[]; -- 2.43.0 From f5c561173d5177f688551ea980333b19dc3a70d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 08:06:09 +0000 Subject: [PATCH 6/7] docs(rfc-001): add Log concept, append-only storage architecture, workflow event sourcing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §2.4: Log as data asset, not trigger source (anti-avalanche constraint) - §3: Add Log to terminology table - §5.4: New storage architecture section - Unified logs table (append-only SQLite) - Workflow state via event sourcing (no mutable tables) - Cold archival: >30d data exported to daily JSONL files - §5.6: Error handling now writes logs instead of error signals - §8: Directory structure updated with logs.db and archive/ - §10: Design principles updated (8 principles, +1 log rule) - Thread outputs are now Logs, not Signals 小橘 --- docs/rfc-001-observation-engine.md | 143 +++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 39 deletions(-) diff --git a/docs/rfc-001-observation-engine.md b/docs/rfc-001-observation-engine.md index 4d190a9..4a3247a 100644 --- a/docs/rfc-001-observation-engine.md +++ b/docs/rfc-001-observation-engine.md @@ -29,6 +29,26 @@ 一个 Sense **怎么算**(compute)和**什么时候算**(reflex)是两个独立的关注点。同一个 Sense 可以被定时触发、被事件触发、被按需查询触发。 +### 2.4 Log 是数据资产,不是触发源 + +系统运行过程中产生的各种记录(reflex 执行记录、workflow 状态变迁、错误日志等)统称为 **Log**。Log 是有价值的数据资产,用于审计、溯源、分析。 + +**Log 与 Signal 的本质区别:** + +- **Signal** — 来自外部世界的感知数据,可以触发 Reflex +- **Log** — 系统内部产出的副作用记录,**不能触发 Reflex** + +因果链是单向的: + +``` +外部世界 → Sense → Signal → Reflex → Action + Log + ↑ + Reflex 可以读 Log(查询/聚合) + Log 不能触发 Reflex ✗ +``` + +禁止 Log 触发 Reflex 是防止雪崩的关键约束——如果 reflex 执行产生的 log 又能触发 reflex,系统会形成无限循环。Log 是因果链的终点,不是起点。 + ## 3. 术语表 | 术语 | 隐喻 | 含义 | @@ -36,6 +56,7 @@ | **Sense** | 感官 | 定义怎么感知——compute 函数,每种 Sense 有自己的数据类型和独立存储 | | **Reflex** | 反射 | 定义什么时候感知——声明式触发条件 | | **Signal** | 信号 | Sense compute 返回非 null 时发出的通知,其他 Reflex 可以监听 | +| **Log** | 日志 | 系统内部产出的记录(执行记录、状态变迁、错误等),数据资产,不触发 Reflex | | **Workflow** | 行动 | 定义怎么做——内含 Moderator(调度)和 Role(执行)| | **Moderator** | 协调者 | Workflow 内部概念,在 Role 之间递话筒 | | **Role** | 执行者 | Workflow 内部概念,执行具体动作,有副作用 | @@ -298,13 +319,13 @@ Signal 和 Thread 是两个独立的循环,单向桥接: Signal 循环 ──→ ThreadStart ──→ Thread 循环 (无状态,幂等,可合并) (有状态,顺序,Command Event 驱动) │ - Thread 产出 Signal + Thread 产出 Log (执行日志,供 retrospection) ``` **Signal 只负责 kickoff Thread**。Thread 启动后,由自己的事件循环驱动——Moderator 递话筒、Role 执行、Command Event 流转。Thread 内部不走 Signal 系统。 -**Thread 产出的 Signal 是执行日志**,记录 Thread 的中间状态和最终结果。这些 Signal 可以被其他 Sense 监听用于 retrospection(如统计成功率、平均耗时),但不作为驱动 Workflow 的动力。 +**Thread 产出的 Log 是执行日志**,记录 Thread 的中间状态和最终结果。这些 Log 可以被 Sense 的 compute 查询用于 retrospection(如统计成功率、平均耗时),但 Log 不能触发 Reflex(见 §2.4)。 这保证了两个循环的性质不被污染: - Signal 循环:无状态、幂等、可合并、可丢弃 @@ -437,50 +458,86 @@ Sense 的运行时属性(`group`、`throttle`、`timeout`)在 `nerve.yaml` - **timeout**:compute 超时上限(soft timeout),超时后 abort 当前 compute,记录错误 signal - **grace_period**:soft timeout 后的宽限期(默认 timeout × 3),超过后 hard kill 整个 group worker 并 respawn。防止跑飞的 compute 堵住同 group -### 5.4 Thread 状态持久化与恢复 +### 5.4 存储架构 -Thread 是状态机。每一步转换之前持久化状态,确保崩溃后可恢复。 +系统有两大类持久化数据,全部 append-only: -```typescript -interface ThreadState { - threadId: string - workflowId: string - currentStep: string // 状态机当前节点 - context: Record // 累积的上下文 - history: CommandEvent[] // 已完成的步骤记录 - status: 'running' | 'crashed' | 'completed' | 'failed' - updatedAt: number -} +#### Signal 存储 + +每个 Sense 独立一个 SQLite 文件(见 §8),由 Sense 自行管理 schema。这部分不变。 + +#### Log 存储 + +所有 Log 写入统一的 SQLite 文件 `data/logs.db`,单表: + +```sql +CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, -- "reflex", "workflow", "system" + type TEXT NOT NULL, -- "run_start", "run_complete", "error", "state_change" + ref_id TEXT, -- 关联的 reflex name / workflow run_id + payload TEXT, -- JSON + ts INTEGER NOT NULL -- unix ms +); + +CREATE INDEX idx_logs_source_type ON logs(source, type); +CREATE INDEX idx_logs_ts ON logs(ts); +CREATE INDEX idx_logs_ref_id ON logs(ref_id); ``` -执行流程: +- **统一一张表**,通过 `source + type` 区分 log 来源和类型 +- Reflex 可以查询 logs 表(只读),但 log 不能触发 reflex(见 §2.4) -``` -moderator 决定下一步 → 持久化状态 → execute role → 写结果 → 下一步 - │ - 如果这里挂了 - │ - 恢复时从这一步重试 +#### Workflow 状态:事件溯源 + +Workflow Thread 的状态不用 mutable 表,而是 append-only 的事件流: + +```sql +-- 也在 logs 表中,source = "workflow" +-- type 取值:queued, started, step_complete, completed, failed, crashed ``` -恢复流程: +当前状态 = 该 run_id 最后一条 log entry。例: ``` -1. engine 检测到 workflow worker 挂了 -2. respawn worker -3. worker 启动时扫描 db,找到 status=running 的 thread -4. 从 currentStep 恢复执行 +source=workflow, type=queued, ref_id=run-7, ts=1000 +source=workflow, type=started, ref_id=run-7, ts=1001 +source=workflow, type=completed, ref_id=run-7, ts=1005 ``` -恢复要求 Role 的 execute 尽量幂等。非幂等的 Role 可以标记: +查询当前活跃 workflow: -```typescript -roles: [ - { id: 'analyzer', execute: ..., idempotent: true }, // 可安全重试 - { id: 'deployer', execute: ..., idempotent: false }, // 崩溃后标记 crashed,等介入 -] +```sql +SELECT ref_id, type FROM logs +WHERE source = 'workflow' +AND (ref_id, ts) IN ( + SELECT ref_id, MAX(ts) FROM logs + WHERE source = 'workflow' + GROUP BY ref_id +) +AND type IN ('queued', 'started') ``` +进程重启时从 log 重建内存状态。运行时用内存 materialized view 加速查询。 + +#### 冷归档 + +Engine 定期(cron 或内置 task)将超过 30 天的 log 和 signal 数据导出为按天 JSONL 文件归档: + +``` +data/ + logs.db # 热数据(近 30 天) + archive/ + logs/ + 2026-03-22.jsonl # 冷数据,按天归档 + 2026-03-23.jsonl + senses/ + cpu-usage/ + 2026-03-22.jsonl +``` + +导出后从主库 DELETE + VACUUM。冷数据用 grep/jq 即可查询,不需要 SQL。 + ### 5.5 热更新 主进程 watch `~/.uncaged-nerve/` 文件变化,按类型处理: @@ -507,9 +564,9 @@ nerve.yaml diff 处理: | 情况 | 处理 | |------|------| -| compute 抛异常 | 记录错误 signal,下次触发重试 | -| compute 超时 | soft timeout → abort + 记录错误 signal;grace_period 后 hard kill worker + respawn | -| 存储写入失败 | 记录错误,不发 signal(未成功产出) | +| compute 抛异常 | 写 log(source=system, type=error),下次触发重试 | +| compute 超时 | soft timeout → abort + 写 error log;grace_period 后 hard kill worker + respawn | +| 存储写入失败 | 写 error log,不发 signal(未成功产出) | | nerve.yaml 语法错误 | daemon 拒绝加载,保持当前配置 | | sense ts 语法错误 | 该 group worker 加载失败,其他 group 正常 | | workflow worker 崩溃 | 幂等 thread 自动恢复,非幂等标记 crashed | @@ -629,10 +686,17 @@ Reflex ──→ Sense ──→ Sense (复合依赖) workflows/ # post-MVP cleanup.ts data/ # ⛔ gitignored + logs.db # 统一 log 存储(append-only) senses/ cpu-usage.db # 每个 sense 独立的 sqlite disk-usage.db active-tasks.db + archive/ # 冷归档(>30天) + logs/ + 2026-03-22.jsonl + senses/ + cpu-usage/ + 2026-03-22.jsonl blobs/ # CAS blob store,sha256 寻址 ab/ cd1234... @@ -665,7 +729,8 @@ Reflex ──→ Sense ──→ Sense (复合依赖) 1. **Sense 是唯一的一等公民** — 原始采样和派生计算统一为 Sense,Sense 不知道下游 2. **计算与触发解耦** — compute 不知道自己什么时候被调用 3. **Reflex 是 Event Mesh** — 所有事件路由都声明在 Reflex 中,Sense 和 Workflow 之间无直连 -4. **存储去中心化** — 每个 Sense 自管存储,没有统一 Event Store -5. **不删除只关停** — 历史不可变,生命周期通过 enabled 控制 -6. **Workflow 是可选扩展** — MVP 不需要,后续按需加入 -7. **引擎极简** — 只做调度,不做业务逻辑 +4. **Log 是终点不是起点** — Log 是数据资产,Reflex 可读但不被 Log 触发,防止雪崩 +5. **存储去中心化** — 每个 Sense 自管存储,Log 统一一张表,全部 append-only +6. **不删除只关停** — 历史不可变,生命周期通过 enabled 控制 +7. **Workflow 是可选扩展** — MVP 不需要,后续按需加入 +8. **引擎极简** — 只做调度,不做业务逻辑 -- 2.43.0 From 852cad9c60253856bb6c0b0dc4788fe782a6028a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 08:15:28 +0000 Subject: [PATCH 7/7] docs(rfc-001): archival watermark + workflow_runs materialized table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cold archival: meta table with archived_up_to watermark for crash-safe recovery - Workflow state: workflow_runs materialized table (UPSERT in same txn as log write) - O(active) queries instead of full table scan - Derivable from logs if lost 小橘 --- docs/rfc-001-observation-engine.md | 64 ++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/rfc-001-observation-engine.md b/docs/rfc-001-observation-engine.md index 4a3247a..77c7d9c 100644 --- a/docs/rfc-001-observation-engine.md +++ b/docs/rfc-001-observation-engine.md @@ -488,9 +488,9 @@ CREATE INDEX idx_logs_ref_id ON logs(ref_id); - **统一一张表**,通过 `source + type` 区分 log 来源和类型 - Reflex 可以查询 logs 表(只读),但 log 不能触发 reflex(见 §2.4) -#### Workflow 状态:事件溯源 +#### Workflow 状态:事件溯源 + 物化表 -Workflow Thread 的状态不用 mutable 表,而是 append-only 的事件流: +Workflow Thread 的状态以 append-only 事件流为 source of truth: ```sql -- 也在 logs 表中,source = "workflow" @@ -505,20 +505,37 @@ source=workflow, type=started, ref_id=run-7, ts=1001 source=workflow, type=completed, ref_id=run-7, ts=1005 ``` -查询当前活跃 workflow: +为避免每次查活跃 workflow 都扫描全表,引擎维护一张 **物化表**,在写 log 的同一事务中 UPSERT: ```sql -SELECT ref_id, type FROM logs -WHERE source = 'workflow' -AND (ref_id, ts) IN ( - SELECT ref_id, MAX(ts) FROM logs - WHERE source = 'workflow' - GROUP BY ref_id -) -AND type IN ('queued', 'started') +CREATE TABLE workflow_runs ( + run_id TEXT PRIMARY KEY, + workflow TEXT NOT NULL, -- workflow 名 + status TEXT NOT NULL, -- 最新状态:queued, started, completed, failed, crashed + ts INTEGER NOT NULL -- 最新状态的时间戳 +); + +CREATE INDEX idx_workflow_runs_status ON workflow_runs(status); ``` -进程重启时从 log 重建内存状态。运行时用内存 materialized view 加速查询。 +写入流程(同一事务): + +```sql +BEGIN; +INSERT INTO logs (source, type, ref_id, payload, ts) VALUES ('workflow', 'started', 'run-7', '{}', 1001); +INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts) VALUES ('run-7', 'alert', 'started', 1001); +COMMIT; +``` + +查询当前活跃 workflow 变为 O(活跃数): + +```sql +SELECT * FROM workflow_runs WHERE status IN ('queued', 'started') +``` + +物化表是 logs 的派生数据——数据丢失时可从 logs 重建。logs 表仍是 source of truth。 + +进程重启时从 log 重建内存状态。运行时用内存 materialized view 进一步加速。 #### 冷归档 @@ -538,6 +555,29 @@ data/ 导出后从主库 DELETE + VACUUM。冷数据用 grep/jq 即可查询,不需要 SQL。 +**水位标记**:归档进度记录在 meta 表中,确保任一步崩溃都能安全恢复: + +```sql +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- 归档水位:已成功归档到哪一天 +-- key = "archived_up_to", value = "2026-03-22" +``` + +归档流程: + +``` +1. 读 meta.archived_up_to,确定从哪天开始 +2. 导出该天数据到 JSONL(幂等:同一天重复导出会覆盖文件) +3. 同一事务:DELETE 该天数据 + UPDATE meta.archived_up_to +4. VACUUM(可选,非事务内) +``` + +任何一步崩溃,重启后从水位标记处继续,不会丢数据也不会重复删除。 + ### 5.5 热更新 主进程 watch `~/.uncaged-nerve/` 文件变化,按类型处理: -- 2.43.0