Files
platform/src/server.tsx
T
pastilhasandClaude Opus 4.6 5e38343f44 add pipeline job management with /jobs pages and per-step output viewer
- Pipeline jobs now persist to DB with progress tracking and cost accumulation
- Jobs survive WebSocket disconnects with in-memory event buffer replay
- New /jobs list page with search, status badges, and cost display
- New /jobs/:id detail page with live WebSocket attachment and REST fallback
- Two-column layout using WorkspaceLayout for resizable steps/output panels
- Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping
- TaskRunnerModal links to job detail page once job is created
- Dock entry added for Jobs page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:48:50 +00:00

372 lines
13 KiB
TypeScript

import './servers/bootstrap';
import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { honoServer } from './servers/hono';
import { verify } from './servers/jwt';
import { isTokenBlacklisted } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { piWebsocket } from './servers/api/pi/websocket';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
import { cliampWebsocket } from './servers/api/cliamp/websocket';
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
import { desktopWebsocket } from './servers/api/desktop/websocket';
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
import officerWeb from './apps/officer-web/index.html';
import { startBrowserRelay } from './servers/api/browser/relay';
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
import { toShellUsername } from './servers/data-path';
const { PORT = '5000' } = process.env;
type WSData = {
userId: number;
email: string;
username: string;
role: string;
provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
sandboxed: boolean;
sessionId?: string;
cwd?: string;
command?: string;
cols?: number;
rows?: number;
files?: string;
devServerPort?: number;
devServerSlug?: string;
wsProxyPath?: string;
wsToken?: string;
};
// Sidecar registration WebSocket handler
const sidecarConnections = new Map<ServerWebSocket<WSData>, string>(); // ws → sidecar ID
const sidecarWebsocket = {
open(_ws: ServerWebSocket<WSData>) {
// Wait for registration message
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
try {
const data = typeof raw === 'string' ? raw : raw.toString();
const msg = JSON.parse(data);
if (msg.type === 'register') {
const id = registerSidecar(ws, msg as SidecarRegistration);
sidecarConnections.set(ws, id);
ws.send(JSON.stringify({ type: 'registered', id }));
return;
}
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) {
handleSidecarQueueCommand(ws, msg);
return;
}
const id = sidecarConnections.get(ws);
if (id) {
handleSidecarMessage(id, msg);
}
} catch {
// skip malformed messages
}
},
close(ws: ServerWebSocket<WSData>) {
const id = sidecarConnections.get(ws);
if (id) {
unregisterSidecar(id);
sidecarConnections.delete(ws);
}
},
drain() {},
};
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
const id = msg.id as string;
try {
switch (msg.type) {
case 'queue:enqueue': {
const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams);
ws.send(JSON.stringify({ type: 'queue:enqueued', id, job }));
break;
}
case 'queue:cancel': {
const job = await queueCancel(msg.jobId as string);
ws.send(JSON.stringify({ type: 'queue:cancelled', id, job }));
break;
}
case 'queue:list': {
const jobs = await queueList();
ws.send(JSON.stringify({ type: 'queue:list', id, jobs }));
break;
}
case 'queue:get': {
const job = await queueGet(msg.jobId as string);
ws.send(JSON.stringify({ type: 'queue:get', id, job }));
break;
}
default:
ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) }));
}
}
const handlers: Record<string, any> = {
terminal: terminalWebsocket,
pi: piWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
cliamp: cliampWebsocket,
'cliamp-audio': cliampAudioWebsocket,
desktop: desktopWebsocket,
sidecar: sidecarWebsocket,
};
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean };
const devServerUpstreams = new Map<ServerWebSocket<WSData>, UpstreamState>();
const devServerWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { devServerPort, wsProxyPath, wsToken } = ws.data;
// Validate JWT (deferred from upgrade which must be synchronous in Bun)
if (!wsToken) {
ws.close(4001, 'Unauthorized');
return;
}
try {
const payload = await verify(wsToken);
if (!payload) { ws.close(4001, 'Unauthorized'); return; }
if (payload.jti && await isTokenBlacklisted(payload.jti)) { ws.close(4001, 'Unauthorized'); return; }
} catch {
ws.close(4001, 'Unauthorized');
return;
}
const upstream = new WebSocket(`ws://localhost:${devServerPort}${wsProxyPath}`);
const state: UpstreamState = { ws: upstream, queue: [], ready: false };
devServerUpstreams.set(ws, state);
upstream.addEventListener('open', () => {
state.ready = true;
for (const msg of state.queue) upstream.send(msg);
state.queue.length = 0;
});
upstream.addEventListener('message', (event) => {
ws.send(event.data as string | ArrayBuffer);
});
upstream.addEventListener('close', () => {
devServerUpstreams.delete(ws);
ws.close();
});
upstream.addEventListener('error', () => {
devServerUpstreams.delete(ws);
ws.close();
});
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const state = devServerUpstreams.get(ws);
if (!state) return;
if (state.ready) {
state.ws.send(raw);
} else {
state.queue.push(raw);
}
},
close(ws: ServerWebSocket<WSData>) {
const state = devServerUpstreams.get(ws);
if (state) {
state.ws.close();
devServerUpstreams.delete(ws);
}
},
};
handlers['dev-server'] = devServerWebsocket;
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop') {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
try {
const user = await verify(token);
if (!user) return new Response('Unauthorized', { status: 401 });
if (user.jti) {
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
}
const url = new URL(req.url);
const sessionId = url.searchParams.get('sessionId') ?? undefined;
const sandboxed = user.role !== 'Super Admin';
const cwd = url.searchParams.get('cwd') ?? undefined;
const command = url.searchParams.get('command') ?? undefined;
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
const files = url.searchParams.get('files') ?? undefined;
const ok = server.upgrade(req, {
data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows, files },
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch {
return new Response('Unauthorized', { status: 401 });
}
}
function upgradeDevServerWs(req: Request, server: any) {
const url = new URL(req.url);
const match = url.pathname.match(/^\/api\/dev-server-proxy\/([^/]+)(\/.*)?$/);
if (!match) return new Response('Not found', { status: 404 });
const proxyId = match[1]!;
const entry = findEntryByProxyId(proxyId);
if (!entry) return new Response('No dev server running', { status: 404 });
const wsToken = url.searchParams.get('token');
if (!wsToken) return new Response('Unauthorized', { status: 401 });
touchEntry(entry);
const wsProxyPath = match[2] || '/';
const ok = server.upgrade(req, {
data: {
userId: 0,
email: '',
role: '',
provider: 'dev-server' as const,
sandboxed: false,
devServerPort: entry.port,
devServerSlug: proxyId,
wsProxyPath,
wsToken,
},
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
}
const server = serve({
port: Number(PORT),
idleTimeout: 60,
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
routes: {
'/og-image.jpg': () => new Response(Bun.file('public/og-image.jpg'), { headers: { 'Content-Type': 'image/jpeg' } }),
'/novnc/*': (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
},
'/vendor/*': (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
},
'/api/dev-server-proxy/*': (req, server) => {
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
return honoServer.fetch(req, server);
},
'/api/sidecar/register': (req: Request, server: any) => {
const ok = server.upgrade(req, {
data: { provider: 'sidecar', userId: 0, email: '', username: '', role: '', sandboxed: false },
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
},
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
'/': officerWeb,
'/*': officerWeb,
'/api': honoServer.fetch,
'/api/*': honoServer.fetch,
},
websocket: {
open(ws) {
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
handlers[provider]!.open(ws as any);
},
message(ws, raw) {
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
handlers[provider]!.message(ws as any, raw);
},
close(ws) {
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
handlers[provider]!.close(ws as any);
},
drain() {},
},
development: process.env.NODE_ENV !== 'production' && {
hmr: true,
},
});
console.log(`🚀 Server running at ${server.url}`);
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
try {
await startBrowserRelay(BROWSER_RELAY_PORT);
console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
} catch (err) {
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
}
// Initialize queue engine in API server process
import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet } from './servers/queue/init';
initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
// Mark any orphaned pipeline jobs from previous server run
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
(async () => {
const pulseaudio = Bun.which('pulseaudio');
const pactl = Bun.which('pactl');
if (!pulseaudio || !pactl) {
console.log('[cliamp] pulseaudio not installed, skipping audio setup');
return;
}
// Start PulseAudio daemon if not running
const check = Bun.spawnSync({ cmd: [pulseaudio, '--check'], stdout: 'ignore', stderr: 'ignore' });
if (check.exitCode !== 0) {
const start = Bun.spawnSync({ cmd: [pulseaudio, '--start', '-D'], stdout: 'ignore', stderr: 'ignore' });
if (start.exitCode !== 0) {
console.error('[cliamp] failed to start pulseaudio');
return;
}
console.log('[cliamp] pulseaudio started');
} else {
console.log('[cliamp] pulseaudio already running');
}
// Load null sink if not already loaded
const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' });
const sinkList = sinks.stdout.toString();
if (!sinkList.includes('virtual_out')) {
const load = Bun.spawnSync({
cmd: [pactl, 'load-module', 'module-null-sink', 'sink_name=virtual_out', 'sink_properties=device.description=Virtual_Output'],
stdout: 'pipe',
stderr: 'pipe',
});
if (load.exitCode !== 0) {
console.error('[cliamp] failed to load null sink:', load.stderr.toString().trim());
} else {
console.log('[cliamp] virtual_out null sink loaded');
}
} else {
console.log('[cliamp] virtual_out sink already exists');
}
})();
// Pi check/install is handled by bootstrap.ts (imported above)