| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193 |
- import crypto from 'node:crypto';
- import * as fsSync from 'node:fs';
- import fs from 'node:fs/promises';
- import {
- createServer,
- type IncomingMessage,
- type Server,
- type ServerResponse,
- } from 'node:http';
- import os from 'node:os';
- import path from 'node:path';
- import { URL } from 'node:url';
- import { log } from '../utils';
- import {
- extractSummarySection,
- extractTitle,
- parseFrontmatter,
- slugify,
- } from './document';
- import {
- extractResumeSlug,
- isValidId,
- readJsonBody,
- sendHtml,
- sendJson,
- } from './helpers';
- import type { InterviewFileItem, InterviewStateEntry } from './types';
- import { renderDashboardPage, renderInterviewPage } from './ui';
- // ─── Auth Token File ────────────────────────────────────────────────
- // Dashboard writes its auth token to a file so sessions can discover it.
- // Both processes run as the same user on the same machine (localhost-only).
- function getAuthFilePath(port: number): string {
- const dataHome =
- process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
- return path.join(dataHome, 'opencode', `.dashboard-${port}.json`);
- }
- function writeAuthFile(port: number, token: string): void {
- const filePath = getAuthFilePath(port);
- const dir = path.dirname(filePath);
- try {
- fsSync.mkdirSync(dir, { recursive: true });
- } catch {
- // Directory exists
- }
- fsSync.writeFileSync(
- filePath,
- JSON.stringify({
- token,
- pid: process.pid,
- startedAt: Date.now(),
- }),
- { mode: 0o600 },
- );
- }
- function removeAuthFile(port: number): void {
- try {
- fsSync.unlinkSync(getAuthFilePath(port));
- } catch {
- // File doesn't exist, ignore
- }
- }
- export async function readDashboardAuthFile(
- port: number,
- ): Promise<{ token: string; pid: number; startedAt: number } | null> {
- try {
- const content = await fs.readFile(getAuthFilePath(port), 'utf8');
- const data = JSON.parse(content) as {
- token: string;
- pid: number;
- startedAt: number;
- };
- // Check if the PID is still alive — stale file from crashed dashboard
- try {
- process.kill(data.pid, 0); // signal 0 = existence check, no actual signal
- } catch {
- // PID doesn't exist — stale auth file from crashed dashboard
- try {
- fsSync.unlinkSync(getAuthFilePath(port));
- } catch {
- // Ignore cleanup errors
- }
- return null;
- }
- return data;
- } catch {
- return null;
- }
- }
- // ─── Helpers ──────────────────────────────────────────────────────────
- function jitterMs(): number {
- return 50 + Math.floor(Math.random() * 150);
- }
- /**
- * When a live interview is created or pushes state, remove any stale
- * recovered- entry for the same slug. Called from three code paths:
- * HTTP create, HTTP state-push, and in-process pushState.
- */
- function dedupRecovered(
- interviewId: string,
- cache: Map<string, InterviewStateEntry>,
- ): void {
- if (interviewId.startsWith('recovered-')) return;
- const slug = extractResumeSlug(interviewId);
- if (!slug) return;
- const recoveredKey = `recovered-${slug}`;
- if (cache.has(recoveredKey)) {
- cache.delete(recoveredKey);
- }
- }
- /**
- * Check whether the cache already contains a live (non-recovered) entry
- * whose slug matches the given one. Used by rebuildFromFiles to skip
- * adding a recovered entry when a live session already covers it.
- */
- function hasLiveForSlug(
- slug: string,
- cache: Map<string, InterviewStateEntry>,
- ): boolean {
- return [...cache.values()].some(
- (e) =>
- !e.interviewId.startsWith('recovered-') &&
- extractResumeSlug(e.interviewId) === slug,
- );
- }
- // ─── Types ────────────────────────────────────────────────────────────
- interface RegisteredSession {
- sessionID: string;
- directory: string;
- pid: number;
- registeredAt: number;
- }
- // ─── Config ───────────────────────────────────────────────────────────
- export const DEFAULT_DASHBOARD_PORT = 43211;
- export interface DashboardConfig {
- port: number;
- outputFolder: string;
- sessionClient?: {
- list: (params?: Record<string, unknown>) => Promise<{
- data?: Array<{
- directory?: string;
- time?: { updated?: number };
- }>;
- }>;
- };
- }
- // ─── Dashboard Server ─────────────────────────────────────────────────
- export function createDashboardServer(config: DashboardConfig): {
- start: () => Promise<string>;
- close: () => void;
- registerSession: (info: RegisteredSession) => void;
- removeSession: (sessionID: string) => void;
- pushState: (entry: InterviewStateEntry) => void;
- getState: (interviewId: string) => InterviewStateEntry | undefined;
- storeAnswers: (
- interviewId: string,
- answers: Array<{ questionId: string; answer: string }>,
- ) => void;
- getPendingAnswers: (interviewId: string) => Array<{
- questionId: string;
- answer: string;
- }> | null;
- consumePendingAnswers: (
- interviewId: string,
- ) => Array<{ questionId: string; answer: string }> | null;
- consumeNudgeAction: (
- interviewId: string,
- ) => 'more-questions' | 'confirm-complete' | null;
- authToken: string;
- discoverSessionDirectories: () => Promise<void>;
- addManualFolder: (dir: string) => void;
- removeManualFolder: (dir: string) => void;
- getManualFolders: () => string[];
- setScanDays: (days: number) => void;
- getScanDays: () => number;
- refreshFiles: () => Promise<void>;
- } {
- const authToken = crypto.randomBytes(32).toString('hex');
- let activeServer: Server | null = null;
- let baseUrl: string | null = null;
- // Session registry
- const sessions = new Map<string, RegisteredSession>();
- // Interview state cache
- const stateCache = new Map<string, InterviewStateEntry>();
- // Periodic cleanup: remove terminal entries older than 24h
- const TERMINAL_MODES = new Set([
- 'abandoned',
- 'completed',
- 'session-disconnected',
- ]);
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
- const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
- const cleanupTimer = setInterval(() => {
- const cutoff = Date.now() - CACHE_TTL_MS;
- for (const [id, entry] of stateCache) {
- if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
- stateCache.delete(id);
- }
- }
- }, CLEANUP_INTERVAL_MS);
- cleanupTimer.unref();
- // File scan cache (TTL 10s)
- let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
- const FILE_CACHE_TTL = 10_000;
- // ─── Auth ─────────────────────────────────────────────────────────
- function isAuthenticated(request: IncomingMessage): boolean {
- // 1. Check HttpOnly cookie (browser requests)
- const cookieHeader = request.headers.cookie ?? '';
- const cookieMatch = cookieHeader.match(/(?:^|;\s*)dashboard_token=([^;]+)/);
- if (cookieMatch?.[1] === authToken) return true;
- // 2. Check query param (inter-process: session → dashboard)
- const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
- const tokenParam = url.searchParams.get('token');
- if (tokenParam === authToken) return true;
- // 3. Check Authorization header (Bearer token)
- const authHeader = request.headers.authorization;
- const bearerToken = authHeader?.startsWith('Bearer ')
- ? authHeader.slice(7)
- : null;
- if (bearerToken === authToken) return true;
- return false;
- }
- function setSessionCookie(response: ServerResponse): void {
- response.setHeader(
- 'Set-Cookie',
- `dashboard_token=${authToken}; HttpOnly; SameSite=Strict; Path=/`,
- );
- }
- // ─── Session Discovery ───────────────────────────────────────────
- const manualFolders = new Set<string>();
- const discoveredFolders = new Set<string>();
- let scanDays = 30;
- function getKnownDirectories(): Set<string> {
- const dirs = new Set<string>();
- // Always scan home directory — interviews may have been created from a
- // session that ran with cwd=$HOME and never registered with the dashboard.
- dirs.add(os.homedir());
- for (const session of sessions.values()) {
- if (session.directory) dirs.add(session.directory);
- }
- for (const folder of manualFolders) {
- dirs.add(folder);
- }
- for (const folder of discoveredFolders) {
- dirs.add(folder);
- }
- return dirs;
- }
- async function discoverSessionDirectories(): Promise<void> {
- if (!config.sessionClient) return;
- try {
- const result = await config.sessionClient.list({ limit: 500 });
- const sessionList = result.data;
- if (!sessionList) return;
- const cutoff =
- scanDays > 0 ? Date.now() - scanDays * 24 * 60 * 60 * 1000 : 0;
- for (const session of sessionList) {
- if (!session.directory) continue;
- if (cutoff > 0 && session.time?.updated) {
- if (session.time.updated < cutoff) continue;
- }
- // Add to discovered set (not manualFolders) so user removal
- // of a manual folder isn't undone by the next scan
- discoveredFolders.add(session.directory);
- }
- } catch {
- // Session list not available — rely on registered sessions
- }
- }
- // ─── File Scanning ───────────────────────────────────────────────
- async function scanInterviewFiles(): Promise<InterviewFileItem[]> {
- if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
- return fileCache.items;
- }
- const directories = getKnownDirectories();
- const items: InterviewFileItem[] = [];
- for (const dir of directories) {
- const interviewDir = path.join(dir, config.outputFolder);
- let entries: string[];
- try {
- entries = await fs.readdir(interviewDir);
- } catch {
- continue;
- }
- for (const entry of entries) {
- if (!entry.endsWith('.md')) continue;
- let content: string;
- try {
- content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
- } catch {
- continue;
- }
- // Extract title and summary using shared extractors
- const title = extractTitle(content) || entry.replace(/\.md$/, '');
- const summary = extractSummarySection(content);
- const baseName = entry.replace(/\.md$/, '');
- const fm = parseFrontmatter(content);
- items.push({
- fileName: entry,
- resumeCommand: `/interview ${baseName}`,
- title,
- summary:
- summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
- sessionID: fm?.sessionID,
- directory: dir,
- });
- }
- }
- const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
- fileCache = { items: sorted, at: Date.now() };
- return sorted;
- }
- // ─── Failover: rebuild state from .md frontmatter ──────────────
- async function rebuildFromFiles(): Promise<void> {
- const directories = getKnownDirectories();
- let rebuilt = 0;
- for (const dir of directories) {
- const interviewDir = path.join(dir, config.outputFolder);
- let entries: string[];
- try {
- entries = await fs.readdir(interviewDir);
- } catch {
- continue;
- }
- for (const entry of entries) {
- if (!entry.endsWith('.md')) continue;
- let content: string;
- try {
- content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
- } catch {
- continue;
- }
- // Parse frontmatter for session ID
- const fm = parseFrontmatter(content);
- if (!fm?.sessionID) continue;
- // Extract title and summary using shared extractors
- const title = extractTitle(content) || entry.replace(/\.md$/, '');
- const summary = extractSummarySection(content);
- // Generate a stable interview ID from slugified filename
- const baseName = entry.replace(/\.md$/, '');
- const interviewId = `recovered-${slugify(baseName) || baseName}`;
- // Only add if not already in cache (sessions may have re-pushed)
- if (stateCache.has(interviewId)) continue;
- // Also skip if a live interview already covers this slug.
- const slug = slugify(baseName) || baseName;
- if (hasLiveForSlug(slug, stateCache)) continue;
- stateCache.set(interviewId, {
- interviewId,
- sessionID: fm.sessionID,
- idea: title,
- mode: 'session-disconnected',
- summary,
- title,
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: fm.updatedAt
- ? new Date(fm.updatedAt).getTime()
- : Date.now(),
- filePath: path.join(interviewDir, entry),
- nudgeAction: null,
- });
- // Also register the session directory
- if (!sessions.has(fm.sessionID)) {
- sessions.set(fm.sessionID, {
- sessionID: fm.sessionID,
- directory: dir,
- pid: 0,
- registeredAt: Date.now(),
- });
- }
- rebuilt++;
- }
- }
- if (rebuilt > 0) {
- fileCache = null;
- log(
- `[interview] dashboard: rebuilt ${rebuilt} interview(s) from files`,
- {},
- );
- }
- }
- // ─── Request Handler ─────────────────────────────────────────────
- async function handleRequest(
- request: IncomingMessage,
- response: ServerResponse,
- ): Promise<void> {
- const url = new URL(
- request.url ?? '/',
- `http://${request.headers.host ?? '127.0.0.1'}`,
- );
- const pathname = decodeURIComponent(url.pathname);
- // NOTE: No CORS headers. Same-origin only — dashboard pages and
- // API share the same origin (127.0.0.1:port). Cross-origin POST is
- // blocked by browser preflight since we don't send Access-Control
- // headers. Do NOT add them without also adding CSRF protection.
- // ── Health check (no auth required) ────────────────────────────
- if (request.method === 'GET' && pathname === '/api/health') {
- // Stable signature: changes only when stateCache or session count changes
- const sig = [...stateCache.values()]
- .map((e) => `${e.interviewId}:${e.mode}:${e.lastUpdatedAt}`)
- .sort()
- .join('|');
- sendJson(response, 200, {
- status: 'ok',
- sessions: sessions.size,
- interviews: stateCache.size,
- sig,
- });
- return;
- }
- // ── API: settings (scan days, folders, discovery) ──────────────
- if (request.method === 'GET' && pathname === '/api/settings') {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- sendJson(response, 200, {
- scanDays,
- folders: [...manualFolders],
- discoveredFolders: [...discoveredFolders],
- registeredSessions: sessions.size,
- });
- return;
- }
- if (request.method === 'POST' && pathname === '/api/settings') {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const data = body as {
- scanDays?: number;
- addFolder?: string;
- removeFolder?: string;
- discover?: boolean;
- };
- if (typeof data.scanDays === 'number' && data.scanDays >= 0) {
- scanDays = data.scanDays;
- }
- if (data.addFolder) {
- manualFolders.add(data.addFolder);
- fileCache = null;
- }
- if (data.removeFolder) {
- manualFolders.delete(data.removeFolder);
- fileCache = null;
- }
- if (data.discover) {
- await discoverSessionDirectories();
- fileCache = null;
- await rebuildFromFiles();
- }
- sendJson(response, 200, {
- scanDays,
- folders: [...manualFolders],
- });
- return;
- }
- // ── Dashboard UI ───────────────────────────────────────────────
- if (request.method === 'GET' && pathname === '/') {
- const files = await scanInterviewFiles();
- // Render actual interviews from state cache (not raw sessions)
- const activeInterviews: Array<{
- id: string;
- idea: string;
- status: 'active' | 'abandoned';
- mode: string;
- createdAt: string;
- url: string;
- resumeSlug: string;
- sessionID?: string;
- directory?: string;
- }> = [...stateCache.values()]
- .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
- .map((entry) => {
- const resumeSlug = extractResumeSlug(entry.interviewId);
- const session = entry.sessionID
- ? sessions.get(entry.sessionID)
- : undefined;
- return {
- id: entry.interviewId,
- idea: entry.idea,
- status:
- entry.mode === 'session-disconnected'
- ? ('abandoned' as const)
- : ('active' as const),
- mode: entry.mode,
- createdAt: new Date(entry.lastUpdatedAt).toISOString(),
- url: `/interview/${entry.interviewId}`,
- resumeSlug,
- sessionID: entry.sessionID,
- directory: session?.directory,
- };
- });
- const outputFolder = config.outputFolder;
- setSessionCookie(response);
- sendHtml(
- response,
- renderDashboardPage(activeInterviews, files, outputFolder),
- );
- return;
- }
- // ── API: list sessions (auth required) ──────────────────────────
- if (request.method === 'GET' && pathname === '/api/sessions') {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const daysParam = url.searchParams.get('days');
- const days = daysParam ? Number.parseInt(daysParam, 10) : 3;
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
- const result = [...sessions.values()]
- .filter((s) => s.registeredAt > cutoff)
- .map((s) => ({
- sessionID: s.sessionID,
- directory: s.directory,
- pid: s.pid,
- }));
- sendJson(response, 200, { sessions: result });
- return;
- }
- // ── API: list files (auth required) ─────────────────────────────
- if (request.method === 'GET' && pathname === '/api/files') {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const files = await scanInterviewFiles();
- sendJson(response, 200, { files });
- return;
- }
- // ── Auth gate for mutation endpoints ───────────────────────────
- if (request.method === 'POST' && !isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- // ── API: register session ──────────────────────────────────────
- if (request.method === 'POST' && pathname === '/api/register') {
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const { sessionID, directory, pid } = body as {
- sessionID?: string;
- directory?: string;
- pid?: number;
- };
- if (!sessionID || !directory || !isValidId(sessionID)) {
- sendJson(response, 400, {
- error: 'sessionID and directory required',
- });
- return;
- }
- sessions.set(sessionID, {
- sessionID,
- directory,
- pid: pid ?? 0,
- registeredAt: Date.now(),
- });
- fileCache = null; // invalidate
- sendJson(response, 200, { status: 'registered' });
- return;
- }
- // ── API: create interview ──────────────────────────────────────
- if (request.method === 'POST' && pathname === '/api/interviews') {
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const { interviewId, sessionID, idea } = body as {
- interviewId?: string;
- sessionID?: string;
- idea?: string;
- };
- if (!interviewId || !sessionID || !idea || !isValidId(interviewId)) {
- sendJson(response, 400, {
- error: 'interviewId, sessionID, and idea required',
- });
- return;
- }
- stateCache.set(interviewId, {
- interviewId,
- sessionID,
- idea,
- mode: 'awaiting-agent',
- summary: 'Interview created.',
- title: idea,
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- dedupRecovered(interviewId, stateCache);
- fileCache = null;
- const interviewUrl = `${baseUrl}/interview/${interviewId}`;
- sendJson(response, 200, {
- interviewId,
- url: interviewUrl,
- });
- return;
- }
- // ── API: push state (session → dashboard) ──────────────────────
- if (
- request.method === 'POST' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/state')
- ) {
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/state', '');
- if (!interviewId || !isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const state = body as Partial<InterviewStateEntry>;
- const existing = stateCache.get(interviewId);
- if (existing) {
- // Merge state update
- if (state.mode) existing.mode = state.mode;
- if (state.summary) existing.summary = state.summary;
- if (state.title) existing.title = state.title;
- if (state.questions) existing.questions = state.questions;
- if (state.filePath) existing.filePath = state.filePath;
- existing.lastUpdatedAt = Date.now();
- dedupRecovered(interviewId, stateCache);
- } else {
- // New entry
- stateCache.set(interviewId, {
- interviewId,
- sessionID: state.sessionID ?? '',
- idea: state.idea ?? '',
- mode: state.mode ?? 'awaiting-agent',
- summary: state.summary ?? '',
- title: state.title ?? '',
- questions: state.questions ?? [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: state.filePath ?? '',
- nudgeAction: null,
- });
- }
- sendJson(response, 200, { status: 'ok' });
- return;
- }
- // ── API: get state (dashboard → browser poll, auth required) ───
- if (
- request.method === 'GET' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/state')
- ) {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/state', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- // Read .md document from disk for completed/disconnected interviews
- let document = '';
- let markdownPath = entry.filePath;
- if (entry.filePath) {
- try {
- document = await fs.readFile(entry.filePath, 'utf8');
- } catch {
- // File may not exist yet
- }
- } else {
- // Fallback: try to find file in known session directories
- const dirs = getKnownDirectories();
- for (const dir of dirs) {
- const slug = extractResumeSlug(interviewId);
- const candidate = path.join(dir, config.outputFolder, `${slug}.md`);
- try {
- document = await fs.readFile(candidate, 'utf8');
- markdownPath = candidate;
- entry.filePath = candidate;
- break;
- } catch {
- // Not in this directory
- }
- }
- }
- // Use just the filename to avoid leaking absolute paths
- const displayPath = markdownPath
- ? markdownPath.split('/').pop() || markdownPath
- : 'interview.md';
- sendJson(response, 200, {
- interview: {
- id: entry.interviewId,
- sessionID: entry.sessionID,
- idea: entry.idea,
- markdownPath: displayPath,
- createdAt: new Date(entry.lastUpdatedAt).toISOString(),
- status:
- entry.mode === 'session-disconnected'
- ? ('abandoned' as const)
- : ('active' as const),
- baseMessageCount: 0, // Unknown for recovered entries
- },
- url: `${baseUrl}/interview/${entry.interviewId}`,
- markdownPath,
- mode: entry.mode,
- isBusy: entry.mode === 'awaiting-agent',
- summary: entry.summary,
- questions: entry.questions,
- document,
- lastUpdatedAt: entry.lastUpdatedAt,
- nudgeAction: entry.nudgeAction,
- });
- return;
- }
- // ── API: submit answers (browser → dashboard) ──────────────────
- if (
- request.method === 'POST' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/answers')
- ) {
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/answers', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const { answers } = body as {
- answers?: Array<{ questionId: string; answer: string }>;
- };
- if (
- !Array.isArray(answers) ||
- !answers.every(
- (a) =>
- typeof a === 'object' &&
- a !== null &&
- typeof a.questionId === 'string' &&
- typeof a.answer === 'string',
- )
- ) {
- sendJson(response, 400, {
- error:
- 'answers array required, each item must have string questionId and answer',
- });
- return;
- }
- entry.pendingAnswers = answers;
- entry.mode = 'awaiting-agent';
- entry.lastUpdatedAt = Date.now();
- sendJson(response, 200, { status: 'ok' });
- return;
- }
- // ── API: get pending answers (session polls, auth required) ────
- if (
- request.method === 'GET' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/pending')
- ) {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/pending', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- // Atomically consume pending answers (like nudge pattern)
- const answers = entry.pendingAnswers;
- if (answers) {
- entry.pendingAnswers = null;
- }
- sendJson(response, 200, {
- answers,
- });
- return;
- }
- // ── API: nudge agent (browser → dashboard) ────────────────────
- if (
- request.method === 'POST' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/nudge')
- ) {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/nudge', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- let body: unknown;
- try {
- body = await readJsonBody(request);
- } catch {
- sendJson(response, 400, { error: 'Invalid JSON' });
- return;
- }
- const { action } = body as {
- action?: 'more-questions' | 'confirm-complete';
- };
- if (action !== 'more-questions' && action !== 'confirm-complete') {
- sendJson(response, 400, {
- error: 'action must be "more-questions" or "confirm-complete"',
- });
- return;
- }
- entry.nudgeAction = action;
- entry.mode = 'awaiting-agent';
- entry.lastUpdatedAt = Date.now();
- sendJson(response, 200, { status: 'ok' });
- return;
- }
- // ── API: get nudge action (session polls, auth required) ──────
- if (
- request.method === 'GET' &&
- pathname.startsWith('/api/interviews/') &&
- pathname.endsWith('/nudge')
- ) {
- if (!isAuthenticated(request)) {
- sendJson(response, 401, { error: 'Unauthorized' });
- return;
- }
- const interviewId = pathname
- .replace('/api/interviews/', '')
- .replace('/nudge', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- const action = entry.nudgeAction;
- if (action) {
- entry.nudgeAction = null; // Clear after reading
- }
- sendJson(response, 200, { action });
- return;
- }
- // ── Interview page ─────────────────────────────────────────────
- if (request.method === 'GET' && pathname.startsWith('/interview/')) {
- const interviewId = pathname.replace('/interview/', '');
- if (!isValidId(interviewId)) {
- sendJson(response, 400, { error: 'Invalid interview ID' });
- return;
- }
- const entry = stateCache.get(interviewId);
- if (!entry) {
- sendJson(response, 404, { error: 'Interview not found' });
- return;
- }
- const resumeSlug = extractResumeSlug(interviewId);
- setSessionCookie(response);
- sendHtml(response, renderInterviewPage(interviewId, resumeSlug));
- return;
- }
- // ── 404 ────────────────────────────────────────────────────────
- sendJson(response, 404, { error: 'Not found' });
- }
- // ─── Server Lifecycle ────────────────────────────────────────────
- function start(): Promise<string> {
- if (baseUrl) return Promise.resolve(baseUrl);
- return new Promise((resolve, reject) => {
- const server = createServer((request, response) => {
- handleRequest(request, response).catch((error: unknown) => {
- sendJson(response, 500, {
- error:
- error instanceof Error ? error.message : 'Internal server error',
- });
- });
- });
- server.requestTimeout = 30_000;
- server.headersTimeout = 10_000;
- server.on('error', (error: NodeJS.ErrnoException) => {
- server.close();
- if (error.code === 'EADDRINUSE') {
- reject(new Error(`Dashboard port ${config.port} is already in use.`));
- } else {
- reject(error);
- }
- });
- server.listen(config.port, '127.0.0.1', () => {
- const address = server.address();
- if (!address || typeof address === 'string') {
- reject(new Error('Failed to start dashboard server'));
- return;
- }
- activeServer = server;
- baseUrl = `http://127.0.0.1:${address.port}`;
- writeAuthFile(config.port, authToken);
- resolve(baseUrl);
- });
- });
- }
- function close(): void {
- if (activeServer) {
- removeAuthFile(config.port);
- activeServer.closeAllConnections();
- activeServer.close();
- activeServer = null;
- baseUrl = null;
- }
- }
- // ─── Public API ──────────────────────────────────────────────────
- return {
- start,
- close,
- registerSession: (info) => {
- const wasEmpty = sessions.size === 0;
- sessions.set(info.sessionID, info);
- fileCache = null;
- // Rebuild from files when first session registers (failover recovery)
- if (wasEmpty) {
- rebuildFromFiles().catch(() => {});
- }
- },
- removeSession: (sessionID: string) => {
- sessions.delete(sessionID);
- // Clean up stateCache entries belonging to this session
- for (const [id, entry] of stateCache) {
- if (entry.sessionID === sessionID) {
- stateCache.delete(id);
- }
- }
- fileCache = null;
- },
- pushState: (entry: InterviewStateEntry) => {
- // Preserve browser-submitted data that the session doesn't know about
- const existing = stateCache.get(entry.interviewId);
- if (existing) {
- if (existing.pendingAnswers)
- entry.pendingAnswers ??= existing.pendingAnswers;
- if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
- }
- stateCache.set(entry.interviewId, entry);
- dedupRecovered(entry.interviewId, stateCache);
- },
- getState: (id) => stateCache.get(id),
- storeAnswers: (id, answers) => {
- const entry = stateCache.get(id);
- if (entry) {
- entry.pendingAnswers = answers;
- entry.mode = 'awaiting-agent';
- entry.lastUpdatedAt = Date.now();
- }
- },
- getPendingAnswers: (id) => stateCache.get(id)?.pendingAnswers ?? null,
- consumePendingAnswers: (id) => {
- const entry = stateCache.get(id);
- if (!entry?.pendingAnswers) return null;
- const answers = entry.pendingAnswers;
- entry.pendingAnswers = null;
- return answers;
- },
- consumeNudgeAction: (id) => {
- const entry = stateCache.get(id);
- if (!entry?.nudgeAction) return null;
- const action = entry.nudgeAction;
- entry.nudgeAction = null;
- return action;
- },
- authToken,
- discoverSessionDirectories,
- addManualFolder: (dir: string) => {
- manualFolders.add(dir);
- fileCache = null;
- },
- removeManualFolder: (dir: string) => {
- manualFolders.delete(dir);
- fileCache = null;
- },
- getManualFolders: () => [...manualFolders],
- setScanDays: (days: number) => {
- scanDays = days;
- },
- getScanDays: () => scanDays,
- refreshFiles: () => {
- fileCache = null;
- return rebuildFromFiles();
- },
- };
- }
- // ─── Health Probe (for session processes) ─────────────────────────────
- export async function probeDashboard(
- port: number,
- ): Promise<{ alive: boolean; timestamp: number }> {
- try {
- const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
- signal: AbortSignal.timeout(2000),
- });
- if (!response.ok) return { alive: false, timestamp: 0 };
- const data = (await response.json()) as {
- status: string;
- timestamp: number;
- };
- return {
- alive: data.status === 'ok',
- timestamp: data.timestamp,
- };
- } catch {
- return { alive: false, timestamp: 0 };
- }
- }
- // ─── Try Become Dashboard (with jitter retry) ─────────────────────────
- export async function tryBecomeDashboard(
- config: DashboardConfig,
- maxAttempts = 3,
- ): Promise<ReturnType<typeof createDashboardServer> | null> {
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
- // First, probe if a dashboard is already running
- const probe = await probeDashboard(config.port);
- if (probe.alive) {
- return null; // Dashboard already running, we're a session
- }
- // Try to bind the port
- const dashboard = createDashboardServer(config);
- try {
- await dashboard.start();
- return dashboard;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- if (message.includes('already in use')) {
- // Another process won the race, wait with jitter and retry
- if (attempt < maxAttempts - 1) {
- await new Promise((resolve) => setTimeout(resolve, jitterMs()));
- continue;
- }
- return null; // All retries exhausted — treat as session
- }
- throw error;
- }
- }
- return null;
- }
|