Compare commits

...

2 Commits

Author SHA1 Message Date
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
5 changed files with 100 additions and 4 deletions
@@ -107,4 +107,27 @@ describe("llmExtract", () => {
}
expect(result.error.kind).toBe("schema_validation_failed");
});
it("dryRun skips fetch and returns an empty stub value", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
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,
});
logSpy.mockRestore();
expect(fetchMock).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({});
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { spawnSafe } from "../spawn-safe.js";
@@ -36,4 +36,28 @@ describe("spawnSafe", () => {
}
expect(result.error.exitCode).toBe(7);
});
it("dryRun skips spawn and returns a zero-exit stub", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await spawnSafe(process.execPath, ["-e", "process.exit(1)"], {
cwd: null,
env: null,
timeoutMs: 10_000,
dryRun: true,
});
logSpy.mockRestore();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
});
});
});
+15 -1
View File
@@ -10,14 +10,27 @@ 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) {
console.log("[dryRun] cursorAgent:", options.prompt, JSON.stringify(options));
return ok("[dryRun] skipped");
}
const args: string[] = [
"-p",
options.prompt,
@@ -38,6 +51,7 @@ export async function cursorAgent(
cwd: options.cwd,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
});
if (!run.ok) {
+16 -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,15 @@ 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) {
console.log("[dryRun] llmExtract:", options.text, JSON.stringify(options.schema));
return ok({} as T);
}
const rawJsonSchema = toJSONSchema(options.schema) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const toolName = readToolName(parameters);
+21 -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,21 @@ 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) {
console.log("[dryRun] spawnSafe:", command, args, JSON.stringify(options));
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);