mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(ai): rewrite bridge.ts to stream stderr progress via spawn
This commit is contained in:
+113
-51
@@ -1,22 +1,19 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PYTHON_DIR = resolve(__dirname, "../python");
|
||||
|
||||
/** Try venv first, then system python. */
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (error && typeof error === "object") {
|
||||
@@ -25,14 +22,12 @@ function extractPythonError(error: unknown): string {
|
||||
stdout?: string;
|
||||
message?: string;
|
||||
};
|
||||
// Try stdout first (Python scripts write JSON errors there), then stderr
|
||||
for (const output of [execError.stdout, execError.stderr]) {
|
||||
if (output) {
|
||||
try {
|
||||
const parsed = JSON.parse(output.trim());
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
// Not JSON, check for human-readable content
|
||||
const trimmed = output.trim();
|
||||
if (trimmed && !trimmed.startsWith("Traceback")) {
|
||||
return trimmed;
|
||||
@@ -45,6 +40,114 @@ function extractPythonError(error: unknown): string {
|
||||
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.
|
||||
* Falls back to system python3 if the venv is not available.
|
||||
@@ -52,48 +155,7 @@ function extractPythonError(error: unknown): string {
|
||||
export async function runPythonScript(
|
||||
scriptName: string,
|
||||
args: string[],
|
||||
timeoutMs = 300000, // 5 min default
|
||||
timeoutMs = 300000,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const scriptPath = resolve(PYTHON_DIR, scriptName);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return runPythonWithProgress(scriptName, args, { timeout: timeoutMs });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user