import type { ServerWebSocket } from 'bun'; import { join, isAbsolute } from 'node:path'; import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs'; import { getTaskByDirName } from './task-files'; import { getOwnerHomeDir } from '../../data-path'; type WSData = { userId: number; email: string; username: string; }; type RunMessage = { type: 'run'; taskDirName: string; inputs: Record; cwd?: string; }; type StopMessage = { type: 'stop'; }; type ClientMessage = RunMessage | StopMessage; type OutMessage = | { type: 'started'; taskName: string } | { type: 'stdout'; data: string } | { type: 'stderr'; data: string } | { type: 'exit'; code: number } | { type: 'error'; message: string }; // Active processes per WebSocket const activeProcs = new WeakMap, { proc: ReturnType; kill: () => void }>(); // Every descendant PID of `root`, from a single /proc walk (Linux). Bun's proc.kill() signals only // the direct child (bash), so a running grandchild like ffmpeg is left orphaned and keeps going. function descendantPids(root: number): number[] { const childrenOf = new Map(); let entries: string[]; try { entries = readdirSync('/proc'); } catch { return []; } for (const entry of entries) { const pid = Number(entry); if (!Number.isInteger(pid)) continue; try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); // "pid (comm) state ppid …" — comm may contain spaces/parens, so parse after the last ')'. const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); const ppid = Number(fields[1]); if (!Number.isInteger(ppid)) continue; const list = childrenOf.get(ppid); if (list) list.push(pid); else childrenOf.set(ppid, [pid]); } catch { // process vanished mid-scan } } const out: number[] = []; const stack = [root]; while (stack.length > 0) { const parent = stack.pop()!; for (const child of childrenOf.get(parent) ?? []) { out.push(child); stack.push(child); } } return out; } // Terminate a process and its whole subtree: SIGTERM everything, then SIGKILL stragglers after a // grace period. Killing the root (bash) too stops it from spawning the next file mid-batch. function killTree(root: number) { const pids = [root, ...descendantPids(root)]; for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } } setTimeout(() => { for (const pid of pids) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } } }, 2000); } import { tmpdir } from 'node:os'; function send(ws: ServerWebSocket, msg: OutMessage) { if (ws.readyState === 1) ws.send(JSON.stringify(msg)); } function getRunner(language: string): string[] { switch (language) { case 'bash': return ['bash']; case 'python': return ['python3']; case 'typescript': return ['bun', 'run']; case 'javascript': return ['node']; default: return ['bash']; } } function getFileName(language: string): string { switch (language) { case 'bash': return 'run.sh'; case 'python': return 'run.py'; case 'typescript': return 'index.ts'; case 'javascript': return 'index.js'; default: return 'run.sh'; } } // Write implementation to a temp file for execution, cleaned up after function materializeScript(language: string, implementation: string): string { const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); const fileName = getFileName(language); const filePath = join(dir, fileName); writeFileSync(filePath, implementation); chmodSync(filePath, 0o755); return filePath; } function buildInputEnv(inputs: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(inputs)) { env[`INPUT_${key.toUpperCase()}`] = value; } return env; } function buildArgs(inputs: Record, argsOrder?: string[] | null): string[] { if (!argsOrder || argsOrder.length === 0) return []; return argsOrder.map((name) => inputs[name] ?? ''); } async function handleRun(ws: ServerWebSocket, msg: RunMessage) { const { email } = ws.data; // Resolve task from the file-backed store const task = await getTaskByDirName(msg.taskDirName); if (!task) { send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` }); return; } if (task.mode !== 'script') { send(ws, { type: 'error', message: 'Task is not a script-mode task' }); return; } if (!task.implementation) { send(ws, { type: 'error', message: `Task ${msg.taskDirName} has no implementation` }); return; } const language = task.language ?? 'bash'; // Write script to temp dir for execution const scriptPath = materializeScript(language, task.implementation); // Build env vars from inputs const inputEnv = buildInputEnv(msg.inputs); // Build positional args const positionalArgs = buildArgs(msg.inputs, task.args); // Build the command const runner = getRunner(language); const cmd = [...runner, scriptPath, ...positionalArgs]; // msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute // (a missing cwd surfaces as ENOENT naming the binary, not the directory) const homeDir = getOwnerHomeDir(email); const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir; const spawnCmd = cmd; const spawnEnv = { ...(process.env as Record), ...inputEnv }; const spawnCwd = cwd; const cleanup = () => { try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ } }; send(ws, { type: 'started', taskName: task.name }); try { const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe', }); activeProcs.set(ws, { proc, kill: () => { try { killTree(proc.pid); } catch { /* already dead */ } }, }); // Keep the WebSocket alive during long silent phases (e.g. ffmpeg's faststart pass rewrites a huge // file for minutes with no output). Bun's default 120s idle timeout would otherwise close the // socket → close(ws) → killTree kills the task mid-run. A ping resets the idle timer. const keepAlive = setInterval(() => { try { ws.ping(); } catch { /* socket gone */ } }, 30_000); const stdoutReader = proc.stdout.getReader(); const stderrReader = proc.stderr.getReader(); const decoder = new TextDecoder(); const readStream = async (reader: ReadableStreamDefaultReader, type: 'stdout' | 'stderr') => { try { while (true) { const { done, value } = await reader.read(); if (done) break; send(ws, { type, data: decoder.decode(value) }); } } catch { // stream closed } }; const [, , exitCode] = await Promise.all([ readStream(stdoutReader, 'stdout'), readStream(stderrReader, 'stderr'), proc.exited, ]); clearInterval(keepAlive); activeProcs.delete(ws); cleanup(); send(ws, { type: 'exit', code: exitCode }); } catch (err) { activeProcs.delete(ws); cleanup(); send(ws, { type: 'error', message: `Failed to spawn: ${err instanceof Error ? err.message : String(err)}` }); } } export function open(_ws: ServerWebSocket) { // nothing to do } export function message(ws: ServerWebSocket, raw: string | Buffer) { const data = typeof raw === 'string' ? raw : raw.toString(); try { const msg = JSON.parse(data) as ClientMessage; if (msg.type === 'run') { handleRun(ws, msg); } else if (msg.type === 'stop') { const active = activeProcs.get(ws); if (active) { active.kill(); activeProcs.delete(ws); send(ws, { type: 'exit', code: -1 }); } } } catch { send(ws, { type: 'error', message: 'Failed to parse message' }); } } export function close(ws: ServerWebSocket) { const active = activeProcs.get(ws); if (active) { active.kill(); activeProcs.delete(ws); } } export const taskRunnerWebsocket = { open, message, close, drain() {}, };