feat(ai): rewrite bridge.ts to stream stderr progress via spawn

This commit is contained in:
Siddharth Kumar Sah
2026-03-23 01:37:12 +08:00
parent 7eddac5119
commit 7d74ddd3a6
+113 -51
View File
@@ -1,22 +1,19 @@
import { execFile } from "node:child_process"; import { spawn } from "node:child_process";
import { promisify } from "node:util";
import { resolve, dirname } from "node:path"; import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python"); const PYTHON_DIR = resolve(__dirname, "../python");
/** Try venv first, then system python. */ /** Try venv first, then system python. */
function getPythonPath(): string { function getPythonPath(): string {
const venvPath = process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv"); const venvPath =
process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
return `${venvPath}/bin/python3`; return `${venvPath}/bin/python3`;
} }
/** /**
* Extract a user-friendly error from a Python process error. * Extract a user-friendly error from a Python process error.
* Python scripts print JSON to stderr/stdout on failure — try to parse it.
*/ */
function extractPythonError(error: unknown): string { function extractPythonError(error: unknown): string {
if (error && typeof error === "object") { if (error && typeof error === "object") {
@@ -25,14 +22,12 @@ function extractPythonError(error: unknown): string {
stdout?: string; stdout?: string;
message?: string; message?: string;
}; };
// Try stdout first (Python scripts write JSON errors there), then stderr
for (const output of [execError.stdout, execError.stderr]) { for (const output of [execError.stdout, execError.stderr]) {
if (output) { if (output) {
try { try {
const parsed = JSON.parse(output.trim()); const parsed = JSON.parse(output.trim());
if (parsed.error) return parsed.error; if (parsed.error) return parsed.error;
} catch { } catch {
// Not JSON, check for human-readable content
const trimmed = output.trim(); const trimmed = output.trim();
if (trimmed && !trimmed.startsWith("Traceback")) { if (trimmed && !trimmed.startsWith("Traceback")) {
return trimmed; return trimmed;
@@ -45,6 +40,114 @@ function extractPythonError(error: unknown): string {
return String(error); return String(error);
} }
export interface ProgressCallback {
(percent: number, stage: string): void;
}
/**
* Run a Python script with real-time progress streaming via stderr.
* Falls back to system python3 if the venv is not available.
*
* Python scripts emit progress as JSON lines to stderr:
* {"progress": 50, "stage": "Processing..."}
*
* Non-JSON stderr lines are collected as error output (backward compatible).
*/
export function runPythonWithProgress(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout = options.timeout ?? 300000;
return new Promise((resolvePromise, rejectPromise) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => {
const child = spawn(pythonBin, [scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
const stderrLines: string[] = [];
let stderrBuffer = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeout);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (
typeof parsed.progress === "number" &&
typeof parsed.stage === "string"
) {
options.onProgress?.(parsed.progress, parsed.stage);
continue;
}
} catch {
// Not JSON — collect as regular stderr
}
stderrLines.push(trimmed);
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT" && !isFallback) {
trySpawn("python3", true);
} else {
rejectPromise(new Error(extractPythonError(err)));
}
});
child.on("close", (code) => {
clearTimeout(timer);
if (stderrBuffer.trim()) {
stderrLines.push(stderrBuffer.trim());
}
if (timedOut) {
rejectPromise(new Error("Python script timed out"));
return;
}
const stderr = stderrLines.join("\n");
if (code !== 0) {
const errorText =
extractPythonError({ stdout: stdout.trim(), stderr }) ||
`Python script exited with code ${code}`;
rejectPromise(new Error(errorText));
return;
}
resolvePromise({ stdout: stdout.trim(), stderr });
});
};
trySpawn(getPythonPath(), false);
});
}
/** /**
* Run a Python script from packages/ai/python/ with the given arguments. * Run a Python script from packages/ai/python/ with the given arguments.
* Falls back to system python3 if the venv is not available. * Falls back to system python3 if the venv is not available.
@@ -52,48 +155,7 @@ function extractPythonError(error: unknown): string {
export async function runPythonScript( export async function runPythonScript(
scriptName: string, scriptName: string,
args: string[], args: string[],
timeoutMs = 300000, // 5 min default timeoutMs = 300000,
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
const scriptPath = resolve(PYTHON_DIR, scriptName); return runPythonWithProgress(scriptName, args, { timeout: timeoutMs });
const pythonPath = getPythonPath();
const execOpts = {
timeout: timeoutMs,
maxBuffer: 50 * 1024 * 1024, // 50MB
};
try {
const { stdout, stderr } = await execFileAsync(
pythonPath,
[scriptPath, ...args],
execOpts,
);
return { stdout: stdout.trim(), stderr: stderr.trim() };
} catch (venvError: unknown) {
// Only fall back to system python if the venv python binary doesn't exist
// (ENOENT). If the script itself failed, re-throw — don't hide the error.
const isNotFound =
venvError &&
typeof venvError === "object" &&
"code" in venvError &&
(venvError as { code?: string }).code === "ENOENT";
if (!isNotFound) {
const message = extractPythonError(venvError);
throw new Error(message);
}
// venv python not found — try system python3 as fallback
try {
const { stdout, stderr } = await execFileAsync(
"python3",
[scriptPath, ...args],
execOpts,
);
return { stdout: stdout.trim(), stderr: stderr.trim() };
} catch (fallbackError: unknown) {
const message = extractPythonError(fallbackError);
throw new Error(message);
}
}
} }