import { spawn } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { hostname } from "node:os"; import { join } from "node:path"; import { errorText, type AgentRequest, type AgentResult, type ExecRequest, type ExecResult, type NotifyRequest, type Runtime } from "./engine.js"; export const ALLOWED_TOOLS_ENV = "PI_SCHEDULER_ALLOWED_TOOLS"; export const RUN_MARKER_ENV = "PI_SCHEDULER_RUN"; const MAX_CAPTURE = 1_000_000; /** Quote one argument for cmd.exe (used only for .cmd/.bat shims on Windows). */ function quoteCmdArg(arg: string): string { if (arg === "") return '""'; if (!/[\s"&|<>^%!(),;=]/.test(arg)) return arg; return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1").replace(/%/g, "%%")}"`.replace(/([&|<>^])/g, "^$1"); } /** Cross-platform execFile without shell. Windows .cmd/.bat shims (az, npm, pi) go through cmd.exe /d /s /c. */ export function execPortable(req: ExecRequest & { stdin?: string }): Promise { return new Promise((resolve, reject) => { let file = req.file; let args = req.args; let windowsVerbatimArguments = false; if (process.platform === "win32" && !/\.(exe|com)$/i.test(file)) { // Unknown extension or .cmd shim: let cmd.exe resolve PATHEXT. args = ["/d", "/s", "/c", `"${[file, ...args].map(quoteCmdArg).join(" ")}"`]; file = process.env.ComSpec ?? "cmd.exe"; windowsVerbatimArguments = true; } const child = spawn(file, args, { cwd: req.cwd, env: { ...process.env, ...(req.env ?? {}) }, signal: req.signal, windowsHide: true, windowsVerbatimArguments, stdio: [req.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (d: Buffer) => { if (stdout.length < MAX_CAPTURE) stdout += d.toString("utf8"); }); child.stderr?.on("data", (d: Buffer) => { if (stderr.length < MAX_CAPTURE) stderr += d.toString("utf8"); }); if (req.stdin !== undefined) child.stdin?.end(req.stdin); const timer = setTimeout(() => child.kill("SIGTERM"), req.timeoutMs); child.on("error", (e) => { clearTimeout(timer); reject(e); }); child.on("close", (code) => { clearTimeout(timer); resolve({ exitCode: code, stdout, stderr }); }); }); } export interface NodeRuntimeOptions { storeRoot: string; /** Command used to launch pi headless (default "pi"). */ piCommand?: string; notify?: (req: NotifyRequest) => Promise; hostName?: string; } /** Runtime for the CLI fallback host (and the server plugin when a workspace is not bound). */ export class NodeRuntime implements Runtime { readonly hostName: string; constructor(private readonly options: NodeRuntimeOptions) { this.hostName = options.hostName ?? `cli@${hostname()}`; } exec(req: ExecRequest): Promise { return execPortable(req); } async agent(req: AgentRequest): Promise { return runHeadlessAgent(req, this.options.storeRoot, this.options.piCommand ?? "pi"); } async notify(req: NotifyRequest): Promise { if (this.options.notify) return this.options.notify(req); const line = `[pi-scheduler] ${req.severity.toUpperCase()} ${req.job.id}: ${req.message}`; if (req.severity === "error") console.error(line); else console.log(line); } } /** `pi -p` in the job cwd; prompt passed as @file to avoid command-line limits. */ export async function runHeadlessAgent(req: AgentRequest, storeRoot: string, piCommand: string): Promise { const tmpDir = join(storeRoot, "tmp"); await mkdir(tmpDir, { recursive: true, mode: 0o700 }); const promptFile = join(tmpDir, `${req.run.id}-${req.step.id}.md`); await writeFile(promptFile, scheduledPrompt(req), { mode: 0o600 }); const sessionName = `sched ${req.job.id} ${req.run.id}`; const args = ["-p", "--name", sessionName, "--approve"]; const tools = req.step.tools ?? []; if (tools.length > 0 && tools.every((t) => !t.includes("*"))) args.push("--tools", tools.join(",")); if (req.step.model) args.push("--model", req.step.model); args.push(`@${promptFile}`, "Esegui il job schedulato descritto nel file allegato."); try { const res = await execPortable({ file: piCommand, args, cwd: req.job.cwd, timeoutMs: req.timeoutMs, signal: req.signal, env: { [ALLOWED_TOOLS_ENV]: tools.join(","), [RUN_MARKER_ENV]: `${req.job.id}/${req.run.id}` }, }); const ok = res.exitCode === 0 && res.stdout.trim().length > 0; return { ok, text: res.stdout.trim(), ...(ok ? {} : { error: `pi exited ${String(res.exitCode)}: ${res.stderr.trim().slice(0, 1000)}` }) }; } catch (e) { return { ok: false, text: "", error: errorText(e) }; } finally { await rm(promptFile, { force: true }); } } /** Prompt envelope shared by hosted and headless agent runs. */ export function scheduledPrompt(req: Pick): string { const tools = req.step.tools?.length ? req.step.tools.join(", ") : "(default)"; return [ `[pi-scheduler job=${req.job.id} run=${req.run.id} step=${req.step.id}]`, `Job: ${req.job.name}`, `Esecuzione non presidiata (trigger=${req.run.trigger}, tentativo=${req.run.attempt}, previsto per ${req.run.scheduledFor}).`, `Tool consentiti: ${tools}. Non porre domande: nessuno risponderà. Concludi con un report finale autosufficiente in testo semplice.`, "", req.prompt, ].join("\n"); }