import "dotenv/config"; import { pathToFileURL } from "node:url"; import { resolve } from "node:path"; import express from "express"; import cors from "cors"; import { z } from "zod"; import { buildSessionGraphInsight, buildSessionSummaries } from "../telemetry/session-insights.js"; import { UiConfigStore, type LimitSettings, type RuntimeNotificationSettings, type SecurityPolicySettings, } from "../store/config-store.js"; import { ManifestStore } from "../agents/manifest-store.js"; import { filterRuntimeEvents, readRuntimeEvents } from "../telemetry/runtime-events-store.js"; import { filterClaudeTraceEvents, readClaudeTraceEvents } from "./claude-trace-store.js"; import { readRunMetaBySession, UiRunService, type RunExecutionMode } from "../runs/run-service.js"; import type { RunProvider } from "../agents/provider-executor.js"; function parsePort(value: string | undefined): number { const parsed = Number(value ?? "4317"); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new Error("UI server port must be an integer between 1 and 65535."); } return parsed; } function parseLimit(value: string | null | undefined, fallback: number): number { if (!value) { return fallback; } const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { return fallback; } return parsed; } function ensureNonEmptyString(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`Field "${field}" is required.`); } return value.trim(); } async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{ stateRoot: string; runtimeEventLogPath: string; claudeTraceLogPath: string; }> { const snapshot = await configStore.readSnapshot(); return { stateRoot: resolve(workspaceRoot, snapshot.paths.stateRoot), runtimeEventLogPath: resolve(workspaceRoot, snapshot.paths.runtimeEventLogPath), claudeTraceLogPath: resolve(workspaceRoot, snapshot.paths.claudeTraceLogPath), }; } const StartRunSchema = z.object({ prompt: z.string().min(1, 'Field "prompt" is required.'), manifestPath: z.string().optional(), manifest: z.unknown().optional(), sessionId: z.string().optional(), topologyHint: z.string().optional(), initialFlags: z.record(z.string(), z.boolean()).optional(), runtimeContextOverrides: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), simulateValidationNodeIds: z.array(z.string()).optional(), executionMode: z.enum(["mock", "provider"]).optional(), provider: z.enum(["claude", "codex"]).optional(), }); type StartRunBody = z.infer; async function resolveManifestFromRunRequest(input: { body: StartRunBody; manifestStore: ManifestStore; }): Promise { if (input.body.manifest !== undefined) { return input.body.manifest; } if (input.body.manifestPath) { return (await input.manifestStore.read(input.body.manifestPath)).source; } throw new Error("A manifest or manifestPath is required to start a run."); } export async function startUiServer(input: { workspaceRoot: string; port?: number; host?: string; }): Promise<{ close: () => Promise; }> { const workspaceRoot = resolve(input.workspaceRoot); const staticRoot = resolve(workspaceRoot, "ui/dist"); const configStore = new UiConfigStore({ workspaceRoot }); const manifestStore = new ManifestStore({ workspaceRoot }); const runService = new UiRunService({ workspaceRoot }); const app = express(); app.use(cors()); app.use(express.json({ limit: "50mb" })); app.get("/api/health", (req, res) => { res.json({ ok: true, now: new Date().toISOString() }); }); app.get("/api/config", async (req, res, next) => { try { const snapshot = await configStore.readSnapshot(); res.json({ ok: true, config: snapshot }); } catch (error) { next(error); } }); app.put("/api/config/runtime-events", async (req, res, next) => { try { const snapshot = await configStore.updateRuntimeEvents(req.body as RuntimeNotificationSettings); res.json({ ok: true, config: snapshot }); } catch (error) { next(error); } }); app.put("/api/config/security", async (req, res, next) => { try { const snapshot = await configStore.updateSecurityPolicy(req.body as SecurityPolicySettings); res.json({ ok: true, config: snapshot }); } catch (error) { next(error); } }); app.put("/api/config/limits", async (req, res, next) => { try { const snapshot = await configStore.updateLimits(req.body as LimitSettings); res.json({ ok: true, config: snapshot }); } catch (error) { next(error); } }); app.get("/api/manifests", async (req, res, next) => { try { const listing = await manifestStore.list(); res.json({ ok: true, manifests: listing.paths }); } catch (error) { next(error); } }); app.get("/api/manifests/read", async (req, res, next) => { try { const manifestPath = req.query.path as string | undefined; if (!manifestPath) { res.status(400).json({ ok: false, error: 'Query parameter "path" is required.' }); return; } const record = await manifestStore.read(manifestPath); res.json({ ok: true, manifest: record }); } catch (error) { next(error); } }); app.post("/api/manifests/validate", async (req, res, next) => { try { const manifest = await manifestStore.validate((req.body as { manifest?: unknown }).manifest); res.json({ ok: true, manifest }); } catch (error) { next(error); } }); app.put("/api/manifests/save", async (req, res, next) => { try { const { path, manifest } = req.body as { path?: unknown; manifest?: unknown }; if (!path || typeof path !== "string") { res.status(400).json({ ok: false, error: 'Field "path" is required.' }); return; } const record = await manifestStore.save(path, manifest); res.json({ ok: true, manifest: record }); } catch (error) { next(error); } }); app.get("/api/runtime-events", async (req, res, next) => { try { const { runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot); const limit = parseLimit(req.query.limit as string | undefined, 200); const sessionId = (req.query.sessionId as string) || undefined; const events = filterRuntimeEvents(await readRuntimeEvents(runtimeEventLogPath), { ...(sessionId ? { sessionId } : {}), limit, }); res.json({ ok: true, events }); } catch (error) { next(error); } }); app.get("/api/claude-trace", async (req, res, next) => { try { const { claudeTraceLogPath } = await readRuntimePaths(configStore, workspaceRoot); const limit = parseLimit(req.query.limit as string | undefined, 200); const sessionId = (req.query.sessionId as string) || undefined; const events = filterClaudeTraceEvents(await readClaudeTraceEvents(claudeTraceLogPath), { ...(sessionId ? { sessionId } : {}), limit, }); res.json({ ok: true, events }); } catch (error) { next(error); } }); app.post("/api/sessions", async (req, res, next) => { try { const projectPath = ensureNonEmptyString((req.body as { projectPath?: unknown }).projectPath, "projectPath"); const session = await runService.createSession({ projectPath }); res.status(201).json({ ok: true, session }); } catch (error) { next(error); } }); app.get("/api/sessions", async (req, res, next) => { try { const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot); const sessions = await buildSessionSummaries({ stateRoot, runtimeEventLogPath }); const metadata = await runService.listSessions(); res.json({ ok: true, sessions, sessionMetadata: metadata, runs: runService.listRuns() }); } catch (error) { next(error); } }); app.get("/api/sessions/graph", async (req, res, next) => { try { const sessionId = (req.query.sessionId as string) ?? ""; if (!sessionId) { res.status(400).json({ ok: false, error: 'Query parameter "sessionId" is required.' }); return; } const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot); const explicitManifestPath = req.query.manifestPath as string | undefined; const runMeta = await readRunMetaBySession({ stateRoot, sessionId }); const manifestPath = explicitManifestPath ?? runMeta?.manifestPath; if (!manifestPath) { res.status(400).json({ ok: false, error: "No manifestPath available for this session. Provide one in query string.", }); return; } const manifestRecord = await manifestStore.read(manifestPath); const graph = await buildSessionGraphInsight({ stateRoot, runtimeEventLogPath, sessionId, manifest: manifestRecord.manifest, }); res.json({ ok: true, graph, manifestPath: manifestRecord.path }); } catch (error) { next(error); } }); app.post("/api/sessions/:sessionId/run", async (req, res, next) => { try { const sessionId = req.params.sessionId; if (!sessionId) { res.status(400).json({ ok: false, error: "Session id is required." }); return; } const parseResult = StartRunSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message ?? "Invalid body" }); return; } const body = parseResult.data; const manifest = await resolveManifestFromRunRequest({ body, manifestStore }); const record = await runService.startRun({ prompt: body.prompt, manifest, manifestPath: body.manifestPath, sessionId, topologyHint: body.topologyHint, initialFlags: body.initialFlags ?? {}, runtimeContextOverrides: body.runtimeContextOverrides ?? {}, simulateValidationNodeIds: body.simulateValidationNodeIds ?? [], executionMode: (body.executionMode ?? "mock") as RunExecutionMode, provider: (body.provider ?? "codex") as RunProvider, }); res.status(202).json({ ok: true, run: record }); } catch (error) { next(error); } }); app.post("/api/sessions/:sessionId/close", async (req, res, next) => { try { const sessionId = req.params.sessionId; if (!sessionId) { res.status(400).json({ ok: false, error: "Session id is required." }); return; } const mergeToProject = typeof (req.body as { mergeToProject?: unknown } | undefined)?.mergeToProject === "boolean" ? ((req.body as { mergeToProject: boolean }).mergeToProject) : false; const session = await runService.closeSession({ sessionId, mergeToProject, }); res.json({ ok: true, session }); } catch (error) { next(error); } }); app.get("/api/runs", (req, res) => { res.json({ ok: true, runs: runService.listRuns() }); }); app.post("/api/runs", async (req, res, next) => { try { const parseResult = StartRunSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message ?? "Invalid body" }); return; } const body = parseResult.data; const manifest = await resolveManifestFromRunRequest({ body, manifestStore }); const record = await runService.startRun({ prompt: body.prompt, manifest, manifestPath: body.manifestPath, sessionId: body.sessionId, topologyHint: body.topologyHint, initialFlags: body.initialFlags ?? {}, runtimeContextOverrides: body.runtimeContextOverrides ?? {}, simulateValidationNodeIds: body.simulateValidationNodeIds ?? [], executionMode: (body.executionMode ?? "mock") as RunExecutionMode, provider: (body.provider ?? "codex") as RunProvider, }); res.status(202).json({ ok: true, run: record }); } catch (error) { next(error); } }); app.post("/api/runs/:runId/cancel", async (req, res, next) => { try { const runId = req.params.runId; if (!runId) { res.status(400).json({ ok: false, error: "runId required" }); return; } const run = await runService.cancelRun(runId); res.json({ ok: true, run }); } catch (error) { next(error); } }); app.get("/api/runs/:runId", (req, res) => { const runId = req.params.runId; if (!runId) { res.status(400).json({ ok: false, error: "runId required" }); return; } const run = runService.getRun(runId); if (!run) { res.status(404).json({ ok: false, error: `Run "${runId}" was not found.` }); return; } res.json({ ok: true, run }); }); app.use("/api", (req, res) => { res.status(404).json({ ok: false, error: `API route "${req.method} ${req.originalUrl}" was not found.`, }); }); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err), }); }); app.use(express.static(staticRoot)); app.get(/(.*)/, (req, res) => { res.sendFile(resolve(staticRoot, "index.html")); }); const host = input.host ?? "127.0.0.1"; const port = input.port ?? parsePort(process.env.AGENT_UI_PORT); const server = app.listen(port, host); await new Promise((resolveReady, rejectReady) => { server.once("error", rejectReady); server.once("listening", () => { server.off("error", rejectReady); console.log(`AI Ops UI listening at http://${host}:${String(port)}`); resolveReady(); }); }); return { close: async () => { if (!server.listening) { return; } await new Promise((resolveClose, rejectClose) => { server.close((error) => { if (error) { if ((error as NodeJS.ErrnoException).code === "ERR_SERVER_NOT_RUNNING") { resolveClose(); return; } rejectClose(error); return; } resolveClose(); }); }); }, }; } async function main(): Promise { const port = parsePort(process.env.AGENT_UI_PORT); await startUiServer({ workspaceRoot: process.cwd(), port, host: process.env.AGENT_UI_HOST ?? "127.0.0.1", }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }