import { join, isAbsolute } from 'node:path'; import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { getTaskByDirName, type TaskRecord } from './task-files'; import { getOwnerHomeDir } from '../../data-path'; import { buildTaskApiEnv } from './task-api-env'; import type { CredentialScope } from '../../auth-token'; /** * Everything a script-mode task run needs before something spawns it. * * ── Why this is its own file ── * * Three runners spawn a script task and they must agree about HOW: the ephemeral socket * (`task-executor.ts`), the background job (`execute-script.ts`) and the direct REST call below. Each * carried its own copy of the same twenty lines — materialize the implementation into a temp dir, build * `INPUT_` env vars, build positional args in `task.args` order, resolve a home-relative cwd, mint * the API env. Identical line for line, and nothing said they had to stay that way. * * They differ in how they REPORT — streaming to a socket, appending to a durable log, or returning a * string — and that difference is real, so it stays with each of them. What must not differ is what the * script sees: a task that behaves one way from the context menu and another from a commander is a bug * nobody can reproduce, because both callers are "running the task". */ const getRunner = (language: string): string[] => language === 'python' ? ['python3'] : language === 'typescript' ? ['bun', 'run'] : language === 'javascript' ? ['node'] : ['bash']; const getFileName = (language: string): string => language === 'python' ? 'run.py' : language === 'typescript' ? 'index.ts' : language === 'javascript' ? 'index.js' : 'run.sh'; /** * Only `index.ts` (or its sibling by language) is written out — a script task is materialized ALONE, so * it has to be self-contained. That is a contract, not an oversight: see `platform/CLAUDE.md`. */ 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 filePath = join(dir, getFileName(language)); writeFileSync(filePath, implementation); chmodSync(filePath, 0o755); return filePath; } export type ScriptRunUser = { id: number; email: string; username?: string | null; /** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */ scope?: CredentialScope | null; }; export type PreparedScriptRun = { taskName: string; cmd: string[]; cwd: string; env: Record; /** Removes the temp directory. Every caller must call it, on every path out. */ cleanup: () => void; }; type PrepareParams = { task: TaskRecord; inputs: Record; /** Home-relative or absolute. Absent means the owner's home. */ cwd?: string; user: ScriptRunUser; }; export async function prepareScriptRun({ task, inputs, cwd, user }: PrepareParams): Promise { if (task.mode !== 'script') throw new Error('Task is not a script-mode task'); if (!task.implementation) throw new Error(`Task ${task.dirName} has no implementation`); const language = task.language ?? 'bash'; const scriptPath = materializeScript(language, task.implementation); // Inputs reach the script two ways, and both are load-bearing: named, as `INPUT_`, and // positionally in the order `task.args` declares. const inputEnv: Record = {}; for (const [key, value] of Object.entries(inputs)) inputEnv[`INPUT_${key.toUpperCase()}`] = value; const positionalArgs = task.args?.length ? task.args.map((name) => inputs[name] ?? '') : []; // A relative cwd is relative to the OWNER's real home, not the managed one — that is where task runs // execute. A missing cwd surfaces as ENOENT naming the binary rather than the directory, which is why // this resolves rather than passing it through. const homeDir = getOwnerHomeDir(user.email); return { taskName: task.name, cmd: [...getRunner(language), scriptPath, ...positionalArgs], cwd: cwd ? (isAbsolute(cwd) ? cwd : join(homeDir, cwd)) : homeDir, env: { ...(process.env as Record), ...(await buildTaskApiEnv(user)), ...inputEnv, }, cleanup: () => { try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ } }, }; } export type DirectRunResult = { code: number; output: string; taskName: string }; /** * Run a script task and wait for it. No job row, no socket, no streaming. * * This is the shape a caller with no browser needs — a commander's loop, a cron entry, anything holding a * credential rather than a session. The two existing runners both require something to watch them: the * socket needs a live connection, and the job needs somebody to open Jobs to see what happened. * * stdout and stderr are interleaved into one string in arrival order, which is what a caller reading it * as "what happened" wants — the script's own narration and its errors in the order it produced them. */ export async function runScriptTaskDirect(params: { taskDirName: string; inputs: Record; cwd?: string; user: ScriptRunUser; }): Promise { const task = await getTaskByDirName(params.taskDirName); if (!task) throw new Error(`Task not found: ${params.taskDirName}`); const prepared = await prepareScriptRun({ task, inputs: params.inputs, cwd: params.cwd, user: params.user }); try { const proc = Bun.spawn(prepared.cmd, { cwd: prepared.cwd, env: prepared.env, stdout: 'pipe', stderr: 'pipe', }); let output = ''; const decoder = new TextDecoder(); const drain = async (stream: ReadableStream) => { const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; output += decoder.decode(value); } }; // Both streams are drained CONCURRENTLY with the wait. Reading them in sequence deadlocks the moment // a script writes more to stderr than the pipe buffer holds while nothing is reading it. await Promise.all([drain(proc.stdout), drain(proc.stderr)]); const code = await proc.exited; return { code, output, taskName: prepared.taskName }; } finally { prepared.cleanup(); } }