// CRITICAL import type { Hono } from "hono"; import { readFileSync, unlinkSync, existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import type { AppContext } from "../types/context"; import { badRequest, notFound } from "../core/errors"; import { streamAsyncStrings, buildSseHeaders } from "../http/sse"; import { Event } from "../services/event-manager"; /** * Register log and SSE routes. * @param app - Hono app. * @param context - App context. */ export const registerLogsRoutes = (app: Hono, context: AppContext): void => { /** * Resolve log file path for a session id. * @param sessionId - Session identifier. * @returns Path to log file. */ const logPathFor = (sessionId: string): string => { const safe = Array.from(sessionId).filter((char) => /[a-zA-Z0-9._-]/.test(char)).join(""); if (!safe) { throw badRequest("Invalid log session id"); } return join("/tmp", `vllm_${safe}.log`); }; /** * Tail the last N lines of a file. * @param path - Log file path. * @param limit - Line limit. * @returns Array of lines. */ const tailLines = (path: string, limit: number): string[] => { if (!existsSync(path)) { throw notFound("Log not found"); } const content = readFileSync(path, "utf-8"); const lines = content.split("\n"); return lines.slice(Math.max(0, lines.length - limit)); }; app.get("/logs", async (ctx) => { const current = await context.processManager.findInferenceProcess(context.config.inference_port); const entries = readdirSync("/tmp") .filter((name) => name.startsWith("vllm_") && name.endsWith(".log")) .map((name) => ({ name, mtime: statSync(join("/tmp", name)).mtimeMs })) .sort((left, right) => right.mtime - left.mtime) .map((entry) => entry.name); const sessions = []; for (const filename of entries) { const sessionId = filename.replace(/^vllm_/, "").replace(/\.log$/, ""); const recipe = context.stores.recipeStore.get(sessionId); const stat = statSync(join("/tmp", filename)); const modifiedAt = new Date(stat.mtimeMs).toISOString(); let status = "stopped"; if (current && recipe && current.model_path && recipe.model_path && current.model_path.includes(recipe.model_path)) { status = "running"; } else if (current && recipe && current.served_model_name && recipe.served_model_name === current.served_model_name) { status = "running"; } sessions.push({ id: sessionId, recipe_id: recipe?.id ?? sessionId, recipe_name: recipe?.name ?? null, model_path: recipe?.model_path ?? null, model: recipe ? (recipe.served_model_name ?? recipe.name) : sessionId, backend: recipe?.backend ?? null, created_at: modifiedAt, status, }); } return ctx.json({ sessions }); }); app.get("/logs/:sessionId", async (ctx) => { const sessionId = ctx.req.param("sessionId"); const limit = Math.min(Math.max(Number(ctx.req.query("limit") ?? 2000), 1), 20000); const path = logPathFor(sessionId); const lines = tailLines(path, limit).map((line) => line.replace(/\n$/, "")); return ctx.json({ id: sessionId, logs: lines, content: lines.join("\n") }); }); app.delete("/logs/:sessionId", async (ctx) => { const sessionId = ctx.req.param("sessionId"); const path = logPathFor(sessionId); try { unlinkSync(path); } catch { throw notFound("Log not found"); } return ctx.json({ success: true }); }); app.get("/events", async (_ctx) => { const stream = streamAsyncStrings((async function* (): AsyncGenerator { for await (const event of context.eventManager.subscribe()) { yield event.toSse(); } })()); return new Response(stream, { headers: buildSseHeaders(), }); }); app.get("/logs/:sessionId/stream", async (ctx) => { const sessionId = ctx.req.param("sessionId"); const path = logPathFor(sessionId); const stream = streamAsyncStrings((async function* (): AsyncGenerator { if (existsSync(path)) { const content = readFileSync(path, "utf-8"); for (const line of content.split("\n")) { if (line.length === 0) { continue; } const event = new Event("log", { line }); yield event.toSse(); } } for await (const event of context.eventManager.subscribe(`logs:${sessionId}`)) { yield event.toSse(); } })()); return new Response(stream, { headers: buildSseHeaders({ "Cache-Control": "no-cache, no-transform", Connection: "keep-alive" }), }); }); app.get("/events/stats", async (ctx) => { return ctx.json(context.eventManager.getStats()); }); };