const state = { config: null, manifests: [], sessions: [], sessionMetadata: [], 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"), runRuntimeContext: document.querySelector("#run-runtime-context"), runValidationNodes: document.querySelector("#run-validation-nodes"), killRun: document.querySelector("#kill-run"), runStatus: document.querySelector("#run-status"), sessionForm: document.querySelector("#session-form"), sessionProjectPath: document.querySelector("#session-project-path"), sessionCreate: document.querySelector("#session-create"), sessionClose: document.querySelector("#session-close"), sessionCloseMerge: document.querySelector("#session-close-merge"), 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"), cfgMergeConflictAttempts: document.querySelector("#cfg-merge-conflict-attempts"), 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_ready_for_review", "task_blocked", "validation_passed", "validation_failed", "branch_merged", "merge_conflict_detected", "merge_conflict_resolved", "merge_conflict_unresolved", "merge_retry_started", ]; const RUN_MANIFEST_EDITOR_VALUE = "__editor__"; const RUN_MANIFEST_EDITOR_LABEL = "[Use Manifest Editor JSON]"; const LABEL_HELP_BY_CONTROL = Object.freeze({ "session-select": "Select which session the graph and feed should focus on.", "graph-manifest-select": "Choose the manifest context used when rendering the selected session graph.", "run-prompt": "Describe the task objective you want the run to complete.", "run-manifest-select": "Choose a saved manifest or use the JSON currently in the editor.", "run-execution-mode": "Use provider for live model execution or mock for simulated execution.", "run-provider": "Choose which model provider backend handles provider-mode runs.", "run-topology-hint": "Optional hint that nudges orchestration toward a topology strategy.", "run-flags": "Optional JSON object passed in as initial run flags.", "run-runtime-context": "Optional JSON object of template values injected into persona prompts (for example repo or ticket).", "run-validation-nodes": "Optional comma-separated node IDs to simulate validation outcomes for.", "session-project-path": "Absolute project path used when creating an explicit managed session.", "session-close-merge": "When enabled, close will merge the session base branch back into the project branch.", "events-limit": "Set how many recent runtime events are loaded per refresh.", "cfg-webhook-url": "Webhook endpoint that receives runtime event notifications.", "cfg-webhook-severity": "Minimum severity level that triggers webhook notifications.", "cfg-webhook-always": "Event types that should always notify, regardless of severity.", "cfg-security-mode": "Policy behavior used when a command violates security rules.", "cfg-security-binaries": "Comma-separated command binaries permitted by policy.", "cfg-security-timeout": "Maximum command execution time before forced timeout.", "cfg-security-inherit": "Environment variable names to pass through to subprocesses.", "cfg-security-scrub": "Environment variable names to strip before command execution.", "cfg-limit-concurrent": "Maximum number of agents that can run concurrently across sessions.", "cfg-limit-session": "Maximum number of agents that can run concurrently within a single session.", "cfg-limit-depth": "Maximum recursive spawn depth allowed for agent tasks.", "cfg-topology-depth": "Maximum orchestration graph depth permitted by topology rules.", "cfg-topology-retries": "Maximum retry expansions allowed by topology orchestration.", "cfg-relationship-children": "Maximum children each persona relationship can spawn.", "cfg-merge-conflict-attempts": "Maximum merge-conflict resolution attempts before emitting unresolved conflict events.", "cfg-port-base": "Starting port number for provisioning port allocations.", "cfg-port-block-size": "Number of ports reserved per allocated block.", "cfg-port-block-count": "Number of port blocks available for allocation.", "cfg-port-primary-offset": "Offset within each block used for the primary service port.", "manifest-path": "Workspace-relative manifest file path to load, validate, or save.", "helper-topology-sequential": "Allow sequential execution topology in this manifest.", "helper-topology-parallel": "Allow parallel execution topology in this manifest.", "helper-topology-hierarchical": "Allow hierarchical parent-child execution topology.", "helper-topology-retry-unrolled": "Allow retry-unrolled topology for explicit retry paths.", "helper-topology-max-depth": "Top-level cap on orchestration depth in this manifest.", "helper-topology-max-retries": "Top-level cap on retry attempts in this manifest.", "helper-entry-node-id": "Node ID used as the pipeline entry point.", "helper-persona-id": "Stable persona identifier referenced by nodes and relationships.", "helper-persona-display-name": "Human-readable persona name shown in summaries and tooling.", "helper-persona-model-constraint": "Optional model restriction for this persona only.", "helper-persona-system-prompt": "Base prompt template that defines persona behavior.", "helper-persona-allowlist": "Comma-separated tool names this persona may use.", "helper-persona-banlist": "Comma-separated tool names this persona must not use.", "helper-relationship-parent": "Parent persona ID that can spawn or delegate to the child.", "helper-relationship-child": "Child persona ID allowed under the selected parent.", "helper-relationship-max-depth": "Optional override limiting recursion depth for this relationship.", "helper-relationship-max-children": "Optional override limiting child fan-out for this relationship.", "helper-node-id": "Unique pipeline node identifier used by entry node and edges.", "helper-node-actor-id": "Runtime actor identifier assigned to this node.", "helper-node-persona-id": "Persona applied when this node executes.", "helper-node-topology-kind": "Optional node-level topology override.", "helper-node-block-id": "Optional topology block identifier for grouped scheduling logic.", "helper-node-max-retries": "Optional node-level retry limit override.", "helper-edge-from": "Source node where this edge starts.", "helper-edge-to": "Target node activated when this edge condition matches.", "helper-edge-trigger-kind": "Choose whether edge activation is status-based or event-based.", "helper-edge-on": "Node status value that triggers this edge when using status mode.", "helper-edge-event": "Domain event name that triggers this edge when using event mode.", "helper-edge-when": "Optional JSON array of additional conditions required to follow the edge.", }); function extractLabelText(label) { const clone = label.cloneNode(true); for (const field of clone.querySelectorAll("input, select, textarea")) { field.remove(); } return clone.textContent?.replace(/\s+/g, " ").trim() || "this field"; } function applyLabelTooltips(root = document) { for (const label of root.querySelectorAll("label")) { const control = label.querySelector("input,select,textarea"); if (!control) { continue; } let help = LABEL_HELP_BY_CONTROL[control.id] || ""; if (!help) { for (const className of control.classList) { help = LABEL_HELP_BY_CONTROL[className] || ""; if (help) { break; } } } if (!help) { const labelText = extractLabelText(label).toLowerCase(); help = `Set ${labelText} for this configuration.`; } label.title = help; control.title = help; } } 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(""); applyLabelTooltips(dom.manifestForm); } 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 dedupeNonEmptyStrings(values) { const output = []; const seen = new Set(); for (const value of values) { if (typeof value !== "string") { continue; } const normalized = value.trim(); if (!normalized || seen.has(normalized)) { continue; } seen.add(normalized); output.push(normalized); } return output; } 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; } } function populateRunManifestSelect(values, selectedValue) { const previous = selectedValue || dom.runManifestSelect.value; dom.runManifestSelect.innerHTML = ""; const editorOption = document.createElement("option"); editorOption.value = RUN_MANIFEST_EDITOR_VALUE; editorOption.textContent = RUN_MANIFEST_EDITOR_LABEL; dom.runManifestSelect.append(editorOption); for (const value of values) { const option = document.createElement("option"); option.value = value; option.textContent = value; dom.runManifestSelect.append(option); } if (previous && [...dom.runManifestSelect.options].some((option) => option.value === previous)) { dom.runManifestSelect.value = previous; return; } if (state.selectedManifestPath && values.includes(state.selectedManifestPath)) { dom.runManifestSelect.value = state.selectedManifestPath; return; } dom.runManifestSelect.value = RUN_MANIFEST_EDITOR_VALUE; } 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.cfgMergeConflictAttempts.value = String(limits.mergeConflictMaxAttempts); 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"); const manifests = dedupeNonEmptyStrings([ ...(Array.isArray(payload.manifests) ? payload.manifests : []), state.selectedManifestPath, dom.manifestPath.value, ]); state.manifests = manifests; if (!state.selectedManifestPath && state.manifests.length > 0) { state.selectedManifestPath = state.manifests[0]; } populateSelect(dom.graphManifestSelect, state.manifests, state.selectedManifestPath); populateRunManifestSelect(state.manifests, state.selectedManifestPath); if (state.selectedManifestPath) { dom.manifestPath.value = state.selectedManifestPath; } } function statusChipClass(status) { return `status-chip status-${status || "unknown"}`; } function getSessionLifecycleStatus(sessionId) { const metadata = state.sessionMetadata.find((entry) => entry?.sessionId === sessionId); if (!metadata) { return undefined; } const status = metadata.sessionStatus; if (status === "active" || status === "suspended" || status === "closed" || status === "closed_with_conflicts") { return status; } return undefined; } function renderRunsAndSessionsTable() { const rows = []; for (const session of state.sessions) { const lifecycleStatus = getSessionLifecycleStatus(session.sessionId); const sessionStatus = lifecycleStatus === "closed" || lifecycleStatus === "closed_with_conflicts" ? lifecycleStatus : session.status || lifecycleStatus || "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.sessionMetadata = payload.sessionMetadata || []; 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 runtimeContext = parseJsonSafe(dom.runRuntimeContext.value, {}); if (typeof runtimeContext !== "object" || Array.isArray(runtimeContext) || !runtimeContext) { showRunStatus("Runtime Context Overrides must be a JSON object.", true); return; } const manifestSelection = dom.runManifestSelect.value.trim(); const payload = { prompt, executionMode: dom.runExecutionMode.value, provider: dom.runProvider.value, topologyHint: dom.runTopologyHint.value.trim() || undefined, initialFlags: flags, runtimeContextOverrides: runtimeContext, simulateValidationNodeIds: fromCsv(dom.runValidationNodes.value), }; const selectedSessionMetadata = state.sessionMetadata.find( (entry) => entry?.sessionId === state.selectedSessionId, ); if ( selectedSessionMetadata && (selectedSessionMetadata.sessionStatus === "active" || selectedSessionMetadata.sessionStatus === "suspended") ) { payload.sessionId = selectedSessionMetadata.sessionId; } if (manifestSelection === RUN_MANIFEST_EDITOR_VALUE) { const manifestFromEditor = parseJsonSafe(dom.manifestEditor.value, null); if (!manifestFromEditor) { showRunStatus("Manifest editor JSON is invalid. Save or fix it before running.", true); return; } payload.manifest = manifestFromEditor; } else if (manifestSelection) { payload.manifestPath = manifestSelection; } else { showRunStatus("Select a manifest path or choose editor JSON.", true); return; } 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 createSessionFromUi() { const projectPath = dom.sessionProjectPath.value.trim(); if (!projectPath) { showRunStatus("Project path is required to create a session.", true); return; } try { const payload = await apiRequest("/api/sessions", { method: "POST", body: JSON.stringify({ projectPath, }), }); const created = payload.session; if (created?.sessionId) { state.selectedSessionId = created.sessionId; showRunStatus(`Session ${created.sessionId} created.`); } else { showRunStatus("Session created."); } await loadSessions(); if (state.selectedSessionId) { dom.sessionSelect.value = state.selectedSessionId; await refreshGraph(); await refreshEvents(); } } catch (error) { showRunStatus(error instanceof Error ? error.message : String(error), true); } } async function closeSelectedSessionFromUi() { const sessionId = state.selectedSessionId || dom.sessionSelect.value; if (!sessionId) { showRunStatus("Select a session before closing.", true); return; } try { const payload = await apiRequest(`/api/sessions/${encodeURIComponent(sessionId)}/close`, { method: "POST", body: JSON.stringify({ mergeToProject: dom.sessionCloseMerge.checked, }), }); const nextStatus = payload?.session?.sessionStatus || "closed"; showRunStatus(`Session ${sessionId} closed with status ${nextStatus}.`); 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), mergeConflictMaxAttempts: Number(dom.cfgMergeConflictAttempts.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); populateRunManifestSelect(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; if ([...dom.runManifestSelect.options].some((option) => option.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; if ([...dom.runManifestSelect.options].some((option) => option.value === state.selectedManifestPath)) { 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.sessionCreate.addEventListener("click", () => { void createSessionFromUi(); }); dom.sessionClose.addEventListener("click", () => { void closeSelectedSessionFromUi(); }); 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(); applyLabelTooltips(); 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"; });