Add script runner with stdout condition rule
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
49
src/scheduler/runner.test.ts
Normal file
49
src/scheduler/runner.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveShell, runScript, tailLines } from "./runner.js";
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
describe("runScript", () => {
|
||||
it("reports output when the script prints", async () => {
|
||||
expect(await runScript("echo hello", { cwd, timeoutSec: 10 })).toEqual({ kind: "output", text: "hello" });
|
||||
});
|
||||
|
||||
it("reports silent on exit 0 with no output", async () => {
|
||||
expect(await runScript("exit 0", { cwd, timeoutSec: 10 })).toEqual({ kind: "silent" });
|
||||
});
|
||||
|
||||
it("reports failure with exit code and stderr tail", async () => {
|
||||
const r = await runScript("echo boom >&2; exit 3", { cwd, timeoutSec: 10 });
|
||||
expect(r).toEqual({ kind: "failed", exitCode: 3, stderrTail: "boom", timedOut: false });
|
||||
});
|
||||
|
||||
it("kills a script that exceeds the timeout", async () => {
|
||||
const r = await runScript("sleep 5; echo late", { cwd, timeoutSec: 1 });
|
||||
expect(r.kind).toBe("failed");
|
||||
if (r.kind === "failed") {
|
||||
expect(r.timedOut).toBe(true);
|
||||
expect(r.stderrTail).toMatch(/timed out after 1s/);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a missing shell as a failure instead of throwing", async () => {
|
||||
const r = await runScript("echo hi", { cwd, timeoutSec: 5, shellPath: "/definitely/not/a/shell" });
|
||||
expect(r.kind).toBe("failed");
|
||||
if (r.kind === "failed") expect(r.stderrTail).toMatch(/failed to start/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell", () => {
|
||||
it("prefers the override", () => {
|
||||
expect(resolveShell("/x/bash", "win32")).toBe("/x/bash");
|
||||
});
|
||||
it("uses bash from PATH on non-Windows", () => {
|
||||
expect(resolveShell(undefined, "linux")).toBe("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tailLines", () => {
|
||||
it("keeps the last n lines", () => {
|
||||
expect(tailLines("a\nb\nc\nd\n", 2)).toBe("c\nd");
|
||||
});
|
||||
});
|
||||
75
src/scheduler/runner.ts
Normal file
75
src/scheduler/runner.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export type ScriptOutcome =
|
||||
| { kind: "silent" }
|
||||
| { kind: "output"; text: string }
|
||||
| { kind: "failed"; exitCode: number | null; stderrTail: string; timedOut: boolean };
|
||||
|
||||
export interface RunScriptOptions {
|
||||
cwd: string;
|
||||
timeoutSec: number;
|
||||
shellPath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
const STDERR_TAIL_LINES = 20;
|
||||
|
||||
const WINDOWS_BASH_CANDIDATES = [
|
||||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||
];
|
||||
|
||||
export function resolveShell(override?: string, platform: NodeJS.Platform = process.platform): string {
|
||||
if (override) return override;
|
||||
if (platform === "win32") {
|
||||
for (const candidate of WINDOWS_BASH_CANDIDATES) if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return "bash";
|
||||
}
|
||||
|
||||
export function tailLines(text: string, n: number): string {
|
||||
return text.trimEnd().split("\n").slice(-n).join("\n");
|
||||
}
|
||||
|
||||
export function runScript(command: string, opts: RunScriptOptions): Promise<ScriptOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
const shell = resolveShell(opts.shellPath);
|
||||
const child = spawn(shell, ["-c", command], {
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
||||
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill();
|
||||
}, opts.timeoutSec * 1000);
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ kind: "failed", exitCode: null, stderrTail: `failed to start ${shell}: ${err.message}`, timedOut: false });
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
const note = `(timed out after ${opts.timeoutSec}s)`;
|
||||
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(`${stderr}\n${note}`, STDERR_TAIL_LINES), timedOut: true });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(stderr, STDERR_TAIL_LINES), timedOut: false });
|
||||
return;
|
||||
}
|
||||
const text = stdout.trim();
|
||||
resolve(text ? { kind: "output", text } : { kind: "silent" });
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user