From 8100f4d1c62cee49c95387b52cf5ac742828e3d5 Mon Sep 17 00:00:00 2001 From: Josh Rzemien Date: Mon, 23 Feb 2026 18:48:50 -0500 Subject: [PATCH] feat(ui): add structured manifest builder helper --- src/ui/public/app.js | 1697 ++++++++++++++++++++++++++++++++++++++ src/ui/public/index.html | 286 +++++++ src/ui/public/styles.css | 489 +++++++++++ 3 files changed, 2472 insertions(+) create mode 100644 src/ui/public/app.js create mode 100644 src/ui/public/index.html create mode 100644 src/ui/public/styles.css 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 || '
-
-
No runtime events.
'; +} + +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 + + + +
+
+
+

AI Ops Control Plane

+

DAG topology, runtime economics, and security telemetry in one surface.

+
+
Connecting...
+
+ +
+
+
+

Graph Visualizer

+
+ + + +
+
+
+ +
+
+
+ + + +
+
+

Live Event Feed

+
+ + +
+
+
+
+ +
+
+

Run History

+ +
+ + + + + + + + + + + + +
SessionStatusAttemptsDurationCostUpdated
+
+ +
+
+

Definitions & Policies

+
+ +
+
+

Notifications / Webhook

+ + + + +
+ +
+

Security Policy

+ + + + + + +
+ +
+

Environment & Limits

+ + + + + + + + + + + +
+
+ +
+

Manifest Builder

+
+ +
+ + + +
+
+ +
+ +
+
+

Manifest Helper

+
+ + + + +
+
+
+
+ Topologies +
+ + + + +
+
+ + +
+
+ +
+ Personas +
+ +
+ +
+ Relationships +
+ +
+ +
+ Pipeline Nodes + +
+ +
+ +
+ Pipeline Edges +
+ +
+
+
+
+
+
+
+ + + + 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; + } +}