remove the dead multi-user surface
Officer is single-user: the server owner is the only account, created once by /auth/bootstrap. Everything that existed to serve additional users was unreachable, so it is gone rather than left looking like it does something. Accounts: drop the invite / resend-invite / delete / list-users routes and the Users settings screen, the inert /auth/signup handler, and the account verification chain it fed (verify, resend-verification, VerifyScreen, the UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token survives for password resets only, and now requires a reset-password token rather than accepting any signed JWT. Roles: drop the users.role column and the four-value USER_ROLES enum. The permissions table granted every role identical methods, and every role === 'Super Admin' check was permanently true. The JWT no longer carries a role claim. Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected only for non-Super-Admin users, so it never ran. It was also not a usable agent jail as written — --share-net, the project root (with .env) bound read-only, and runuser dropping to the server's own uid. Rebuilding it for agent containment would be a different construction, and git history keeps this one. getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the owner's real login home, which is what terminals, chats and task runs use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -2,8 +2,7 @@ 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 { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||
import { killTree } from './process-tree';
|
||||
|
||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
||||
@@ -18,8 +17,6 @@ export type ScriptEvent =
|
||||
export type ExecuteScriptParams = {
|
||||
jobId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
@@ -28,9 +25,21 @@ export type ExecuteScriptParams = {
|
||||
};
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python' ? ['python3'] : language === 'typescript' ? ['bun', 'run'] : language === 'javascript' ? ['node'] : ['bash'];
|
||||
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';
|
||||
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)}`);
|
||||
@@ -57,7 +66,7 @@ export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.l
|
||||
// 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 { jobId, email, inputs, abortSignal, emit } = params;
|
||||
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
@@ -70,35 +79,21 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
const positionalArgs = buildArgs(inputs, task.args);
|
||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
||||
|
||||
const homeDir = getHomeDirForRole(email, role);
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
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;
|
||||
}
|
||||
const spawnCmd = cmd;
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
const 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 */ }
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
emit({ type: 'started', taskName: task.name });
|
||||
@@ -109,7 +104,11 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
const abortPoll = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
clearInterval(abortPoll);
|
||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
||||
try {
|
||||
killTree(proc.pid);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import { resolveBaseCwd } from '../chat/websocket';
|
||||
import { SANDBOX_HOME } from '../../sidecar/sandbox';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import type { ChatEvent, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
@@ -41,7 +40,12 @@ type PipelineConfig = {
|
||||
// Messages sent to client
|
||||
export type OutMessage =
|
||||
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> }
|
||||
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| {
|
||||
type: 'step:start';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iteration?: { current: number; total: number; label: string };
|
||||
}
|
||||
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||
@@ -51,8 +55,22 @@ export type OutMessage =
|
||||
| { type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
|
||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
|
||||
| {
|
||||
type: 'tool:start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool:result';
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| { type: 'pipeline:complete'; totalCost: MessageCost }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
@@ -67,7 +85,6 @@ type RunStepParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
taskDirName: string;
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
@@ -92,14 +109,28 @@ async function refreshProxyToken(): Promise<void> {
|
||||
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s
|
||||
|
||||
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
|
||||
async function runAgenticStep({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName,
|
||||
prompt,
|
||||
cwd,
|
||||
model,
|
||||
abortSignal,
|
||||
emit,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
}: RunStepParams): Promise<MessageCost> {
|
||||
const sessionId = randomUUID();
|
||||
const isClaudeCode = model.startsWith('claude-code');
|
||||
|
||||
// Ensure fresh OAuth token before spawning Claude Code
|
||||
if (isClaudeCode) await refreshProxyToken();
|
||||
|
||||
console.log(`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`);
|
||||
console.log(
|
||||
`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`,
|
||||
);
|
||||
|
||||
return new Promise<MessageCost>(async (resolve, reject) => {
|
||||
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
||||
@@ -129,19 +160,42 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
|
||||
break;
|
||||
case 'tool:start':
|
||||
emit({ type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput, stepIndex, iterationLabel });
|
||||
emit({
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
break;
|
||||
case 'tool:result':
|
||||
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
|
||||
emit({
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
settle(() => { cleanup?.(); resolve(event.cost); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
resolve(event.cost);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
settle(() => { cleanup?.(); reject(new Error(event.message)); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error(event.message));
|
||||
});
|
||||
break;
|
||||
case 'stopped':
|
||||
settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error('Step was stopped'));
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -149,14 +203,20 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
// Poll for abort signal and activity timeout
|
||||
const abortPoll = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error('Pipeline was stopped'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Activity timeout (skip for Claude Code which has its own mechanisms)
|
||||
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
|
||||
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
||||
console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`);
|
||||
settle(() => { cleanup?.(); reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`));
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -176,12 +236,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
role,
|
||||
onEvent,
|
||||
});
|
||||
cleanup = handle.kill;
|
||||
} catch (err) {
|
||||
settle(() => { cleanup?.(); reject(err); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -192,16 +254,6 @@ function resolveInputTemplate(template: string, variables: Record<string, string
|
||||
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
|
||||
}
|
||||
|
||||
/** Convert a host-side absolute path to the path the agent sees inside the sandbox. */
|
||||
function toAgentPath(hostPath: string, email: string, role: string): string {
|
||||
if (role === 'Super Admin') return hostPath;
|
||||
const hostHome = getHomeDir(email);
|
||||
if (hostPath.startsWith(hostHome)) {
|
||||
return SANDBOX_HOME + hostPath.slice(hostHome.length);
|
||||
}
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
|
||||
const inputLines = Object.entries(inputs)
|
||||
.filter(([, v]) => v.trim())
|
||||
@@ -219,7 +271,6 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
|
||||
|
||||
type RunScriptStepParams = {
|
||||
email: string;
|
||||
role: string;
|
||||
task: { name: string; implementation: string; language: string; args?: string[] | null };
|
||||
inputs: Record<string, string>;
|
||||
cwd: string;
|
||||
@@ -230,25 +281,43 @@ type RunScriptStepParams = {
|
||||
|
||||
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'];
|
||||
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';
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit, stepIndex }: RunScriptStepParams): Promise<void> {
|
||||
async function runScriptStep({
|
||||
email,
|
||||
task,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
emit,
|
||||
stepIndex,
|
||||
}: RunScriptStepParams): Promise<void> {
|
||||
const language = task.language ?? 'bash';
|
||||
|
||||
// Write script to temp file
|
||||
@@ -260,7 +329,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const cleanup = () => {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
// Build env vars from inputs
|
||||
@@ -275,7 +348,7 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
const runner = getRunner(language);
|
||||
const cmd = [...runner, scriptPath, ...positionalArgs];
|
||||
|
||||
const spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
|
||||
console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`);
|
||||
|
||||
@@ -305,7 +378,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
// Check abort periodically
|
||||
const abortCheck = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -336,7 +413,6 @@ type ForeachParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
stepIdx: number;
|
||||
step: PipelineStep;
|
||||
stepTask: { name: string; body: string };
|
||||
@@ -352,10 +428,22 @@ type ForeachParams = {
|
||||
};
|
||||
|
||||
async function runForeach({
|
||||
userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd,
|
||||
inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
stepIdx,
|
||||
step,
|
||||
stepTask,
|
||||
subdirs,
|
||||
baseCwd,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
totalCost,
|
||||
emit,
|
||||
concurrency,
|
||||
model,
|
||||
}: ForeachParams) {
|
||||
|
||||
// Determine skip vs run
|
||||
const toSkip: string[] = [];
|
||||
const toRun: string[] = [];
|
||||
@@ -400,13 +488,15 @@ async function runForeach({
|
||||
}
|
||||
|
||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||
const targetDir = toAgentPath(resolvedCwd, email, role);
|
||||
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
|
||||
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
|
||||
const targetDir = resolvedCwd;
|
||||
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
||||
|
||||
try {
|
||||
const cost = await runAgenticStep({
|
||||
userId, email, username, role,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName: step.task,
|
||||
prompt,
|
||||
cwd: resolvedCwd,
|
||||
@@ -424,12 +514,19 @@ async function runForeach({
|
||||
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
|
||||
} catch (err) {
|
||||
if (!abortSignal.aborted) {
|
||||
emit({ type: 'iteration:error', stepIndex: stepIdx, label: subdir, error: err instanceof Error ? err.message : String(err) });
|
||||
emit({
|
||||
type: 'iteration:error',
|
||||
stepIndex: stepIdx,
|
||||
label: subdir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const p = run().then(() => { executing.delete(p); });
|
||||
const p = run().then(() => {
|
||||
executing.delete(p);
|
||||
});
|
||||
executing.add(p);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
@@ -446,7 +543,6 @@ export type ExecutePipelineParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
@@ -456,7 +552,18 @@ export type ExecutePipelineParams = {
|
||||
emit: EmitEvent;
|
||||
};
|
||||
|
||||
export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, model: modelOverride, startAt, abortSignal, emit }: ExecutePipelineParams): Promise<void> {
|
||||
export async function executePipeline({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName,
|
||||
inputs,
|
||||
cwd,
|
||||
model: modelOverride,
|
||||
startAt,
|
||||
abortSignal,
|
||||
emit,
|
||||
}: ExecutePipelineParams): Promise<void> {
|
||||
const pipelineTask = await getTaskByDirName(taskDirName);
|
||||
if (!pipelineTask) {
|
||||
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
|
||||
@@ -473,7 +580,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCwd = resolveBaseCwd(email, role, cwd);
|
||||
const baseCwd = resolveBaseCwd(email, cwd);
|
||||
let model = modelOverride || (await resolveModel(userId));
|
||||
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||
@@ -532,8 +639,13 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
|
||||
try {
|
||||
await runScriptStep({
|
||||
email, role,
|
||||
task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null },
|
||||
email,
|
||||
task: {
|
||||
name: stepTask.name,
|
||||
implementation: stepTask.implementation,
|
||||
language: stepTask.language ?? 'bash',
|
||||
args: stepTask.args as string[] | null,
|
||||
},
|
||||
inputs: resolvedInputs,
|
||||
cwd: baseCwd,
|
||||
abortSignal,
|
||||
@@ -566,19 +678,33 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
const concurrency = step.concurrency ? runtimeConcurrency : 1;
|
||||
|
||||
await runForeach({
|
||||
userId, email, username, role,
|
||||
stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! },
|
||||
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
stepIdx,
|
||||
step,
|
||||
stepTask: { name: stepTask.name, body: stepTask.body! },
|
||||
subdirs,
|
||||
baseCwd,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
totalCost,
|
||||
emit,
|
||||
concurrency,
|
||||
model,
|
||||
});
|
||||
} else {
|
||||
// Single execution step
|
||||
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
||||
|
||||
const targetDir = toAgentPath(baseCwd, email, role);
|
||||
const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir);
|
||||
const targetDir = baseCwd;
|
||||
const prompt = buildStepPrompt(stepTask.body!, resolvedInputs, targetDir);
|
||||
|
||||
const cost = await runAgenticStep({
|
||||
userId, email, username, role,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName: step.task,
|
||||
prompt,
|
||||
cwd: baseCwd,
|
||||
@@ -609,8 +735,6 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type ClientMessage =
|
||||
@@ -633,7 +757,7 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'run': {
|
||||
const { userId, email, username, role } = ws.data;
|
||||
const { userId, email, username } = ws.data;
|
||||
|
||||
// Resolve task name for the DB record
|
||||
const task = await getTaskByDirName(msg.taskDirName);
|
||||
@@ -646,7 +770,6 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
role,
|
||||
taskDirName: msg.taskDirName,
|
||||
taskName: task.name,
|
||||
inputs: msg.inputs,
|
||||
@@ -672,7 +795,13 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
// Job not live — send the DB state
|
||||
const job = await jobManager.getJob(msg.jobId);
|
||||
if (job) {
|
||||
send(ws, { type: 'job:state', jobId: msg.jobId, status: job.status, progress: job.progress, cost: job.totalCost });
|
||||
send(ws, {
|
||||
type: 'job:state',
|
||||
jobId: msg.jobId,
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
cost: job.totalCost,
|
||||
});
|
||||
} else {
|
||||
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
|
||||
}
|
||||
@@ -682,7 +811,19 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
|
||||
case 'list': {
|
||||
const jobs = await jobManager.getJobsForUser(ws.data.userId);
|
||||
send(ws, { type: 'job:list', jobs: jobs.map((j) => ({ id: j.id, taskDirName: j.taskDirName, taskName: j.taskName, status: j.status, isLive: j.isLive, totalCost: j.totalCost, createdAt: j.createdAt, completedAt: j.completedAt })) });
|
||||
send(ws, {
|
||||
type: 'job:list',
|
||||
jobs: jobs.map((j) => ({
|
||||
id: j.id,
|
||||
taskDirName: j.taskDirName,
|
||||
taskName: j.taskName,
|
||||
status: j.status,
|
||||
isLive: j.isLive,
|
||||
totalCost: j.totalCost,
|
||||
createdAt: j.createdAt,
|
||||
completedAt: j.completedAt,
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type LiveJob = {
|
||||
@@ -74,9 +72,7 @@ type StartJobParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller
|
||||
sandboxed?: boolean; // script jobs only
|
||||
taskDirName: string;
|
||||
taskName: string;
|
||||
inputs: Record<string, string>;
|
||||
@@ -92,7 +88,10 @@ export function runningCount(): number {
|
||||
|
||||
// Create a job. action 'start' runs it now; 'queue' runs it only if nothing is running, else it stays
|
||||
// 'pending' and gets promoted when the running job finishes. (Single user → one global queue.)
|
||||
export async function enqueueJob(params: StartJobParams, action: 'start' | 'queue'): Promise<{ jobId: string; status: 'running' | 'pending' }> {
|
||||
export async function enqueueJob(
|
||||
params: StartJobParams,
|
||||
action: 'start' | 'queue',
|
||||
): Promise<{ jobId: string; status: 'running' | 'pending' }> {
|
||||
const jobId = randomUUID();
|
||||
const mode: JobMode = params.mode ?? 'pipeline';
|
||||
const run = action === 'start' || runningCount() === 0;
|
||||
@@ -145,7 +144,11 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
if (event.type === 'step:complete' || event.type === 'iteration:complete') {
|
||||
const cost = 'cost' in event ? event.cost : undefined;
|
||||
if (cost) {
|
||||
const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
job.lastCost = {
|
||||
inputTokens: prev.inputTokens + cost.inputTokens,
|
||||
outputTokens: prev.outputTokens + cost.outputTokens,
|
||||
@@ -177,8 +180,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
? executeScript({
|
||||
jobId,
|
||||
email: params.email,
|
||||
role: params.role,
|
||||
sandboxed: params.sandboxed ?? false,
|
||||
taskDirName: params.taskDirName,
|
||||
inputs: params.inputs,
|
||||
cwd: params.cwd,
|
||||
@@ -189,7 +190,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
userId: params.userId,
|
||||
email: params.email,
|
||||
username: params.username,
|
||||
role: params.role,
|
||||
taskDirName: params.taskDirName,
|
||||
inputs: params.inputs,
|
||||
cwd: params.cwd,
|
||||
@@ -199,36 +199,38 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
emit,
|
||||
});
|
||||
|
||||
runner.then(async (result) => {
|
||||
clearInterval(flushInterval);
|
||||
// Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void.
|
||||
const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null;
|
||||
const failed = exitCode !== null && exitCode !== 0;
|
||||
await updatePipelineJob(jobId, {
|
||||
status: failed ? 'failed' : 'completed',
|
||||
exitCode,
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: failed ? `Script exited with code ${exitCode}` : undefined,
|
||||
completedAt: new Date(),
|
||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
}).catch(async (err) => {
|
||||
clearInterval(flushInterval);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isStopped = job.abortSignal.aborted;
|
||||
broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message });
|
||||
await updatePipelineJob(jobId, {
|
||||
status: isStopped ? 'stopped' : 'failed',
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: isStopped ? undefined : message,
|
||||
completedAt: new Date(),
|
||||
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
});
|
||||
runner
|
||||
.then(async (result) => {
|
||||
clearInterval(flushInterval);
|
||||
// Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void.
|
||||
const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null;
|
||||
const failed = exitCode !== null && exitCode !== 0;
|
||||
await updatePipelineJob(jobId, {
|
||||
status: failed ? 'failed' : 'completed',
|
||||
exitCode,
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: failed ? `Script exited with code ${exitCode}` : undefined,
|
||||
completedAt: new Date(),
|
||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
})
|
||||
.catch(async (err) => {
|
||||
clearInterval(flushInterval);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isStopped = job.abortSignal.aborted;
|
||||
broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message });
|
||||
await updatePipelineJob(jobId, {
|
||||
status: isStopped ? 'stopped' : 'failed',
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: isStopped ? undefined : message,
|
||||
completedAt: new Date(),
|
||||
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
});
|
||||
}
|
||||
|
||||
// When a job finishes (and nothing else is running), promote the oldest queued job. Also called on
|
||||
@@ -239,7 +241,9 @@ async function promoteNext(): Promise<void> {
|
||||
if (!next) return;
|
||||
const user = await getUserById(next.userId);
|
||||
if (!user) {
|
||||
await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(() => {});
|
||||
await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(
|
||||
() => {},
|
||||
);
|
||||
return promoteNext();
|
||||
}
|
||||
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
||||
@@ -248,9 +252,7 @@ async function promoteNext(): Promise<void> {
|
||||
userId: next.userId,
|
||||
email: user.email,
|
||||
username: toShellUsername(user.username ?? '', user.email),
|
||||
role: user.role ?? '',
|
||||
mode: nextMode,
|
||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||
taskDirName: next.taskDirName,
|
||||
taskName: next.taskName,
|
||||
inputs: next.inputs as Record<string, string>,
|
||||
@@ -340,7 +342,9 @@ export async function clearHistory(userId: number): Promise<number> {
|
||||
|
||||
// Lightweight header-badge summary: how many of the user's jobs are running / queued, and which one
|
||||
// is running (for the "running" badge's link).
|
||||
export async function getCounts(userId: number): Promise<{ running: number; runningJobId: string | null; queued: number }> {
|
||||
export async function getCounts(
|
||||
userId: number,
|
||||
): Promise<{ running: number; runningJobId: string | null; queued: number }> {
|
||||
let running = 0;
|
||||
let runningJobId: string | null = null;
|
||||
for (const [id, job] of liveJobs) {
|
||||
@@ -386,7 +390,11 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
return {
|
||||
...p,
|
||||
currentStepIndex: event.stepIndex,
|
||||
parallel: { taskName: event.taskName, concurrency: event.concurrency, iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })) },
|
||||
parallel: {
|
||||
taskName: event.taskName,
|
||||
concurrency: event.concurrency,
|
||||
iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })),
|
||||
},
|
||||
};
|
||||
|
||||
case 'iteration:start':
|
||||
@@ -396,7 +404,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
...p,
|
||||
parallel: {
|
||||
...parallel,
|
||||
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status: 'running' } : it),
|
||||
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status: 'running' } : it)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -411,7 +419,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
...p,
|
||||
parallel: {
|
||||
...parallel,
|
||||
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status } : it),
|
||||
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status } : it)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,12 @@ pipelineJobsRouter.get('/', async (c) => {
|
||||
// job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use.
|
||||
pipelineJobsRouter.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json<{ taskDirName: string; inputs?: Record<string, string>; cwd?: string; action?: 'start' | 'queue' }>();
|
||||
const body = await c.req.json<{
|
||||
taskDirName: string;
|
||||
inputs?: Record<string, string>;
|
||||
cwd?: string;
|
||||
action?: 'start' | 'queue';
|
||||
}>();
|
||||
if (!body.taskDirName) throw errors.BAD_REQUEST('taskDirName is required');
|
||||
|
||||
const task = await getTaskByDirName(body.taskDirName);
|
||||
@@ -52,9 +57,7 @@ pipelineJobsRouter.post('/', async (c) => {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username ?? '',
|
||||
role: user.role ?? '',
|
||||
mode,
|
||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||
taskDirName: body.taskDirName,
|
||||
taskName: task.name,
|
||||
inputs: body.inputs ?? {},
|
||||
|
||||
@@ -2,15 +2,12 @@ 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 { getHomeDirForRole, DATA_PATH } from '../../data-path';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type RunMessage = {
|
||||
@@ -79,11 +76,19 @@ function descendantPids(root: number): number[] {
|
||||
function killTree(root: number) {
|
||||
const pids = [root, ...descendantPids(root)];
|
||||
for (const pid of pids) {
|
||||
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const pid of pids) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -96,21 +101,31 @@ function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
||||
|
||||
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'];
|
||||
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';
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +157,7 @@ function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null):
|
||||
}
|
||||
|
||||
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
const { email, role, sandboxed } = ws.data;
|
||||
const { email } = ws.data;
|
||||
|
||||
// Resolve task from the file-backed store
|
||||
const task = await getTaskByDirName(msg.taskDirName);
|
||||
@@ -178,44 +193,19 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
|
||||
// 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 = getHomeDirForRole(email, role);
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
|
||||
|
||||
let spawnCmd: string[];
|
||||
let spawnEnv: Record<string, string>;
|
||||
let spawnCwd: string;
|
||||
|
||||
if (sandboxed) {
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
const suffix = buildRunuserSuffix();
|
||||
|
||||
// Translate paths in inputs and args: DATA_PATH/{email}/... → /data/...
|
||||
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));
|
||||
}
|
||||
|
||||
// Translate positional args too
|
||||
const sandboxCmd = cmd.map((arg) => translatePath(arg));
|
||||
|
||||
// Script is in /tmp which is a tmpfs inside bwrap — need to bind-mount the host tmp dir
|
||||
const scriptDir = join(scriptPath, '..');
|
||||
const extraMounts = ['--ro-bind', scriptDir, scriptDir];
|
||||
|
||||
spawnCmd = [...prefix, ...extraMounts, ...envArgs, ...suffix, ...sandboxCmd];
|
||||
spawnEnv = {};
|
||||
spawnCwd = '/';
|
||||
} else {
|
||||
spawnCmd = cmd;
|
||||
spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
|
||||
spawnCwd = cwd;
|
||||
}
|
||||
const spawnCmd = cmd;
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
|
||||
const cleanup = () => {
|
||||
try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
send(ws, { type: 'started', taskName: task.name });
|
||||
@@ -231,7 +221,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
activeProcs.set(ws, {
|
||||
proc,
|
||||
kill: () => {
|
||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
||||
try {
|
||||
killTree(proc.pid);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -239,7 +233,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
// 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 */ }
|
||||
try {
|
||||
ws.ping();
|
||||
} catch {
|
||||
/* socket gone */
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const stdoutReader = proc.stdout.getReader();
|
||||
|
||||
Reference in New Issue
Block a user