b9b804eac5
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
113 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
}
|