BID · Console
Baseline · Intelligence · Decision
web/server.ts 6,398 bytes · typescript
/**
 * BID Console — light web UI for partner demos.
 *
 * Single Node http server (no Express, no framework, no build step).
 * Three things it does:
 *   1. Browse past runs from output/run-*.json — list + detail pages
 *   2. Run a fresh question live, streaming the pipeline's stdout via
 *      Server-Sent Events as the agents work
 *   3. Code browser over src/, scripts/, web/ with syntax highlighting
 *
 *   npm run web              # default port 4178
 *   PORT=3000 npm run web
 */

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import {
  homepage,
  runDetail,
  codePage,
  notFoundPage,
} from './views.js';
import { listRuns, loadRunById } from './runs.js';
import { buildCodeTree, readCodeFile } from './code-tree.js';
import { startRun, attachStream, getRunStatus } from './runner.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const STATIC_DIR = path.join(__dirname, 'static');
const PORT = Number(process.env.PORT) || 4178;

function send(res: ServerResponse, status: number, contentType: string, body: string | Buffer): void {
  res.writeHead(status, { 'Content-Type': contentType, 'Cache-Control': 'no-store' });
  res.end(body);
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  send(res, status, 'application/json; charset=utf-8', JSON.stringify(body));
}

function sendHtml(res: ServerResponse, status: number, body: string): void {
  send(res, status, 'text/html; charset=utf-8', body);
}

async function serveStatic(req: IncomingMessage, res: ServerResponse, rel: string): Promise<boolean> {
  const safe = path.normalize(rel).replace(/^([./\\])+/, '');
  const full = path.join(STATIC_DIR, safe);
  if (!full.startsWith(STATIC_DIR) || !existsSync(full)) return false;
  const ext = path.extname(full).toLowerCase();
  const types: Record<string, string> = {
    '.css': 'text/css; charset=utf-8',
    '.js':  'application/javascript; charset=utf-8',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
  };
  const buf = await readFile(full);
  send(res, 200, types[ext] ?? 'application/octet-stream', buf);
  return true;
}

async function readBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', (c: Buffer) => chunks.push(c));
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    req.on('error', reject);
  });
}

async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
  const method = req.method ?? 'GET';
  const pathname = url.pathname;

  /* Static files */
  if (pathname.startsWith('/static/')) {
    const ok = await serveStatic(req, res, pathname.slice('/static/'.length));
    if (ok) return;
  }

  /* Pages */
  if (method === 'GET' && pathname === '/') {
    const runs = await listRuns();
    return sendHtml(res, 200, homepage(runs));
  }
  if (method === 'GET' && pathname.startsWith('/run/')) {
    const id = pathname.slice('/run/'.length);
    const run = await loadRunById(id);
    if (!run) return sendHtml(res, 404, notFoundPage(`Run "${id}" not found.`));
    return sendHtml(res, 200, runDetail(run));
  }
  if (method === 'GET' && pathname === '/code') {
    const tree = await buildCodeTree(ROOT);
    const filePath = url.searchParams.get('path');
    let file: { path: string; content: string; language: string } | null = null;
    if (filePath) {
      const f = await readCodeFile(ROOT, filePath);
      if (f) file = f;
    }
    return sendHtml(res, 200, codePage(tree, file));
  }

  /* JSON API */
  if (method === 'GET' && pathname === '/api/runs') {
    return sendJson(res, 200, await listRuns());
  }
  if (method === 'GET' && pathname.startsWith('/api/run/') && pathname.endsWith('/stream')) {
    const id = pathname.slice('/api/run/'.length, -'/stream'.length);
    return attachStream(id, res);
  }
  if (method === 'GET' && pathname.startsWith('/api/run/')) {
    const id = pathname.slice('/api/run/'.length);
    /* Live run status (if still active) takes precedence over disk
     * lookup so the client can poll the latest state. */
    const liveStatus = getRunStatus(id);
    if (liveStatus) return sendJson(res, 200, liveStatus);
    const run = await loadRunById(id);
    if (!run) return sendJson(res, 404, { error: 'not-found' });
    return sendJson(res, 200, run);
  }
  if (method === 'POST' && pathname === '/api/run') {
    const body = await readBody(req);
    let parsed: { question?: string } = {};
    try { parsed = JSON.parse(body); } catch { /* ignore */ }
    const question = (parsed.question ?? '').trim();
    if (!question) return sendJson(res, 400, { error: 'question required' });
    if (!process.env.ANTHROPIC_API_KEY) {
      return sendJson(res, 503, { error: 'server has no ANTHROPIC_API_KEY set; cannot run live demos' });
    }
    const runId = await startRun(question, ROOT);
    return sendJson(res, 200, { runId });
  }
  if (method === 'GET' && pathname === '/api/code/tree') {
    return sendJson(res, 200, await buildCodeTree(ROOT));
  }
  if (method === 'GET' && pathname === '/api/code/file') {
    const filePath = url.searchParams.get('path') ?? '';
    const file = await readCodeFile(ROOT, filePath);
    if (!file) return sendJson(res, 404, { error: 'not found or outside allowed roots' });
    return sendJson(res, 200, file);
  }

  return sendHtml(res, 404, notFoundPage(`No route for ${method} ${pathname}.`));
}

const server = createServer((req, res) => {
  handle(req, res).catch(err => {
    console.error('[web] unhandled', err);
    if (!res.writableEnded) {
      try { sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); } catch { /* */ }
    }
  });
});

server.listen(PORT, () => {
  const keyState = process.env.ANTHROPIC_API_KEY ? 'set' : 'NOT SET (live runs will be rejected)';
  console.log(`BID Console listening on http://localhost:${PORT}`);
  console.log(`  ANTHROPIC_API_KEY: ${keyState}`);
  console.log(`  Past runs read from: ${path.relative(process.cwd(), path.join(ROOT, 'output'))}/`);
});