/** * Kitchen HTTP Handler - Dispatches /__kitchen/* requests. * * Mounted inside handleRequestInternal() when isDev === true. * All Kitchen routes are under /__kitchen prefix. */ import type { RoutesManifest } from "../spec/schema"; import type { GuardConfig } from "../guard/types"; import { getGlobalCache, getCacheStoreStats } from "../runtime/cache"; import { ActivitySSEBroadcaster } from "./stream/activity-sse"; import { GuardAPI } from "./api/guard-api"; import { handleRoutesRequest } from "./api/routes-api"; import { FileAPI } from "./api/file-api"; import { GuardDecisionManager } from "./api/guard-decisions"; import { ContractPlaygroundAPI } from "./api/contract-api"; import { handleAgentContextRequest } from "./api/agent-devtools-api"; import { groupErrors } from "./api/errors-grouping"; import type { BundleManifest } from "../bundler/types"; import type { DiagnoseReport } from "../diagnose/types"; import { runExtendedDiagnose } from "../diagnose/run"; import { renderKitchenHTML } from "./kitchen-ui"; import { eventBus } from "../observability/event-bus"; import fs from "fs/promises"; import path from "path"; export const KITCHEN_PREFIX = "/__kitchen"; function reverseCopy(items: readonly T[]): T[] { const reversed: T[] = []; for (let index = items.length - 1; index >= 0; index -= 1) { reversed.push(items[index] as T); } return reversed; } export interface KitchenOptions { rootDir: string; manifest: RoutesManifest; guardConfig: GuardConfig | null; } /** In-memory error store for Kitchen → MCP bridge */ interface KitchenError { id: string; type: string; severity: string; message: string; stack?: string; url?: string; source?: string; line?: number; column?: number; timestamp: number; } const MAX_STORED_ERRORS = 50; let storedErrors: KitchenError[] = []; /** Get stored errors (used by MCP tools) */ export function getKitchenErrors(): KitchenError[] { return storedErrors; } /** Clear stored errors */ export function clearKitchenErrors(): void { storedErrors = []; } // ========== Request Ring Buffer ========== export interface RequestEntry { id: string; method: string; path: string; status: number; duration: number; timestamp: number; cacheStatus?: string; } const MAX_REQUESTS = 100; const recentRequests: RequestEntry[] = []; export function recordRequest(entry: RequestEntry): void { recentRequests.push(entry); if (recentRequests.length > MAX_REQUESTS) recentRequests.shift(); } export function getRecentRequests(): RequestEntry[] { return reverseCopy(recentRequests); } /** Parse a window string like "5m", "30s", "1h" into milliseconds. */ function parseWindow(input: string): number { const match = /^(\d+)\s*(ms|s|m|h)?$/.exec(input.trim()); if (!match) return 5 * 60 * 1000; const value = parseInt(match[1], 10); const unit = match[2] || "m"; switch (unit) { case "ms": return value; case "s": return value * 1000; case "m": return value * 60 * 1000; case "h": return value * 60 * 60 * 1000; default: return 5 * 60 * 1000; } } // ========== Agent Context Pack — signal collectors (plan 18 P0-2) ========== /** * Read the bundle manifest from `.mandu/manifest.json`. Returns `null` * when the file is missing or malformed — the builder treats `null` as * "not collected" rather than "empty", so missing-vs-empty stays * distinguishable for the build-broken classifier. */ async function readBundleManifest(rootDir: string): Promise { const manifestPath = path.join(rootDir, ".mandu", "manifest.json"); try { const raw = await fs.readFile(manifestPath, "utf-8"); return JSON.parse(raw) as BundleManifest; } catch { return null; } } /** * Run the extended diagnose suite. Returns `null` on failure so the * supervisor degrades gracefully — no diagnose summary, but the rest * of the context pack still works. */ async function runDiagnoseSignal(rootDir: string): Promise { try { return await runExtendedDiagnose(rootDir); } catch { return null; } } /** * Collect changed file paths via `git diff --name-only `. Base * defaults to `HEAD` (uncommitted changes only), overridable with the * `MANDU_DIFF_BASE` environment variable. Returns `undefined` (not * `[]`) when git is unavailable so the builder can omit the summary * block entirely. */ async function collectChangedFiles(rootDir: string): Promise { const base = process.env.MANDU_DIFF_BASE ?? "HEAD"; try { const proc = Bun.spawnSync({ cmd: ["git", "diff", "--name-only", base], cwd: rootDir, stdout: "pipe", stderr: "pipe", }); if (proc.exitCode !== 0) return undefined; const text = new TextDecoder().decode(proc.stdout); return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); } catch { return undefined; } } // ========== Per-Agent Stats ========== export interface AgentStats { toolCalls: number; failures: number; topTools: Array<{ tool: string; count: number }>; avgDuration: number; firstSeen: number; lastSeen: number; } export interface AgentStatsResponse { agents: Record; totalAgents: number; totalEvents: number; } /** * Aggregate recent MCP events by sessionId to produce per-agent usage stats. * Events without a sessionId are grouped under "unknown". */ export function computeAgentStats(): AgentStatsResponse { const events = eventBus.getRecent(500, { type: "mcp" }); const agents: Record = {}; const toolCounts: Record> = {}; const durations: Record = {}; for (const e of events) { const data = (e.data ?? {}) as Record; const sessionId = typeof data.sessionId === "string" && data.sessionId ? data.sessionId : "unknown"; let agent = agents[sessionId]; if (!agent) { agent = { toolCalls: 0, failures: 0, topTools: [], avgDuration: 0, firstSeen: e.timestamp, lastSeen: e.timestamp, }; agents[sessionId] = agent; toolCounts[sessionId] = new Map(); durations[sessionId] = []; } agent.toolCalls++; if (e.severity === "error") agent.failures++; if (e.timestamp < agent.firstSeen) agent.firstSeen = e.timestamp; if (e.timestamp > agent.lastSeen) agent.lastSeen = e.timestamp; const tool = typeof data.tool === "string" && data.tool ? data.tool : e.source || "unknown"; toolCounts[sessionId].set(tool, (toolCounts[sessionId].get(tool) ?? 0) + 1); if (typeof e.duration === "number") { durations[sessionId].push(e.duration); } } for (const [sessionId, agent] of Object.entries(agents)) { agent.topTools = Array.from(toolCounts[sessionId].entries()) .map(([tool, count]) => ({ tool, count })) .sort((a, b) => b.count - a.count) .slice(0, 5); const ds = durations[sessionId]; agent.avgDuration = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : 0; } return { agents, totalAgents: Object.keys(agents).length, totalEvents: events.length, }; } export class KitchenHandler { private sse: ActivitySSEBroadcaster; private guardAPI: GuardAPI; private fileAPI: FileAPI; private guardDecisions: GuardDecisionManager; private contractAPI: ContractPlaygroundAPI; private manifest: RoutesManifest; constructor(private options: KitchenOptions) { this.manifest = options.manifest; this.sse = new ActivitySSEBroadcaster(options.rootDir); this.guardAPI = new GuardAPI(options.guardConfig, options.rootDir); this.fileAPI = new FileAPI(options.rootDir); this.guardDecisions = new GuardDecisionManager(options.rootDir); this.contractAPI = new ContractPlaygroundAPI(options.manifest, options.rootDir); } async start(): Promise { this.sse.start(); await this.loadPersistedErrors(); } /** Load errors persisted from previous sessions */ private async loadPersistedErrors(): Promise { const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl"); try { const content = await fs.readFile(errorsPath, "utf-8"); const lines = content.trim().split("\n").filter(Boolean); for (const line of lines.slice(-MAX_STORED_ERRORS)) { try { storedErrors.push(JSON.parse(line)); } catch { /* skip malformed lines */ } } } catch { /* file doesn't exist yet — fine */ } } stop(): void { this.sse.stop(); } /** Update manifest when routes change (HMR rebuild) */ updateManifest(manifest: RoutesManifest): void { this.manifest = manifest; this.contractAPI.updateManifest(manifest); } /** Update guard config when mandu.config.ts changes */ updateGuardConfig(config: GuardConfig | null): void { this.options.guardConfig = config; this.guardAPI.updateConfig(config); } /** Get the SSE broadcaster for external event injection */ get broadcaster(): ActivitySSEBroadcaster { return this.sse; } /** Get the Guard API for pushing violation reports */ get guard(): GuardAPI { return this.guardAPI; } /** * Handle a /__kitchen/* request. * Returns Response or null if path doesn't match. */ async handle(req: Request, pathname: string): Promise { if (!pathname.startsWith(KITCHEN_PREFIX)) { return null; } const sub = pathname.slice(KITCHEN_PREFIX.length) || "/"; // Kitchen dashboard UI if (sub === "/" || sub === "") { return new Response(renderKitchenHTML(), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // SSE activity stream if (sub === "/sse/activity") { return this.sse.createResponse(); } // Routes API if (sub === "/api/routes") { return handleRoutesRequest(this.manifest); } // Guard API if (sub === "/api/guard" && req.method === "GET") { return this.guardAPI.handleGetReport(); } if (sub === "/api/guard/scan" && req.method === "POST") { return this.guardAPI.handleScan(); } // Guard Decisions API if (sub === "/api/guard/decisions" && req.method === "GET") { const decisions = await this.guardDecisions.load(); return Response.json({ decisions }); } if (sub === "/api/guard/approve" && req.method === "POST") { try { const body = await req.json(); const decision = await this.guardDecisions.save({ violationKey: `${body.ruleId}::${body.filePath}`, action: "approve", ruleId: body.ruleId, filePath: body.filePath, reason: body.reason, }); return Response.json({ decision }); } catch { return Response.json({ error: "Invalid request body" }, { status: 400 }); } } if (sub === "/api/guard/reject" && req.method === "POST") { try { const body = await req.json(); const decision = await this.guardDecisions.save({ violationKey: `${body.ruleId}::${body.filePath}`, action: "reject", ruleId: body.ruleId, filePath: body.filePath, reason: body.reason, }); return Response.json({ decision }); } catch { return Response.json({ error: "Invalid request body" }, { status: 400 }); } } if (sub.startsWith("/api/guard/decisions/") && req.method === "DELETE") { const id = sub.slice("/api/guard/decisions/".length); const removed = await this.guardDecisions.remove(id); if (!removed) { return Response.json({ error: "Decision not found" }, { status: 404 }); } return Response.json({ removed: true }); } // Requests API — recent HTTP events from eventBus (fallback: ring buffer) if (sub === "/api/requests" && req.method === "GET") { const url = new URL(req.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10) || 100, 500); const busEvents = eventBus.getRecent(limit, { type: "http" }); if (busEvents.length > 0) { return Response.json({ requests: reverseCopy(busEvents) }); } return Response.json({ requests: getRecentRequests().slice(0, limit) }); } // Correlation API — all events linked to a correlationId if (sub === "/api/correlation" && req.method === "GET") { const url = new URL(req.url); const cid = url.searchParams.get("id") || ""; if (!cid) return Response.json({ events: [] }); const all = eventBus.getRecent(500); const events = all.filter((e) => e.correlationId === cid); return Response.json({ events }); } // Activity API — recent MCP events from eventBus (fallback: activity.jsonl) if (sub === "/api/activity" && req.method === "GET") { const url = new URL(req.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10) || 100, 500); const busEvents = eventBus.getRecent(limit, { type: "mcp" }); if (busEvents.length > 0) { return Response.json({ events: reverseCopy(busEvents) }); } const events = await this.readRecentActivity(); return Response.json({ events }); } // Agent Stats API — per-agent (sessionId) aggregation of MCP events if (sub === "/api/agent-stats" && req.method === "GET") { return Response.json(computeAgentStats()); } // Generic events API (plan 18 P1-3). // Exposes the full eventBus surface — `build` / `cache` / `ws` / `ate` // / `error` categories live on the bus but were previously invisible // because /api/activity and /api/requests filter to mcp/http only. // Query params: // ?type=build|cache|ws|ate|http|mcp|guard|error (optional — omit for all) // ?severity=info|warn|error (optional) // ?limit=N (default 100, max 500) // ?stats=1 (return per-type counts instead of events) if (sub === "/api/events" && req.method === "GET") { const url = new URL(req.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10) || 100, 500); if (url.searchParams.get("stats") === "1") { return Response.json({ stats: eventBus.getStats() }); } const typeParam = url.searchParams.get("type"); const sevParam = url.searchParams.get("severity"); const allowedTypes = new Set(["http", "mcp", "guard", "build", "error", "cache", "ws", "ate"]); const allowedSev = new Set(["info", "warn", "error"]); const filter: { type?: "http" | "mcp" | "guard" | "build" | "error" | "cache" | "ws" | "ate"; severity?: "info" | "warn" | "error" } = {}; if (typeParam && allowedTypes.has(typeParam)) filter.type = typeParam as typeof filter.type; if (sevParam && allowedSev.has(sevParam)) filter.severity = sevParam as typeof filter.severity; const events = eventBus.getRecent(limit, filter); return Response.json({ events: reverseCopy(events) }); } // Agent DevTools API — read-only context pack for supervised coding sessions. // // Plan 18 P0-2 — collect three additional signals (bundle manifest, // diagnose report, changed files) so the supervisor can distinguish // build-broken / boot-breaking categories from runtime errors. // Each collector is fail-safe: failure to read disk or run a // sub-process yields `null` / `[]` rather than throwing. Query // params allow callers to skip expensive signals: // ?bundle=0 → skip reading .mandu/manifest.json // ?diagnose=0 → skip runExtendedDiagnose (a11y_hints is the // most expensive check; opt out when latency matters) // ?diff=0 → skip `git diff --name-only` if (sub === "/api/agent-context" && req.method === "GET") { const urlObj = new URL(req.url); const wantBundle = urlObj.searchParams.get("bundle") !== "0"; const wantDiagnose = urlObj.searchParams.get("diagnose") !== "0"; const wantDiff = urlObj.searchParams.get("diff") !== "0"; const [bundleManifest, diagnoseReport, changedFiles] = await Promise.all([ wantBundle ? readBundleManifest(this.options.rootDir) : Promise.resolve(null), wantDiagnose ? runDiagnoseSignal(this.options.rootDir) : Promise.resolve(null), wantDiff ? collectChangedFiles(this.options.rootDir) : Promise.resolve(undefined), ]); return handleAgentContextRequest({ rootDir: this.options.rootDir, manifest: this.manifest, guardEnabled: !!this.options.guardConfig, errors: getKitchenErrors(), requests: getRecentRequests().slice(0, 100), httpEvents: eventBus.getRecent(100, { type: "http" }), mcpEvents: eventBus.getRecent(100, { type: "mcp" }), guardEvents: eventBus.getRecent(100, { type: "guard" }), agentStats: computeAgentStats(), bundleManifest, diagnoseReport, changedFiles, }); } // Cache API — cache store stats if ((sub === "/api/cache" || sub === "/api/cache-stats") && req.method === "GET") { const store = getGlobalCache(); return Response.json({ enabled: !!store, size: store?.size ?? 0, stats: getCacheStoreStats(store), }); } // Metrics API — rolling-window eventBus stats if (sub === "/api/metrics" && req.method === "GET") { const url = new URL(req.url); const windowParam = url.searchParams.get("window") || "5m"; const windowMs = parseWindow(windowParam); const stats = eventBus.getStats(windowMs); const httpEvents = eventBus.getRecent(500, { type: "http" }) .filter((e) => e.timestamp >= Date.now() - windowMs); const durations = httpEvents .map((e) => e.duration) .filter((d): d is number => typeof d === "number") .sort((a, b) => a - b); const percentile = (p: number) => { if (!durations.length) return 0; const idx = Math.min(durations.length - 1, Math.floor((p / 100) * durations.length)); return durations[idx]; }; const totalEvents = Object.values(stats).reduce((sum, s) => sum + s.count, 0); const totalErrors = Object.values(stats).reduce((sum, s) => sum + s.errors, 0); return Response.json({ window: windowParam, windowMs, stats, http: { count: httpEvents.length, p50: percentile(50), p95: percentile(95), p99: percentile(99), }, mcp: { count: stats.mcp.count, errors: stats.mcp.errors, avgDuration: stats.mcp.avgDuration, }, errorRate: totalEvents > 0 ? totalErrors / totalEvents : 0, totalEvents, }); } // Error API (Kitchen → MCP bridge) if (sub === "/api/errors" && req.method === "POST") { try { const body = await req.json() as KitchenError | KitchenError[]; const errors = Array.isArray(body) ? body : [body]; for (const error of errors) { if (!error.message) continue; error.timestamp = error.timestamp || Date.now(); error.id = error.id || `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; storedErrors.push(error); if (storedErrors.length > MAX_STORED_ERRORS) { storedErrors.shift(); } // Persist to disk const errorLine = JSON.stringify(error) + "\n"; const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl"); fs.appendFile(errorsPath, errorLine).catch(() => {}); } return Response.json({ received: errors.length, total: storedErrors.length }); } catch { return Response.json({ error: "Invalid error payload" }, { status: 400 }); } } if (sub === "/api/errors" && req.method === "GET") { return Response.json({ errors: storedErrors, count: storedErrors.length }); } // Plan 18 P1-4 — Errors panel grouped view. Collapses identical // signatures into one row with count/firstSeen/lastSeen/affected // sources. The operator no longer scrolls past five copies of the // same hydration error to find the new one underneath. if (sub === "/api/errors/grouped" && req.method === "GET") { const groups = groupErrors(storedErrors); return Response.json({ groups, groupCount: groups.length, totalCount: storedErrors.length, }); } if (sub === "/api/errors" && req.method === "DELETE") { const count = storedErrors.length; clearKitchenErrors(); return Response.json({ cleared: count }); } // File API if (sub === "/api/file" && req.method === "GET") { return this.fileAPI.handleReadFile(new URL(req.url)); } if (sub === "/api/file/diff" && req.method === "GET") { return this.fileAPI.handleFileDiff(new URL(req.url)); } if (sub === "/api/file/changes" && req.method === "GET") { return this.fileAPI.handleRecentChanges(); } // Contract API if (sub === "/api/contracts" && req.method === "GET") { return this.contractAPI.handleList(); } if (sub === "/api/contracts/validate" && req.method === "POST") { return this.contractAPI.handleValidate(req); } if (sub === "/api/contracts/openapi" && req.method === "GET") { return this.contractAPI.handleOpenAPI(); } if (sub === "/api/contracts/openapi.yaml" && req.method === "GET") { return this.contractAPI.handleOpenAPIYAML(); } if (sub.startsWith("/api/contracts/") && req.method === "GET") { const id = sub.slice("/api/contracts/".length); if (id && !id.includes("/")) { return this.contractAPI.handleDetail(id); } } // Unknown kitchen route return Response.json( { error: "Not found", path: pathname }, { status: 404 }, ); } /** Read last 50 entries from .mandu/activity.jsonl */ private async readRecentActivity(): Promise { const logPath = path.join(this.options.rootDir, ".mandu", "activity.jsonl"); try { const content = await fs.readFile(logPath, "utf-8"); const lines = content.trim().split("\n").filter(Boolean); return reverseCopy(lines.slice(-50)).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean); } catch { return []; } } }