dashboard.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193
  1. import crypto from 'node:crypto';
  2. import * as fsSync from 'node:fs';
  3. import fs from 'node:fs/promises';
  4. import {
  5. createServer,
  6. type IncomingMessage,
  7. type Server,
  8. type ServerResponse,
  9. } from 'node:http';
  10. import os from 'node:os';
  11. import path from 'node:path';
  12. import { URL } from 'node:url';
  13. import { log } from '../utils';
  14. import {
  15. extractSummarySection,
  16. extractTitle,
  17. parseFrontmatter,
  18. slugify,
  19. } from './document';
  20. import {
  21. extractResumeSlug,
  22. isValidId,
  23. readJsonBody,
  24. sendHtml,
  25. sendJson,
  26. } from './helpers';
  27. import type { InterviewFileItem, InterviewStateEntry } from './types';
  28. import { renderDashboardPage, renderInterviewPage } from './ui';
  29. // ─── Auth Token File ────────────────────────────────────────────────
  30. // Dashboard writes its auth token to a file so sessions can discover it.
  31. // Both processes run as the same user on the same machine (localhost-only).
  32. function getAuthFilePath(port: number): string {
  33. const dataHome =
  34. process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
  35. return path.join(dataHome, 'opencode', `.dashboard-${port}.json`);
  36. }
  37. function writeAuthFile(port: number, token: string): void {
  38. const filePath = getAuthFilePath(port);
  39. const dir = path.dirname(filePath);
  40. try {
  41. fsSync.mkdirSync(dir, { recursive: true });
  42. } catch {
  43. // Directory exists
  44. }
  45. fsSync.writeFileSync(
  46. filePath,
  47. JSON.stringify({
  48. token,
  49. pid: process.pid,
  50. startedAt: Date.now(),
  51. }),
  52. { mode: 0o600 },
  53. );
  54. }
  55. function removeAuthFile(port: number): void {
  56. try {
  57. fsSync.unlinkSync(getAuthFilePath(port));
  58. } catch {
  59. // File doesn't exist, ignore
  60. }
  61. }
  62. export async function readDashboardAuthFile(
  63. port: number,
  64. ): Promise<{ token: string; pid: number; startedAt: number } | null> {
  65. try {
  66. const content = await fs.readFile(getAuthFilePath(port), 'utf8');
  67. const data = JSON.parse(content) as {
  68. token: string;
  69. pid: number;
  70. startedAt: number;
  71. };
  72. // Check if the PID is still alive — stale file from crashed dashboard
  73. try {
  74. process.kill(data.pid, 0); // signal 0 = existence check, no actual signal
  75. } catch {
  76. // PID doesn't exist — stale auth file from crashed dashboard
  77. try {
  78. fsSync.unlinkSync(getAuthFilePath(port));
  79. } catch {
  80. // Ignore cleanup errors
  81. }
  82. return null;
  83. }
  84. return data;
  85. } catch {
  86. return null;
  87. }
  88. }
  89. // ─── Helpers ──────────────────────────────────────────────────────────
  90. function jitterMs(): number {
  91. return 50 + Math.floor(Math.random() * 150);
  92. }
  93. /**
  94. * When a live interview is created or pushes state, remove any stale
  95. * recovered- entry for the same slug. Called from three code paths:
  96. * HTTP create, HTTP state-push, and in-process pushState.
  97. */
  98. function dedupRecovered(
  99. interviewId: string,
  100. cache: Map<string, InterviewStateEntry>,
  101. ): void {
  102. if (interviewId.startsWith('recovered-')) return;
  103. const slug = extractResumeSlug(interviewId);
  104. if (!slug) return;
  105. const recoveredKey = `recovered-${slug}`;
  106. if (cache.has(recoveredKey)) {
  107. cache.delete(recoveredKey);
  108. }
  109. }
  110. /**
  111. * Check whether the cache already contains a live (non-recovered) entry
  112. * whose slug matches the given one. Used by rebuildFromFiles to skip
  113. * adding a recovered entry when a live session already covers it.
  114. */
  115. function hasLiveForSlug(
  116. slug: string,
  117. cache: Map<string, InterviewStateEntry>,
  118. ): boolean {
  119. return [...cache.values()].some(
  120. (e) =>
  121. !e.interviewId.startsWith('recovered-') &&
  122. extractResumeSlug(e.interviewId) === slug,
  123. );
  124. }
  125. // ─── Types ────────────────────────────────────────────────────────────
  126. interface RegisteredSession {
  127. sessionID: string;
  128. directory: string;
  129. pid: number;
  130. registeredAt: number;
  131. }
  132. // ─── Config ───────────────────────────────────────────────────────────
  133. export const DEFAULT_DASHBOARD_PORT = 43211;
  134. export interface DashboardConfig {
  135. port: number;
  136. outputFolder: string;
  137. sessionClient?: {
  138. list: (params?: Record<string, unknown>) => Promise<{
  139. data?: Array<{
  140. directory?: string;
  141. time?: { updated?: number };
  142. }>;
  143. }>;
  144. };
  145. }
  146. // ─── Dashboard Server ─────────────────────────────────────────────────
  147. export function createDashboardServer(config: DashboardConfig): {
  148. start: () => Promise<string>;
  149. close: () => void;
  150. registerSession: (info: RegisteredSession) => void;
  151. removeSession: (sessionID: string) => void;
  152. pushState: (entry: InterviewStateEntry) => void;
  153. getState: (interviewId: string) => InterviewStateEntry | undefined;
  154. storeAnswers: (
  155. interviewId: string,
  156. answers: Array<{ questionId: string; answer: string }>,
  157. ) => void;
  158. getPendingAnswers: (interviewId: string) => Array<{
  159. questionId: string;
  160. answer: string;
  161. }> | null;
  162. consumePendingAnswers: (
  163. interviewId: string,
  164. ) => Array<{ questionId: string; answer: string }> | null;
  165. consumeNudgeAction: (
  166. interviewId: string,
  167. ) => 'more-questions' | 'confirm-complete' | null;
  168. authToken: string;
  169. discoverSessionDirectories: () => Promise<void>;
  170. addManualFolder: (dir: string) => void;
  171. removeManualFolder: (dir: string) => void;
  172. getManualFolders: () => string[];
  173. setScanDays: (days: number) => void;
  174. getScanDays: () => number;
  175. refreshFiles: () => Promise<void>;
  176. } {
  177. const authToken = crypto.randomBytes(32).toString('hex');
  178. let activeServer: Server | null = null;
  179. let baseUrl: string | null = null;
  180. // Session registry
  181. const sessions = new Map<string, RegisteredSession>();
  182. // Interview state cache
  183. const stateCache = new Map<string, InterviewStateEntry>();
  184. // Periodic cleanup: remove terminal entries older than 24h
  185. const TERMINAL_MODES = new Set([
  186. 'abandoned',
  187. 'completed',
  188. 'session-disconnected',
  189. ]);
  190. const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
  191. const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
  192. const cleanupTimer = setInterval(() => {
  193. const cutoff = Date.now() - CACHE_TTL_MS;
  194. for (const [id, entry] of stateCache) {
  195. if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
  196. stateCache.delete(id);
  197. }
  198. }
  199. }, CLEANUP_INTERVAL_MS);
  200. cleanupTimer.unref();
  201. // File scan cache (TTL 10s)
  202. let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
  203. const FILE_CACHE_TTL = 10_000;
  204. // ─── Auth ─────────────────────────────────────────────────────────
  205. function isAuthenticated(request: IncomingMessage): boolean {
  206. // 1. Check HttpOnly cookie (browser requests)
  207. const cookieHeader = request.headers.cookie ?? '';
  208. const cookieMatch = cookieHeader.match(/(?:^|;\s*)dashboard_token=([^;]+)/);
  209. if (cookieMatch?.[1] === authToken) return true;
  210. // 2. Check query param (inter-process: session → dashboard)
  211. const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
  212. const tokenParam = url.searchParams.get('token');
  213. if (tokenParam === authToken) return true;
  214. // 3. Check Authorization header (Bearer token)
  215. const authHeader = request.headers.authorization;
  216. const bearerToken = authHeader?.startsWith('Bearer ')
  217. ? authHeader.slice(7)
  218. : null;
  219. if (bearerToken === authToken) return true;
  220. return false;
  221. }
  222. function setSessionCookie(response: ServerResponse): void {
  223. response.setHeader(
  224. 'Set-Cookie',
  225. `dashboard_token=${authToken}; HttpOnly; SameSite=Strict; Path=/`,
  226. );
  227. }
  228. // ─── Session Discovery ───────────────────────────────────────────
  229. const manualFolders = new Set<string>();
  230. const discoveredFolders = new Set<string>();
  231. let scanDays = 30;
  232. function getKnownDirectories(): Set<string> {
  233. const dirs = new Set<string>();
  234. // Always scan home directory — interviews may have been created from a
  235. // session that ran with cwd=$HOME and never registered with the dashboard.
  236. dirs.add(os.homedir());
  237. for (const session of sessions.values()) {
  238. if (session.directory) dirs.add(session.directory);
  239. }
  240. for (const folder of manualFolders) {
  241. dirs.add(folder);
  242. }
  243. for (const folder of discoveredFolders) {
  244. dirs.add(folder);
  245. }
  246. return dirs;
  247. }
  248. async function discoverSessionDirectories(): Promise<void> {
  249. if (!config.sessionClient) return;
  250. try {
  251. const result = await config.sessionClient.list({ limit: 500 });
  252. const sessionList = result.data;
  253. if (!sessionList) return;
  254. const cutoff =
  255. scanDays > 0 ? Date.now() - scanDays * 24 * 60 * 60 * 1000 : 0;
  256. for (const session of sessionList) {
  257. if (!session.directory) continue;
  258. if (cutoff > 0 && session.time?.updated) {
  259. if (session.time.updated < cutoff) continue;
  260. }
  261. // Add to discovered set (not manualFolders) so user removal
  262. // of a manual folder isn't undone by the next scan
  263. discoveredFolders.add(session.directory);
  264. }
  265. } catch {
  266. // Session list not available — rely on registered sessions
  267. }
  268. }
  269. // ─── File Scanning ───────────────────────────────────────────────
  270. async function scanInterviewFiles(): Promise<InterviewFileItem[]> {
  271. if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
  272. return fileCache.items;
  273. }
  274. const directories = getKnownDirectories();
  275. const items: InterviewFileItem[] = [];
  276. for (const dir of directories) {
  277. const interviewDir = path.join(dir, config.outputFolder);
  278. let entries: string[];
  279. try {
  280. entries = await fs.readdir(interviewDir);
  281. } catch {
  282. continue;
  283. }
  284. for (const entry of entries) {
  285. if (!entry.endsWith('.md')) continue;
  286. let content: string;
  287. try {
  288. content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
  289. } catch {
  290. continue;
  291. }
  292. // Extract title and summary using shared extractors
  293. const title = extractTitle(content) || entry.replace(/\.md$/, '');
  294. const summary = extractSummarySection(content);
  295. const baseName = entry.replace(/\.md$/, '');
  296. const fm = parseFrontmatter(content);
  297. items.push({
  298. fileName: entry,
  299. resumeCommand: `/interview ${baseName}`,
  300. title,
  301. summary:
  302. summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
  303. sessionID: fm?.sessionID,
  304. directory: dir,
  305. });
  306. }
  307. }
  308. const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
  309. fileCache = { items: sorted, at: Date.now() };
  310. return sorted;
  311. }
  312. // ─── Failover: rebuild state from .md frontmatter ──────────────
  313. async function rebuildFromFiles(): Promise<void> {
  314. const directories = getKnownDirectories();
  315. let rebuilt = 0;
  316. for (const dir of directories) {
  317. const interviewDir = path.join(dir, config.outputFolder);
  318. let entries: string[];
  319. try {
  320. entries = await fs.readdir(interviewDir);
  321. } catch {
  322. continue;
  323. }
  324. for (const entry of entries) {
  325. if (!entry.endsWith('.md')) continue;
  326. let content: string;
  327. try {
  328. content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
  329. } catch {
  330. continue;
  331. }
  332. // Parse frontmatter for session ID
  333. const fm = parseFrontmatter(content);
  334. if (!fm?.sessionID) continue;
  335. // Extract title and summary using shared extractors
  336. const title = extractTitle(content) || entry.replace(/\.md$/, '');
  337. const summary = extractSummarySection(content);
  338. // Generate a stable interview ID from slugified filename
  339. const baseName = entry.replace(/\.md$/, '');
  340. const interviewId = `recovered-${slugify(baseName) || baseName}`;
  341. // Only add if not already in cache (sessions may have re-pushed)
  342. if (stateCache.has(interviewId)) continue;
  343. // Also skip if a live interview already covers this slug.
  344. const slug = slugify(baseName) || baseName;
  345. if (hasLiveForSlug(slug, stateCache)) continue;
  346. stateCache.set(interviewId, {
  347. interviewId,
  348. sessionID: fm.sessionID,
  349. idea: title,
  350. mode: 'session-disconnected',
  351. summary,
  352. title,
  353. questions: [],
  354. pendingAnswers: null,
  355. lastUpdatedAt: fm.updatedAt
  356. ? new Date(fm.updatedAt).getTime()
  357. : Date.now(),
  358. filePath: path.join(interviewDir, entry),
  359. nudgeAction: null,
  360. });
  361. // Also register the session directory
  362. if (!sessions.has(fm.sessionID)) {
  363. sessions.set(fm.sessionID, {
  364. sessionID: fm.sessionID,
  365. directory: dir,
  366. pid: 0,
  367. registeredAt: Date.now(),
  368. });
  369. }
  370. rebuilt++;
  371. }
  372. }
  373. if (rebuilt > 0) {
  374. fileCache = null;
  375. log(
  376. `[interview] dashboard: rebuilt ${rebuilt} interview(s) from files`,
  377. {},
  378. );
  379. }
  380. }
  381. // ─── Request Handler ─────────────────────────────────────────────
  382. async function handleRequest(
  383. request: IncomingMessage,
  384. response: ServerResponse,
  385. ): Promise<void> {
  386. const url = new URL(
  387. request.url ?? '/',
  388. `http://${request.headers.host ?? '127.0.0.1'}`,
  389. );
  390. const pathname = decodeURIComponent(url.pathname);
  391. // NOTE: No CORS headers. Same-origin only — dashboard pages and
  392. // API share the same origin (127.0.0.1:port). Cross-origin POST is
  393. // blocked by browser preflight since we don't send Access-Control
  394. // headers. Do NOT add them without also adding CSRF protection.
  395. // ── Health check (no auth required) ────────────────────────────
  396. if (request.method === 'GET' && pathname === '/api/health') {
  397. // Stable signature: changes only when stateCache or session count changes
  398. const sig = [...stateCache.values()]
  399. .map((e) => `${e.interviewId}:${e.mode}:${e.lastUpdatedAt}`)
  400. .sort()
  401. .join('|');
  402. sendJson(response, 200, {
  403. status: 'ok',
  404. sessions: sessions.size,
  405. interviews: stateCache.size,
  406. sig,
  407. });
  408. return;
  409. }
  410. // ── API: settings (scan days, folders, discovery) ──────────────
  411. if (request.method === 'GET' && pathname === '/api/settings') {
  412. if (!isAuthenticated(request)) {
  413. sendJson(response, 401, { error: 'Unauthorized' });
  414. return;
  415. }
  416. sendJson(response, 200, {
  417. scanDays,
  418. folders: [...manualFolders],
  419. discoveredFolders: [...discoveredFolders],
  420. registeredSessions: sessions.size,
  421. });
  422. return;
  423. }
  424. if (request.method === 'POST' && pathname === '/api/settings') {
  425. if (!isAuthenticated(request)) {
  426. sendJson(response, 401, { error: 'Unauthorized' });
  427. return;
  428. }
  429. let body: unknown;
  430. try {
  431. body = await readJsonBody(request);
  432. } catch {
  433. sendJson(response, 400, { error: 'Invalid JSON' });
  434. return;
  435. }
  436. const data = body as {
  437. scanDays?: number;
  438. addFolder?: string;
  439. removeFolder?: string;
  440. discover?: boolean;
  441. };
  442. if (typeof data.scanDays === 'number' && data.scanDays >= 0) {
  443. scanDays = data.scanDays;
  444. }
  445. if (data.addFolder) {
  446. manualFolders.add(data.addFolder);
  447. fileCache = null;
  448. }
  449. if (data.removeFolder) {
  450. manualFolders.delete(data.removeFolder);
  451. fileCache = null;
  452. }
  453. if (data.discover) {
  454. await discoverSessionDirectories();
  455. fileCache = null;
  456. await rebuildFromFiles();
  457. }
  458. sendJson(response, 200, {
  459. scanDays,
  460. folders: [...manualFolders],
  461. });
  462. return;
  463. }
  464. // ── Dashboard UI ───────────────────────────────────────────────
  465. if (request.method === 'GET' && pathname === '/') {
  466. const files = await scanInterviewFiles();
  467. // Render actual interviews from state cache (not raw sessions)
  468. const activeInterviews: Array<{
  469. id: string;
  470. idea: string;
  471. status: 'active' | 'abandoned';
  472. mode: string;
  473. createdAt: string;
  474. url: string;
  475. resumeSlug: string;
  476. sessionID?: string;
  477. directory?: string;
  478. }> = [...stateCache.values()]
  479. .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
  480. .map((entry) => {
  481. const resumeSlug = extractResumeSlug(entry.interviewId);
  482. const session = entry.sessionID
  483. ? sessions.get(entry.sessionID)
  484. : undefined;
  485. return {
  486. id: entry.interviewId,
  487. idea: entry.idea,
  488. status:
  489. entry.mode === 'session-disconnected'
  490. ? ('abandoned' as const)
  491. : ('active' as const),
  492. mode: entry.mode,
  493. createdAt: new Date(entry.lastUpdatedAt).toISOString(),
  494. url: `/interview/${entry.interviewId}`,
  495. resumeSlug,
  496. sessionID: entry.sessionID,
  497. directory: session?.directory,
  498. };
  499. });
  500. const outputFolder = config.outputFolder;
  501. setSessionCookie(response);
  502. sendHtml(
  503. response,
  504. renderDashboardPage(activeInterviews, files, outputFolder),
  505. );
  506. return;
  507. }
  508. // ── API: list sessions (auth required) ──────────────────────────
  509. if (request.method === 'GET' && pathname === '/api/sessions') {
  510. if (!isAuthenticated(request)) {
  511. sendJson(response, 401, { error: 'Unauthorized' });
  512. return;
  513. }
  514. const daysParam = url.searchParams.get('days');
  515. const days = daysParam ? Number.parseInt(daysParam, 10) : 3;
  516. const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
  517. const result = [...sessions.values()]
  518. .filter((s) => s.registeredAt > cutoff)
  519. .map((s) => ({
  520. sessionID: s.sessionID,
  521. directory: s.directory,
  522. pid: s.pid,
  523. }));
  524. sendJson(response, 200, { sessions: result });
  525. return;
  526. }
  527. // ── API: list files (auth required) ─────────────────────────────
  528. if (request.method === 'GET' && pathname === '/api/files') {
  529. if (!isAuthenticated(request)) {
  530. sendJson(response, 401, { error: 'Unauthorized' });
  531. return;
  532. }
  533. const files = await scanInterviewFiles();
  534. sendJson(response, 200, { files });
  535. return;
  536. }
  537. // ── Auth gate for mutation endpoints ───────────────────────────
  538. if (request.method === 'POST' && !isAuthenticated(request)) {
  539. sendJson(response, 401, { error: 'Unauthorized' });
  540. return;
  541. }
  542. // ── API: register session ──────────────────────────────────────
  543. if (request.method === 'POST' && pathname === '/api/register') {
  544. let body: unknown;
  545. try {
  546. body = await readJsonBody(request);
  547. } catch {
  548. sendJson(response, 400, { error: 'Invalid JSON' });
  549. return;
  550. }
  551. const { sessionID, directory, pid } = body as {
  552. sessionID?: string;
  553. directory?: string;
  554. pid?: number;
  555. };
  556. if (!sessionID || !directory || !isValidId(sessionID)) {
  557. sendJson(response, 400, {
  558. error: 'sessionID and directory required',
  559. });
  560. return;
  561. }
  562. sessions.set(sessionID, {
  563. sessionID,
  564. directory,
  565. pid: pid ?? 0,
  566. registeredAt: Date.now(),
  567. });
  568. fileCache = null; // invalidate
  569. sendJson(response, 200, { status: 'registered' });
  570. return;
  571. }
  572. // ── API: create interview ──────────────────────────────────────
  573. if (request.method === 'POST' && pathname === '/api/interviews') {
  574. let body: unknown;
  575. try {
  576. body = await readJsonBody(request);
  577. } catch {
  578. sendJson(response, 400, { error: 'Invalid JSON' });
  579. return;
  580. }
  581. const { interviewId, sessionID, idea } = body as {
  582. interviewId?: string;
  583. sessionID?: string;
  584. idea?: string;
  585. };
  586. if (!interviewId || !sessionID || !idea || !isValidId(interviewId)) {
  587. sendJson(response, 400, {
  588. error: 'interviewId, sessionID, and idea required',
  589. });
  590. return;
  591. }
  592. stateCache.set(interviewId, {
  593. interviewId,
  594. sessionID,
  595. idea,
  596. mode: 'awaiting-agent',
  597. summary: 'Interview created.',
  598. title: idea,
  599. questions: [],
  600. pendingAnswers: null,
  601. lastUpdatedAt: Date.now(),
  602. filePath: '',
  603. nudgeAction: null,
  604. });
  605. dedupRecovered(interviewId, stateCache);
  606. fileCache = null;
  607. const interviewUrl = `${baseUrl}/interview/${interviewId}`;
  608. sendJson(response, 200, {
  609. interviewId,
  610. url: interviewUrl,
  611. });
  612. return;
  613. }
  614. // ── API: push state (session → dashboard) ──────────────────────
  615. if (
  616. request.method === 'POST' &&
  617. pathname.startsWith('/api/interviews/') &&
  618. pathname.endsWith('/state')
  619. ) {
  620. const interviewId = pathname
  621. .replace('/api/interviews/', '')
  622. .replace('/state', '');
  623. if (!interviewId || !isValidId(interviewId)) {
  624. sendJson(response, 400, { error: 'Invalid interview ID' });
  625. return;
  626. }
  627. let body: unknown;
  628. try {
  629. body = await readJsonBody(request);
  630. } catch {
  631. sendJson(response, 400, { error: 'Invalid JSON' });
  632. return;
  633. }
  634. const state = body as Partial<InterviewStateEntry>;
  635. const existing = stateCache.get(interviewId);
  636. if (existing) {
  637. // Merge state update
  638. if (state.mode) existing.mode = state.mode;
  639. if (state.summary) existing.summary = state.summary;
  640. if (state.title) existing.title = state.title;
  641. if (state.questions) existing.questions = state.questions;
  642. if (state.filePath) existing.filePath = state.filePath;
  643. existing.lastUpdatedAt = Date.now();
  644. dedupRecovered(interviewId, stateCache);
  645. } else {
  646. // New entry
  647. stateCache.set(interviewId, {
  648. interviewId,
  649. sessionID: state.sessionID ?? '',
  650. idea: state.idea ?? '',
  651. mode: state.mode ?? 'awaiting-agent',
  652. summary: state.summary ?? '',
  653. title: state.title ?? '',
  654. questions: state.questions ?? [],
  655. pendingAnswers: null,
  656. lastUpdatedAt: Date.now(),
  657. filePath: state.filePath ?? '',
  658. nudgeAction: null,
  659. });
  660. }
  661. sendJson(response, 200, { status: 'ok' });
  662. return;
  663. }
  664. // ── API: get state (dashboard → browser poll, auth required) ───
  665. if (
  666. request.method === 'GET' &&
  667. pathname.startsWith('/api/interviews/') &&
  668. pathname.endsWith('/state')
  669. ) {
  670. if (!isAuthenticated(request)) {
  671. sendJson(response, 401, { error: 'Unauthorized' });
  672. return;
  673. }
  674. const interviewId = pathname
  675. .replace('/api/interviews/', '')
  676. .replace('/state', '');
  677. if (!isValidId(interviewId)) {
  678. sendJson(response, 400, { error: 'Invalid interview ID' });
  679. return;
  680. }
  681. const entry = stateCache.get(interviewId);
  682. if (!entry) {
  683. sendJson(response, 404, { error: 'Interview not found' });
  684. return;
  685. }
  686. // Read .md document from disk for completed/disconnected interviews
  687. let document = '';
  688. let markdownPath = entry.filePath;
  689. if (entry.filePath) {
  690. try {
  691. document = await fs.readFile(entry.filePath, 'utf8');
  692. } catch {
  693. // File may not exist yet
  694. }
  695. } else {
  696. // Fallback: try to find file in known session directories
  697. const dirs = getKnownDirectories();
  698. for (const dir of dirs) {
  699. const slug = extractResumeSlug(interviewId);
  700. const candidate = path.join(dir, config.outputFolder, `${slug}.md`);
  701. try {
  702. document = await fs.readFile(candidate, 'utf8');
  703. markdownPath = candidate;
  704. entry.filePath = candidate;
  705. break;
  706. } catch {
  707. // Not in this directory
  708. }
  709. }
  710. }
  711. // Use just the filename to avoid leaking absolute paths
  712. const displayPath = markdownPath
  713. ? markdownPath.split('/').pop() || markdownPath
  714. : 'interview.md';
  715. sendJson(response, 200, {
  716. interview: {
  717. id: entry.interviewId,
  718. sessionID: entry.sessionID,
  719. idea: entry.idea,
  720. markdownPath: displayPath,
  721. createdAt: new Date(entry.lastUpdatedAt).toISOString(),
  722. status:
  723. entry.mode === 'session-disconnected'
  724. ? ('abandoned' as const)
  725. : ('active' as const),
  726. baseMessageCount: 0, // Unknown for recovered entries
  727. },
  728. url: `${baseUrl}/interview/${entry.interviewId}`,
  729. markdownPath,
  730. mode: entry.mode,
  731. isBusy: entry.mode === 'awaiting-agent',
  732. summary: entry.summary,
  733. questions: entry.questions,
  734. document,
  735. lastUpdatedAt: entry.lastUpdatedAt,
  736. nudgeAction: entry.nudgeAction,
  737. });
  738. return;
  739. }
  740. // ── API: submit answers (browser → dashboard) ──────────────────
  741. if (
  742. request.method === 'POST' &&
  743. pathname.startsWith('/api/interviews/') &&
  744. pathname.endsWith('/answers')
  745. ) {
  746. const interviewId = pathname
  747. .replace('/api/interviews/', '')
  748. .replace('/answers', '');
  749. if (!isValidId(interviewId)) {
  750. sendJson(response, 400, { error: 'Invalid interview ID' });
  751. return;
  752. }
  753. const entry = stateCache.get(interviewId);
  754. if (!entry) {
  755. sendJson(response, 404, { error: 'Interview not found' });
  756. return;
  757. }
  758. let body: unknown;
  759. try {
  760. body = await readJsonBody(request);
  761. } catch {
  762. sendJson(response, 400, { error: 'Invalid JSON' });
  763. return;
  764. }
  765. const { answers } = body as {
  766. answers?: Array<{ questionId: string; answer: string }>;
  767. };
  768. if (
  769. !Array.isArray(answers) ||
  770. !answers.every(
  771. (a) =>
  772. typeof a === 'object' &&
  773. a !== null &&
  774. typeof a.questionId === 'string' &&
  775. typeof a.answer === 'string',
  776. )
  777. ) {
  778. sendJson(response, 400, {
  779. error:
  780. 'answers array required, each item must have string questionId and answer',
  781. });
  782. return;
  783. }
  784. entry.pendingAnswers = answers;
  785. entry.mode = 'awaiting-agent';
  786. entry.lastUpdatedAt = Date.now();
  787. sendJson(response, 200, { status: 'ok' });
  788. return;
  789. }
  790. // ── API: get pending answers (session polls, auth required) ────
  791. if (
  792. request.method === 'GET' &&
  793. pathname.startsWith('/api/interviews/') &&
  794. pathname.endsWith('/pending')
  795. ) {
  796. if (!isAuthenticated(request)) {
  797. sendJson(response, 401, { error: 'Unauthorized' });
  798. return;
  799. }
  800. const interviewId = pathname
  801. .replace('/api/interviews/', '')
  802. .replace('/pending', '');
  803. if (!isValidId(interviewId)) {
  804. sendJson(response, 400, { error: 'Invalid interview ID' });
  805. return;
  806. }
  807. const entry = stateCache.get(interviewId);
  808. if (!entry) {
  809. sendJson(response, 404, { error: 'Interview not found' });
  810. return;
  811. }
  812. // Atomically consume pending answers (like nudge pattern)
  813. const answers = entry.pendingAnswers;
  814. if (answers) {
  815. entry.pendingAnswers = null;
  816. }
  817. sendJson(response, 200, {
  818. answers,
  819. });
  820. return;
  821. }
  822. // ── API: nudge agent (browser → dashboard) ────────────────────
  823. if (
  824. request.method === 'POST' &&
  825. pathname.startsWith('/api/interviews/') &&
  826. pathname.endsWith('/nudge')
  827. ) {
  828. if (!isAuthenticated(request)) {
  829. sendJson(response, 401, { error: 'Unauthorized' });
  830. return;
  831. }
  832. const interviewId = pathname
  833. .replace('/api/interviews/', '')
  834. .replace('/nudge', '');
  835. if (!isValidId(interviewId)) {
  836. sendJson(response, 400, { error: 'Invalid interview ID' });
  837. return;
  838. }
  839. const entry = stateCache.get(interviewId);
  840. if (!entry) {
  841. sendJson(response, 404, { error: 'Interview not found' });
  842. return;
  843. }
  844. let body: unknown;
  845. try {
  846. body = await readJsonBody(request);
  847. } catch {
  848. sendJson(response, 400, { error: 'Invalid JSON' });
  849. return;
  850. }
  851. const { action } = body as {
  852. action?: 'more-questions' | 'confirm-complete';
  853. };
  854. if (action !== 'more-questions' && action !== 'confirm-complete') {
  855. sendJson(response, 400, {
  856. error: 'action must be "more-questions" or "confirm-complete"',
  857. });
  858. return;
  859. }
  860. entry.nudgeAction = action;
  861. entry.mode = 'awaiting-agent';
  862. entry.lastUpdatedAt = Date.now();
  863. sendJson(response, 200, { status: 'ok' });
  864. return;
  865. }
  866. // ── API: get nudge action (session polls, auth required) ──────
  867. if (
  868. request.method === 'GET' &&
  869. pathname.startsWith('/api/interviews/') &&
  870. pathname.endsWith('/nudge')
  871. ) {
  872. if (!isAuthenticated(request)) {
  873. sendJson(response, 401, { error: 'Unauthorized' });
  874. return;
  875. }
  876. const interviewId = pathname
  877. .replace('/api/interviews/', '')
  878. .replace('/nudge', '');
  879. if (!isValidId(interviewId)) {
  880. sendJson(response, 400, { error: 'Invalid interview ID' });
  881. return;
  882. }
  883. const entry = stateCache.get(interviewId);
  884. if (!entry) {
  885. sendJson(response, 404, { error: 'Interview not found' });
  886. return;
  887. }
  888. const action = entry.nudgeAction;
  889. if (action) {
  890. entry.nudgeAction = null; // Clear after reading
  891. }
  892. sendJson(response, 200, { action });
  893. return;
  894. }
  895. // ── Interview page ─────────────────────────────────────────────
  896. if (request.method === 'GET' && pathname.startsWith('/interview/')) {
  897. const interviewId = pathname.replace('/interview/', '');
  898. if (!isValidId(interviewId)) {
  899. sendJson(response, 400, { error: 'Invalid interview ID' });
  900. return;
  901. }
  902. const entry = stateCache.get(interviewId);
  903. if (!entry) {
  904. sendJson(response, 404, { error: 'Interview not found' });
  905. return;
  906. }
  907. const resumeSlug = extractResumeSlug(interviewId);
  908. setSessionCookie(response);
  909. sendHtml(response, renderInterviewPage(interviewId, resumeSlug));
  910. return;
  911. }
  912. // ── 404 ────────────────────────────────────────────────────────
  913. sendJson(response, 404, { error: 'Not found' });
  914. }
  915. // ─── Server Lifecycle ────────────────────────────────────────────
  916. function start(): Promise<string> {
  917. if (baseUrl) return Promise.resolve(baseUrl);
  918. return new Promise((resolve, reject) => {
  919. const server = createServer((request, response) => {
  920. handleRequest(request, response).catch((error: unknown) => {
  921. sendJson(response, 500, {
  922. error:
  923. error instanceof Error ? error.message : 'Internal server error',
  924. });
  925. });
  926. });
  927. server.requestTimeout = 30_000;
  928. server.headersTimeout = 10_000;
  929. server.on('error', (error: NodeJS.ErrnoException) => {
  930. server.close();
  931. if (error.code === 'EADDRINUSE') {
  932. reject(new Error(`Dashboard port ${config.port} is already in use.`));
  933. } else {
  934. reject(error);
  935. }
  936. });
  937. server.listen(config.port, '127.0.0.1', () => {
  938. const address = server.address();
  939. if (!address || typeof address === 'string') {
  940. reject(new Error('Failed to start dashboard server'));
  941. return;
  942. }
  943. activeServer = server;
  944. baseUrl = `http://127.0.0.1:${address.port}`;
  945. writeAuthFile(config.port, authToken);
  946. resolve(baseUrl);
  947. });
  948. });
  949. }
  950. function close(): void {
  951. if (activeServer) {
  952. removeAuthFile(config.port);
  953. activeServer.closeAllConnections();
  954. activeServer.close();
  955. activeServer = null;
  956. baseUrl = null;
  957. }
  958. }
  959. // ─── Public API ──────────────────────────────────────────────────
  960. return {
  961. start,
  962. close,
  963. registerSession: (info) => {
  964. const wasEmpty = sessions.size === 0;
  965. sessions.set(info.sessionID, info);
  966. fileCache = null;
  967. // Rebuild from files when first session registers (failover recovery)
  968. if (wasEmpty) {
  969. rebuildFromFiles().catch(() => {});
  970. }
  971. },
  972. removeSession: (sessionID: string) => {
  973. sessions.delete(sessionID);
  974. // Clean up stateCache entries belonging to this session
  975. for (const [id, entry] of stateCache) {
  976. if (entry.sessionID === sessionID) {
  977. stateCache.delete(id);
  978. }
  979. }
  980. fileCache = null;
  981. },
  982. pushState: (entry: InterviewStateEntry) => {
  983. // Preserve browser-submitted data that the session doesn't know about
  984. const existing = stateCache.get(entry.interviewId);
  985. if (existing) {
  986. if (existing.pendingAnswers)
  987. entry.pendingAnswers ??= existing.pendingAnswers;
  988. if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
  989. }
  990. stateCache.set(entry.interviewId, entry);
  991. dedupRecovered(entry.interviewId, stateCache);
  992. },
  993. getState: (id) => stateCache.get(id),
  994. storeAnswers: (id, answers) => {
  995. const entry = stateCache.get(id);
  996. if (entry) {
  997. entry.pendingAnswers = answers;
  998. entry.mode = 'awaiting-agent';
  999. entry.lastUpdatedAt = Date.now();
  1000. }
  1001. },
  1002. getPendingAnswers: (id) => stateCache.get(id)?.pendingAnswers ?? null,
  1003. consumePendingAnswers: (id) => {
  1004. const entry = stateCache.get(id);
  1005. if (!entry?.pendingAnswers) return null;
  1006. const answers = entry.pendingAnswers;
  1007. entry.pendingAnswers = null;
  1008. return answers;
  1009. },
  1010. consumeNudgeAction: (id) => {
  1011. const entry = stateCache.get(id);
  1012. if (!entry?.nudgeAction) return null;
  1013. const action = entry.nudgeAction;
  1014. entry.nudgeAction = null;
  1015. return action;
  1016. },
  1017. authToken,
  1018. discoverSessionDirectories,
  1019. addManualFolder: (dir: string) => {
  1020. manualFolders.add(dir);
  1021. fileCache = null;
  1022. },
  1023. removeManualFolder: (dir: string) => {
  1024. manualFolders.delete(dir);
  1025. fileCache = null;
  1026. },
  1027. getManualFolders: () => [...manualFolders],
  1028. setScanDays: (days: number) => {
  1029. scanDays = days;
  1030. },
  1031. getScanDays: () => scanDays,
  1032. refreshFiles: () => {
  1033. fileCache = null;
  1034. return rebuildFromFiles();
  1035. },
  1036. };
  1037. }
  1038. // ─── Health Probe (for session processes) ─────────────────────────────
  1039. export async function probeDashboard(
  1040. port: number,
  1041. ): Promise<{ alive: boolean; timestamp: number }> {
  1042. try {
  1043. const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
  1044. signal: AbortSignal.timeout(2000),
  1045. });
  1046. if (!response.ok) return { alive: false, timestamp: 0 };
  1047. const data = (await response.json()) as {
  1048. status: string;
  1049. timestamp: number;
  1050. };
  1051. return {
  1052. alive: data.status === 'ok',
  1053. timestamp: data.timestamp,
  1054. };
  1055. } catch {
  1056. return { alive: false, timestamp: 0 };
  1057. }
  1058. }
  1059. // ─── Try Become Dashboard (with jitter retry) ─────────────────────────
  1060. export async function tryBecomeDashboard(
  1061. config: DashboardConfig,
  1062. maxAttempts = 3,
  1063. ): Promise<ReturnType<typeof createDashboardServer> | null> {
  1064. for (let attempt = 0; attempt < maxAttempts; attempt++) {
  1065. // First, probe if a dashboard is already running
  1066. const probe = await probeDashboard(config.port);
  1067. if (probe.alive) {
  1068. return null; // Dashboard already running, we're a session
  1069. }
  1070. // Try to bind the port
  1071. const dashboard = createDashboardServer(config);
  1072. try {
  1073. await dashboard.start();
  1074. return dashboard;
  1075. } catch (error) {
  1076. const message = error instanceof Error ? error.message : String(error);
  1077. if (message.includes('already in use')) {
  1078. // Another process won the race, wait with jitter and retry
  1079. if (attempt < maxAttempts - 1) {
  1080. await new Promise((resolve) => setTimeout(resolve, jitterMs()));
  1081. continue;
  1082. }
  1083. return null; // All retries exhausted — treat as session
  1084. }
  1085. throw error;
  1086. }
  1087. }
  1088. return null;
  1089. }