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
122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
import type { SenseConfig, SenseTrigger, ShellTrigger, WorkflowTrigger } from "./config.js";
|
|
import { type Result, err, isPlainRecord, ok } from "./util.js";
|
|
|
|
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
|
|
export type SenseInfo = {
|
|
name: string;
|
|
group: string;
|
|
throttle: number | null;
|
|
timeout: number | null;
|
|
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
|
|
triggers: ReadonlyArray<string>;
|
|
};
|
|
|
|
/**
|
|
* The function signature every sense `src/index.ts` must export as a named
|
|
* `compute` export.
|
|
*
|
|
* Pure: no DB, no peers.
|
|
* Returns the next sense state and an optional trigger (`workflow: null` means no side effect).
|
|
*/
|
|
export type SenseComputeFn<S = unknown> = (
|
|
state: S,
|
|
) => Promise<{ state: S; workflow: SenseTrigger | null }>;
|
|
|
|
/**
|
|
* The full shape a sense module (`src/index.ts`) must export.
|
|
*/
|
|
export type SenseModule<S = unknown> = {
|
|
compute: SenseComputeFn<S>;
|
|
initialState: S;
|
|
};
|
|
|
|
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<SenseConfig, "interval" | "on">): 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, SenseConfig>,
|
|
): 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 })];
|
|
}
|
|
|
|
function parseWorkflowTriggerBranch(value: Record<string, unknown>): Result<WorkflowTrigger> {
|
|
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({
|
|
kind: "workflow",
|
|
name: nameRaw.trim(),
|
|
maxRounds,
|
|
prompt,
|
|
dryRun,
|
|
});
|
|
}
|
|
|
|
function parseShellTriggerBranch(value: Record<string, unknown>): Result<ShellTrigger> {
|
|
const command = value.command;
|
|
if (typeof command !== "string" || command.trim().length === 0) {
|
|
return err(new Error('shell trigger: "command" must be a non-empty string'));
|
|
}
|
|
return ok({ kind: "shell", command: command.trim() });
|
|
}
|
|
|
|
/**
|
|
* Validates a structured sense trigger from Sense compute or IPC (`workflow` field).
|
|
*/
|
|
export function parseSenseTrigger(value: unknown): Result<SenseTrigger> {
|
|
if (!isPlainRecord(value)) {
|
|
return err(new Error("sense trigger must be a plain object"));
|
|
}
|
|
const kind = value.kind;
|
|
if (kind === "workflow") {
|
|
return parseWorkflowTriggerBranch(value);
|
|
}
|
|
if (kind === "shell") {
|
|
return parseShellTriggerBranch(value);
|
|
}
|
|
return err(new Error('sense trigger: "kind" must be "workflow" or "shell"'));
|
|
}
|