This repository has been archived on 2026-06-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nerve/packages/daemon/src/sense-runtime.ts
T
xiaoju b9b804eac5 feat(core): sense trigger supports arbitrary shell commands
Extend SenseComputeReturn to support shell triggers in addition to workflow
triggers via a discriminated union (kind: 'shell' | 'workflow').

Shell triggers execute a command string in the sense worker subprocess
(spawned detached). The kernel logs 'shell-launch' events without involving
the workflow manager.

Breaking change: WorkflowTrigger now requires kind: 'workflow'.
New ShellTrigger type: { kind: 'shell', command: string }.
SenseTrigger = WorkflowTrigger | ShellTrigger.

Closes #315
2026-05-02 10:00:23 +00:00

113 lines
3.6 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { Result, SenseComputeFn, SenseTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** All state held for one sense inside a worker */
export type SenseRuntime = {
name: string;
compute: SenseComputeFn;
state: unknown;
statePath: string;
};
export function readState(statePath: string, initialState: unknown): unknown {
try {
if (!existsSync(statePath)) return initialState;
const raw = readFileSync(statePath, "utf8");
return JSON.parse(raw);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`,
);
return initialState;
}
}
export function writeState(statePath: string, state: unknown): void {
const dir = dirname(statePath);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `.${Date.now()}.tmp`);
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, statePath);
}
/**
* Dynamically import `compute` and `initialState` from a sense's index.ts/js.
* The module must export named `compute` and `initialState`.
*/
export async function loadSenseModule(
senseIndexPath: string,
): Promise<Result<{ compute: SenseComputeFn; initialState: unknown }>> {
let mod: unknown;
try {
// Dynamic import required: user-authored sense module, path resolved at runtime
mod = await import(senseIndexPath);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to import sense module "${senseIndexPath}": ${msg}`));
}
if (!isPlainRecord(mod) || !("compute" in mod) || typeof mod.compute !== "function") {
return err(
new Error(`Sense module "${senseIndexPath}" must export a named "compute" function`),
);
}
if (!("initialState" in mod)) {
return err(new Error(`Sense module "${senseIndexPath}" must export a named "initialState"`));
}
return ok({
compute: mod.compute as SenseComputeFn,
initialState: mod.initialState,
});
}
/**
* Execute a sense's compute with current runtime state and an optional soft timeout.
* On success, persists `result.state` to `runtime.statePath` and updates `runtime.state`.
*/
export async function executeCompute(
runtime: SenseRuntime,
timeoutMs?: number,
): Promise<Result<{ state: unknown; workflow: SenseTrigger | null }>> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise =
timeoutMs !== undefined
? new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`compute("${runtime.name}") timed out after ${timeoutMs}ms`));
}, timeoutMs);
})
: null;
try {
const computePromise = runtime.compute(runtime.state);
const result = timeoutPromise
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
writeState(runtime.statePath, result.state);
runtime.state = result.state;
return ok(result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (controller.signal.aborted) {
return err(
new Error(`compute("${runtime.name}") timed out after ${String(timeoutMs ?? "?")}ms`),
);
}
return err(new Error(`compute("${runtime.name}") threw: ${msg}`));
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}