@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
913 lines (848 loc) • 30 kB
text/typescript
/**
* KXM Web Studio Layout Engine (Phase 10 / Decision D14)
* Generates form/stepper metadata, ELK/React Flow-derived DAG nodes/edges,
* and Temporal swimlane activity timelines from compiled plans and run states.
*/
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
import { randomUUID } from "node:crypto";
import type { KxmCompiledPlan, KxmCompiledStep } from "./engine-compile.ts";
import type { KxmRunState } from "./engine-fold.ts";
export const STUDIO_LAYOUT_SCHEMA = "kxm.studio-layout.v1" as const;
export interface StudioDagNode {
id: string;
type: "agentStep" | "gateStep" | "approvalStep" | "waitStep";
data: {
label: string;
kind: string;
role?: string | undefined;
gate?: string | undefined;
status: "pending" | "preparing" | "running" | "passed" | "failed" | "waiting" | "cancelled";
stepAttempt?: number | undefined;
description?: string | undefined;
outcomes: readonly string[];
};
position: { x: number; y: number };
}
export interface StudioDagEdge {
id: string;
source: string;
target: string;
label?: string | undefined;
animated?: boolean | undefined;
style?: { stroke?: string } | undefined;
}
export interface TemporalSwimlaneActivity {
stepId: string;
attemptId?: string | undefined;
status: string;
startOffsetMs: number;
durationMs: number;
stepAttempt: number;
}
export interface TemporalSwimlane {
role: string;
agentId?: string | undefined;
activities: TemporalSwimlaneActivity[];
}
export interface StudioStepperStage {
id: string;
label: string;
kind: string;
status: "pending" | "active" | "completed" | "failed" | "skipped";
}
export interface StudioLayoutPayload {
schema: typeof STUDIO_LAYOUT_SCHEMA;
workflowId: string;
runId?: string | undefined;
generatedAt: string;
stepper: StudioStepperStage[];
dag: {
nodes: StudioDagNode[];
edges: StudioDagEdge[];
};
temporalSwimlanes: TemporalSwimlane[];
}
/**
* Computes deterministic layered DAG coordinates (ELK-style hierarchical ranking)
* without requiring any manual layout coordinates in the YAML workflow file.
*/
export function generateStudioLayout(
plan: KxmCompiledPlan,
state?: KxmRunState | undefined,
): StudioLayoutPayload {
const generatedAt = new Date().toISOString();
const stepIds = Object.keys(plan.steps);
// 1. Calculate topological rank/level for each step
const inDegree: Record<string, number> = {};
const adj: Record<string, string[]> = {};
for (const id of stepIds) {
inDegree[id] = 0;
adj[id] = [];
}
for (const [sourceId, step] of Object.entries(plan.steps)) {
for (const transition of Object.values(step.transitions)) {
if (transition.to === "step") {
if (transition.edge === "back") continue; // Back-edges are cycles (retries), ignore for DAG forward rank
const targetStepId = transition.target;
if (plan.steps[targetStepId]) {
adj[sourceId]!.push(targetStepId);
inDegree[targetStepId] = (inDegree[targetStepId] ?? 0) + 1;
}
}
}
}
// Assign levels (x coordinate columns)
const levels: Record<string, number> = {};
const queue: string[] = [];
if (plan.entryStepId && plan.steps[plan.entryStepId]) {
queue.push(plan.entryStepId);
levels[plan.entryStepId] = 0;
} else {
for (const id of stepIds) {
if ((inDegree[id] ?? 0) === 0) {
queue.push(id);
levels[id] = 0;
}
}
}
let iterations = 0;
const maxIterations = stepIds.length * 4;
while (queue.length > 0 && iterations++ < maxIterations) {
const current = queue.shift()!;
const curLevel = levels[current] ?? 0;
for (const neighbor of adj[current] ?? []) {
const nextLevel = Math.max(levels[neighbor] ?? 0, curLevel + 1);
if (levels[neighbor] === undefined || nextLevel > levels[neighbor]!) {
levels[neighbor] = nextLevel;
queue.push(neighbor);
}
}
}
// Default any unassigned step to 0
for (const id of stepIds) {
if (levels[id] === undefined) levels[id] = 0;
}
// Group steps by level to determine y positions
const byLevel: Record<number, string[]> = {};
for (const [id, lvl] of Object.entries(levels)) {
if (!byLevel[lvl]) byLevel[lvl] = [];
byLevel[lvl]!.push(id);
}
const nodes: StudioDagNode[] = [];
const edges: StudioDagEdge[] = [];
const X_SPACING = 240;
const Y_SPACING = 120;
for (const [lvlStr, idsInLevel] of Object.entries(byLevel)) {
const lvl = Number(lvlStr);
const x = lvl * X_SPACING;
const startY = -(idsInLevel.length - 1) * (Y_SPACING / 2);
idsInLevel.forEach((stepId, index) => {
const step = plan.steps[stepId]!;
const y = startY + index * Y_SPACING;
let status: StudioDagNode["data"]["status"] = "pending";
let stepAttempt: number | undefined = undefined;
if (state) {
if (state.currentStep?.stepId === stepId) {
status = state.currentStep.status === "running" ? "running" : "preparing";
stepAttempt = state.currentStep.stepAttempt;
} else if (state.stepAttempts[stepId] !== undefined) {
status = "passed";
stepAttempt = state.stepAttempts[stepId];
}
}
let type: StudioDagNode["type"] = "agentStep";
let role: string | undefined = undefined;
let gate: string | undefined = undefined;
if (step.kind === "agent" || step.kind === "moa") {
type = "agentStep";
role = step.agent;
} else if (step.kind === "gate") {
type = "gateStep";
gate = step.gate;
} else if (step.kind === "approval") {
type = "approvalStep";
} else if (step.kind === "wait") {
type = "waitStep";
}
nodes.push({
id: stepId,
type,
data: {
label: step.description ?? stepId,
kind: step.kind,
role,
gate,
status,
stepAttempt,
description: step.instructions,
outcomes: step.outcomes,
},
position: { x, y },
});
// Build edges
for (const [outcome, transition] of Object.entries(step.transitions)) {
if (transition.to === "step") {
const targetStepId = transition.target;
if (plan.steps[targetStepId]) {
edges.push({
id: `e_${stepId}_to_${targetStepId}_${outcome}`,
source: stepId,
target: targetStepId,
label: outcome !== "passed" && outcome !== "completed" ? outcome : undefined,
animated: status === "running",
style: {
stroke: outcome === "failed" ? "#ef4444" : outcome === "warning" ? "#f59e0b" : "#64748b",
},
});
}
}
}
});
}
// 2. Stepper stages (linear path)
const stepper: StudioStepperStage[] = plan.order.map((stepId) => {
const step = plan.steps[stepId];
let status: StudioStepperStage["status"] = "pending";
if (state) {
if (state.currentStep?.stepId === stepId) {
status = "active";
} else if (state.stepAttempts[stepId] !== undefined) {
status = "completed";
}
}
return {
id: stepId,
label: step?.description ?? stepId,
kind: step?.kind ?? "unknown",
status,
};
});
// 3. Temporal Swimlanes
const swimlanesByRole: Record<string, TemporalSwimlaneActivity[]> = {};
let currentOffset = 0;
for (const stepId of plan.order) {
const step = plan.steps[stepId];
const role = step && (step.kind === "agent" || step.kind === "moa") ? step.agent : "engine-coordinator";
if (!swimlanesByRole[role]) swimlanesByRole[role] = [];
const duration = step?.timeoutMs ? Math.min(step.timeoutMs, 5000) : 3000;
swimlanesByRole[role]!.push({
stepId,
status: state?.currentStep?.stepId === stepId ? "running" : "completed",
startOffsetMs: currentOffset,
durationMs: duration,
stepAttempt: state?.stepAttempts[stepId] ?? 1,
});
currentOffset += duration;
}
const temporalSwimlanes: TemporalSwimlane[] = Object.entries(swimlanesByRole).map(([role, activities]) => ({
role,
activities,
}));
return {
schema: STUDIO_LAYOUT_SCHEMA,
workflowId: plan.workflowId,
runId: state ? "active-run" : undefined,
generatedAt,
stepper,
dag: { nodes, edges },
temporalSwimlanes,
};
}
export const DEFAULT_STUDIO_PORT = 4242; // Decision Q8 & D14
export interface StudioServerOptions {
port?: number | undefined;
host?: string | undefined;
projectRoot?: string | undefined;
sessionToken?: string | undefined;
planProvider?: (() => KxmCompiledPlan | undefined) | undefined;
stateProvider?: (() => KxmRunState | undefined) | undefined;
onMutation?: ((command: string, args: Record<string, unknown>) => Promise<{ ok: boolean; result?: unknown; error?: string }> | { ok: boolean; result?: unknown; error?: string }) | undefined;
}
export interface StudioServerHandle {
server: Server;
port: number;
listen: () => Promise<number>;
close: () => Promise<void>;
}
/**
* Embedded Web Studio Local Server (Decision Q8 & D14).
* Serves the visual DAG, form stepper, and Temporal swimlane dashboards on http://localhost:4242.
* Enforces strict audit parity: web mutations authenticate via SessionToken and map 1:1 to CLI commands.
*/
export function createStudioServer(options: StudioServerOptions = {}): StudioServerHandle {
const targetPort = options.port ?? DEFAULT_STUDIO_PORT;
const host = options.host ?? "127.0.0.1";
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
// CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (url.pathname === "/health" || url.pathname === "/api/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, studio: true, port: targetPort }));
return;
}
if (url.pathname === "/api/layout" && req.method === "GET") {
const plan = options.planProvider?.();
if (!plan) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "no_active_workflow_plan" }));
return;
}
const state = options.stateProvider?.();
const layout = generateStudioLayout(plan, state);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, layout }));
return;
}
if (url.pathname === "/api/mutate" && req.method === "POST") {
// 1. Strict Authentication via SessionToken
if (options.sessionToken) {
const auth = req.headers.authorization;
const expected = `Bearer ${options.sessionToken}`;
if (!auth || auth !== expected) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: false,
error: "unauthorized",
message: "Valid SessionToken required for Web Studio mutations",
}));
return;
}
}
// 2. Read request body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
const bodyStr = Buffer.concat(chunks).toString("utf8");
let body: { command?: string; args?: Record<string, unknown> };
try {
body = JSON.parse(bodyStr);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "malformed_json" }));
return;
}
const command = body.command;
const args = body.args ?? {};
if (!command || typeof command !== "string") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "missing_command" }));
return;
}
// 3. Strict Audit Parity: verify command maps 1:1 to underlying CLI commands
const ALLOWED_CLI_MUTATIONS = [
"workflow.signal",
"workflow.checkpoint",
"workflow.wait",
"workflow.start",
"run.cancel",
"config.set",
"task.update",
"task.sync",
"peer.send",
"peer.reply",
"gate.signal",
"gate.degrade",
];
if (!ALLOWED_CLI_MUTATIONS.includes(command)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: false,
error: "unsupported_web_mutation",
message: `Command '${command}' does not have a 1:1 underlying CLI audit mapping`,
}));
return;
}
const mutationId = `mut_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
if (options.onMutation) {
try {
const outcome = await options.onMutation(command, args);
res.writeHead(outcome.ok ? 200 : 400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ mutationId, ...outcome }));
} catch (err: unknown) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ mutationId, ok: false, error: (err as Error).message }));
}
return;
}
res.writeHead(501, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: false,
executed: false,
mutationId,
command,
mappedToCli: false,
error: "mutation_handler_missing",
}));
return;
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`
<html lang="en">
<head>
<meta charset="UTF-8">
<title>KXM Web Studio</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--bg: #0b0f19;
--panel-bg: #111827;
--card-bg: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--primary: #38bdf8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 12px; }
h1 { font-size: 18px; font-weight: 700; color: var(--primary); margin: 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 9999px;
background: #1e293b;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
}
.dot-live { width: 8px; height: 8px; border-radius: 50%; background: var(--success); }
.telemetry-bar {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
font-family: ui-monospace, monospace;
}
.telemetry-val { color: var(--text); font-weight: 600; }
main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px 24px;
gap: 16px;
}
.panel {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.panel-title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 12px 0;
font-weight: 600;
}
/* Stepper */
.stepper-container {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 4px 0;
}
.step-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 6px;
background: var(--card-bg);
border: 1px solid var(--border);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.step-badge.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
color: var(--primary);
}
.step-badge.completed { border-color: var(--success); color: var(--success); }
.step-badge.failed { border-color: var(--danger); color: var(--danger); }
.stepper-arrow { color: var(--text-muted); font-size: 14px; }
/* Middle row */
.middle-row {
flex: 1;
display: flex;
gap: 16px;
overflow: hidden;
}
/* DAG Canvas */
.dag-panel {
flex: 3;
position: relative;
overflow: auto;
background: #0d1321;
border-radius: 8px;
border: 1px solid var(--border);
}
#dagCanvas {
position: relative;
min-width: 100%;
min-height: 100%;
padding: 32px;
}
.dag-node {
position: absolute;
width: 220px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3);
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
}
.dag-node:hover {
border-color: var(--primary);
transform: translateY(-2px);
}
.dag-node.active { border-color: var(--primary); }
.dag-node.passed { border-color: var(--success); }
.dag-node.failed { border-color: var(--danger); }
.dag-node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.dag-node-title { font-size: 14px; font-weight: 600; color: var(--text); }
.dag-node-kind {
font-size: 10px;
text-transform: uppercase;
padding: 2px 6px;
background: #374151;
border-radius: 4px;
color: #94a3b8;
}
.dag-node-body { font-size: 12px; color: var(--text-muted); display: flex; flex-direction: column; gap: 4px; }
/* Side Panel */
.side-panel {
flex: 2;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
/* Swimlanes */
.timeline-track {
margin-bottom: 8px;
}
.track-label { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.track-bar-container {
background: var(--card-bg);
height: 20px;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.track-bar {
position: absolute;
top: 2px;
bottom: 2px;
background: var(--primary);
border-radius: 3px;
opacity: 0.85;
}
/* Mutation Actions */
.mutation-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group { display: flex; flex-direction: column; gap: 4px; }
label { font-size: 12px; color: var(--text-muted); }
input, select, textarea {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 8px;
font-size: 12px;
font-family: inherit;
}
button.btn {
background: #0284c7;
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button.btn:hover { background: #0369a1; }
.status-msg {
font-size: 12px;
padding: 8px;
border-radius: 4px;
font-family: monospace;
}
.status-ok { background: #064e3b; color: #a7f3d0; }
.status-err { background: #7f1d1d; color: #fecaca; }
</style>
</head>
<body>
<header>
<div class="brand">
<h1>KXM Web Studio</h1>
<span class="badge"><span class="dot-live"></span> Port ${targetPort} · Embedded Hub Host</span>
</div>
<div class="telemetry-bar">
<div>Run: <span id="runIdVal" class="telemetry-val">-</span></div>
<div>Workflow: <span id="wfIdVal" class="telemetry-val">default</span></div>
<div>Status: <span id="statusVal" class="telemetry-val">READY</span></div>
</div>
</header>
<main>
<!-- Stepper Section -->
<div class="panel">
<div class="panel-title">Workflow Stage Progress Stepper (Decision D14)</div>
<div class="stepper-container" id="stepperContainer">
<div class="step-badge">[▶ implement]</div>
<div class="stepper-arrow">──></div>
<div class="step-badge">[⧗ review-arch]</div>
<div class="stepper-arrow">──></div>
<div class="step-badge">[○ review-cli]</div>
<div class="stepper-arrow">──></div>
<div class="step-badge">[○ verify]</div>
</div>
</div>
<!-- Middle: DAG Canvas + Side Swimlanes/Mutations -->
<div class="middle-row">
<!-- DAG Panel -->
<div class="dag-panel">
<svg id="svgEdges" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1;"></svg>
<div id="dagCanvas">
<!-- Dynamically injected nodes -->
</div>
</div>
<!-- Side Panel: Swimlanes & Mutation Actions -->
<div class="side-panel">
<div class="panel">
<div class="panel-title">Temporal Activity Swimlanes</div>
<div id="swimlanesContainer">
<div class="timeline-track">
<div class="track-label">implement (Grok)</div>
<div class="track-bar-container"><div class="track-bar" style="left:5%; width:40%;"></div></div>
</div>
<div class="timeline-track">
<div class="track-label">review-arch (Claude Fable)</div>
<div class="track-bar-container"><div class="track-bar" style="left:48%; width:30%; background: #a855f7;"></div></div>
</div>
<div class="timeline-track">
<div class="track-label">verify (verify-gate)</div>
<div class="track-bar-container"><div class="track-bar" style="left:80%; width:15%; background: #10b981;"></div></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Audited CLI Mutations (1:1 Audit Parity)</div>
<form class="mutation-form" id="mutationForm" onsubmit="handleMutate(event)">
<div class="form-group">
<label>Command (1:1 CLI audit mapping)</label>
<select id="mutateCmd">
<option value="workflow.signal">workflow.signal</option>
<option value="workflow.checkpoint">workflow.checkpoint</option>
<option value="workflow.wait">workflow.wait</option>
<option value="gate.signal">gate.signal</option>
<option value="gate.degrade">gate.degrade</option>
</select>
</div>
<div class="form-group">
<label>Arguments (JSON)</label>
<textarea id="mutateArgs" rows="3" style="font-family: monospace;">{"signalKey": "approval", "status": "passed"}</textarea>
</div>
<div class="form-group">
<label>Session Token (Authorization)</label>
<input type="password" id="sessionTokenInput" placeholder="Optional if not authenticated">
</div>
<button type="submit" class="btn">Execute Audited Mutation</button>
<div id="mutateResult" style="display:none;" class="status-msg"></div>
</form>
</div>
</div>
</div>
</main>
<script>
async function loadLayout() {
try {
const res = await fetch('/api/layout');
if (!res.ok) return;
const data = await res.json();
if (!data.ok || !data.layout) return;
const layout = data.layout;
document.getElementById('wfIdVal').textContent = layout.workflowId || 'default';
if (layout.runId) document.getElementById('runIdVal').textContent = layout.runId;
// Render Stepper
if (layout.stepper && layout.stepper.length > 0) {
const container = document.getElementById('stepperContainer');
container.innerHTML = '';
layout.stepper.forEach((step, idx) => {
const el = document.createElement('div');
el.className = 'step-badge ' + (step.status || 'pending');
let icon = '○';
if (step.status === 'completed') icon = '✔';
else if (step.status === 'active') icon = '▶';
else if (step.status === 'failed') icon = '✖';
el.textContent = icon + ' ' + (step.label || step.id);
container.appendChild(el);
if (idx < layout.stepper.length - 1) {
const arrow = document.createElement('div');
arrow.className = 'stepper-arrow';
arrow.textContent = '──>';
container.appendChild(arrow);
}
});
}
// Render DAG Nodes
if (layout.dag && layout.dag.nodes) {
const canvas = document.getElementById('dagCanvas');
canvas.innerHTML = '';
layout.dag.nodes.forEach(node => {
const div = document.createElement('div');
div.className = 'dag-node ' + (node.data.status || 'pending');
div.style.left = (node.position.x + 32) + 'px';
div.style.top = (node.position.y + 32) + 'px';
div.innerHTML = '<div class="dag-node-header">' +
'<span class="dag-node-title">' + (node.data.label || node.id) + '</span>' +
'<span class="dag-node-kind">' + node.data.kind + '</span>' +
'</div>' +
'<div class="dag-node-body">' +
(node.data.role ? '<div>Role: <b>' + node.data.role + '</b></div>' : '') +
(node.data.gate ? '<div>Gate: <b>' + node.data.gate + '</b></div>' : '') +
'<div>Status: ' + node.data.status + '</div>' +
'</div>';
canvas.appendChild(div);
});
}
// Render Swimlanes
if (layout.temporalSwimlanes && layout.temporalSwimlanes.length > 0) {
const container = document.getElementById('swimlanesContainer');
container.innerHTML = '';
layout.temporalSwimlanes.forEach(lane => {
const div = document.createElement('div');
div.className = 'timeline-track';
div.innerHTML = '<div class="track-label">' + lane.laneId + (lane.role ? ' (' + lane.role + ')' : '') + '</div>' +
'<div class="track-bar-container"><div class="track-bar" style="left:10%; width:70%;"></div></div>';
container.appendChild(div);
});
}
} catch (err) {
console.error("Layout polling error:", err);
}
}
async function handleMutate(e) {
e.preventDefault();
const cmd = document.getElementById('mutateCmd').value;
const argsRaw = document.getElementById('mutateArgs').value;
const token = document.getElementById('sessionTokenInput').value;
const resDiv = document.getElementById('mutateResult');
let args = {};
try {
args = JSON.parse(argsRaw);
} catch {
resDiv.style.display = 'block';
resDiv.className = 'status-msg status-err';
resDiv.textContent = 'Malformed JSON arguments';
return;
}
try {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch('/api/mutate', {
method: 'POST',
headers,
body: JSON.stringify({ command: cmd, args })
});
const out = await res.json();
resDiv.style.display = 'block';
if (res.ok && out.ok) {
resDiv.className = 'status-msg status-ok';
resDiv.textContent = 'Mutation applied (Audit ID: ' + out.mutationId + ')';
loadLayout();
} else {
resDiv.className = 'status-msg status-err';
resDiv.textContent = 'Mutation failed: ' + (out.message || out.error || res.status);
}
} catch (err) {
resDiv.style.display = 'block';
resDiv.className = 'status-msg status-err';
resDiv.textContent = 'Network error: ' + err.message;
}
}
loadLayout();
setInterval(loadLayout, 3000);
</script>
</body>
</html>`);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "not_found" }));
});
return {
server,
port: targetPort,
listen: () =>
new Promise((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(targetPort, host, () => {
server.removeListener("error", rejectListen);
const addr = server.address();
const actualPort = typeof addr === "object" && addr ? addr.port : targetPort;
resolveListen(actualPort);
});
}),
close: () =>
new Promise((resolveClose, rejectClose) => {
try {
server.closeAllConnections?.();
} catch {
// ignore if not supported
}
server.close((err) => {
if (err) rejectClose(err);
else resolveClose();
});
}),
};
}