jobs: run script tasks as background jobs via executeScript (phase 1b)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:42:57 +00:00
co-authored by Claude Opus 4.8
parent 843580afff
commit 234a24ddaf
4 changed files with 257 additions and 26 deletions
+145
View File
@@ -0,0 +1,145 @@
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { getTaskByDirName } from './task-files';
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
import { killTree } from './process-tree';
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
// file); `started`/`exit`/`error` are structural and get buffered for viewer replay.
export type ScriptEvent =
| { type: 'started'; taskName: string }
| { type: 'stdout'; data: string }
| { type: 'stderr'; data: string }
| { type: 'exit'; code: number }
| { type: 'error'; message: string };
export type ExecuteScriptParams = {
jobId: string;
email: string;
role: string;
sandboxed: boolean;
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
abortSignal: { aborted: boolean };
emit: (event: ScriptEvent) => void;
};
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';
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;
}
const buildInputEnv = (inputs: Record<string, string>): Record<string, string> => {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(inputs)) env[`INPUT_${key.toUpperCase()}`] = value;
return env;
};
const buildArgs = (inputs: Record<string, string>, argsOrder?: string[] | null): string[] =>
!argsOrder || argsOrder.length === 0 ? [] : argsOrder.map((name) => inputs[name] ?? '');
// The persisted per-job output log — served by GET /jobs/:id/log for backfill / unattended viewing.
export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.log`);
// Run a script-mode task as a background job. Emits ScriptEvents (for live viewers) AND appends all
// output to a durable log file. Resolves with the process exit code; throws only on spawn failure or
// when aborted (the manager maps those to failed/stopped).
export async function executeScript(params: ExecuteScriptParams): Promise<{ exitCode: number }> {
const { jobId, email, role, sandboxed, inputs, abortSignal, emit } = params;
const task = await getTaskByDirName(params.taskDirName);
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
if (!task.implementation) throw new Error(`Task ${params.taskDirName} has no implementation`);
const language = task.language ?? 'bash';
const scriptPath = materializeScript(language, task.implementation);
const inputEnv = buildInputEnv(inputs);
const positionalArgs = buildArgs(inputs, task.args);
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
const homeDir = getHomeDirForRole(email, role);
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
let spawnCmd: string[];
let spawnEnv: Record<string, string>;
let spawnCwd: string;
if (sandboxed) {
const prefix = buildSandboxPrefix(email);
const suffix = buildRunuserSuffix();
const userDataPrefix = join(DATA_PATH, email);
const translatePath = (v: string) => (v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v);
const envArgs: string[] = [];
for (const [key, value] of Object.entries(inputEnv)) envArgs.push('--setenv', key, translatePath(value));
const sandboxCmd = cmd.map((arg) => translatePath(arg));
const scriptDir = join(scriptPath, '..');
spawnCmd = [...prefix, '--ro-bind', scriptDir, scriptDir, ...envArgs, ...suffix, ...sandboxCmd];
spawnEnv = {};
spawnCwd = '/';
} else {
spawnCmd = cmd;
spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
spawnCwd = cwd;
}
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
const cleanup = () => {
try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ }
};
emit({ type: 'started', taskName: task.name });
const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' });
// Cooperative cancellation: poll the shared abort flag and tear down the whole process tree.
const abortPoll = setInterval(() => {
if (abortSignal.aborted) {
clearInterval(abortPoll);
try { killTree(proc.pid); } catch { /* already dead */ }
}
}, 500);
const decoder = new TextDecoder();
const pump = async (reader: ReadableStreamDefaultReader<Uint8Array>, type: 'stdout' | 'stderr') => {
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const data = decoder.decode(value);
emit({ type, data });
log.write(data);
}
} catch {
// stream closed
}
};
const [, , exitCode] = await Promise.all([
pump(proc.stdout.getReader(), 'stdout'),
pump(proc.stderr.getReader(), 'stderr'),
proc.exited,
]);
clearInterval(abortPoll);
await new Promise<void>((resolve) => log.end(resolve));
cleanup();
if (abortSignal.aborted) throw new Error('Job was stopped');
emit({ type: 'exit', code: exitCode });
return { exitCode };
}