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>
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import { randomUUID } from 'crypto';
|
|
import { EventEmitter } from 'events';
|
|
import {
|
|
createPipelineJob,
|
|
getPipelineJob,
|
|
updatePipelineJob,
|
|
getPipelineJobsForUser,
|
|
getOldestPendingJob,
|
|
countPendingJobs,
|
|
deletePipelineJob,
|
|
deleteTerminalJobsForUser,
|
|
markInterruptedJobs,
|
|
getUserById,
|
|
} from 'officerdb';
|
|
import { unlink } from 'node:fs/promises';
|
|
import { toShellUsername } from '../../data-path';
|
|
import { executePipeline } from './pipeline-executor';
|
|
import type { OutMessage } from './pipeline-executor';
|
|
import { executeScript, jobLogPath, type ScriptEvent } from './execute-script';
|
|
|
|
// Everything a job can stream — pipeline structural events plus script stdout/stderr/exit.
|
|
type JobEvent = OutMessage | ScriptEvent;
|
|
type JobMode = 'pipeline' | 'script' | 'agentic';
|
|
|
|
type WSData = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
};
|
|
|
|
type LiveJob = {
|
|
jobId: string;
|
|
userId: number;
|
|
abortSignal: { aborted: boolean };
|
|
emitter: EventEmitter;
|
|
eventBuffer: JobEvent[];
|
|
viewers: Set<ServerWebSocket<WSData>>;
|
|
progressDirty: boolean;
|
|
lastProgress: unknown;
|
|
lastCost: unknown;
|
|
};
|
|
|
|
const EVENT_BUFFER_SIZE = 500;
|
|
const PROGRESS_FLUSH_MS = 3000;
|
|
|
|
const liveJobs = new Map<string, LiveJob>();
|
|
|
|
function sendToViewer(ws: ServerWebSocket<WSData>, jobId: string, event: JobEvent) {
|
|
if (ws.readyState === 1) {
|
|
ws.send(JSON.stringify({ jobId, ...event }));
|
|
}
|
|
}
|
|
|
|
function broadcast(job: LiveJob, event: JobEvent) {
|
|
// Buffer for replay — skip high-frequency streams (deltas / script stdout+stderr). Those are
|
|
// live-only; a late viewer backfills script output from the persisted log file instead.
|
|
if (event.type !== 'assistant:delta' && event.type !== 'stdout' && event.type !== 'stderr') {
|
|
job.eventBuffer.push(event);
|
|
if (job.eventBuffer.length > EVENT_BUFFER_SIZE) {
|
|
job.eventBuffer.shift();
|
|
}
|
|
}
|
|
|
|
// Forward to all viewers
|
|
for (const ws of job.viewers) {
|
|
sendToViewer(ws, job.jobId, event);
|
|
}
|
|
}
|
|
|
|
type StartJobParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller
|
|
taskDirName: string;
|
|
taskName: string;
|
|
inputs: Record<string, string>;
|
|
cwd?: string;
|
|
config: unknown;
|
|
model?: string;
|
|
startAt?: number;
|
|
};
|
|
|
|
export function runningCount(): number {
|
|
return liveJobs.size;
|
|
}
|
|
|
|
// 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' }> {
|
|
const jobId = randomUUID();
|
|
const mode: JobMode = params.mode ?? 'pipeline';
|
|
const run = action === 'start' || runningCount() === 0;
|
|
await createPipelineJob({
|
|
id: jobId,
|
|
userId: params.userId,
|
|
taskDirName: params.taskDirName,
|
|
taskName: params.taskName,
|
|
mode,
|
|
status: run ? 'running' : 'pending',
|
|
inputs: params.inputs,
|
|
cwd: params.cwd ?? null,
|
|
config: params.config ?? {}, // column is NOT NULL; many script tasks have no config block
|
|
startedAt: run ? new Date() : null,
|
|
});
|
|
if (run) launch(jobId, mode, params);
|
|
return { jobId, status: run ? 'running' : 'pending' };
|
|
}
|
|
|
|
// Back-compat: the pipeline WS caller expects startJob(params) -> jobId, running immediately.
|
|
export async function startJob(params: StartJobParams): Promise<string> {
|
|
return (await enqueueJob(params, 'start')).jobId;
|
|
}
|
|
|
|
// Build live state + run the executor for a job whose DB row is already status 'running'.
|
|
function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
|
// Create live state
|
|
const job: LiveJob = {
|
|
jobId,
|
|
userId: params.userId,
|
|
abortSignal: { aborted: false },
|
|
emitter: new EventEmitter(),
|
|
eventBuffer: [],
|
|
viewers: new Set(),
|
|
progressDirty: false,
|
|
lastProgress: null,
|
|
lastCost: null,
|
|
};
|
|
liveJobs.set(jobId, job);
|
|
|
|
// Emit callback — broadcasts to viewers and tracks progress
|
|
const emit = (event: JobEvent) => {
|
|
broadcast(job, event);
|
|
|
|
// Track progress for DB persistence (skip streaming messages — only structural events)
|
|
if (event.type === 'pipeline:init' || event.type.startsWith('step:') || event.type.startsWith('iteration:')) {
|
|
job.progressDirty = true;
|
|
job.lastProgress = extractProgress(event, job.lastProgress);
|
|
}
|
|
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,
|
|
};
|
|
job.lastCost = {
|
|
inputTokens: prev.inputTokens + cost.inputTokens,
|
|
outputTokens: prev.outputTokens + cost.outputTokens,
|
|
totalUSD: prev.totalUSD + cost.totalUSD,
|
|
};
|
|
job.progressDirty = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Periodic progress flush to DB
|
|
const flushInterval = setInterval(async () => {
|
|
if (job.progressDirty) {
|
|
job.progressDirty = false;
|
|
try {
|
|
await updatePipelineJob(jobId, {
|
|
progress: job.lastProgress as Record<string, unknown>,
|
|
totalCost: job.lastCost as Record<string, unknown>,
|
|
});
|
|
} catch (err) {
|
|
console.error(`[pipeline-jobs] failed to flush progress for ${jobId}:`, err);
|
|
}
|
|
}
|
|
}, PROGRESS_FLUSH_MS);
|
|
|
|
// Run the job in the background — dispatch by mode.
|
|
const runner: Promise<void | { exitCode: number }> =
|
|
mode === 'script'
|
|
? executeScript({
|
|
jobId,
|
|
email: params.email,
|
|
taskDirName: params.taskDirName,
|
|
inputs: params.inputs,
|
|
cwd: params.cwd,
|
|
abortSignal: job.abortSignal,
|
|
emit,
|
|
})
|
|
: executePipeline({
|
|
userId: params.userId,
|
|
email: params.email,
|
|
username: params.username,
|
|
taskDirName: params.taskDirName,
|
|
inputs: params.inputs,
|
|
cwd: params.cwd,
|
|
model: params.model,
|
|
startAt: params.startAt,
|
|
abortSignal: job.abortSignal,
|
|
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();
|
|
});
|
|
}
|
|
|
|
// When a job finishes (and nothing else is running), promote the oldest queued job. Also called on
|
|
// startup to resume a backlog. Rebuilds the user context from userId since there's no request here.
|
|
async function promoteNext(): Promise<void> {
|
|
if (runningCount() > 0) return;
|
|
const next = await getOldestPendingJob();
|
|
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(
|
|
() => {},
|
|
);
|
|
return promoteNext();
|
|
}
|
|
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
|
const nextMode = (next.mode as JobMode) ?? 'pipeline';
|
|
launch(next.id, nextMode, {
|
|
userId: next.userId,
|
|
email: user.email,
|
|
username: toShellUsername(user.username ?? '', user.email),
|
|
mode: nextMode,
|
|
taskDirName: next.taskDirName,
|
|
taskName: next.taskName,
|
|
inputs: next.inputs as Record<string, string>,
|
|
cwd: next.cwd ?? undefined,
|
|
config: next.config,
|
|
});
|
|
}
|
|
|
|
export function attachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
|
|
const job = liveJobs.get(jobId);
|
|
if (!job) return false;
|
|
|
|
job.viewers.add(ws);
|
|
|
|
// Replay buffered events so the viewer catches up
|
|
for (const event of job.eventBuffer) {
|
|
sendToViewer(ws, jobId, event);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function detachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
|
|
const job = liveJobs.get(jobId);
|
|
if (job) job.viewers.delete(ws);
|
|
}
|
|
|
|
export function detachAllForViewer(ws: ServerWebSocket<WSData>) {
|
|
for (const job of liveJobs.values()) {
|
|
job.viewers.delete(ws);
|
|
}
|
|
}
|
|
|
|
export function stopJob(jobId: string): boolean {
|
|
const job = liveJobs.get(jobId);
|
|
if (!job) return false;
|
|
job.abortSignal.aborted = true;
|
|
return true;
|
|
}
|
|
|
|
// Stop a running job (cooperative abort) OR cancel a queued one (mark stopped so it won't promote).
|
|
export async function requestStop(jobId: string): Promise<'stopped' | 'cancelled' | 'noop'> {
|
|
if (stopJob(jobId)) return 'stopped';
|
|
const job = await getPipelineJob(jobId);
|
|
if (job && job.status === 'pending') {
|
|
await updatePipelineJob(jobId, { status: 'stopped', completedAt: new Date() });
|
|
return 'cancelled';
|
|
}
|
|
return 'noop';
|
|
}
|
|
|
|
export function isJobLive(jobId: string): boolean {
|
|
return liveJobs.has(jobId);
|
|
}
|
|
|
|
export function getLiveJobForUser(userId: number): string | null {
|
|
for (const [jobId, job] of liveJobs) {
|
|
if (job.userId === userId) return jobId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function getJobsForUser(userId: number) {
|
|
const jobs = await getPipelineJobsForUser(userId);
|
|
return jobs.map((j) => ({
|
|
...j,
|
|
isLive: liveJobs.has(j.id),
|
|
}));
|
|
}
|
|
|
|
// Delete a job (row + log). Refuses a running job — it must be stopped first.
|
|
export async function deleteJob(jobId: string): Promise<'deleted' | 'running' | 'noop'> {
|
|
if (liveJobs.has(jobId)) return 'running';
|
|
const job = await getPipelineJob(jobId);
|
|
if (!job) return 'noop';
|
|
await deletePipelineJob(jobId);
|
|
await unlink(jobLogPath(jobId)).catch(() => {});
|
|
return 'deleted';
|
|
}
|
|
|
|
// Clear all finished jobs (rows + logs). Running/queued jobs are untouched.
|
|
export async function clearHistory(userId: number): Promise<number> {
|
|
const ids = await deleteTerminalJobsForUser(userId);
|
|
await Promise.all(ids.map((id) => unlink(jobLogPath(id)).catch(() => {})));
|
|
return ids.length;
|
|
}
|
|
|
|
// 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 }> {
|
|
let running = 0;
|
|
let runningJobId: string | null = null;
|
|
for (const [id, job] of liveJobs) {
|
|
if (job.userId === userId) {
|
|
running++;
|
|
if (!runningJobId) runningJobId = id;
|
|
}
|
|
}
|
|
const queued = await countPendingJobs(userId);
|
|
return { running, runningJobId, queued };
|
|
}
|
|
|
|
export async function getJob(jobId: string) {
|
|
const job = await getPipelineJob(jobId);
|
|
if (!job) return null;
|
|
return { ...job, isLive: liveJobs.has(job.id) };
|
|
}
|
|
|
|
export async function cleanupOnStartup() {
|
|
const count = await markInterruptedJobs();
|
|
if (count > 0) {
|
|
console.log(`[pipeline-jobs] marked ${count} interrupted jobs on startup`);
|
|
}
|
|
// Resume any queued backlog that survived the restart.
|
|
await promoteNext();
|
|
}
|
|
|
|
/** Extract minimal progress info from events for DB storage. Script events fall through to `prev`. */
|
|
function extractProgress(event: JobEvent, prev: unknown): unknown {
|
|
const p = (prev as Record<string, unknown>) ?? {};
|
|
|
|
switch (event.type) {
|
|
case 'pipeline:init':
|
|
return { ...p, steps: event.steps, currentStepIndex: -1 };
|
|
|
|
case 'step:start':
|
|
return { ...p, currentStepIndex: event.stepIndex, currentIteration: event.iteration ?? null };
|
|
|
|
case 'step:complete':
|
|
return { ...p, currentStepIndex: event.stepIndex };
|
|
|
|
case 'step:parallel':
|
|
return {
|
|
...p,
|
|
currentStepIndex: event.stepIndex,
|
|
parallel: {
|
|
taskName: event.taskName,
|
|
concurrency: event.concurrency,
|
|
iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })),
|
|
},
|
|
};
|
|
|
|
case 'iteration:start':
|
|
if (p.parallel && Array.isArray((p.parallel as Record<string, unknown>).iterations)) {
|
|
const parallel = p.parallel as { iterations: Array<{ label: string; status: string }> };
|
|
return {
|
|
...p,
|
|
parallel: {
|
|
...parallel,
|
|
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status: 'running' } : it)),
|
|
},
|
|
};
|
|
}
|
|
return p;
|
|
|
|
case 'iteration:complete':
|
|
case 'iteration:error':
|
|
if (p.parallel && Array.isArray((p.parallel as Record<string, unknown>).iterations)) {
|
|
const parallel = p.parallel as { iterations: Array<{ label: string; status: string }> };
|
|
const status = event.type === 'iteration:complete' ? 'complete' : 'error';
|
|
return {
|
|
...p,
|
|
parallel: {
|
|
...parallel,
|
|
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status } : it)),
|
|
},
|
|
};
|
|
}
|
|
return p;
|
|
|
|
default:
|
|
return p;
|
|
}
|
|
}
|