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
This commit is contained in:
2026-05-02 09:58:24 +00:00
parent 6b8c917358
commit b9b804eac5
11 changed files with 233 additions and 66 deletions
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { parseWorkflowTrigger } from "../sense.js";
import { parseSenseTrigger } from "../sense.js";
describe("parseWorkflowTrigger", () => {
it("accepts a valid trigger object", () => {
const r = parseWorkflowTrigger({
describe("parseSenseTrigger", () => {
it("accepts a valid workflow trigger", () => {
const r = parseSenseTrigger({
kind: "workflow",
name: "my-wf",
maxRounds: 3,
prompt: "go",
@@ -12,11 +13,18 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ name: "my-wf", maxRounds: 3, prompt: "go", dryRun: true });
expect(r.value).toEqual({
kind: "workflow",
name: "my-wf",
maxRounds: 3,
prompt: "go",
dryRun: true,
});
});
it("trims workflow name", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: " spaced ",
maxRounds: 1,
prompt: "",
@@ -24,16 +32,45 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.kind).toBe("workflow");
if (r.value.kind !== "workflow") return;
expect(r.value.name).toBe("spaced");
});
it("rejects empty name", () => {
const r = parseWorkflowTrigger({ name: "", maxRounds: 1, prompt: "x", dryRun: false });
it("accepts a valid shell trigger", () => {
const r = parseSenseTrigger({
kind: "shell",
command: " echo hi ",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ kind: "shell", command: "echo hi" });
});
it("rejects workflow without kind", () => {
const r = parseSenseTrigger({
name: "my-wf",
maxRounds: 1,
prompt: "x",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects empty workflow name", () => {
const r = parseSenseTrigger({
kind: "workflow",
name: "",
maxRounds: 1,
prompt: "x",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects non-integer maxRounds", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 1.5,
prompt: "",
@@ -43,12 +80,19 @@ describe("parseWorkflowTrigger", () => {
});
it("rejects maxRounds < 1", () => {
const r = parseWorkflowTrigger({ name: "w", maxRounds: 0, prompt: "", dryRun: false });
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 0,
prompt: "",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects non-boolean dryRun", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 1,
prompt: "",
@@ -56,4 +100,14 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(false);
});
it("rejects empty shell command", () => {
const r = parseSenseTrigger({ kind: "shell", command: "" });
expect(r.ok).toBe(false);
});
it("rejects unknown kind", () => {
const r = parseSenseTrigger({ kind: "other", x: 1 });
expect(r.ok).toBe(false);
});
});
+10
View File
@@ -54,12 +54,22 @@ export type ExtractConfig = {
/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */
export type WorkflowTrigger = {
kind: "workflow";
name: string;
maxRounds: number;
prompt: string;
dryRun: boolean;
};
/** Run a shell command from a Sense compute result (daemon executes in the sense worker). */
export type ShellTrigger = {
kind: "shell";
command: string;
};
/** Optional side effect requested by `compute()` — workflow launch or shell command. */
export type SenseTrigger = WorkflowTrigger | ShellTrigger;
export type NerveConfig = {
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
maxRounds: number;
+3 -1
View File
@@ -8,6 +8,8 @@ export type {
ExtractConfig,
NerveConfig,
WorkflowTrigger,
ShellTrigger,
SenseTrigger,
} from "./config.js";
export type { SenseInfo } from "./sense.js";
export type { SenseComputeFn, SenseModule } from "./sense.js";
@@ -44,7 +46,7 @@ export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.js";
export { parseWorkflowTrigger } from "./sense.js";
export { parseSenseTrigger } from "./sense.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
export type {
+36 -11
View File
@@ -1,4 +1,4 @@
import type { SenseConfig, WorkflowTrigger } from "./config.js";
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). */
@@ -16,11 +16,11 @@ export type SenseInfo = {
* `compute` export.
*
* Pure: no DB, no peers.
* Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
* 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: WorkflowTrigger | null }>;
) => Promise<{ state: S; workflow: SenseTrigger | null }>;
/**
* The full shape a sense module (`src/index.ts`) must export.
@@ -69,13 +69,7 @@ export function senseTriggerLabels(
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<WorkflowTrigger> {
if (!isPlainRecord(value)) {
return err(new Error("workflow trigger must be a plain object"));
}
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'));
@@ -92,5 +86,36 @@ export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
if (typeof dryRun !== "boolean") {
return err(new Error('workflow trigger: "dryRun" must be a boolean'));
}
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
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"'));
}