diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index c3ce7d5..9443f28 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseNerveConfig } from "../parse-nerve-config.js"; +import { parseNerveConfig } from "../config.js"; const VALID_CONFIG = ` senses: diff --git a/packages/core/src/__tests__/daemon-ipc-protocol.test.ts b/packages/core/src/__tests__/daemon-ipc-protocol.test.ts index 401b9f1..2463c5a 100644 --- a/packages/core/src/__tests__/daemon-ipc-protocol.test.ts +++ b/packages/core/src/__tests__/daemon-ipc-protocol.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseDaemonIpcRequest } from "../daemon-ipc-protocol.js"; +import { parseDaemonIpcRequest } from "../daemon.js"; describe("parseDaemonIpcRequest", () => { it("parses trigger-workflow", () => { diff --git a/packages/core/src/__tests__/knowledge-config.test.ts b/packages/core/src/__tests__/knowledge-config.test.ts index cd7c6b7..126af78 100644 --- a/packages/core/src/__tests__/knowledge-config.test.ts +++ b/packages/core/src/__tests__/knowledge-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseKnowledgeYaml } from "../knowledge-config.js"; +import { parseKnowledgeYaml } from "../config.js"; describe("parseKnowledgeYaml", () => { it("parses include and exclude glob lists", () => { diff --git a/packages/core/src/__tests__/sense-workflow-directive.test.ts b/packages/core/src/__tests__/sense-workflow-directive.test.ts index f54fab4..402dbea 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-workflow-directive.js"; +import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js"; describe("parseWorkflowTrigger", () => { it("accepts a valid trigger object", () => { diff --git a/packages/core/src/__tests__/spawn-safe.test.ts b/packages/core/src/__tests__/spawn-safe.test.ts index bca7ae3..5124575 100644 --- a/packages/core/src/__tests__/spawn-safe.test.ts +++ b/packages/core/src/__tests__/spawn-safe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { spawnSafe } from "../spawn-safe.js"; +import { spawnSafe } from "../util.js"; describe("spawnSafe", () => { it("passes argv literally without shell interpretation (injection-safe)", async () => { diff --git a/packages/core/src/agent-adapter-ids.ts b/packages/core/src/agent-adapter-ids.ts deleted file mode 100644 index f96afce..0000000 --- a/packages/core/src/agent-adapter-ids.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Agent adapter ids referenced by tooling / docs (RFC-003). - * Workflows import adapter packages directly; echo may be used in tests via a small factory. - */ -export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const; diff --git a/packages/core/src/extract-layer.ts b/packages/core/src/agent.ts similarity index 73% rename from packages/core/src/extract-layer.ts rename to packages/core/src/agent.ts index 51a9e56..21aff3f 100644 --- a/packages/core/src/extract-layer.ts +++ b/packages/core/src/agent.ts @@ -21,3 +21,9 @@ export class ExtractError extends Error { Object.setPrototypeOf(this, new.target.prototype); } } + +/** + * Agent adapter ids referenced by tooling / docs (RFC-003). + * Workflows import adapter packages directly; echo may be used in tests via a small factory. + */ +export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ee9b596..ce85a1b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,5 +1,7 @@ -/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */ -export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000; +import { parse } from "yaml"; + +import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js"; +import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; export type SenseConfig = { group: string; @@ -75,3 +77,398 @@ export type NerveConfig = { /** Global extract defaults; `null` when the section is omitted. */ extract: ExtractConfig | null; }; + +export type KnowledgeConfig = { + include: ReadonlyArray; + 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") { + return err( + new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), + ); + } + const msResult = parseDurationStringToMs(field); + if (!msResult.ok) { + return err(new Error(`${label}: ${msResult.error.message}`)); + } + return ok(msResult.value); +} + +function validateSenseConfig(name: string, raw: unknown): Result { + if (!isPlainRecord(raw)) { + return err(new Error(`senses.${name}: must be an object`)); + } + + const obj = raw; + + 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 = parseDurationField(obj.throttle, `senses.${name}.throttle`); + if (!throttleResult.ok) return throttleResult; + + 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; + + const retentionResult = parseRetentionField(name, obj.retention); + if (!retentionResult.ok) return retentionResult; + + const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`); + if (!intervalResult.ok) return intervalResult; + + let on: string[] = []; + if (obj.on !== undefined && obj.on !== null) { + if ( + !Array.isArray(obj.on) || + !obj.on.every((item: unknown): item is string => typeof item === "string") + ) { + return err(new Error(`senses.${name}.on: must be an array of strings`)); + } + on = obj.on; + } + + return ok({ + group: obj.group, + throttle: throttleResult.value, + timeout: timeoutResult.value, + gracePeriod: graceResult.value, + retention: retentionResult.value, + interval: intervalResult.value, + on, + }); +} + +function parseEngineMaxRounds(obj: Record): Result { + if (obj.max_rounds === undefined || obj.max_rounds === null) { + return ok(DEFAULT_ENGINE_MAX_ROUNDS); + } + if ( + typeof obj.max_rounds !== "number" || + !Number.isInteger(obj.max_rounds) || + obj.max_rounds < 1 + ) { + return err(new Error("max_rounds: must be a positive integer")); + } + return ok(obj.max_rounds); +} + +function validateWorkflowConfig(name: string, raw: unknown): Result { + if (!isPlainRecord(raw)) { + return err(new Error(`workflows.${name}: must be an object`)); + } + + const obj = raw; + + 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.overflow === "drop") { + if (obj.max_queue !== undefined && obj.max_queue !== null) { + return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`)); + } + return ok({ + concurrency: obj.concurrency, + overflow: "drop" as const, + }); + } + + // overflow: "queue" + let maxQueue = 100; // default + if (obj.max_queue !== undefined && obj.max_queue !== null) { + if ( + typeof obj.max_queue !== "number" || + !Number.isInteger(obj.max_queue) || + obj.max_queue < 1 + ) { + return err(new Error(`workflows.${name}.max_queue: must be a positive integer`)); + } + maxQueue = obj.max_queue; + } + + return ok({ + concurrency: obj.concurrency, + overflow: "queue" as const, + maxQueue, + }); +} + +function parseSenses( + obj: Record, +): Result<{ senses: Record }> { + if (!isPlainRecord(obj.senses)) { + return err(new Error("senses: required object")); + } + + const sensesRaw = obj.senses; + const senses: Record = {}; + + 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 }); +} + +const DEFAULT_API_BIND_HOST = "127.0.0.1"; + +/** Hosts that may bind the HTTP API without `api.token` (loopback-only). */ +function isLoopbackOnlyApiHost(host: string): boolean { + const h = host.trim(); + return h === "127.0.0.1" || h.toLowerCase() === "localhost"; +} + +function parseApiTokenField(api: Record): Result { + if (api.token === undefined || api.token === null) { + return ok(null); + } + if (typeof api.token !== "string") { + return err(new Error("api.token: must be a string when provided")); + } + if (api.token.length === 0) { + return err(new Error("api.token: must not be empty when provided")); + } + return ok(api.token); +} + +function parseApiHostField(api: Record): Result { + if (api.host === undefined || api.host === null) { + return ok(DEFAULT_API_BIND_HOST); + } + if (typeof api.host !== "string") { + return err(new Error("api.host: must be a string when provided")); + } + if (api.host.length === 0) { + return err(new Error("api.host: must not be empty when provided")); + } + return ok(api.host); +} + +function parseApiConfig(obj: Record): Result { + if (obj.api === undefined || obj.api === null) { + return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST }); + } + if (!isPlainRecord(obj.api)) { + return err(new Error("api: must be an object if provided")); + } + const api = obj.api; + if (api.port === undefined || api.port === null) { + return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST }); + } + if ( + typeof api.port !== "number" || + !Number.isInteger(api.port) || + api.port < 1 || + api.port > 65_535 + ) { + return err(new Error("api.port: must be an integer between 1 and 65535 if provided")); + } + + const tokenResult = parseApiTokenField(api); + if (!tokenResult.ok) return tokenResult; + const hostResult = parseApiHostField(api); + if (!hostResult.ok) return hostResult; + + if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) { + return err( + new Error("api.host binds to non-loopback address, api.token is required for security"), + ); + } + + return ok({ port: api.port, token: tokenResult.value, host: hostResult.value }); +} + +function parseWorkflows(obj: Record): Result> { + if (obj.workflows === undefined || obj.workflows === null) return ok({}); + + if (!isPlainRecord(obj.workflows)) { + return err(new Error("workflows: must be an object if provided")); + } + + const workflowsRaw = obj.workflows; + 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); +} + +function parseExtract(obj: Record): Result { + if (obj.extract === undefined || obj.extract === null) { + return ok(null); + } + + if (!isPlainRecord(obj.extract)) { + return err(new Error("extract: must be an object if provided")); + } + + const ext = obj.extract; + + if (typeof ext.provider !== "string" || ext.provider.trim() === "") { + return err(new Error("extract.provider: required non-empty string")); + } + + if (typeof ext.model !== "string" || ext.model.trim() === "") { + return err(new Error("extract.model: required non-empty string")); + } + + return ok({ provider: ext.provider, model: ext.model }); +} + +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 (!isPlainRecord(parsed)) { + return err(new Error("Config must be a YAML object")); + } + + const obj = parsed; + + const sensesResult = parseSenses(obj); + if (!sensesResult.ok) return sensesResult; + const { senses } = sensesResult.value; + + // Legacy top-level `reflexes` is rejected; each sense carries `interval` / `on` for the sense scheduler. + if (Object.hasOwn(obj, "reflexes")) { + return err( + new Error( + "reflexes: top-level key is no longer supported; set `interval` and `on` on each sense under `senses.`", + ), + ); + } + + const workflowsResult = parseWorkflows(obj); + if (!workflowsResult.ok) return workflowsResult; + + const maxRoundsResult = parseEngineMaxRounds(obj); + if (!maxRoundsResult.ok) return maxRoundsResult; + + const apiResult = parseApiConfig(obj); + if (!apiResult.ok) return apiResult; + + if (Object.hasOwn(obj, "agents")) { + return err( + new Error( + "agents: key is no longer supported — declare adapters on workflow roles (RFC-003)", + ), + ); + } + + const extractResult = parseExtract(obj); + if (!extractResult.ok) return extractResult; + + return ok({ + maxRounds: maxRoundsResult.value, + senses, + workflows: workflowsResult.value, + api: apiResult.value, + extract: extractResult.value, + }); +} + +function parseStringList(field: unknown, label: string): Result> { + if (field === undefined || field === null) { + return ok([]); + } + if (!Array.isArray(field)) { + return err(new Error(`${label}: must be an array of strings`)); + } + const out: string[] = []; + for (let i = 0; i < field.length; i++) { + const item = field[i]; + if (typeof item !== "string" || item.length === 0) { + return err(new Error(`${label}[${String(i)}]: must be a non-empty string`)); + } + out.push(item); + } + return ok(out); +} + +/** + * Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer). + * `include` / `exclude` entries are glob patterns resolved against the repo root. + */ +export function parseKnowledgeYaml(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 === undefined || parsed === null) { + return ok({ include: [], exclude: [] }); + } + + if (!isPlainRecord(parsed)) { + return err(new Error("knowledge.yaml: root must be a mapping")); + } + + const includeResult = parseStringList(parsed.include, "include"); + if (!includeResult.ok) { + return includeResult; + } + const excludeResult = parseStringList(parsed.exclude, "exclude"); + if (!excludeResult.ok) { + return excludeResult; + } + + return ok({ + include: includeResult.value, + exclude: excludeResult.value, + }); +} diff --git a/packages/core/src/daemon-payload-guards.ts b/packages/core/src/daemon-payload-guards.ts deleted file mode 100644 index c665172..0000000 --- a/packages/core/src/daemon-payload-guards.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { WorkflowStatus } from "./daemon-ipc-protocol.js"; -import { isPlainRecord } from "./is-plain-record.js"; -import type { SenseInfo } from "./sense.js"; - -/** Type guard for JSON {@link SenseInfo} payloads from daemon HTTP/IPC. */ -export function isSenseInfo(value: unknown): value is SenseInfo { - if (!isPlainRecord(value)) return false; - return ( - typeof value.name === "string" && - typeof value.group === "string" && - (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") - ); -} - -/** Type guard for JSON {@link WorkflowStatus} payloads from daemon HTTP/IPC. */ -export function isWorkflowStatus(value: unknown): value is WorkflowStatus { - if (!isPlainRecord(value)) return false; - const cfg = value.config; - if (!isPlainRecord(cfg)) return false; - return ( - typeof value.name === "string" && - typeof value.activeThreads === "number" && - Array.isArray(value.activeRunIds) && - value.activeRunIds.every((id: unknown) => typeof id === "string") && - typeof value.queuedThreads === "number" && - typeof cfg.concurrency === "number" && - typeof cfg.overflow === "string" - ); -} diff --git a/packages/core/src/daemon-transport.ts b/packages/core/src/daemon-transport.ts deleted file mode 100644 index 01f0847..0000000 --- a/packages/core/src/daemon-transport.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { HealthInfo, WorkflowStatus } from "./daemon-ipc-protocol.js"; -import type { SenseInfo } from "./sense.js"; - -export type DaemonTransportTriggerResult = { ok: true } | { ok: false; error: string }; - -export type DaemonTransportWorkflowLaunch = { - prompt: string; - maxRounds: number; - dryRun: boolean; -}; - -/** - * Abstraction over daemon control plane (Unix socket IPC today, HTTP in Phase 2). - * Implementations live in CLI / tools; the daemon kernel uses shared handler logic. - */ -export type DaemonTransport = { - health(): Promise; - listSenses(): Promise; - listWorkflows(): Promise; - triggerSense(name: string): Promise; - /** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */ - triggerWorkflow( - name: string, - launch: DaemonTransportWorkflowLaunch | null, - ): Promise; - /** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */ - killWorkflow(runId: string): Promise; -}; diff --git a/packages/core/src/daemon-ipc-protocol.ts b/packages/core/src/daemon.ts similarity index 66% rename from packages/core/src/daemon-ipc-protocol.ts rename to packages/core/src/daemon.ts index b6c4f87..aeb2a74 100644 --- a/packages/core/src/daemon-ipc-protocol.ts +++ b/packages/core/src/daemon.ts @@ -4,8 +4,8 @@ * one response object per line from the daemon. */ -import { isPlainRecord } from "./is-plain-record.js"; import type { SenseInfo } from "./sense.js"; +import { isPlainRecord } from "./util.js"; /** Runtime status of a registered workflow (for listing / observability). */ export type WorkflowStatus = { @@ -100,6 +100,32 @@ export type DaemonIpcResponse = | DaemonIpcListWorkflowsResponse | DaemonIpcHealthResponse; +export type DaemonTransportTriggerResult = { ok: true } | { ok: false; error: string }; + +export type DaemonTransportWorkflowLaunch = { + prompt: string; + maxRounds: number; + dryRun: boolean; +}; + +/** + * Abstraction over daemon control plane (Unix socket IPC today, HTTP in Phase 2). + * Implementations live in CLI / tools; the daemon kernel uses shared handler logic. + */ +export type DaemonTransport = { + health(): Promise; + listSenses(): Promise; + listWorkflows(): Promise; + triggerSense(name: string): Promise; + /** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */ + triggerWorkflow( + name: string, + launch: DaemonTransportWorkflowLaunch | null, + ): Promise; + /** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */ + killWorkflow(runId: string): Promise; +}; + function parseTriggerWorkflowFields( req: Record, ): DaemonIpcTriggerWorkflowRequest | null { @@ -150,3 +176,33 @@ export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null { return null; } } + +/** Type guard for JSON {@link SenseInfo} payloads from daemon HTTP/IPC. */ +export function isSenseInfo(value: unknown): value is SenseInfo { + if (!isPlainRecord(value)) return false; + return ( + typeof value.name === "string" && + typeof value.group === "string" && + (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") + ); +} + +/** Type guard for JSON {@link WorkflowStatus} payloads from daemon HTTP/IPC. */ +export function isWorkflowStatus(value: unknown): value is WorkflowStatus { + if (!isPlainRecord(value)) return false; + const cfg = value.config; + if (!isPlainRecord(cfg)) return false; + return ( + typeof value.name === "string" && + typeof value.activeThreads === "number" && + Array.isArray(value.activeRunIds) && + value.activeRunIds.every((id: unknown) => typeof id === "string") && + typeof value.queuedThreads === "number" && + typeof cfg.concurrency === "number" && + typeof cfg.overflow === "string" + ); +} diff --git a/packages/core/src/duration.ts b/packages/core/src/duration.ts deleted file mode 100644 index 8b38775..0000000 --- a/packages/core/src/duration.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Result } from "./result.js"; -import { err, ok } from "./result.js"; - -const DURATION_RE = /^(\d+)([smh])$/; - -const DURATION_MULTIPLIERS: Record = { - s: 1_000, - m: 60_000, - h: 3_600_000, -}; - -/** - * Parse a duration string such as `5s`, `10m`, `1h` to milliseconds. - * Used by `parseNerveConfig` sense/workflow duration fields. - */ -export function parseDurationStringToMs(value: string): Result { - const match = DURATION_RE.exec(value); - if (!match) { - return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`)); - } - return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7eec532..affc366 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,8 +12,8 @@ export type { ComputeResult, } from "./config.js"; export type { Signal, SenseInfo } from "./sense.js"; -export type { SenseComputeFn, SenseModule } from "./sense-contract.js"; -export { labelSenseTrigger, senseTriggerLabels } from "./sense-trigger-labels.js"; +export type { SenseComputeFn, SenseModule } from "./sense.js"; +export { labelSenseTrigger, senseTriggerLabels } from "./sense.js"; export type { WorkflowMessage, RoleResult, @@ -29,11 +29,11 @@ export type { WorkflowDefinition, } from "./workflow.js"; export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; -export { parseDurationStringToMs } from "./duration.js"; -export type { Schema, ExtractFn } from "./extract-layer.js"; -export { ExtractError } from "./extract-layer.js"; -export type { Result } from "./result.js"; -export { ok, err } from "./result.js"; +export { parseDurationStringToMs } from "./util.js"; +export type { Schema, ExtractFn } from "./agent.js"; +export { ExtractError } from "./agent.js"; +export type { Result } from "./util.js"; +export { ok, err } from "./util.js"; export { nerveCommandEnv, spawnSafe, @@ -41,17 +41,17 @@ export { type SpawnError, type SpawnResult, type SpawnSafeOptions, -} from "./spawn-safe.js"; -export { parseNerveConfig } from "./parse-nerve-config.js"; -export type { KnowledgeConfig } from "./knowledge-config.js"; -export { parseKnowledgeYaml } from "./knowledge-config.js"; -export { isPlainRecord } from "./is-plain-record.js"; -export { KNOWN_AGENT_ADAPTER_IDS } from "./agent-adapter-ids.js"; +} from "./util.js"; +export { parseNerveConfig } from "./config.js"; +export type { KnowledgeConfig } from "./config.js"; +export { parseKnowledgeYaml } from "./config.js"; +export { isPlainRecord } from "./util.js"; +export { KNOWN_AGENT_ADAPTER_IDS } from "./agent.js"; -export type { RoutedSenseOutput } from "./sense-workflow-directive.js"; -export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense-workflow-directive.js"; +export type { RoutedSenseOutput } from "./sense.js"; +export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js"; -export { isSenseInfo, isWorkflowStatus } from "./daemon-payload-guards.js"; +export { isSenseInfo, isWorkflowStatus } from "./daemon.js"; export type { WorkflowStatus, HealthInfo, @@ -69,10 +69,10 @@ export type { DaemonIpcListWorkflowsResponse, DaemonIpcHealthResponse, DaemonIpcResponse, -} from "./daemon-ipc-protocol.js"; -export { parseDaemonIpcRequest } from "./daemon-ipc-protocol.js"; +} from "./daemon.js"; +export { parseDaemonIpcRequest } from "./daemon.js"; export type { DaemonTransport, DaemonTransportTriggerResult, DaemonTransportWorkflowLaunch, -} from "./daemon-transport.js"; +} from "./daemon.js"; diff --git a/packages/core/src/is-plain-record.ts b/packages/core/src/is-plain-record.ts deleted file mode 100644 index e0d6176..0000000 --- a/packages/core/src/is-plain-record.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Narrows `unknown` to a plain JSON-style object (not null, not array). - * Use after `JSON.parse` / YAML / IPC when validating structure field-by-field. - */ -export function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/core/src/knowledge-config.ts b/packages/core/src/knowledge-config.ts deleted file mode 100644 index f7c4289..0000000 --- a/packages/core/src/knowledge-config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { parse } from "yaml"; - -import { isPlainRecord } from "./is-plain-record.js"; -import type { Result } from "./result.js"; -import { err, ok } from "./result.js"; - -export type KnowledgeConfig = { - include: ReadonlyArray; - exclude: ReadonlyArray; -}; - -function parseStringList(field: unknown, label: string): Result> { - if (field === undefined || field === null) { - return ok([]); - } - if (!Array.isArray(field)) { - return err(new Error(`${label}: must be an array of strings`)); - } - const out: string[] = []; - for (let i = 0; i < field.length; i++) { - const item = field[i]; - if (typeof item !== "string" || item.length === 0) { - return err(new Error(`${label}[${String(i)}]: must be a non-empty string`)); - } - out.push(item); - } - return ok(out); -} - -/** - * Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer). - * `include` / `exclude` entries are glob patterns resolved against the repo root. - */ -export function parseKnowledgeYaml(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 === undefined || parsed === null) { - return ok({ include: [], exclude: [] }); - } - - if (!isPlainRecord(parsed)) { - return err(new Error("knowledge.yaml: root must be a mapping")); - } - - const includeResult = parseStringList(parsed.include, "include"); - if (!includeResult.ok) { - return includeResult; - } - const excludeResult = parseStringList(parsed.exclude, "exclude"); - if (!excludeResult.ok) { - return excludeResult; - } - - return ok({ - include: includeResult.value, - exclude: excludeResult.value, - }); -} diff --git a/packages/core/src/parse-nerve-config.ts b/packages/core/src/parse-nerve-config.ts deleted file mode 100644 index 8a614c6..0000000 --- a/packages/core/src/parse-nerve-config.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { parse } from "yaml"; - -import { - DEFAULT_SENSE_SIGNAL_RETENTION, - type ExtractConfig, - type NerveApiConfig, - type NerveConfig, - type SenseConfig, - type WorkflowConfig, -} from "./config.js"; -import { parseDurationStringToMs } from "./duration.js"; -import { isPlainRecord } from "./is-plain-record.js"; -import type { Result } from "./result.js"; -import { err, ok } from "./result.js"; -import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; - -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") { - return err( - new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`), - ); - } - const msResult = parseDurationStringToMs(field); - if (!msResult.ok) { - return err(new Error(`${label}: ${msResult.error.message}`)); - } - return ok(msResult.value); -} - -function validateSenseConfig(name: string, raw: unknown): Result { - if (!isPlainRecord(raw)) { - return err(new Error(`senses.${name}: must be an object`)); - } - - const obj = raw; - - 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 = parseDurationField(obj.throttle, `senses.${name}.throttle`); - if (!throttleResult.ok) return throttleResult; - - 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; - - const retentionResult = parseRetentionField(name, obj.retention); - if (!retentionResult.ok) return retentionResult; - - const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`); - if (!intervalResult.ok) return intervalResult; - - let on: string[] = []; - if (obj.on !== undefined && obj.on !== null) { - if ( - !Array.isArray(obj.on) || - !obj.on.every((item: unknown): item is string => typeof item === "string") - ) { - return err(new Error(`senses.${name}.on: must be an array of strings`)); - } - on = obj.on; - } - - return ok({ - group: obj.group, - throttle: throttleResult.value, - timeout: timeoutResult.value, - gracePeriod: graceResult.value, - retention: retentionResult.value, - interval: intervalResult.value, - on, - }); -} - -function parseEngineMaxRounds(obj: Record): Result { - if (obj.max_rounds === undefined || obj.max_rounds === null) { - return ok(DEFAULT_ENGINE_MAX_ROUNDS); - } - if ( - typeof obj.max_rounds !== "number" || - !Number.isInteger(obj.max_rounds) || - obj.max_rounds < 1 - ) { - return err(new Error("max_rounds: must be a positive integer")); - } - return ok(obj.max_rounds); -} - -function validateWorkflowConfig(name: string, raw: unknown): Result { - if (!isPlainRecord(raw)) { - return err(new Error(`workflows.${name}: must be an object`)); - } - - const obj = raw; - - 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.overflow === "drop") { - if (obj.max_queue !== undefined && obj.max_queue !== null) { - return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`)); - } - return ok({ - concurrency: obj.concurrency, - overflow: "drop" as const, - }); - } - - // overflow: "queue" - let maxQueue = 100; // default - if (obj.max_queue !== undefined && obj.max_queue !== null) { - if ( - typeof obj.max_queue !== "number" || - !Number.isInteger(obj.max_queue) || - obj.max_queue < 1 - ) { - return err(new Error(`workflows.${name}.max_queue: must be a positive integer`)); - } - maxQueue = obj.max_queue; - } - - return ok({ - concurrency: obj.concurrency, - overflow: "queue" as const, - maxQueue, - }); -} - -function parseSenses( - obj: Record, -): Result<{ senses: Record }> { - if (!isPlainRecord(obj.senses)) { - return err(new Error("senses: required object")); - } - - const sensesRaw = obj.senses; - const senses: Record = {}; - - 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 }); -} - -const DEFAULT_API_BIND_HOST = "127.0.0.1"; - -/** Hosts that may bind the HTTP API without `api.token` (loopback-only). */ -function isLoopbackOnlyApiHost(host: string): boolean { - const h = host.trim(); - return h === "127.0.0.1" || h.toLowerCase() === "localhost"; -} - -function parseApiTokenField(api: Record): Result { - if (api.token === undefined || api.token === null) { - return ok(null); - } - if (typeof api.token !== "string") { - return err(new Error("api.token: must be a string when provided")); - } - if (api.token.length === 0) { - return err(new Error("api.token: must not be empty when provided")); - } - return ok(api.token); -} - -function parseApiHostField(api: Record): Result { - if (api.host === undefined || api.host === null) { - return ok(DEFAULT_API_BIND_HOST); - } - if (typeof api.host !== "string") { - return err(new Error("api.host: must be a string when provided")); - } - if (api.host.length === 0) { - return err(new Error("api.host: must not be empty when provided")); - } - return ok(api.host); -} - -function parseApiConfig(obj: Record): Result { - if (obj.api === undefined || obj.api === null) { - return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST }); - } - if (!isPlainRecord(obj.api)) { - return err(new Error("api: must be an object if provided")); - } - const api = obj.api; - if (api.port === undefined || api.port === null) { - return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST }); - } - if ( - typeof api.port !== "number" || - !Number.isInteger(api.port) || - api.port < 1 || - api.port > 65_535 - ) { - return err(new Error("api.port: must be an integer between 1 and 65535 if provided")); - } - - const tokenResult = parseApiTokenField(api); - if (!tokenResult.ok) return tokenResult; - const hostResult = parseApiHostField(api); - if (!hostResult.ok) return hostResult; - - if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) { - return err( - new Error("api.host binds to non-loopback address, api.token is required for security"), - ); - } - - return ok({ port: api.port, token: tokenResult.value, host: hostResult.value }); -} - -function parseWorkflows(obj: Record): Result> { - if (obj.workflows === undefined || obj.workflows === null) return ok({}); - - if (!isPlainRecord(obj.workflows)) { - return err(new Error("workflows: must be an object if provided")); - } - - const workflowsRaw = obj.workflows; - 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); -} - -function parseExtract(obj: Record): Result { - if (obj.extract === undefined || obj.extract === null) { - return ok(null); - } - - if (!isPlainRecord(obj.extract)) { - return err(new Error("extract: must be an object if provided")); - } - - const ext = obj.extract; - - if (typeof ext.provider !== "string" || ext.provider.trim() === "") { - return err(new Error("extract.provider: required non-empty string")); - } - - if (typeof ext.model !== "string" || ext.model.trim() === "") { - return err(new Error("extract.model: required non-empty string")); - } - - return ok({ provider: ext.provider, model: ext.model }); -} - -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 (!isPlainRecord(parsed)) { - return err(new Error("Config must be a YAML object")); - } - - const obj = parsed; - - const sensesResult = parseSenses(obj); - if (!sensesResult.ok) return sensesResult; - const { senses } = sensesResult.value; - - // Legacy top-level `reflexes` is rejected; each sense carries `interval` / `on` for the sense scheduler. - if (Object.hasOwn(obj, "reflexes")) { - return err( - new Error( - "reflexes: top-level key is no longer supported; set `interval` and `on` on each sense under `senses.`", - ), - ); - } - - const workflowsResult = parseWorkflows(obj); - if (!workflowsResult.ok) return workflowsResult; - - const maxRoundsResult = parseEngineMaxRounds(obj); - if (!maxRoundsResult.ok) return maxRoundsResult; - - const apiResult = parseApiConfig(obj); - if (!apiResult.ok) return apiResult; - - if (Object.hasOwn(obj, "agents")) { - return err( - new Error( - "agents: key is no longer supported — declare adapters on workflow roles (RFC-003)", - ), - ); - } - - const extractResult = parseExtract(obj); - if (!extractResult.ok) return extractResult; - - return ok({ - maxRounds: maxRoundsResult.value, - senses, - workflows: workflowsResult.value, - api: apiResult.value, - extract: extractResult.value, - }); -} diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts deleted file mode 100644 index 335788c..0000000 --- a/packages/core/src/result.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/sense-contract.ts b/packages/core/src/sense-contract.ts deleted file mode 100644 index 6b55506..0000000 --- a/packages/core/src/sense-contract.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -import type { ComputeResult } from "./config.js"; - -/** - * The function signature every sense `src/index.ts` must export as a named - * `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)`. - */ -export type SenseComputeFn = () => Promise>; - -/** - * 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; -}; diff --git a/packages/core/src/sense-trigger-labels.ts b/packages/core/src/sense-trigger-labels.ts deleted file mode 100644 index eb8261c..0000000 --- a/packages/core/src/sense-trigger-labels.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SenseConfig } from "./config.js"; - -function formatIntervalMs(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; - const minutes = Math.floor(totalSeconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} - -/** Human-readable label for a sense schedule (`interval` and/or `on`). */ -export function labelSenseTrigger(slice: Pick): string { - const parts: string[] = []; - if (slice.interval !== null) { - parts.push(`every ${formatIntervalMs(slice.interval)}`); - } - if (slice.on.length > 0) { - parts.push(`on: ${slice.on.join(", ")}`); - } - if (parts.length === 0) { - return "trigger (no interval or on)"; - } - return parts.join(" · "); -} - -/** - * Human-readable trigger labels for a sense from its `SenseConfig.interval` / `.on`. - * Returns an empty array when the sense is missing or has no schedule. - */ -export function senseTriggerLabels( - senseName: string, - senses: Record, -): string[] { - const sc = senses[senseName]; - if (sc === undefined) return []; - if (sc.interval === null && sc.on.length === 0) return []; - return [labelSenseTrigger({ interval: sc.interval, on: sc.on })]; -} diff --git a/packages/core/src/sense-workflow-directive.ts b/packages/core/src/sense-workflow-directive.ts deleted file mode 100644 index bb0ecdf..0000000 --- a/packages/core/src/sense-workflow-directive.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { WorkflowTrigger } from "./config.js"; -import { isPlainRecord } from "./is-plain-record.js"; -import type { Result } from "./result.js"; -import { err, ok } from "./result.js"; - -/** Normalized non-null compute output for the kernel (unknown signal payload). */ -export type RoutedSenseOutput = { - signal: unknown; - workflow: WorkflowTrigger | null; -}; - -/** - * Validates a structured workflow trigger object from Sense compute or IPC. - */ -export function parseWorkflowTrigger(value: unknown): Result { - if (!isPlainRecord(value)) { - return err(new Error("workflow trigger must be a plain object")); - } - const nameRaw = value.name; - if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { - return err(new Error('workflow trigger: "name" must be a non-empty string')); - } - const maxRounds = value.maxRounds; - if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) { - return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1')); - } - const prompt = value.prompt; - if (typeof prompt !== "string") { - return err(new Error('workflow trigger: "prompt" must be a string')); - } - const dryRun = value.dryRun; - if (typeof dryRun !== "boolean") { - return err(new Error('workflow trigger: "dryRun" must be a boolean')); - } - 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 }); -} diff --git a/packages/core/src/sense.ts b/packages/core/src/sense.ts index 2d2709f..1981aa7 100644 --- a/packages/core/src/sense.ts +++ b/packages/core/src/sense.ts @@ -1,3 +1,8 @@ +import type { SQLiteTable } from "drizzle-orm/sqlite-core"; + +import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js"; +import { type Result, err, isPlainRecord, ok } from "./util.js"; + export type Signal = { id: number; senseId: string; @@ -15,3 +20,114 @@ export type SenseInfo = { triggers: string[]; lastSignalTimestamp: number | null; }; + +/** + * The function signature every sense `src/index.ts` must export as a named + * `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)`. + */ +export type SenseComputeFn = () => Promise>; + +/** + * 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; +}; + +function formatIntervalMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +/** Human-readable label for a sense schedule (`interval` and/or `on`). */ +export function labelSenseTrigger(slice: Pick): string { + const parts: string[] = []; + if (slice.interval !== null) { + parts.push(`every ${formatIntervalMs(slice.interval)}`); + } + if (slice.on.length > 0) { + parts.push(`on: ${slice.on.join(", ")}`); + } + if (parts.length === 0) { + return "trigger (no interval or on)"; + } + return parts.join(" · "); +} + +/** + * Human-readable trigger labels for a sense from its `SenseConfig.interval` / `.on`. + * Returns an empty array when the sense is missing or has no schedule. + */ +export function senseTriggerLabels( + senseName: string, + senses: Record, +): string[] { + const sc = senses[senseName]; + if (sc === undefined) return []; + if (sc.interval === null && sc.on.length === 0) return []; + return [labelSenseTrigger({ interval: sc.interval, on: sc.on })]; +} + +/** + * Validates a structured workflow trigger object from Sense compute or IPC. + */ +export function parseWorkflowTrigger(value: unknown): Result { + if (!isPlainRecord(value)) { + return err(new Error("workflow trigger must be a plain object")); + } + const nameRaw = value.name; + if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { + return err(new Error('workflow trigger: "name" must be a non-empty string')); + } + const maxRounds = value.maxRounds; + if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) { + return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1')); + } + const prompt = value.prompt; + if (typeof prompt !== "string") { + return err(new Error('workflow trigger: "prompt" must be a string')); + } + const dryRun = value.dryRun; + if (typeof dryRun !== "boolean") { + return err(new Error('workflow trigger: "dryRun" must be a boolean')); + } + 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 }); +} diff --git a/packages/core/src/spawn-safe.ts b/packages/core/src/util.ts similarity index 82% rename from packages/core/src/spawn-safe.ts rename to packages/core/src/util.ts index d2c17ce..cf455ee 100644 --- a/packages/core/src/spawn-safe.ts +++ b/packages/core/src/util.ts @@ -1,7 +1,8 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; -import { type Result, err, ok } from "./result.js"; + +export type Result = { ok: true; value: T } | { ok: false; error: E }; /** Compatible with `process.env` for `child_process.spawn`. */ export type SpawnEnv = Record; @@ -40,6 +41,42 @@ type SpawnSafeOptionsInput = SpawnSafeOptions | Omit const DEFAULT_TIMEOUT_MS = 300_000; +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} + +/** + * Narrows `unknown` to a plain JSON-style object (not null, not array). + * Use after `JSON.parse` / YAML / IPC when validating structure field-by-field. + */ +export function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const DURATION_RE = /^(\d+)([smh])$/; + +const DURATION_MULTIPLIERS: Record = { + s: 1_000, + m: 60_000, + h: 3_600_000, +}; + +/** + * Parse a duration string such as `5s`, `10m`, `1h` to milliseconds. + * Used by `parseNerveConfig` sense/workflow duration fields. + */ +export function parseDurationStringToMs(value: string): Result { + const match = DURATION_RE.exec(value); + if (!match) { + return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`)); + } + return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]); +} + /** * PATH and PNPM_HOME for running `pnpm` and `nerve` from workflow roles. * Uses the pnpm store home only (no npm user bin); binaries must resolve via PATH.