Compare commits

...

10 Commits

Author SHA1 Message Date
xiaoju 020a1bfe85 refactor(core): remove unnecessary | null, unify timestamp naming
- SenseReflexConfig.on: string[] | null → string[] (empty = no conditions)
- NerveConfig.workflows: Record | null → Record (empty = no workflows)
- Signal.ts → Signal.timestamp
- SenseInfo.lastSignalTs → SenseInfo.lastSignalTimestamp
- All consumers across daemon/cli/store updated
- parseNerveConfig: on defaults to [], workflows defaults to {}

Fixes #108
2026-04-25 01:52:58 +00:00
xiaomo 7ce3970027 Merge pull request 'feat(cli): workspace biome.json with noConsole, remove dryRun console.log' (#107) from feat/106-workspace-biome into main 2026-04-25 01:15:53 +00:00
xiaoju fcde29ed1c feat(cli): add biome.json to workspace init, remove dryRun console.log
- init.ts: scaffold biome.json with noConsole: error for workflows
- package.json template: add @biomejs/biome to devDependencies
- workflow-utils: remove console.log from dryRun paths (stub returns
  are captured by log-store via role results)

Fixes #106
2026-04-25 01:00:41 +00:00
xiaomo 611bc48751 Merge pull request 'feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract' (#105) from feat/104-dryrun-utils into main 2026-04-25 00:26:27 +00:00
xiaoju 70bea92133 feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract
When dryRun=true, each function logs its parameters and returns a stub
result without executing any subprocess or network call. Log output is
captured by log-store for analysis.

- spawnSafe: returns { exitCode: 0, stdout: '[dryRun] skipped' }
- cursorAgent: short-circuits before spawnSafe, returns ok('[dryRun] skipped')
- llmExtract: skips fetch, returns ok({} as T)
- Tests added for spawnSafe and llmExtract dryRun paths

Fixes #104
2026-04-25 00:23:43 +00:00
xiaomo 6f2cddd695 Merge pull request 'feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal' (#103) from feat/101-dry-run into main 2026-04-24 23:50:55 +00:00
xiaoju c4dc707eb0 feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal
- StartSignal.meta gains dryRun: boolean (alongside maxRounds)
- DaemonIpcTriggerWorkflowRequest includes dryRun, parsed with default false
- CLI parses dryRun from --payload JSON, passes through daemon client
- workflow-worker/workflow-manager propagate dryRun through full IPC chain
- Sense-triggered workflows default to dryRun: false
- workflow-utils exports isDryRun(start) helper
- All tests updated, 376 pass

Fixes #101
2026-04-24 23:45:29 +00:00
xiaomo a7ce8401ce Merge pull request 'refactor(core,daemon): extract StartSignal as independent Role parameter' (#102) from refactor/100-extract-start-signal into main 2026-04-24 23:35:09 +00:00
xiaoju e9e6df2f5a refactor(core,daemon): extract StartSignal as independent Role parameter
- Role<Meta> now takes (start: StartSignal, messages: WorkflowMessage[])
- messages no longer contains the __start__ frame
- Add ModeratorContext<M> discriminated union (kind: start | step)
- Moderator receives typed context instead of raw StartSignal | RoleSignal union
- workflow-worker separates start from role messages throughout

Refs #100
2026-04-24 23:14:45 +00:00
xingyue b3b0dad2bb Merge pull request 'feat: add workflow-utils package' (#98) from feat/97-workflow-utils into main 2026-04-24 22:43:07 +00:00
40 changed files with 534 additions and 210 deletions
+37 -11
View File
@@ -29,10 +29,22 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTs: 1_700_000_000_000,
lastSignalTimestamp: 1_700_000_000_000,
},
{
name: "disk-usage",
group: "system",
throttle: 30000,
timeout: null,
lastSignalTimestamp: null,
},
{
name: "active-tasks",
group: "tasks",
throttle: 10000,
timeout: 30000,
lastSignalTimestamp: null,
},
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
{ name: "active-tasks", group: "tasks", throttle: 10000, timeout: 30000, lastSignalTs: null },
];
// ---------------------------------------------------------------------------
@@ -100,14 +112,14 @@ describe("formatSenseList", () => {
expect(output).toContain("—");
});
it("shows '(never)' when lastSignalTs is null", () => {
it("shows '(never)' when lastSignalTimestamp is null", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
});
it("shows ISO timestamp when lastSignalTs is set", () => {
it("shows ISO timestamp when lastSignalTimestamp is set", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage has lastSignalTs = 1_700_000_000_000
// cpu-usage has lastSignalTimestamp = 1_700_000_000_000
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
});
});
@@ -157,11 +169,19 @@ reflexes: []
);
const result = sensesFromConfig(path);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", lastSignalTs: null });
expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", lastSignalTs: null });
expect(result[0]).toMatchObject({
name: "cpu-usage",
group: "system",
lastSignalTimestamp: null,
});
expect(result[1]).toMatchObject({
name: "disk-usage",
group: "system",
lastSignalTimestamp: null,
});
});
it("always sets lastSignalTs to null (static fallback)", () => {
it("always sets lastSignalTimestamp to null (static fallback)", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
@@ -173,7 +193,7 @@ reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].lastSignalTs).toBeNull();
expect(result[0].lastSignalTimestamp).toBeNull();
});
it("populates throttle and timeout from config", () => {
@@ -238,7 +258,13 @@ describe("listSensesViaDaemon", () => {
it("resolves with populated senses array", async () => {
const senses: SenseInfo[] = [
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 12345 },
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 12345,
},
];
const server = createServer((s) => {
s.on("data", () => {
+27
View File
@@ -21,6 +21,31 @@ reflexes:
interval: 10s
`;
const BIOME_JSON = `{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsole": "error"
}
}
}
}
`;
const PACKAGE_JSON = `{
"name": "my-nerve-workspace",
"version": "0.0.1",
@@ -32,6 +57,7 @@ const PACKAGE_JSON = `{
"drizzle-orm": "latest"
},
"devDependencies": {
"@biomejs/biome": "latest",
"drizzle-kit": "latest"
},
"pnpm": {
@@ -320,6 +346,7 @@ async function runInitWorkspace(force: boolean): Promise<void> {
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.js"), CPU_INDEX_JS);
+3 -2
View File
@@ -43,7 +43,8 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
}
return lines.join("");
@@ -64,7 +65,7 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
lastSignalTs: null,
lastSignalTimestamp: null,
}));
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const validateCommand = defineCommand({
const config = result.value;
const senseCount = Object.keys(config.senses).length;
const reflexCount = config.reflexes.length;
const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0;
const workflowCount = Object.keys(config.workflows).length;
process.stdout.write(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
+4 -2
View File
@@ -515,7 +515,7 @@ const workflowTriggerCommand = defineCommand({
payload: {
type: "string",
description:
'JSON with optional "prompt" (string) and "maxRounds" (number) for the workflow run (default: {})',
'JSON with optional "prompt" (string), "maxRounds" (number), and "dryRun" (boolean) for the workflow run (default: {})',
default: "{}",
},
},
@@ -530,10 +530,12 @@ const workflowTriggerCommand = defineCommand({
let prompt = "";
let maxRounds = DEFAULT_ENGINE_MAX_ROUNDS;
let dryRun = false;
if (isPlainRecord(triggerPayload)) {
const p = triggerPayload;
if (typeof p.prompt === "string") prompt = p.prompt;
if (typeof p.maxRounds === "number") maxRounds = p.maxRounds;
if (typeof p.dryRun === "boolean") dryRun = p.dryRun;
}
if (!isRunning()) {
@@ -544,7 +546,7 @@ const workflowTriggerCommand = defineCommand({
const socketPath = getSocketPath();
let response: DaemonIpcTriggerResponse;
try {
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds);
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds, dryRun);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
+3 -1
View File
@@ -28,7 +28,7 @@ function isSenseInfo(value: unknown): value is SenseInfo {
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
(value.lastSignalTs === null || typeof value.lastSignalTs === "number")
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
@@ -135,12 +135,14 @@ export function triggerWorkflowViaDaemon(
workflow: string,
prompt: string,
maxRounds: number,
dryRun = false,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = {
type: "trigger-workflow",
workflow,
prompt,
maxRounds,
dryRun,
};
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
+7 -6
View File
@@ -50,7 +50,7 @@ describe("parseNerveConfig", () => {
kind: "sense",
sense: "cpu",
interval: 30_000,
on: null,
on: [],
});
expect(result.value.reflexes[1]).toEqual({
kind: "sense",
@@ -58,7 +58,7 @@ describe("parseNerveConfig", () => {
interval: null,
on: ["high_usage"],
});
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 2,
overflow: "queue",
maxQueue: 10,
@@ -85,11 +85,12 @@ senses:
group: system
reflexes:
- sense: cpu
interval: 1s
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows).toBeNull();
expect(result.value.workflows).toEqual({});
});
it("sense config has null for omitted throttle/timeout/gracePeriod", () => {
@@ -142,11 +143,11 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
overflow: "drop",
});
expect("maxQueue" in (result.value.workflows?.alert ?? {})).toBe(false);
expect("maxQueue" in result.value.workflows.alert).toBe(false);
});
it("overflow: queue defaults maxQueue to 100", () => {
@@ -163,7 +164,7 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
overflow: "queue",
maxQueue: 100,
@@ -18,6 +18,27 @@ describe("parseDaemonIpcRequest", () => {
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: false,
});
});
it("parses trigger-workflow with dryRun true", () => {
expect(
parseDaemonIpcRequest(
JSON.stringify({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: true,
}),
),
).toEqual({
type: "trigger-workflow",
workflow: "wf",
prompt: "go",
maxRounds: 3,
dryRun: true,
});
});
+6 -8
View File
@@ -76,8 +76,8 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
});
}
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[] | null> {
if (obj.on === undefined || obj.on === null) return ok(null);
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[]> {
if (obj.on === undefined || obj.on === null) return ok([]);
if (!Array.isArray(obj.on) || !obj.on.every((item): item is string => typeof item === "string")) {
return err(new Error(`reflexes[${index}].on: must be an array of strings`));
}
@@ -88,7 +88,7 @@ function parseSenseReflex(
index: number,
obj: Record<string, unknown>,
senseNames: Set<string>,
on: string[] | null,
on: string[],
): Result<ReflexConfig> {
if (typeof obj.sense !== "string") {
return err(new Error(`reflexes[${index}].sense: must be a string`));
@@ -100,7 +100,7 @@ function parseSenseReflex(
const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`);
if (!intervalResult.ok) return intervalResult;
if (intervalResult.value === null && on !== null && on.length === 0) {
if (intervalResult.value === null && on.length === 0) {
return err(
new Error(`reflexes[${index}]: sense reflex must have at least one of "interval" or "on"`),
);
@@ -245,10 +245,8 @@ function parseReflexes(
return ok(reflexes);
}
function parseWorkflows(
obj: Record<string, unknown>,
): Result<Record<string, WorkflowConfig> | null> {
if (obj.workflows === undefined || obj.workflows === null) return ok(null);
function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, WorkflowConfig>> {
if (obj.workflows === undefined || obj.workflows === null) return ok({});
if (!isPlainRecord(obj.workflows)) {
return err(new Error("workflows: must be an object if provided"));
+18 -9
View File
@@ -13,6 +13,7 @@ export type DaemonIpcTriggerWorkflowRequest = {
workflow: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
/** Client → daemon: run a sense compute on demand. */
@@ -51,6 +52,22 @@ export type DaemonIpcResponse =
| DaemonIpcErrorResponse
| { ok: true; senses: SenseInfo[] };
function parseTriggerWorkflowFields(
req: Record<string, unknown>,
): DaemonIpcTriggerWorkflowRequest | null {
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
if (typeof req.prompt !== "string") return null;
if (typeof req.maxRounds !== "number") return null;
const dryRun = typeof req.dryRun === "boolean" ? req.dryRun : false;
return {
type: "trigger-workflow",
workflow: req.workflow,
prompt: req.prompt,
maxRounds: req.maxRounds,
dryRun,
};
}
/**
* Parse a single line of JSON into a {@link DaemonIpcRequest}, or null if invalid.
* Kept in core with the request types so CLI and daemon stay aligned at compile time.
@@ -61,15 +78,7 @@ export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
if (!isPlainRecord(obj)) return null;
const req = obj;
if (req.type === "trigger-workflow") {
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
if (typeof req.prompt !== "string") return null;
if (typeof req.maxRounds !== "number") return null;
return {
type: "trigger-workflow",
workflow: req.workflow,
prompt: req.prompt,
maxRounds: req.maxRounds,
};
return parseTriggerWorkflowFields(req);
}
if (req.type === "trigger-sense") {
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
+1
View File
@@ -14,6 +14,7 @@ export type {
RoleMeta,
StartSignal,
RoleSignal,
ModeratorContext,
Moderator,
WorkflowDefinition,
SenseResult,
+23 -11
View File
@@ -2,7 +2,7 @@ export type Signal = {
id: number;
senseId: string;
payload: unknown;
ts: number;
timestamp: number;
};
export type SenseConfig = {
@@ -18,14 +18,14 @@ export type SenseInfo = {
group: string;
throttle: number | null;
timeout: number | null;
lastSignalTs: number | null;
lastSignalTimestamp: number | null;
};
export type SenseReflexConfig = {
kind: "sense";
sense: string;
interval: number | null;
on: string[] | null;
on: string[];
};
/** Reflexes only schedule Senses; workflow launches come from Sense return values. */
@@ -49,7 +49,7 @@ export type NerveConfig = {
maxRounds: number;
senses: Record<string, SenseConfig>;
reflexes: ReflexConfig[];
workflows: Record<string, WorkflowConfig> | null;
workflows: Record<string, WorkflowConfig>;
};
// ---------------------------------------------------------------------------
@@ -76,20 +76,24 @@ export type WorkflowMessage = {
export type RoleResult<Meta> = { content: string; meta: Meta };
/**
* A Role is a pure async function: receives the full message chain,
* returns typed content + meta. Implementation can be an agent, LLM call,
* A Role is a pure async function: receives the engine start frame plus prior
* role messages only (the start frame is not included in `messages`).
* Returns typed content + meta. Implementation can be an agent, LLM call,
* script, HTTP request, etc.
*/
export type Role<Meta> = (messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>;
export type Role<Meta> = (
start: StartSignal,
messages: WorkflowMessage[],
) => Promise<RoleResult<Meta>>;
/** Maps role names to their meta types — the single generic that drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>;
/** First message in the thread chain (`messages[0]`) — passed to the moderator on start. */
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
export type StartSignal = {
role: START;
content: string;
meta: { maxRounds: number };
meta: { maxRounds: number; dryRun: boolean };
timestamp: number;
};
@@ -99,11 +103,19 @@ export type RoleSignal<M extends RoleMeta> = {
}[keyof M & string];
/**
* The moderator — a pure routing function. Receives the last signal,
* Moderator input: either the initial start frame or a role signal after a step.
* Lets implementations branch on `context.kind` with full typing for each arm.
*/
export type ModeratorContext<M extends RoleMeta> =
| { kind: "start"; start: StartSignal }
| { kind: "step"; signal: RoleSignal<M> };
/**
* The moderator — a pure routing function. Receives start vs step context,
* current round, and maxRounds. Returns the next role name or END.
*/
export type Moderator<M extends RoleMeta> = (
signal: StartSignal | RoleSignal<M>,
context: ModeratorContext<M>,
round: number,
maxRounds: number,
) => (keyof M & string) | END;
@@ -127,8 +127,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
// Simulate unexpected exit (not shutdown)
@@ -154,8 +154,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const child = mockChildren[0];
@@ -179,7 +179,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const child = mockChildren[0];
@@ -212,7 +212,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -256,7 +256,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -281,7 +281,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const child = mockChildren[0];
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
@@ -317,7 +317,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
const launch = { prompt: "build-docker for myrepo", maxRounds: 10 };
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
mgr.startWorkflow("my-wf", launch);
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
@@ -327,7 +327,11 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const logEntry = startedCall?.[0] as { payload: string | null };
expect(logEntry.payload).not.toBeNull();
const parsed = JSON.parse(logEntry.payload as string) as Record<string, unknown>;
expect(parsed).toMatchObject({ prompt: "build-docker for myrepo", maxRounds: 10 });
expect(parsed).toMatchObject({
prompt: "build-docker for myrepo",
maxRounds: 10,
dryRun: false,
});
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
@@ -349,7 +353,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Start one thread to fill the concurrency slot
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
// Crash once → respawn → crash again → second respawn
@@ -385,7 +389,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -415,7 +419,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10, dryRun: false });
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
for (let i = 0; i < 6; i++) {
@@ -154,6 +154,7 @@ describe("daemon-ipc — trigger-sense", () => {
workflow: "my-workflow",
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
expect(resp).toEqual({ ok: true });
@@ -161,6 +162,7 @@ describe("daemon-ipc — trigger-sense", () => {
expect(wfManager.startWorkflow).toHaveBeenCalledWith("my-workflow", {
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
});
@@ -198,8 +200,20 @@ describe("daemon-ipc — list-senses", () => {
it("responds ok:true with senses populated from listSenses", async () => {
const sensesData = [
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 1000 },
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 1000,
},
{
name: "disk-usage",
group: "system",
throttle: 30000,
timeout: null,
lastSignalTimestamp: null,
},
];
const listSenses = vi.fn(() => sensesData);
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
@@ -102,7 +102,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
// Remove workflow from config before drain completes
@@ -121,8 +121,8 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -153,7 +153,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -169,7 +169,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -186,7 +186,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
@@ -211,14 +211,14 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10, dryRun: false });
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
await drainPromise;
// Start a new thread on the fresh worker
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10, dryRun: false });
const newChild = mockChildren[1];
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
@@ -250,7 +250,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
const logStore = makeLogStore();
const config: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
@@ -261,7 +261,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
});
// Trigger a workflow thread so a worker is spawned
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
@@ -285,7 +285,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "old-wf", on: null } as any],
reflexes: [],
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
@@ -296,14 +296,18 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
});
// Spawn a worker for old-wf
kernel.workflowManager.startWorkflow("old-wf", { prompt: "test", maxRounds: 10 });
kernel.workflowManager.startWorkflow("old-wf", {
prompt: "test",
maxRounds: 10,
dryRun: false,
});
expect(mockChildren).toHaveLength(1);
// Reload config without old-wf
const newConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
@@ -324,7 +328,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
@@ -334,13 +338,13 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
logStore,
});
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const workersBefore = mockChildren.length;
// Reload with updated concurrency — should NOT spawn a new workflow worker
const newConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
maxRounds: 10,
};
@@ -354,8 +358,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
expect(kernel.workflowManager.activeCount("my-wf")).toBe(1);
// Can now start up to 5 concurrent threads (previously only 1)
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
const stopPromise = kernel.stop();
@@ -26,7 +26,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -73,7 +73,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -180,7 +180,7 @@ describe("kernel — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -197,7 +197,7 @@ describe("kernel — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-test");
@@ -212,7 +212,7 @@ describe("kernel — reloadConfig", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -235,7 +235,7 @@ describe("kernel — reloadConfig", () => {
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -92,7 +92,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -95,7 +95,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -202,6 +202,7 @@ describe("kernel + workflowManager integration", () => {
workflow: "alert-workflow",
prompt: "handle critical alert",
maxRounds: 5,
dryRun: false,
});
const stopPromise = kernel.stop();
@@ -297,7 +298,7 @@ describe("kernel + workflowManager integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -365,7 +366,7 @@ describe("kernel + workflowManager integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
+3 -3
View File
@@ -59,7 +59,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -200,7 +200,7 @@ describe("kernel — groupForSense mapping", () => {
"net-usage": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-test");
@@ -215,7 +215,7 @@ describe("kernel — groupForSense mapping", () => {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
});
createKernel(config, "/tmp/nerve-test");
@@ -29,7 +29,7 @@ describe("LogStore + ReflexScheduler integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: null,
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -38,7 +38,7 @@ describe("LogStore + ReflexScheduler integration", () => {
logStore,
});
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, ts: Date.now() };
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, timestamp: Date.now() };
bus.emit(signal);
const logs = logStore.query({ source: "reflex", type: "run_start" });
@@ -56,8 +56,8 @@ describe("LogStore + ReflexScheduler integration", () => {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
workflows: null,
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -88,7 +88,7 @@ describe("LogStore + ReflexScheduler integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: null,
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -23,7 +23,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -136,7 +136,7 @@ describe("phase6 — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
@@ -156,7 +156,7 @@ describe("phase6 — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
@@ -171,7 +171,7 @@ describe("phase6 — reloadConfig", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
@@ -202,7 +202,7 @@ describe("phase6 — error isolation", () => {
"bad-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
@@ -306,7 +306,7 @@ describe("phase6 — getHealth", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
@@ -10,14 +10,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
describe("ReflexScheduler — throttle + pending deferred trigger", () => {
@@ -16,14 +16,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
// ---------------------------------------------------------------------------
@@ -41,7 +41,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("fires triggerFn on schedule", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
});
const bus = createSignalBus();
// Use a ref so the triggerFn can call back into the scheduler
@@ -66,7 +66,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("stops firing after stop() is called", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
});
const bus = createSignalBus();
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = {
@@ -89,7 +89,7 @@ describe("ReflexScheduler — interval reflex", () => {
it("starts from current time — does not compensate for past intervals", () => {
const triggered: string[] = [];
const config = makeConfig({
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
});
const bus = createSignalBus();
const scheduler = createReflexScheduler(config, bus, (name) => triggered.push(name));
@@ -4,7 +4,7 @@ import type { Signal } from "@uncaged/nerve-core";
import { createSignalBus } from "../signal-bus.js";
function makeSignal(senseId: string, payload: unknown = 1): Signal {
return { id: 1, senseId, payload, ts: Date.now() };
return { id: 1, senseId, payload, timestamp: Date.now() };
}
describe("createSignalBus", () => {
@@ -115,7 +115,7 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(1);
expect(mockChildren[0].send).toHaveBeenCalledWith(
@@ -131,8 +131,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
// Only one forked child — worker is reused
expect(mockChildren).toHaveLength(1);
@@ -147,7 +147,7 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ source: "workflow", type: "started" }),
@@ -164,9 +164,9 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10, dryRun: false });
// now at limit — second call should be dropped
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("drop-wf")).toBe(1);
expect(mgr.queueLength("drop-wf")).toBe(0);
@@ -181,8 +181,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const droppedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "dropped",
@@ -199,8 +199,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
@@ -213,8 +213,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
const queuedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "queued",
@@ -233,12 +233,12 @@ describe("WorkflowManager", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// Fill the concurrency slot
mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10, dryRun: false });
// Fill the queue to maxQueue
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
// This one should push out the oldest
mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10, dryRun: false });
// Queue should still be at maxQueue (2)
expect(mgr.queueLength("queue-wf")).toBe(2);
@@ -259,8 +259,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
@@ -294,8 +294,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
const child = mockChildren[0];
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
@@ -321,8 +321,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
// Two distinct workers should have been forked
expect(mockChildren).toHaveLength(2);
@@ -348,7 +348,7 @@ describe("WorkflowManager", () => {
await vi.runAllTimersAsync();
await stopPromise;
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
// No worker should have been spawned
expect(mockChildren).toHaveLength(0);
@@ -361,7 +361,7 @@ describe("WorkflowManager", () => {
const config = makeConfig({});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
expect(mockChildren).toHaveLength(0);
expect(logStore.upsertWorkflowRun).not.toHaveBeenCalled();
+1
View File
@@ -55,6 +55,7 @@ export function createDaemonIpcServer(
workflowManager.startWorkflow(req.workflow, {
prompt: req.prompt,
maxRounds: req.maxRounds,
dryRun: req.dryRun,
});
const resp: DaemonIpcResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
+8
View File
@@ -34,6 +34,8 @@ export type StartThreadMessage = {
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
@@ -44,6 +46,8 @@ export type ResumeThreadMessage = {
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Union of all messages the parent sends to a worker */
@@ -135,6 +139,7 @@ function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
@@ -143,6 +148,7 @@ function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
@@ -180,6 +186,7 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
if (obj.type === "resume-thread") {
@@ -191,6 +198,7 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
+6 -6
View File
@@ -148,7 +148,7 @@ export function createKernel(
const route = routeSenseComputeOutput(msg.payload);
if (route.kind === "launch") {
const { workflowName, maxRounds, prompt } = route.launch;
workflowManager.startWorkflow(workflowName, { prompt, maxRounds });
workflowManager.startWorkflow(workflowName, { prompt, maxRounds, dryRun: false });
logStore.append({
source: "sense",
type: "workflow-launch",
@@ -161,14 +161,14 @@ export function createKernel(
id: nextSignalId(),
senseId: msg.sense,
payload: route.payload,
ts: Date.now(),
timestamp: Date.now(),
};
logStore.append({
source: "sense",
type: "signal",
refId: msg.sense,
payload: JSON.stringify(route.payload),
ts: signal.ts,
ts: signal.timestamp,
});
bus.emit(signal);
}
@@ -239,7 +239,7 @@ export function createKernel(
function reloadConfig(newConfig: NerveConfig): void {
const oldGroups = collectSenseGroups(config);
const oldConfig = config;
const oldWorkflows = config.workflows ?? {};
const oldWorkflows = config.workflows;
config = newConfig;
scheduler.stop();
scheduler = createReflexScheduler(config, bus, triggerFn, {
@@ -247,7 +247,7 @@ export function createKernel(
});
workflowManager.updateConfig(newConfig);
const newWorkflows = newConfig.workflows ?? {};
const newWorkflows = newConfig.workflows;
for (const workflowName of Object.keys(oldWorkflows)) {
if (!(workflowName in newWorkflows)) {
@@ -327,7 +327,7 @@ export function createKernel(
group: senseConfig.group,
throttle: senseConfig.throttle,
timeout: senseConfig.timeout,
lastSignalTs: lastEntry !== null ? lastEntry.ts : null,
lastSignalTimestamp: lastEntry !== null ? lastEntry.ts : null,
};
});
},
+1 -1
View File
@@ -164,7 +164,7 @@ export function createReflexScheduler(
intervals.push(id);
}
if (senseReflex.on !== null && senseReflex.on.length > 0) {
if (senseReflex.on.length > 0) {
const watchedSenses = new Set(senseReflex.on);
const unsub = bus.subscribe((signal) => {
if (watchedSenses.has(signal.senseId)) {
+29 -12
View File
@@ -31,6 +31,7 @@ import {
export type WorkflowLaunchParams = {
prompt: string;
maxRounds: number;
dryRun: boolean;
};
export type WorkflowManager = {
@@ -58,6 +59,7 @@ type PendingThread = {
runId: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
type WorkflowState = {
@@ -90,20 +92,22 @@ const DEFAULT_MAX_QUEUE = 100;
function readLaunchFromTriggerPayload(
raw: unknown,
engineDefaultMaxRounds: number,
): { prompt: string; maxRounds: number } {
): { prompt: string; maxRounds: number; dryRun: boolean } {
if (isPlainRecord(raw)) {
const o = raw;
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
return { prompt: o.prompt, maxRounds: o.maxRounds };
const dryRun = typeof o.dryRun === "boolean" ? o.dryRun : false;
return { prompt: o.prompt, maxRounds: o.maxRounds, dryRun };
}
}
return { prompt: "", maxRounds: engineDefaultMaxRounds };
return { prompt: "", maxRounds: engineDefaultMaxRounds, dryRun: false };
}
function ensureThreadMessagesWithStart(
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
): WorkflowMessage[] {
const mapped: WorkflowMessage[] = messages.map((m) => ({
role: m.role,
@@ -117,7 +121,7 @@ function ensureThreadMessagesWithStart(
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds },
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun },
timestamp: Date.now(),
};
return [start, ...mapped];
@@ -213,7 +217,7 @@ export function createWorkflowManager(
}
function workflowConfig(workflowName: string): WorkflowConfig | null {
return config.workflows?.[workflowName] ?? null;
return config.workflows[workflowName] ?? null;
}
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
@@ -260,6 +264,7 @@ export function createWorkflowManager(
runId: string,
prompt: string,
maxRounds: number,
dryRun: boolean,
): void {
const state = getOrCreateState(workflowName);
state.active.add(runId);
@@ -271,9 +276,10 @@ export function createWorkflowManager(
workflow: workflowName,
prompt,
maxRounds,
dryRun,
};
sendStartThread(worker.process, msg);
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds });
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds, dryRun });
}
function dequeueNext(workflowName: string): void {
@@ -286,7 +292,7 @@ export function createWorkflowManager(
if (state.active.size < concurrency) {
const next = state.queue.shift();
if (next !== undefined) {
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds);
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds, next.dryRun);
}
}
}
@@ -311,7 +317,12 @@ export function createWorkflowManager(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
state.queue.push({ runId, prompt: launch.prompt, maxRounds: launch.maxRounds });
state.queue.push({
runId,
prompt: launch.prompt,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
});
process.stderr.write(
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
);
@@ -329,13 +340,19 @@ export function createWorkflowManager(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
const messages = ensureThreadMessagesWithStart(rawMessages, launch.prompt, launch.maxRounds);
const messages = ensureThreadMessagesWithStart(
rawMessages,
launch.prompt,
launch.maxRounds,
launch.dryRun,
);
state.active.add(runId);
const msg: ResumeThreadMessage = {
type: "resume-thread",
runId,
messages,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
};
sendResumeThread(worker.process, msg);
process.stderr.write(
@@ -531,10 +548,10 @@ export function createWorkflowManager(
const state = getOrCreateState(workflowName);
const runId = crypto.randomUUID();
const { prompt, maxRounds } = launch;
const { prompt, maxRounds, dryRun } = launch;
if (state.active.size < wfConfig.concurrency) {
dispatchThread(workflowName, runId, prompt, maxRounds);
dispatchThread(workflowName, runId, prompt, maxRounds, dryRun);
return;
}
@@ -559,7 +576,7 @@ export function createWorkflowManager(
}
}
state.queue.push({ runId, prompt, maxRounds });
state.queue.push({ runId, prompt, maxRounds, dryRun });
logWorkflowEvent(workflowName, runId, "queued");
process.stderr.write(
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
+94 -39
View File
@@ -13,7 +13,7 @@ import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type {
Moderator,
ModeratorContext,
RoleMeta,
StartSignal,
WorkflowDefinition,
@@ -29,8 +29,6 @@ import type {
import { parseParentMessage } from "./ipc.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
type ModeratorInput = Parameters<Moderator<RoleMeta>>[0];
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
@@ -87,42 +85,95 @@ function validateRoleResult(
return true;
}
function buildInitialLastSignal(lastMsg: WorkflowMessage): ModeratorInput {
if (lastMsg.role === START) {
return {
role: START,
content: lastMsg.content,
meta: lastMsg.meta as StartSignal["meta"],
timestamp: lastMsg.timestamp,
};
}
return { role: lastMsg.role, meta: lastMsg.meta as Record<string, unknown> };
function isStartMeta(meta: unknown): meta is StartSignal["meta"] {
return (
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
);
}
function initChain(
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartSignal["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false };
}
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
return { maxRounds, dryRun };
}
function startSignalFromWorkflowMessage(
msg: WorkflowMessage,
maxRoundsFallback: number,
): StartSignal {
if (msg.role !== START) {
return {
role: START,
content: "",
meta: { maxRounds: maxRoundsFallback, dryRun: false },
timestamp: Date.now(),
};
}
const meta = isStartMeta(msg.meta) ? msg.meta : normalizeStartMeta(msg.meta, maxRoundsFallback);
return {
role: START,
content: msg.content,
meta,
timestamp: msg.timestamp,
};
}
type ThreadMessagesState = {
start: StartSignal;
/** Role outputs only; never includes the `__start__` frame. */
messages: WorkflowMessage[];
};
function initThreadMessages(
runId: string,
resumeMessages: WorkflowMessage[],
freshPrompt: string | null,
maxRounds: number,
): WorkflowMessage[] {
dryRun: boolean,
): ThreadMessagesState {
if (resumeMessages.length > 0) {
return [...resumeMessages];
const [first, ...rest] = resumeMessages;
if (first.role === START) {
return {
start: startSignalFromWorkflowMessage(first, maxRounds),
messages: [...rest],
};
}
const prompt = freshPrompt ?? "";
return {
start: {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
timestamp: Date.now(),
},
messages: [...resumeMessages],
};
}
const prompt = freshPrompt ?? "";
const startMsg: WorkflowMessage = {
const start: StartSignal = {
role: START,
content: prompt,
meta: { maxRounds },
meta: { maxRounds, dryRun },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, startMsg);
return [startMsg];
sendWorkflowMessage(runId, {
role: start.role,
content: start.content,
meta: start.meta,
timestamp: start.timestamp,
});
return { start, messages: [] };
}
async function executeRole(
def: WorkflowDefinition<RoleMeta>,
nextRole: string,
chain: WorkflowMessage[],
start: StartSignal,
messages: WorkflowMessage[],
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
const role = def.roles[nextRole];
@@ -133,7 +184,7 @@ async function executeRole(
let result: { content: string; meta: Record<string, unknown> };
try {
result = await role(chain);
result = await role(start, messages);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendThreadEvent(runId, "failed", { error: errMsg });
@@ -150,17 +201,18 @@ async function runThread(
maxRounds: number,
resumeMessages: WorkflowMessage[] = [],
freshPrompt: string | null = null,
dryRun = false,
): Promise<void> {
const chain = initChain(runId, resumeMessages, freshPrompt, maxRounds);
const { start, messages: roleMessages } = initThreadMessages(
runId,
resumeMessages,
freshPrompt,
maxRounds,
dryRun,
);
let roleRound = chain.filter((m) => m.role !== START).length;
const lastMsg = chain[chain.length - 1];
if (lastMsg === undefined) {
sendWorkflowError(runId, "empty workflow message chain");
return;
}
let nextRole = def.moderator(buildInitialLastSignal(lastMsg), roleRound, maxRounds);
let roleRound = roleMessages.length;
let nextRole = def.moderator({ kind: "start", start }, roleRound, maxRounds);
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
@@ -168,7 +220,7 @@ async function runThread(
}
while (roleRound < maxRounds) {
const result = await executeRole(def, nextRole, chain, runId);
const result = await executeRole(def, nextRole, start, roleMessages, runId);
if (result === null) return;
const message: WorkflowMessage = {
@@ -177,13 +229,16 @@ async function runThread(
meta: result.meta,
timestamp: Date.now(),
};
chain.push(message);
roleMessages.push(message);
sendWorkflowMessage(runId, message);
roleRound += 1;
const signal: ModeratorInput = { role: nextRole, meta: result.meta };
nextRole = def.moderator(signal, roleRound, maxRounds);
const stepContext: ModeratorContext<RoleMeta> = {
kind: "step",
signal: { role: nextRole, meta: result.meta },
};
nextRole = def.moderator(stepContext, roleRound, maxRounds);
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
@@ -267,11 +322,11 @@ function handleMessage(
if (msg.type === "start-thread") {
if (shuttingDown.value) return;
const { runId, prompt, maxRounds } = msg;
const { runId, prompt, maxRounds, dryRun } = msg;
const previous = inFlight.get(runId) ?? Promise.resolve();
const next = previous
.then(() => runThread(def, runId, maxRounds, [], prompt))
.then(() => runThread(def, runId, maxRounds, [], prompt, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
@@ -286,11 +341,11 @@ function handleMessage(
if (msg.type === "resume-thread") {
if (shuttingDown.value) return;
const { runId, messages, maxRounds } = msg;
const { runId, messages, maxRounds, dryRun } = msg;
const previous = inFlight.get(runId) ?? Promise.resolve();
const next = previous
.then(() => runThread(def, runId, maxRounds, messages, null))
.then(() => runThread(def, runId, maxRounds, messages, null, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
+36 -9
View File
@@ -242,6 +242,41 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
}
}
function launchShapeFromRecord(rec: Record<string, unknown>): {
prompt: string;
maxRounds: number;
dryRun: boolean;
} | null {
if (typeof rec.prompt !== "string" || typeof rec.maxRounds !== "number") return null;
return {
prompt: rec.prompt,
maxRounds: rec.maxRounds,
dryRun: typeof rec.dryRun === "boolean" ? rec.dryRun : false,
};
}
/** Parse JSON from a workflow `started` log row into a trigger / launch payload for crash recovery. */
function triggerPayloadFromStartedLogJson(payload: string): unknown | null {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
return null;
}
if (!isPlainRecord(parsed)) return null;
const direct = launchShapeFromRecord(parsed);
if (direct !== null) return direct;
const inner = parsed.triggerPayload;
if (inner !== null && isPlainRecord(inner)) {
const fromInner = launchShapeFromRecord(inner);
if (fromInner !== null) return fromInner;
return inner;
}
return null;
}
function runOptionalVacuum(sqlite: DatabaseSync, vacuum?: boolean): boolean {
if (vacuum !== true) return false;
sqlite.exec("VACUUM");
@@ -513,15 +548,7 @@ export function createLogStore(dbPath: string): LogStore {
function getTriggerPayload(runId: string): unknown {
const row = getTriggerPayloadStmt.get(runId) as { payload: string | null } | undefined;
if (row === undefined || row.payload === null) return null;
try {
const parsed: unknown = JSON.parse(row.payload);
if (isPlainRecord(parsed)) {
return parsed.triggerPayload ?? null;
}
} catch {
// malformed
}
return null;
return triggerPayloadFromStartedLogJson(row.payload);
}
function getThreadEvents(runId: string): Array<{ type: string; [key: string]: unknown }> {
@@ -107,4 +107,24 @@ describe("llmExtract", () => {
}
expect(result.error.kind).toBe("schema_validation_failed");
});
it("dryRun skips fetch and returns an empty stub value", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const schema = z.object({ n: z.number() });
const result = await llmExtract({
text: "ignored",
schema,
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
dryRun: true,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({});
});
});
@@ -36,4 +36,24 @@ describe("spawnSafe", () => {
}
expect(result.error.exitCode).toBe(7);
});
it("dryRun skips spawn and returns a zero-exit stub", async () => {
const result = await spawnSafe(process.execPath, ["-e", "process.exit(1)"], {
cwd: null,
env: null,
timeoutMs: 10_000,
dryRun: true,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
});
});
});
+14 -1
View File
@@ -10,14 +10,26 @@ export type CursorAgentOptions = {
cwd: string;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
};
type CursorAgentOptionsInput = CursorAgentOptions | Omit<CursorAgentOptions, "dryRun">;
function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
/**
* Invokes `cursor-agent` with the prompt passed as a single argv slot (`shell: false`).
*/
export async function cursorAgent(
options: CursorAgentOptions,
options: CursorAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveCursorAgentDryRun(options);
if (dryRun) {
return ok("[dryRun] skipped");
}
const args: string[] = [
"-p",
options.prompt,
@@ -38,6 +50,7 @@ export async function cursorAgent(
cwd: options.cwd,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
});
if (!run.ok) {
+1
View File
@@ -19,3 +19,4 @@ export {
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
export { isDryRun } from "./start-signal.js";
+15 -1
View File
@@ -11,8 +11,15 @@ export type LlmExtractOptions<T> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
dryRun: boolean;
};
type LlmExtractOptionsInput<T> = LlmExtractOptions<T> | Omit<LlmExtractOptions<T>, "dryRun">;
function resolveLlmExtractDryRun<T>(options: LlmExtractOptionsInput<T>): boolean {
return "dryRun" in options ? options.dryRun : false;
}
export type LlmError =
| { kind: "http_error"; status: number; body: string }
| { kind: "invalid_response_json"; message: string }
@@ -90,7 +97,14 @@ function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<s
* Calls an OpenAI-compatible chat completions API with `tool_choice` forced to a single function
* derived from a Zod v4 schema (`toJSONSchema`). Uses `fetch()` only (no shell).
*/
export async function llmExtract<T>(options: LlmExtractOptions<T>): Promise<Result<T, LlmError>> {
export async function llmExtract<T>(
options: LlmExtractOptionsInput<T>,
): Promise<Result<T, LlmError>> {
const dryRun = resolveLlmExtractDryRun(options);
if (dryRun) {
return ok({} as T);
}
const rawJsonSchema = toJSONSchema(options.schema) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const toolName = readToolName(parameters);
+20 -1
View File
@@ -30,8 +30,11 @@ export type SpawnSafeOptions = {
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
const DEFAULT_TIMEOUT_MS = 300_000;
/**
@@ -63,6 +66,10 @@ function resolveTimeout(timeoutMs: number | null): number {
return timeoutMs;
}
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
/**
* Spawn a process with `shell: false` (argv only), default {@link nerveCommandEnv}, and optional timeout.
* Returns `ok` only when the process exits with code 0.
@@ -70,8 +77,20 @@ function resolveTimeout(timeoutMs: number | null): number {
export function spawnSafe(
command: string,
args: ReadonlyArray<string>,
options: SpawnSafeOptions,
options: SpawnSafeOptionsInput,
): Promise<Result<SpawnResult, SpawnError>> {
const dryRun = resolveDryRun(options);
if (dryRun) {
return Promise.resolve(
ok({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
}),
);
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
@@ -0,0 +1,6 @@
import type { StartSignal } from "@uncaged/nerve-core";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartSignal): boolean {
return start.meta.dryRun;
}