466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
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<typeof StartRunSchema>;
|
|
|
|
async function resolveManifestFromRunRequest(input: {
|
|
body: StartRunBody;
|
|
manifestStore: ManifestStore;
|
|
}): Promise<unknown> {
|
|
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<void>;
|
|
}> {
|
|
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<void>((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<void>((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<void> {
|
|
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;
|
|
});
|
|
}
|