diff --git a/src/ui/public/app.js b/src/ui/public/app.js
new file mode 100644
index 0000000..57abb4a
--- /dev/null
+++ b/src/ui/public/app.js
@@ -0,0 +1,1697 @@
+const state = {
+ config: null,
+ manifests: [],
+ sessions: [],
+ runs: [],
+ selectedSessionId: "",
+ selectedManifestPath: "",
+ selectedNodeId: "",
+ graph: null,
+ activeRunId: "",
+ manifestDraft: null,
+};
+
+const dom = {
+ serverStatus: document.querySelector("#server-status"),
+ sessionSelect: document.querySelector("#session-select"),
+ graphManifestSelect: document.querySelector("#graph-manifest-select"),
+ graphRefresh: document.querySelector("#graph-refresh"),
+ graphSvg: document.querySelector("#graph-svg"),
+ graphBanner: document.querySelector("#graph-banner"),
+ runForm: document.querySelector("#run-form"),
+ runPrompt: document.querySelector("#run-prompt"),
+ runManifestSelect: document.querySelector("#run-manifest-select"),
+ runExecutionMode: document.querySelector("#run-execution-mode"),
+ runProvider: document.querySelector("#run-provider"),
+ runTopologyHint: document.querySelector("#run-topology-hint"),
+ runFlags: document.querySelector("#run-flags"),
+ runValidationNodes: document.querySelector("#run-validation-nodes"),
+ killRun: document.querySelector("#kill-run"),
+ runStatus: document.querySelector("#run-status"),
+ nodeInspector: document.querySelector("#node-inspector"),
+ eventsLimit: document.querySelector("#events-limit"),
+ eventsRefresh: document.querySelector("#events-refresh"),
+ eventFeed: document.querySelector("#event-feed"),
+ historyRefresh: document.querySelector("#history-refresh"),
+ historyBody: document.querySelector("#history-body"),
+ notificationsForm: document.querySelector("#notifications-form"),
+ securityForm: document.querySelector("#security-form"),
+ limitsForm: document.querySelector("#limits-form"),
+ manifestForm: document.querySelector("#manifest-form"),
+ manifestPath: document.querySelector("#manifest-path"),
+ manifestLoad: document.querySelector("#manifest-load"),
+ manifestValidate: document.querySelector("#manifest-validate"),
+ manifestEditor: document.querySelector("#manifest-editor"),
+ manifestStatus: document.querySelector("#manifest-status"),
+ manifestTemplateMinimal: document.querySelector("#manifest-template-minimal"),
+ manifestTemplateTwoStage: document.querySelector("#manifest-template-two-stage"),
+ manifestSyncFromEditor: document.querySelector("#manifest-sync-from-editor"),
+ manifestApplyToEditor: document.querySelector("#manifest-apply-to-editor"),
+ manifestHelperStatus: document.querySelector("#manifest-helper-status"),
+ helperTopologySequential: document.querySelector("#helper-topology-sequential"),
+ helperTopologyParallel: document.querySelector("#helper-topology-parallel"),
+ helperTopologyHierarchical: document.querySelector("#helper-topology-hierarchical"),
+ helperTopologyRetryUnrolled: document.querySelector("#helper-topology-retry-unrolled"),
+ helperTopologyMaxDepth: document.querySelector("#helper-topology-max-depth"),
+ helperTopologyMaxRetries: document.querySelector("#helper-topology-max-retries"),
+ helperEntryNodeId: document.querySelector("#helper-entry-node-id"),
+ helperPersonas: document.querySelector("#helper-personas"),
+ helperRelationships: document.querySelector("#helper-relationships"),
+ helperNodes: document.querySelector("#helper-nodes"),
+ helperEdges: document.querySelector("#helper-edges"),
+ helperAddPersona: document.querySelector("#helper-add-persona"),
+ helperAddRelationship: document.querySelector("#helper-add-relationship"),
+ helperAddNode: document.querySelector("#helper-add-node"),
+ helperAddEdge: document.querySelector("#helper-add-edge"),
+ cfgWebhookUrl: document.querySelector("#cfg-webhook-url"),
+ cfgWebhookSeverity: document.querySelector("#cfg-webhook-severity"),
+ cfgWebhookAlways: document.querySelector("#cfg-webhook-always"),
+ cfgSecurityMode: document.querySelector("#cfg-security-mode"),
+ cfgSecurityBinaries: document.querySelector("#cfg-security-binaries"),
+ cfgSecurityTimeout: document.querySelector("#cfg-security-timeout"),
+ cfgSecurityInherit: document.querySelector("#cfg-security-inherit"),
+ cfgSecurityScrub: document.querySelector("#cfg-security-scrub"),
+ cfgLimitConcurrent: document.querySelector("#cfg-limit-concurrent"),
+ cfgLimitSession: document.querySelector("#cfg-limit-session"),
+ cfgLimitDepth: document.querySelector("#cfg-limit-depth"),
+ cfgTopologyDepth: document.querySelector("#cfg-topology-depth"),
+ cfgTopologyRetries: document.querySelector("#cfg-topology-retries"),
+ cfgRelationshipChildren: document.querySelector("#cfg-relationship-children"),
+ cfgPortBase: document.querySelector("#cfg-port-base"),
+ cfgPortBlockSize: document.querySelector("#cfg-port-block-size"),
+ cfgPortBlockCount: document.querySelector("#cfg-port-block-count"),
+ cfgPortPrimaryOffset: document.querySelector("#cfg-port-primary-offset"),
+};
+
+const TOPOLOGY_COLORS = {
+ sequential: "#56c3ff",
+ parallel: "#f6b73c",
+ hierarchical: "#44d38f",
+ "retry-unrolled": "#ff8a5b",
+};
+
+const STATUS_COLORS = {
+ success: "#44d38f",
+ validation_fail: "#ffc94a",
+ failure: "#ff6e4a",
+ running: "#ffc94a",
+ cancelled: "#ff6e4a",
+ unknown: "#9bb8cf",
+};
+
+const MANIFEST_TOPOLOGY_ORDER = [
+ "sequential",
+ "parallel",
+ "hierarchical",
+ "retry-unrolled",
+];
+
+const MANIFEST_ON_TRIGGERS = ["success", "validation_fail", "failure", "always"];
+const MANIFEST_EVENT_TRIGGERS = [
+ "requirements_defined",
+ "tasks_planned",
+ "code_committed",
+ "task_blocked",
+ "validation_passed",
+ "validation_failed",
+ "branch_merged",
+];
+
+function fmtMoney(value) {
+ return `$${Number(value || 0).toFixed(4)}`;
+}
+
+function fmtMs(value) {
+ const ms = Number(value || 0);
+ if (ms < 1000) {
+ return `${ms.toFixed(0)}ms`;
+ }
+ return `${(ms / 1000).toFixed(2)}s`;
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function showRunStatus(message, isError = false) {
+ dom.runStatus.textContent = message;
+ dom.runStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
+}
+
+function setManifestStatus(message, isError = false) {
+ dom.manifestStatus.textContent = message;
+ dom.manifestStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
+}
+
+function setManifestHelperStatus(message, isError = false) {
+ dom.manifestHelperStatus.textContent = message;
+ dom.manifestHelperStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
+}
+
+function cloneJson(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function toIntegerOrFallback(value, fallback, min = 0) {
+ const parsed = Number(value);
+ if (!Number.isInteger(parsed) || parsed < min) {
+ return fallback;
+ }
+ return parsed;
+}
+
+function readOptionalIntegerField(value, min = 0) {
+ const trimmed = String(value ?? "").trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ const parsed = Number(trimmed);
+ if (!Number.isInteger(parsed) || parsed < min) {
+ return undefined;
+ }
+ return parsed;
+}
+
+function buildMinimalManifestTemplate() {
+ return {
+ schemaVersion: "1",
+ topologies: ["sequential"],
+ personas: [
+ {
+ id: "builder",
+ displayName: "Builder",
+ systemPromptTemplate: "Build and validate for {{repo}}",
+ toolClearance: {
+ allowlist: ["read_file", "write_file"],
+ banlist: [],
+ },
+ },
+ ],
+ relationships: [],
+ topologyConstraints: {
+ maxDepth: 4,
+ maxRetries: 1,
+ },
+ pipeline: {
+ entryNodeId: "build-node",
+ nodes: [
+ {
+ id: "build-node",
+ actorId: "builder_actor",
+ personaId: "builder",
+ },
+ ],
+ edges: [],
+ },
+ };
+}
+
+function buildTwoStageManifestTemplate() {
+ return {
+ schemaVersion: "1",
+ topologies: ["sequential", "retry-unrolled"],
+ personas: [
+ {
+ id: "planner",
+ displayName: "Planner",
+ systemPromptTemplate: "Plan delivery for {{repo}}",
+ toolClearance: {
+ allowlist: ["read_file"],
+ banlist: [],
+ },
+ },
+ {
+ id: "coder",
+ displayName: "Coder",
+ systemPromptTemplate: "Implement ticket {{ticket}}",
+ toolClearance: {
+ allowlist: ["read_file", "write_file"],
+ banlist: [],
+ },
+ },
+ ],
+ relationships: [
+ {
+ parentPersonaId: "planner",
+ childPersonaId: "coder",
+ constraints: {
+ maxDepth: 2,
+ maxChildren: 2,
+ },
+ },
+ ],
+ topologyConstraints: {
+ maxDepth: 5,
+ maxRetries: 2,
+ },
+ pipeline: {
+ entryNodeId: "plan",
+ nodes: [
+ {
+ id: "plan",
+ actorId: "planner_actor",
+ personaId: "planner",
+ },
+ {
+ id: "implement",
+ actorId: "coder_actor",
+ personaId: "coder",
+ constraints: {
+ maxRetries: 1,
+ },
+ topology: {
+ kind: "retry-unrolled",
+ },
+ },
+ ],
+ edges: [
+ {
+ from: "plan",
+ to: "implement",
+ on: "success",
+ },
+ ],
+ },
+ };
+}
+
+function normalizeManifestDraft(manifest) {
+ const fallback = buildMinimalManifestTemplate();
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
+ return fallback;
+ }
+
+ const input = manifest;
+ const topologies = Array.isArray(input.topologies)
+ ? input.topologies.filter((value) => MANIFEST_TOPOLOGY_ORDER.includes(value))
+ : [];
+ const personas = Array.isArray(input.personas) ? input.personas : [];
+ const relationships = Array.isArray(input.relationships) ? input.relationships : [];
+ const pipeline = input.pipeline && typeof input.pipeline === "object" ? input.pipeline : {};
+ const nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
+ const edges = Array.isArray(pipeline.edges) ? pipeline.edges : [];
+ const constraints =
+ input.topologyConstraints && typeof input.topologyConstraints === "object"
+ ? input.topologyConstraints
+ : {};
+
+ return {
+ schemaVersion: "1",
+ topologies: topologies.length > 0 ? topologies : ["sequential"],
+ personas: personas.map((persona, index) => {
+ const toolClearance =
+ persona && typeof persona.toolClearance === "object" && persona.toolClearance
+ ? persona.toolClearance
+ : {};
+ return {
+ id: String(persona?.id ?? `persona_${index + 1}`),
+ displayName: String(persona?.displayName ?? `Persona ${index + 1}`),
+ systemPromptTemplate: String(persona?.systemPromptTemplate ?? "Prompt for {{repo}}"),
+ ...(typeof persona?.modelConstraint === "string" && persona.modelConstraint.trim()
+ ? { modelConstraint: persona.modelConstraint.trim() }
+ : {}),
+ toolClearance: {
+ allowlist: Array.isArray(toolClearance.allowlist)
+ ? toolClearance.allowlist.map((value) => String(value))
+ : [],
+ banlist: Array.isArray(toolClearance.banlist)
+ ? toolClearance.banlist.map((value) => String(value))
+ : [],
+ },
+ };
+ }),
+ relationships: relationships.map((relationship) => ({
+ parentPersonaId: String(relationship?.parentPersonaId ?? ""),
+ childPersonaId: String(relationship?.childPersonaId ?? ""),
+ ...(relationship?.constraints && typeof relationship.constraints === "object"
+ ? {
+ constraints: {
+ ...(readOptionalIntegerField(relationship.constraints.maxDepth, 1) !== undefined
+ ? { maxDepth: readOptionalIntegerField(relationship.constraints.maxDepth, 1) }
+ : {}),
+ ...(readOptionalIntegerField(relationship.constraints.maxChildren, 1) !== undefined
+ ? { maxChildren: readOptionalIntegerField(relationship.constraints.maxChildren, 1) }
+ : {}),
+ },
+ }
+ : {}),
+ })),
+ topologyConstraints: {
+ maxDepth: toIntegerOrFallback(constraints.maxDepth, 4, 1),
+ maxRetries: toIntegerOrFallback(constraints.maxRetries, 2, 0),
+ },
+ pipeline: {
+ entryNodeId: String(pipeline.entryNodeId ?? (nodes[0]?.id || "")),
+ nodes: nodes.map((node, index) => ({
+ id: String(node?.id ?? `node_${index + 1}`),
+ actorId: String(node?.actorId ?? `actor_${index + 1}`),
+ personaId: String(node?.personaId ?? ""),
+ ...(node?.constraints && typeof node.constraints === "object"
+ ? {
+ constraints: {
+ ...(readOptionalIntegerField(node.constraints.maxRetries, 0) !== undefined
+ ? { maxRetries: readOptionalIntegerField(node.constraints.maxRetries, 0) }
+ : {}),
+ },
+ }
+ : {}),
+ ...(node?.topology && typeof node.topology === "object"
+ ? {
+ topology: {
+ kind: String(node.topology.kind ?? ""),
+ ...(typeof node.topology.blockId === "string" && node.topology.blockId.trim()
+ ? { blockId: node.topology.blockId.trim() }
+ : {}),
+ },
+ }
+ : {}),
+ })),
+ edges: edges.map((edge) => ({
+ from: String(edge?.from ?? ""),
+ to: String(edge?.to ?? ""),
+ ...(typeof edge?.on === "string" ? { on: edge.on } : {}),
+ ...(typeof edge?.event === "string" ? { event: edge.event } : {}),
+ ...(Array.isArray(edge?.when) ? { when: edge.when } : {}),
+ })),
+ },
+ };
+}
+
+function renderSelectOptions(values, selectedValue, input = {}) {
+ const includeBlank = input.includeBlank === true;
+ const blankLabel = input.blankLabel || "(none)";
+ const options = [];
+
+ if (includeBlank) {
+ options.push(``);
+ }
+
+ for (const value of values) {
+ const selected = value === selectedValue ? " selected" : "";
+ options.push(
+ ``,
+ );
+ }
+
+ return options.join("");
+}
+
+function renderManifestHelper() {
+ if (!state.manifestDraft) {
+ state.manifestDraft = buildMinimalManifestTemplate();
+ }
+
+ const draft = state.manifestDraft;
+ const topologies = Array.isArray(draft.topologies) ? draft.topologies : [];
+ const topologySet = new Set(topologies);
+ dom.helperTopologySequential.checked = topologySet.has("sequential");
+ dom.helperTopologyParallel.checked = topologySet.has("parallel");
+ dom.helperTopologyHierarchical.checked = topologySet.has("hierarchical");
+ dom.helperTopologyRetryUnrolled.checked = topologySet.has("retry-unrolled");
+ dom.helperTopologyMaxDepth.value = String(
+ toIntegerOrFallback(draft.topologyConstraints?.maxDepth, 4, 1),
+ );
+ dom.helperTopologyMaxRetries.value = String(
+ toIntegerOrFallback(draft.topologyConstraints?.maxRetries, 2, 0),
+ );
+ dom.helperEntryNodeId.value = String(draft.pipeline?.entryNodeId || "");
+
+ const personas = Array.isArray(draft.personas) ? draft.personas : [];
+ const personaIds = personas.map((persona) => String(persona.id || "")).filter((value) => value);
+ dom.helperPersonas.innerHTML = personas
+ .map((persona, index) => {
+ const allowlist = Array.isArray(persona.toolClearance?.allowlist)
+ ? persona.toolClearance.allowlist
+ : [];
+ const banlist = Array.isArray(persona.toolClearance?.banlist)
+ ? persona.toolClearance.banlist
+ : [];
+ return `
+
+ `;
+ })
+ .join("");
+
+ const relationships = Array.isArray(draft.relationships) ? draft.relationships : [];
+ dom.helperRelationships.innerHTML = relationships
+ .map(
+ (relationship, index) => `
+
+ `,
+ )
+ .join("");
+
+ const nodes = Array.isArray(draft.pipeline?.nodes) ? draft.pipeline.nodes : [];
+ const nodeIds = nodes.map((node) => String(node.id || "")).filter((value) => value);
+ dom.helperNodes.innerHTML = nodes
+ .map(
+ (node, index) => `
+
+ `,
+ )
+ .join("");
+
+ const edges = Array.isArray(draft.pipeline?.edges) ? draft.pipeline.edges : [];
+ dom.helperEdges.innerHTML = edges
+ .map((edge, index) => {
+ const triggerKind = edge.event ? "event" : "on";
+ const whenText = Array.isArray(edge.when) && edge.when.length > 0 ? JSON.stringify(edge.when) : "";
+ return `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ })
+ .join("");
+}
+
+function readManifestDraftFromHelper() {
+ const topologies = [];
+ if (dom.helperTopologySequential.checked) topologies.push("sequential");
+ if (dom.helperTopologyParallel.checked) topologies.push("parallel");
+ if (dom.helperTopologyHierarchical.checked) topologies.push("hierarchical");
+ if (dom.helperTopologyRetryUnrolled.checked) topologies.push("retry-unrolled");
+ if (topologies.length === 0) {
+ topologies.push("sequential");
+ }
+
+ const personas = [];
+ for (const row of dom.helperPersonas.querySelectorAll(".helper-persona-row")) {
+ const id = row.querySelector(".helper-persona-id")?.value.trim() || "";
+ const displayName = row.querySelector(".helper-persona-display-name")?.value.trim() || "";
+ const systemPromptTemplate = row.querySelector(".helper-persona-system-prompt")?.value.trim() || "";
+ const modelConstraint = row.querySelector(".helper-persona-model-constraint")?.value.trim() || "";
+ const allowlistCsv = row.querySelector(".helper-persona-allowlist")?.value || "";
+ const banlistCsv = row.querySelector(".helper-persona-banlist")?.value || "";
+
+ personas.push({
+ id,
+ displayName,
+ systemPromptTemplate,
+ ...(modelConstraint ? { modelConstraint } : {}),
+ toolClearance: {
+ allowlist: fromCsv(allowlistCsv),
+ banlist: fromCsv(banlistCsv),
+ },
+ });
+ }
+
+ const relationships = [];
+ for (const row of dom.helperRelationships.querySelectorAll(".helper-relationship-row")) {
+ const parentPersonaId = row.querySelector(".helper-relationship-parent")?.value.trim() || "";
+ const childPersonaId = row.querySelector(".helper-relationship-child")?.value.trim() || "";
+ const maxDepth = readOptionalIntegerField(
+ row.querySelector(".helper-relationship-max-depth")?.value,
+ 1,
+ );
+ const maxChildren = readOptionalIntegerField(
+ row.querySelector(".helper-relationship-max-children")?.value,
+ 1,
+ );
+
+ relationships.push({
+ parentPersonaId,
+ childPersonaId,
+ ...((maxDepth !== undefined || maxChildren !== undefined)
+ ? {
+ constraints: {
+ ...(maxDepth !== undefined ? { maxDepth } : {}),
+ ...(maxChildren !== undefined ? { maxChildren } : {}),
+ },
+ }
+ : {}),
+ });
+ }
+
+ const nodes = [];
+ for (const row of dom.helperNodes.querySelectorAll(".helper-node-row")) {
+ const id = row.querySelector(".helper-node-id")?.value.trim() || "";
+ const actorId = row.querySelector(".helper-node-actor-id")?.value.trim() || "";
+ const personaId = row.querySelector(".helper-node-persona-id")?.value.trim() || "";
+ const topologyKind = row.querySelector(".helper-node-topology-kind")?.value.trim() || "";
+ const blockId = row.querySelector(".helper-node-block-id")?.value.trim() || "";
+ const maxRetries = readOptionalIntegerField(
+ row.querySelector(".helper-node-max-retries")?.value,
+ 0,
+ );
+
+ nodes.push({
+ id,
+ actorId,
+ personaId,
+ ...(maxRetries !== undefined ? { constraints: { maxRetries } } : {}),
+ ...(topologyKind
+ ? {
+ topology: {
+ kind: topologyKind,
+ ...(blockId ? { blockId } : {}),
+ },
+ }
+ : {}),
+ });
+ }
+
+ const edges = [];
+ for (const row of dom.helperEdges.querySelectorAll(".helper-edge-row")) {
+ const from = row.querySelector(".helper-edge-from")?.value.trim() || "";
+ const to = row.querySelector(".helper-edge-to")?.value.trim() || "";
+ const triggerKind = row.querySelector(".helper-edge-trigger-kind")?.value.trim() || "on";
+ const on = row.querySelector(".helper-edge-on")?.value.trim() || "";
+ const event = row.querySelector(".helper-edge-event")?.value.trim() || "";
+ const whenRaw = row.querySelector(".helper-edge-when")?.value.trim() || "";
+
+ const edge = {
+ from,
+ to,
+ ...(triggerKind === "event" && event ? { event } : { on: on || "success" }),
+ };
+
+ if (whenRaw) {
+ const parsed = parseJsonSafe(whenRaw, null);
+ if (Array.isArray(parsed)) {
+ edge.when = parsed;
+ }
+ }
+
+ edges.push(edge);
+ }
+
+ const requestedEntryNodeId = dom.helperEntryNodeId.value.trim();
+ const fallbackEntryNodeId = nodes[0]?.id || "";
+ const entryNodeId = nodes.some((node) => node.id === requestedEntryNodeId)
+ ? requestedEntryNodeId
+ : fallbackEntryNodeId;
+
+ return {
+ schemaVersion: "1",
+ topologies,
+ personas,
+ relationships,
+ topologyConstraints: {
+ maxDepth: toIntegerOrFallback(dom.helperTopologyMaxDepth.value, 4, 1),
+ maxRetries: toIntegerOrFallback(dom.helperTopologyMaxRetries.value, 2, 0),
+ },
+ pipeline: {
+ entryNodeId,
+ nodes,
+ edges,
+ },
+ };
+}
+
+function applyHelperToEditor() {
+ state.manifestDraft = readManifestDraftFromHelper();
+ dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
+ setManifestHelperStatus("Structured helper applied to JSON editor.");
+}
+
+function syncEditorFromHelperQuiet() {
+ state.manifestDraft = readManifestDraftFromHelper();
+ dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
+}
+
+function syncHelperFromEditor() {
+ const manifest = parseJsonSafe(dom.manifestEditor.value, null);
+ if (!manifest) {
+ setManifestHelperStatus("Cannot sync from JSON: editor is not valid JSON.", true);
+ return;
+ }
+
+ state.manifestDraft = normalizeManifestDraft(manifest);
+ renderManifestHelper();
+ setManifestHelperStatus("Helper synchronized from JSON editor.");
+}
+
+function setHelperTemplate(templateKind) {
+ state.manifestDraft =
+ templateKind === "two-stage" ? buildTwoStageManifestTemplate() : buildMinimalManifestTemplate();
+ renderManifestHelper();
+ dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
+ setManifestHelperStatus(
+ templateKind === "two-stage"
+ ? "Two-stage template loaded into helper and JSON editor."
+ : "Minimal template loaded into helper and JSON editor.",
+ );
+}
+
+function addHelperRow(kind) {
+ const draft = readManifestDraftFromHelper();
+
+ if (kind === "persona") {
+ draft.personas.push({
+ id: `persona_${draft.personas.length + 1}`,
+ displayName: `Persona ${draft.personas.length + 1}`,
+ systemPromptTemplate: "Prompt for {{repo}}",
+ toolClearance: {
+ allowlist: [],
+ banlist: [],
+ },
+ });
+ } else if (kind === "relationship") {
+ draft.relationships.push({
+ parentPersonaId: "",
+ childPersonaId: "",
+ });
+ } else if (kind === "node") {
+ draft.pipeline.nodes.push({
+ id: `node_${draft.pipeline.nodes.length + 1}`,
+ actorId: `actor_${draft.pipeline.nodes.length + 1}`,
+ personaId: "",
+ });
+ if (!draft.pipeline.entryNodeId) {
+ draft.pipeline.entryNodeId = draft.pipeline.nodes[0].id;
+ }
+ } else if (kind === "edge") {
+ draft.pipeline.edges.push({
+ from: "",
+ to: "",
+ on: "success",
+ });
+ }
+
+ state.manifestDraft = draft;
+ renderManifestHelper();
+}
+
+function removeHelperRow(kind, index) {
+ const draft = readManifestDraftFromHelper();
+ if (kind === "persona") {
+ draft.personas.splice(index, 1);
+ } else if (kind === "relationship") {
+ draft.relationships.splice(index, 1);
+ } else if (kind === "node") {
+ draft.pipeline.nodes.splice(index, 1);
+ } else if (kind === "edge") {
+ draft.pipeline.edges.splice(index, 1);
+ }
+
+ state.manifestDraft = draft;
+ renderManifestHelper();
+}
+
+async function apiRequest(path, options = {}) {
+ const response = await fetch(path, {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ ...options,
+ });
+
+ const payload = await response.json().catch(() => ({ ok: false, error: "Invalid JSON response." }));
+ if (!response.ok || payload.ok === false) {
+ const message = payload.error || `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ return payload;
+}
+
+function toCsv(items) {
+ return items.join(",");
+}
+
+function fromCsv(value) {
+ return value
+ .split(",")
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0);
+}
+
+function parseJsonSafe(text, fallback) {
+ if (!text.trim()) {
+ return fallback;
+ }
+
+ try {
+ return JSON.parse(text);
+ } catch {
+ return fallback;
+ }
+}
+
+function populateSelect(select, values, selectedValue) {
+ const previous = selectedValue || select.value;
+ select.innerHTML = "";
+
+ for (const value of values) {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = value;
+ if (value === previous) {
+ option.selected = true;
+ }
+ select.append(option);
+ }
+
+ if (select.options.length > 0 && !select.value) {
+ select.options[0].selected = true;
+ }
+}
+
+async function loadConfig() {
+ const payload = await apiRequest("/api/config");
+ state.config = payload.config;
+
+ const runtime = state.config.runtimeEvents;
+ dom.cfgWebhookUrl.value = runtime.webhookUrl;
+ dom.cfgWebhookSeverity.value = runtime.minSeverity;
+ dom.cfgWebhookAlways.value = toCsv(runtime.alwaysNotifyTypes);
+
+ const security = state.config.security;
+ dom.cfgSecurityMode.value = security.violationMode;
+ dom.cfgSecurityBinaries.value = toCsv(security.allowedBinaries);
+ dom.cfgSecurityTimeout.value = String(security.commandTimeoutMs);
+ dom.cfgSecurityInherit.value = toCsv(security.inheritedEnv);
+ dom.cfgSecurityScrub.value = toCsv(security.scrubbedEnv);
+
+ const limits = state.config.limits;
+ dom.cfgLimitConcurrent.value = String(limits.maxConcurrent);
+ dom.cfgLimitSession.value = String(limits.maxSession);
+ dom.cfgLimitDepth.value = String(limits.maxRecursiveDepth);
+ dom.cfgTopologyDepth.value = String(limits.topologyMaxDepth);
+ dom.cfgTopologyRetries.value = String(limits.topologyMaxRetries);
+ dom.cfgRelationshipChildren.value = String(limits.relationshipMaxChildren);
+ dom.cfgPortBase.value = String(limits.portBase);
+ dom.cfgPortBlockSize.value = String(limits.portBlockSize);
+ dom.cfgPortBlockCount.value = String(limits.portBlockCount);
+ dom.cfgPortPrimaryOffset.value = String(limits.portPrimaryOffset);
+}
+
+async function loadManifests() {
+ const payload = await apiRequest("/api/manifests");
+ state.manifests = payload.manifests;
+
+ if (!state.selectedManifestPath && state.manifests.length > 0) {
+ state.selectedManifestPath = state.manifests[0];
+ }
+
+ populateSelect(dom.graphManifestSelect, state.manifests, state.selectedManifestPath);
+ populateSelect(dom.runManifestSelect, state.manifests, state.selectedManifestPath);
+
+ if (state.selectedManifestPath) {
+ dom.manifestPath.value = state.selectedManifestPath;
+ }
+}
+
+function statusChipClass(status) {
+ return `status-chip status-${status || "unknown"}`;
+}
+
+function renderRunsAndSessionsTable() {
+ const rows = [];
+
+ for (const session of state.sessions) {
+ const sessionStatus = session.status || "unknown";
+ rows.push(`
+
+ | ${escapeHtml(session.sessionId)} |
+ ${escapeHtml(sessionStatus)} |
+ ${Number(session.nodeAttemptCount || 0)} |
+ ${fmtMs(session.durationMs)} |
+ ${fmtMoney(session.costUsd)} |
+ ${escapeHtml(session.endedAt || session.startedAt || "-")} |
+
+ `);
+ }
+
+ dom.historyBody.innerHTML = rows.join("");
+
+ for (const row of dom.historyBody.querySelectorAll("tr[data-session-id]")) {
+ row.addEventListener("click", () => {
+ const sessionId = row.getAttribute("data-session-id") || "";
+ state.selectedSessionId = sessionId;
+ dom.sessionSelect.value = sessionId;
+ void refreshGraph();
+ });
+ }
+}
+
+async function loadSessions() {
+ const payload = await apiRequest("/api/sessions");
+ state.sessions = payload.sessions || [];
+ state.runs = payload.runs || [];
+
+ if (!state.selectedSessionId && state.sessions.length > 0) {
+ state.selectedSessionId = state.sessions[0].sessionId;
+ }
+
+ populateSelect(
+ dom.sessionSelect,
+ state.sessions.map((session) => session.sessionId),
+ state.selectedSessionId,
+ );
+
+ renderRunsAndSessionsTable();
+}
+
+function createSvg(tag, attrs = {}) {
+ const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value === undefined || value === null) {
+ continue;
+ }
+ element.setAttribute(key, String(value));
+ }
+ return element;
+}
+
+function computeLayout(nodes, edges) {
+ const nodeIds = nodes.map((node) => node.nodeId);
+ const indegree = new Map(nodeIds.map((nodeId) => [nodeId, 0]));
+ const outgoing = new Map(nodeIds.map((nodeId) => [nodeId, []]));
+
+ for (const edge of edges) {
+ if (!indegree.has(edge.from) || !indegree.has(edge.to)) {
+ continue;
+ }
+ indegree.set(edge.to, (indegree.get(edge.to) || 0) + 1);
+ outgoing.get(edge.from).push(edge.to);
+ }
+
+ const queue = [];
+ for (const [nodeId, count] of indegree.entries()) {
+ if (count === 0) {
+ queue.push(nodeId);
+ }
+ }
+
+ const levelByNode = new Map();
+ for (const nodeId of queue) {
+ levelByNode.set(nodeId, 0);
+ }
+
+ while (queue.length > 0) {
+ const nodeId = queue.shift();
+ const level = levelByNode.get(nodeId) || 0;
+
+ for (const nextNodeId of outgoing.get(nodeId) || []) {
+ const candidate = level + 1;
+ const current = levelByNode.get(nextNodeId);
+ if (current === undefined || candidate > current) {
+ levelByNode.set(nextNodeId, candidate);
+ }
+
+ indegree.set(nextNodeId, (indegree.get(nextNodeId) || 0) - 1);
+ if ((indegree.get(nextNodeId) || 0) <= 0) {
+ queue.push(nextNodeId);
+ }
+ }
+ }
+
+ for (const nodeId of nodeIds) {
+ if (!levelByNode.has(nodeId)) {
+ levelByNode.set(nodeId, 0);
+ }
+ }
+
+ const maxLevel = Math.max(...levelByNode.values(), 0);
+ const columns = Array.from({ length: maxLevel + 1 }, () => []);
+ for (const node of nodes) {
+ const level = levelByNode.get(node.nodeId) || 0;
+ columns[level].push(node.nodeId);
+ }
+
+ const width = 1280;
+ const height = 640;
+ const nodeWidth = 190;
+ const nodeHeight = 76;
+ const xGap = (width - 160) / Math.max(columns.length, 1);
+ const positions = new Map();
+
+ columns.forEach((column, level) => {
+ const yGap = Math.min(130, Math.max(96, (height - 120) / Math.max(column.length, 1)));
+ const startY = Math.max(50, height / 2 - ((column.length - 1) * yGap) / 2);
+
+ column.forEach((nodeId, index) => {
+ positions.set(nodeId, {
+ x: 80 + level * xGap,
+ y: startY + index * yGap,
+ width: nodeWidth,
+ height: nodeHeight,
+ });
+ });
+ });
+
+ return positions;
+}
+
+function renderNodeInspector() {
+ if (!state.graph || !state.selectedNodeId) {
+ dom.nodeInspector.classList.add("empty");
+ dom.nodeInspector.innerHTML = "Select a graph node.";
+ return;
+ }
+
+ const node = state.graph.nodes.find((entry) => entry.nodeId === state.selectedNodeId);
+ if (!node) {
+ dom.nodeInspector.classList.add("empty");
+ dom.nodeInspector.innerHTML = "Selected node is not in the current graph.";
+ return;
+ }
+
+ dom.nodeInspector.classList.remove("empty");
+
+ const attemptsSummary = node.attempts
+ .map((attempt) => {
+ const toolCalls = attempt.usage?.toolCalls ?? 0;
+ const duration = attempt.usage?.durationMs ?? 0;
+ const cost = attempt.usage?.costUsd ?? 0;
+ return `- #${attempt.attempt} ${attempt.status} | ${fmtMs(duration)} | ${fmtMoney(cost)} | tools:${toolCalls} | retrySpawned:${attempt.retrySpawned}`;
+ })
+ .join("\n");
+
+ const domainEvents = (node.domainEvents || []).map((event) => `- ${event.type} @ ${event.timestamp}`).join("\n");
+ const sandbox = JSON.stringify(node.sandboxPayload || {}, null, 2);
+
+ dom.nodeInspector.innerHTML = `
+ ${escapeHtml(
+`Node: ${node.nodeId}
+Actor: ${node.actorId}
+Persona: ${node.personaId}
+Topology: ${node.topology}
+From: ${node.fromNodeId || "(entry)"}
+Attempts: ${node.attemptCount}
+Last status: ${node.lastStatus || "-"}
+Cost: ${fmtMoney(node.usage?.costUsd || 0)}
+Duration: ${fmtMs(node.usage?.durationMs || 0)}
+Subtasks: ${node.subtaskCount}
+Security violations: ${node.securityViolationCount}
+
+Attempt Timeline:
+${attemptsSummary || "-"}
+
+Domain Events:
+${domainEvents || "-"}
+
+Sandbox Payload (ResolvedExecutionContext):
+${sandbox}`,
+ )}
+ `;
+}
+
+function renderGraph() {
+ const svg = dom.graphSvg;
+ svg.innerHTML = "";
+
+ if (!state.graph) {
+ dom.graphBanner.textContent = "No graph loaded.";
+ return;
+ }
+
+ const graph = state.graph;
+ const positions = computeLayout(graph.nodes, graph.edges);
+
+ const defs = createSvg("defs");
+ const marker = createSvg("marker", {
+ id: "arrow",
+ viewBox: "0 0 10 10",
+ refX: "10",
+ refY: "5",
+ markerWidth: "7",
+ markerHeight: "7",
+ orient: "auto-start-reverse",
+ });
+ marker.appendChild(createSvg("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "#5e89ab" }));
+ defs.appendChild(marker);
+ svg.appendChild(defs);
+
+ for (const edge of graph.edges) {
+ const from = positions.get(edge.from);
+ const to = positions.get(edge.to);
+ if (!from || !to) {
+ continue;
+ }
+
+ const startX = from.x + from.width;
+ const startY = from.y + from.height / 2;
+ const endX = to.x;
+ const endY = to.y + to.height / 2;
+ const midX = (startX + endX) / 2;
+
+ const path = createSvg("path", {
+ d: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
+ fill: "none",
+ stroke: edge.critical ? "#ff6e4a" : edge.visited ? "#7fbce4" : "#486987",
+ "stroke-width": edge.critical ? "2.7" : edge.visited ? "2" : "1.4",
+ "stroke-dasharray": edge.visited ? "" : "6 5",
+ "marker-end": "url(#arrow)",
+ opacity: edge.visited || edge.critical ? "1" : "0.7",
+ });
+ svg.appendChild(path);
+
+ const label = createSvg("text", {
+ x: String(midX),
+ y: String((startY + endY) / 2 - 6),
+ fill: edge.critical ? "#ff6e4a" : "#9dc1dc",
+ "font-size": "11",
+ "text-anchor": "middle",
+ "font-family": "IBM Plex Mono, monospace",
+ });
+ const condition = (edge.conditionLabels || []).join(" & ");
+ label.textContent = condition ? `${edge.trigger} [${condition}]` : edge.trigger;
+ svg.appendChild(label);
+ }
+
+ for (const node of graph.nodes) {
+ const box = positions.get(node.nodeId);
+ if (!box) {
+ continue;
+ }
+
+ const group = createSvg("g", {
+ cursor: "pointer",
+ });
+
+ const color = TOPOLOGY_COLORS[node.topology] || "#56c3ff";
+ const status = node.lastStatus || "unknown";
+ const statusColor = STATUS_COLORS[status] || "#9bb8cf";
+ const isSelected = state.selectedNodeId === node.nodeId;
+ const isCritical = (graph.criticalPathNodeIds || []).includes(node.nodeId);
+
+ const rect = createSvg("rect", {
+ x: box.x,
+ y: box.y,
+ width: box.width,
+ height: box.height,
+ rx: 12,
+ ry: 12,
+ fill: "#0d2234",
+ stroke: isSelected ? "#f6b73c" : isCritical ? "#ff6e4a" : color,
+ "stroke-width": isSelected ? "3" : "2",
+ });
+
+ const top = createSvg("rect", {
+ x: box.x,
+ y: box.y,
+ width: box.width,
+ height: 18,
+ rx: 12,
+ ry: 12,
+ fill: color,
+ opacity: "0.18",
+ stroke: "none",
+ });
+
+ const title = createSvg("text", {
+ x: box.x + 10,
+ y: box.y + 32,
+ fill: "#e8f5ff",
+ "font-size": "13",
+ "font-weight": "700",
+ "font-family": "Space Grotesk, sans-serif",
+ });
+ title.textContent = node.nodeId;
+
+ const meta = createSvg("text", {
+ x: box.x + 10,
+ y: box.y + 50,
+ fill: "#9dc1dc",
+ "font-size": "11",
+ "font-family": "IBM Plex Mono, monospace",
+ });
+ meta.textContent = `${node.topology} | ${node.personaId}`;
+
+ const metrics = createSvg("text", {
+ x: box.x + 10,
+ y: box.y + 66,
+ fill: statusColor,
+ "font-size": "10",
+ "font-family": "IBM Plex Mono, monospace",
+ });
+ const duration = node.usage?.durationMs || 0;
+ const cost = node.usage?.costUsd || 0;
+ metrics.textContent = `a:${node.attemptCount} ${fmtMs(duration)} ${fmtMoney(cost)}`;
+
+ const dot = createSvg("circle", {
+ cx: box.x + box.width - 13,
+ cy: box.y + 13,
+ r: 6,
+ fill: statusColor,
+ });
+
+ group.append(rect, top, title, meta, metrics, dot);
+ group.addEventListener("click", () => {
+ state.selectedNodeId = node.nodeId;
+ renderGraph();
+ renderNodeInspector();
+ });
+
+ svg.appendChild(group);
+ }
+
+ const statusText = graph.status || "unknown";
+ const abortText = graph.aborted ? ` | aborted: ${graph.abortMessage || "yes"}` : "";
+ dom.graphBanner.textContent = `Session ${graph.sessionId} | status: ${statusText}${abortText} | critical path: ${graph.criticalPathNodeIds.join(" -> ") || "(none)"}`;
+}
+
+async function refreshGraph() {
+ if (!state.selectedSessionId || !state.selectedManifestPath) {
+ state.graph = null;
+ renderGraph();
+ renderNodeInspector();
+ return;
+ }
+
+ const query = new URLSearchParams({
+ sessionId: state.selectedSessionId,
+ manifestPath: state.selectedManifestPath,
+ });
+
+ try {
+ const payload = await apiRequest(`/api/sessions/graph?${query.toString()}`);
+ state.graph = payload.graph;
+
+ if (!state.selectedNodeId && state.graph.nodes.length > 0) {
+ state.selectedNodeId = state.graph.nodes[0].nodeId;
+ }
+
+ renderGraph();
+ renderNodeInspector();
+ } catch (error) {
+ state.graph = null;
+ dom.graphSvg.innerHTML = "";
+ dom.graphBanner.textContent = `Graph unavailable: ${error instanceof Error ? error.message : String(error)}`;
+ dom.nodeInspector.classList.add("empty");
+ dom.nodeInspector.textContent = "Select a graph node.";
+ }
+}
+
+function renderEventFeed(events) {
+ const rows = [...events]
+ .reverse()
+ .map((event) => {
+ const ts = new Date(event.timestamp).toLocaleTimeString();
+ return `
+
+
${escapeHtml(ts)}
+
${escapeHtml(event.type)}
+
${escapeHtml(event.message)}
+
+ `;
+ })
+ .join("");
+
+ dom.eventFeed.innerHTML = rows || '';
+}
+
+async function refreshEvents() {
+ const limit = Number(dom.eventsLimit.value || "150");
+ const params = new URLSearchParams({
+ limit: String(limit),
+ });
+
+ if (state.selectedSessionId) {
+ params.set("sessionId", state.selectedSessionId);
+ }
+
+ const payload = await apiRequest(`/api/runtime-events?${params.toString()}`);
+ renderEventFeed(payload.events || []);
+}
+
+async function startRun(event) {
+ event.preventDefault();
+
+ const prompt = dom.runPrompt.value.trim();
+ if (!prompt) {
+ showRunStatus("Prompt is required.", true);
+ return;
+ }
+
+ const flags = parseJsonSafe(dom.runFlags.value, {});
+ if (typeof flags !== "object" || Array.isArray(flags) || !flags) {
+ showRunStatus("Initial Flags must be a JSON object.", true);
+ return;
+ }
+
+ const payload = {
+ prompt,
+ manifestPath: dom.runManifestSelect.value,
+ executionMode: dom.runExecutionMode.value,
+ provider: dom.runProvider.value,
+ topologyHint: dom.runTopologyHint.value.trim() || undefined,
+ initialFlags: flags,
+ simulateValidationNodeIds: fromCsv(dom.runValidationNodes.value),
+ };
+
+ try {
+ const response = await apiRequest("/api/runs", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+
+ const run = response.run;
+ state.activeRunId = run.runId;
+ state.selectedSessionId = run.sessionId;
+ showRunStatus(
+ `Run ${run.runId} started (session ${run.sessionId}, ${run.executionMode}/${run.provider}).`,
+ );
+ await loadSessions();
+ dom.sessionSelect.value = run.sessionId;
+ await refreshGraph();
+ await refreshEvents();
+ } catch (error) {
+ showRunStatus(error instanceof Error ? error.message : String(error), true);
+ }
+}
+
+async function cancelActiveRun() {
+ if (!state.activeRunId) {
+ showRunStatus("No active run selected.", true);
+ return;
+ }
+
+ try {
+ const payload = await apiRequest(`/api/runs/${encodeURIComponent(state.activeRunId)}/cancel`, {
+ method: "POST",
+ });
+ state.activeRunId = "";
+ showRunStatus(`Run cancelled: ${payload.run.status}`);
+ await loadSessions();
+ await refreshGraph();
+ await refreshEvents();
+ } catch (error) {
+ showRunStatus(error instanceof Error ? error.message : String(error), true);
+ }
+}
+
+async function saveNotifications(event) {
+ event.preventDefault();
+ const payload = {
+ webhookUrl: dom.cfgWebhookUrl.value.trim(),
+ minSeverity: dom.cfgWebhookSeverity.value,
+ alwaysNotifyTypes: fromCsv(dom.cfgWebhookAlways.value),
+ };
+
+ await apiRequest("/api/config/runtime-events", {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+
+ showRunStatus("Notification config updated.");
+ await loadConfig();
+}
+
+async function saveSecurityPolicy(event) {
+ event.preventDefault();
+ const payload = {
+ violationMode: dom.cfgSecurityMode.value,
+ allowedBinaries: fromCsv(dom.cfgSecurityBinaries.value),
+ commandTimeoutMs: Number(dom.cfgSecurityTimeout.value || "0"),
+ inheritedEnv: fromCsv(dom.cfgSecurityInherit.value),
+ scrubbedEnv: fromCsv(dom.cfgSecurityScrub.value),
+ };
+
+ await apiRequest("/api/config/security", {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+
+ showRunStatus("Security policy updated.");
+ await loadConfig();
+}
+
+async function saveLimits(event) {
+ event.preventDefault();
+ const payload = {
+ maxConcurrent: Number(dom.cfgLimitConcurrent.value),
+ maxSession: Number(dom.cfgLimitSession.value),
+ maxRecursiveDepth: Number(dom.cfgLimitDepth.value),
+ topologyMaxDepth: Number(dom.cfgTopologyDepth.value),
+ topologyMaxRetries: Number(dom.cfgTopologyRetries.value),
+ relationshipMaxChildren: Number(dom.cfgRelationshipChildren.value),
+ portBase: Number(dom.cfgPortBase.value),
+ portBlockSize: Number(dom.cfgPortBlockSize.value),
+ portBlockCount: Number(dom.cfgPortBlockCount.value),
+ portPrimaryOffset: Number(dom.cfgPortPrimaryOffset.value),
+ };
+
+ await apiRequest("/api/config/limits", {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+
+ showRunStatus("Limits updated.");
+ await loadConfig();
+}
+
+async function loadManifestEditor() {
+ const path = dom.manifestPath.value.trim() || state.selectedManifestPath;
+ if (!path) {
+ setManifestStatus("Manifest path is required.", true);
+ return;
+ }
+
+ try {
+ const payload = await apiRequest(`/api/manifests/read?path=${encodeURIComponent(path)}`);
+ dom.manifestEditor.value = JSON.stringify(payload.manifest.manifest, null, 2);
+ state.manifestDraft = normalizeManifestDraft(payload.manifest.manifest);
+ renderManifestHelper();
+ dom.manifestPath.value = payload.manifest.path;
+ state.selectedManifestPath = payload.manifest.path;
+ populateSelect(dom.graphManifestSelect, state.manifests, state.selectedManifestPath);
+ populateSelect(dom.runManifestSelect, state.manifests, state.selectedManifestPath);
+ setManifestStatus(`Loaded ${payload.manifest.path}.`);
+ } catch (error) {
+ setManifestStatus(error instanceof Error ? error.message : String(error), true);
+ }
+}
+
+async function validateManifest() {
+ const manifest = parseJsonSafe(dom.manifestEditor.value, null);
+ if (!manifest) {
+ setManifestStatus("Manifest JSON is invalid.", true);
+ return;
+ }
+
+ try {
+ await apiRequest("/api/manifests/validate", {
+ method: "POST",
+ body: JSON.stringify({ manifest }),
+ });
+ setManifestStatus("Manifest is valid.");
+ } catch (error) {
+ setManifestStatus(error instanceof Error ? error.message : String(error), true);
+ }
+}
+
+async function saveManifest(event) {
+ event.preventDefault();
+ const path = dom.manifestPath.value.trim();
+ if (!path) {
+ setManifestStatus("File path is required.", true);
+ return;
+ }
+
+ const manifest = parseJsonSafe(dom.manifestEditor.value, null);
+ if (!manifest) {
+ setManifestStatus("Manifest JSON is invalid.", true);
+ return;
+ }
+
+ try {
+ const payload = await apiRequest("/api/manifests/save", {
+ method: "PUT",
+ body: JSON.stringify({
+ path,
+ manifest,
+ }),
+ });
+
+ state.selectedManifestPath = payload.manifest.path;
+ state.manifestDraft = normalizeManifestDraft(payload.manifest.manifest);
+ renderManifestHelper();
+ await loadManifests();
+ dom.graphManifestSelect.value = state.selectedManifestPath;
+ dom.runManifestSelect.value = state.selectedManifestPath;
+ setManifestStatus(`Saved ${payload.manifest.path}.`);
+ await refreshGraph();
+ } catch (error) {
+ setManifestStatus(error instanceof Error ? error.message : String(error), true);
+ }
+}
+
+function bindUiEvents() {
+ dom.sessionSelect.addEventListener("change", async () => {
+ state.selectedSessionId = dom.sessionSelect.value;
+ await refreshGraph();
+ await refreshEvents();
+ });
+
+ dom.graphManifestSelect.addEventListener("change", async () => {
+ state.selectedManifestPath = dom.graphManifestSelect.value;
+ dom.runManifestSelect.value = state.selectedManifestPath;
+ dom.manifestPath.value = state.selectedManifestPath;
+ await refreshGraph();
+ });
+
+ dom.graphRefresh.addEventListener("click", async () => {
+ await refreshGraph();
+ });
+
+ dom.eventsRefresh.addEventListener("click", async () => {
+ await refreshEvents();
+ });
+
+ dom.historyRefresh.addEventListener("click", async () => {
+ await loadSessions();
+ await refreshGraph();
+ });
+
+ dom.runForm.addEventListener("submit", startRun);
+ dom.killRun.addEventListener("click", () => {
+ void cancelActiveRun();
+ });
+
+ dom.notificationsForm.addEventListener("submit", (event) => {
+ void saveNotifications(event);
+ });
+ dom.securityForm.addEventListener("submit", (event) => {
+ void saveSecurityPolicy(event);
+ });
+ dom.limitsForm.addEventListener("submit", (event) => {
+ void saveLimits(event);
+ });
+
+ dom.manifestLoad.addEventListener("click", () => {
+ void loadManifestEditor();
+ });
+ dom.manifestValidate.addEventListener("click", () => {
+ void validateManifest();
+ });
+ dom.manifestForm.addEventListener("submit", (event) => {
+ void saveManifest(event);
+ });
+ dom.manifestTemplateMinimal.addEventListener("click", () => {
+ setHelperTemplate("minimal");
+ });
+ dom.manifestTemplateTwoStage.addEventListener("click", () => {
+ setHelperTemplate("two-stage");
+ });
+ dom.manifestSyncFromEditor.addEventListener("click", () => {
+ syncHelperFromEditor();
+ });
+ dom.manifestApplyToEditor.addEventListener("click", () => {
+ applyHelperToEditor();
+ });
+ dom.helperAddPersona.addEventListener("click", () => {
+ addHelperRow("persona");
+ });
+ dom.helperAddRelationship.addEventListener("click", () => {
+ addHelperRow("relationship");
+ });
+ dom.helperAddNode.addEventListener("click", () => {
+ addHelperRow("node");
+ });
+ dom.helperAddEdge.addEventListener("click", () => {
+ addHelperRow("edge");
+ });
+ dom.manifestForm.addEventListener("input", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const classNames = [...target.classList];
+ const isHelperInput =
+ target.id.startsWith("helper-") ||
+ classNames.some((name) => name.startsWith("helper-"));
+
+ if (!isHelperInput) {
+ return;
+ }
+
+ syncEditorFromHelperQuiet();
+ });
+ dom.manifestForm.addEventListener("change", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const classNames = [...target.classList];
+ const isHelperInput =
+ target.id.startsWith("helper-") ||
+ classNames.some((name) => name.startsWith("helper-"));
+
+ if (!isHelperInput) {
+ return;
+ }
+
+ syncEditorFromHelperQuiet();
+ });
+ dom.manifestForm.addEventListener("click", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ if (!target.classList.contains("helper-remove-row")) {
+ return;
+ }
+
+ const kind = target.dataset.kind;
+ const index = Number(target.dataset.index || "-1");
+ if (!kind || !Number.isInteger(index) || index < 0) {
+ return;
+ }
+
+ removeHelperRow(kind, index);
+ });
+}
+
+async function refreshAll() {
+ await loadConfig();
+ await loadManifests();
+ await loadSessions();
+
+ state.selectedManifestPath = dom.graphManifestSelect.value || state.selectedManifestPath;
+ state.selectedSessionId = dom.sessionSelect.value || state.selectedSessionId;
+
+ await refreshGraph();
+ await refreshEvents();
+}
+
+async function initialize() {
+ bindUiEvents();
+
+ try {
+ await apiRequest("/api/health");
+ dom.serverStatus.textContent = "Connected";
+ } catch (error) {
+ dom.serverStatus.textContent = `Offline: ${error instanceof Error ? error.message : String(error)}`;
+ dom.serverStatus.style.color = "#ff9d87";
+ return;
+ }
+
+ await refreshAll();
+ if (!dom.manifestEditor.value.trim()) {
+ setHelperTemplate("minimal");
+ } else {
+ syncHelperFromEditor();
+ }
+
+ setInterval(() => {
+ void loadSessions();
+ }, 5000);
+
+ setInterval(() => {
+ void refreshEvents();
+ }, 3000);
+
+ setInterval(() => {
+ void refreshGraph();
+ }, 7000);
+}
+
+initialize().catch((error) => {
+ dom.serverStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
+ dom.serverStatus.style.color = "#ff9d87";
+});
diff --git a/src/ui/public/index.html b/src/ui/public/index.html
new file mode 100644
index 0000000..e27a6cf
--- /dev/null
+++ b/src/ui/public/index.html
@@ -0,0 +1,286 @@
+
+
+
+
+
+ AI Ops Control Plane
+
+
+
+
+
+
+
+
+
+
Graph Visualizer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Live Event Feed
+
+
+
+
+
+
+
+
+
+
+
Run History
+
+
+
+
+
+ | Session |
+ Status |
+ Attempts |
+ Duration |
+ Cost |
+ Updated |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ui/public/styles.css b/src/ui/public/styles.css
new file mode 100644
index 0000000..c3c41c1
--- /dev/null
+++ b/src/ui/public/styles.css
@@ -0,0 +1,489 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap");
+
+:root {
+ --bg: #08101a;
+ --bg-soft: #102031;
+ --panel: #12283d;
+ --panel-2: #19364f;
+ --text: #e8f5ff;
+ --muted: #9bb8cf;
+ --accent: #f6b73c;
+ --accent-cool: #56c3ff;
+ --ok: #44d38f;
+ --warn: #ffc94a;
+ --critical: #ff6e4a;
+ --border: #2b4f6b;
+ --shadow: 0 12px 35px rgba(4, 10, 17, 0.45);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 0% 0%, #1e3f63 0%, transparent 35%),
+ radial-gradient(circle at 100% 0%, #3a2e59 0%, transparent 32%),
+ linear-gradient(160deg, var(--bg) 0%, #0b1624 55%, #070d16 100%);
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+.app-shell {
+ padding: 1.2rem;
+ max-width: 1700px;
+ margin: 0 auto;
+}
+
+.masthead {
+ background: linear-gradient(120deg, rgba(86, 195, 255, 0.16), rgba(246, 183, 60, 0.15));
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 1rem 1.2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: var(--shadow);
+}
+
+.masthead h1 {
+ font-size: 1.55rem;
+ letter-spacing: 0.02em;
+}
+
+.masthead p {
+ color: var(--muted);
+ margin-top: 0.25rem;
+ font-size: 0.92rem;
+}
+
+.server-status {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 0.84rem;
+ color: var(--accent-cool);
+}
+
+.layout {
+ margin-top: 1rem;
+ display: grid;
+ gap: 0.85rem;
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
+ grid-template-areas:
+ "graph side"
+ "feed history"
+ "config config";
+}
+
+.panel {
+ background: linear-gradient(180deg, rgba(18, 40, 61, 0.96), rgba(12, 27, 43, 0.95));
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 0.85rem;
+ box-shadow: var(--shadow);
+}
+
+.panel-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.65rem;
+ margin-bottom: 0.7rem;
+}
+
+.panel-head h2 {
+ font-size: 1.02rem;
+}
+
+.panel-head h3 {
+ font-size: 0.94rem;
+}
+
+.panel-actions {
+ display: flex;
+ gap: 0.55rem;
+ align-items: center;
+}
+
+.graph-panel {
+ grid-area: graph;
+}
+
+.side-panel {
+ grid-area: side;
+}
+
+.feed-panel {
+ grid-area: feed;
+}
+
+.history-panel {
+ grid-area: history;
+}
+
+.config-panel {
+ grid-area: config;
+}
+
+label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: var(--muted);
+ letter-spacing: 0.015em;
+}
+
+input,
+select,
+textarea,
+button {
+ font: inherit;
+}
+
+input,
+select,
+textarea {
+ border-radius: 9px;
+ border: 1px solid #2f5673;
+ background: #0b1b2b;
+ color: var(--text);
+ padding: 0.5rem 0.55rem;
+}
+
+textarea {
+ resize: vertical;
+ font-family: "IBM Plex Mono", monospace;
+ line-height: 1.35;
+}
+
+button {
+ border-radius: 9px;
+ border: 1px solid #3c6585;
+ background: linear-gradient(160deg, #264a68, #173248);
+ color: var(--text);
+ cursor: pointer;
+ padding: 0.48rem 0.74rem;
+ font-size: 0.83rem;
+}
+
+button:hover {
+ border-color: var(--accent-cool);
+}
+
+button.danger {
+ border-color: #7d3f37;
+ background: linear-gradient(160deg, #5d231e, #3f1612);
+}
+
+.stacked-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.52rem;
+}
+
+.stacked-form.compact label {
+ font-size: 0.73rem;
+}
+
+.inline-actions,
+.inline-fields {
+ display: flex;
+ gap: 0.5rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.inline-fields label {
+ flex: 1;
+ min-width: 220px;
+}
+
+.graph-wrap {
+ border: 1px solid var(--border);
+ border-radius: 11px;
+ overflow: hidden;
+ background: radial-gradient(circle at 20% 15%, #11253a, #08131f 70%);
+}
+
+#graph-svg {
+ width: 100%;
+ height: 560px;
+ display: block;
+}
+
+.graph-banner {
+ margin-top: 0.55rem;
+ padding: 0.52rem 0.6rem;
+ border-radius: 8px;
+ border: 1px solid #355b77;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.inspector {
+ min-height: 270px;
+ border: 1px solid var(--border);
+ border-radius: 11px;
+ padding: 0.65rem;
+ background: var(--bg-soft);
+ overflow: auto;
+}
+
+.inspector.empty {
+ color: var(--muted);
+}
+
+.inspector pre {
+ margin: 0;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 0.75rem;
+ white-space: pre-wrap;
+ line-height: 1.4;
+}
+
+.subtle {
+ color: var(--muted);
+ font-size: 0.77rem;
+}
+
+.divider {
+ height: 1px;
+ background: var(--border);
+ margin: 0.8rem 0;
+}
+
+.event-feed {
+ max-height: 360px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: #0b1826;
+}
+
+.event-row {
+ display: grid;
+ grid-template-columns: 110px 90px 1fr;
+ gap: 0.45rem;
+ padding: 0.42rem 0.5rem;
+ border-bottom: 1px solid rgba(65, 98, 127, 0.3);
+ font-size: 0.74rem;
+}
+
+.event-row:last-child {
+ border-bottom: none;
+}
+
+.event-time {
+ color: var(--muted);
+ font-family: "IBM Plex Mono", monospace;
+}
+
+.event-type {
+ font-family: "IBM Plex Mono", monospace;
+ color: var(--accent-cool);
+}
+
+.event-row.info .event-type {
+ color: var(--accent-cool);
+}
+
+.event-row.warning .event-type {
+ color: var(--warn);
+}
+
+.event-row.critical {
+ background: rgba(153, 34, 20, 0.16);
+}
+
+.event-row.critical .event-type {
+ color: var(--critical);
+}
+
+.history-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.77rem;
+}
+
+.history-table th,
+.history-table td {
+ border-bottom: 1px solid rgba(63, 98, 128, 0.35);
+ padding: 0.38rem;
+ text-align: left;
+}
+
+.history-table tbody tr {
+ cursor: pointer;
+}
+
+.history-table tbody tr:hover {
+ background: rgba(87, 158, 211, 0.14);
+}
+
+.history-table .status-chip {
+ display: inline-block;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ padding: 0.14rem 0.45rem;
+ border: 1px solid;
+}
+
+.status-success {
+ color: var(--ok);
+ border-color: rgba(68, 211, 143, 0.6);
+}
+
+.status-failure,
+.status-cancelled {
+ color: var(--critical);
+ border-color: rgba(255, 110, 74, 0.65);
+}
+
+.status-running {
+ color: var(--warn);
+ border-color: rgba(255, 201, 74, 0.6);
+}
+
+.status-unknown {
+ color: var(--muted);
+ border-color: rgba(155, 184, 207, 0.45);
+}
+
+.config-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.7rem;
+ margin-bottom: 0.8rem;
+}
+
+.config-grid h3,
+#manifest-form h3,
+.side-panel h3 {
+ font-size: 0.86rem;
+ margin-bottom: 0.25rem;
+}
+
+.manifest-helper-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+}
+
+.manifest-helper-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.65rem;
+}
+
+.helper-section {
+ border: 1px solid rgba(61, 103, 136, 0.65);
+ border-radius: 10px;
+ padding: 0.55rem;
+ margin: 0;
+ min-width: 0;
+}
+
+.helper-section legend {
+ padding: 0 0.3rem;
+ color: var(--muted);
+ font-size: 0.74rem;
+}
+
+.helper-topologies {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem;
+ margin-bottom: 0.5rem;
+}
+
+.helper-topologies label {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.helper-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ margin-bottom: 0.5rem;
+}
+
+.helper-row {
+ border: 1px solid rgba(66, 111, 145, 0.45);
+ border-radius: 10px;
+ background: rgba(7, 17, 28, 0.45);
+ padding: 0.45rem;
+}
+
+.helper-row-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+
+.helper-span-2 {
+ grid-column: span 2;
+}
+
+.helper-remove-row {
+ margin-top: 0.5rem;
+ border-color: rgba(255, 110, 74, 0.45);
+ background: linear-gradient(160deg, #4f2a26, #38201d);
+}
+
+#manifest-editor {
+ min-height: 260px;
+}
+
+.badge {
+ display: inline-block;
+ border-radius: 8px;
+ padding: 0.15rem 0.42rem;
+ font-size: 0.7rem;
+ border: 1px solid rgba(120, 156, 183, 0.5);
+ color: var(--muted);
+}
+
+@media (max-width: 1200px) {
+ .layout {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "graph"
+ "side"
+ "feed"
+ "history"
+ "config";
+ }
+
+ .config-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .manifest-helper-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .helper-row-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .helper-span-2 {
+ grid-column: span 1;
+ }
+
+ #graph-svg {
+ height: 460px;
+ }
+}