1991 lines
67 KiB
JavaScript
1991 lines
67 KiB
JavaScript
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(`<option value="">${escapeHtml(blankLabel)}</option>`);
|
|
}
|
|
|
|
for (const value of values) {
|
|
const selected = value === selectedValue ? " selected" : "";
|
|
options.push(
|
|
`<option value="${escapeHtml(value)}"${selected}>${escapeHtml(value)}</option>`,
|
|
);
|
|
}
|
|
|
|
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 `
|
|
<div class="helper-row helper-persona-row" data-index="${index}">
|
|
<div class="helper-row-grid">
|
|
<label>ID<input class="helper-persona-id" type="text" value="${escapeHtml(String(persona.id || ""))}" /></label>
|
|
<label>Display Name<input class="helper-persona-display-name" type="text" value="${escapeHtml(String(persona.displayName || ""))}" /></label>
|
|
<label>Model Constraint<input class="helper-persona-model-constraint" type="text" value="${escapeHtml(String(persona.modelConstraint || ""))}" /></label>
|
|
<label class="helper-span-2">System Prompt Template
|
|
<textarea class="helper-persona-system-prompt" rows="2">${escapeHtml(String(persona.systemPromptTemplate || ""))}</textarea>
|
|
</label>
|
|
<label>Allowlist (CSV)<input class="helper-persona-allowlist" type="text" value="${escapeHtml(toCsv(allowlist))}" /></label>
|
|
<label>Banlist (CSV)<input class="helper-persona-banlist" type="text" value="${escapeHtml(toCsv(banlist))}" /></label>
|
|
</div>
|
|
<button class="helper-remove-row" type="button" data-kind="persona" data-index="${index}">Remove</button>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
const relationships = Array.isArray(draft.relationships) ? draft.relationships : [];
|
|
dom.helperRelationships.innerHTML = relationships
|
|
.map(
|
|
(relationship, index) => `
|
|
<div class="helper-row helper-relationship-row" data-index="${index}">
|
|
<div class="helper-row-grid">
|
|
<label>Parent Persona
|
|
<select class="helper-relationship-parent">
|
|
${renderSelectOptions(personaIds, String(relationship.parentPersonaId || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(choose persona)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Child Persona
|
|
<select class="helper-relationship-child">
|
|
${renderSelectOptions(personaIds, String(relationship.childPersonaId || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(choose persona)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Max Depth
|
|
<input class="helper-relationship-max-depth" type="number" min="1" value="${escapeHtml(String(relationship.constraints?.maxDepth ?? ""))}" />
|
|
</label>
|
|
<label>Max Children
|
|
<input class="helper-relationship-max-children" type="number" min="1" value="${escapeHtml(String(relationship.constraints?.maxChildren ?? ""))}" />
|
|
</label>
|
|
</div>
|
|
<button class="helper-remove-row" type="button" data-kind="relationship" data-index="${index}">Remove</button>
|
|
</div>
|
|
`,
|
|
)
|
|
.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) => `
|
|
<div class="helper-row helper-node-row" data-index="${index}">
|
|
<div class="helper-row-grid">
|
|
<label>Node ID<input class="helper-node-id" type="text" value="${escapeHtml(String(node.id || ""))}" /></label>
|
|
<label>Actor ID<input class="helper-node-actor-id" type="text" value="${escapeHtml(String(node.actorId || ""))}" /></label>
|
|
<label>Persona
|
|
<select class="helper-node-persona-id">
|
|
${renderSelectOptions(personaIds, String(node.personaId || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(choose persona)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Topology Kind
|
|
<select class="helper-node-topology-kind">
|
|
${renderSelectOptions(MANIFEST_TOPOLOGY_ORDER, String(node.topology?.kind || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(default)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Block ID<input class="helper-node-block-id" type="text" value="${escapeHtml(String(node.topology?.blockId || ""))}" /></label>
|
|
<label>Max Retries<input class="helper-node-max-retries" type="number" min="0" value="${escapeHtml(String(node.constraints?.maxRetries ?? ""))}" /></label>
|
|
</div>
|
|
<button class="helper-remove-row" type="button" data-kind="node" data-index="${index}">Remove</button>
|
|
</div>
|
|
`,
|
|
)
|
|
.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 `
|
|
<div class="helper-row helper-edge-row" data-index="${index}">
|
|
<div class="helper-row-grid">
|
|
<label>From
|
|
<select class="helper-edge-from">
|
|
${renderSelectOptions(nodeIds, String(edge.from || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(choose node)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>To
|
|
<select class="helper-edge-to">
|
|
${renderSelectOptions(nodeIds, String(edge.to || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(choose node)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Trigger Type
|
|
<select class="helper-edge-trigger-kind">
|
|
${renderSelectOptions(["on", "event"], triggerKind)}
|
|
</select>
|
|
</label>
|
|
<label>Status Trigger (on)
|
|
<select class="helper-edge-on">
|
|
${renderSelectOptions(MANIFEST_ON_TRIGGERS, String(edge.on || "success"), {
|
|
includeBlank: true,
|
|
blankLabel: "(none)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>Domain Event Trigger
|
|
<select class="helper-edge-event">
|
|
${renderSelectOptions(MANIFEST_EVENT_TRIGGERS, String(edge.event || ""), {
|
|
includeBlank: true,
|
|
blankLabel: "(none)",
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label class="helper-span-2">When Conditions (JSON array, optional)
|
|
<input class="helper-edge-when" type="text" value="${escapeHtml(whenText)}" />
|
|
</label>
|
|
</div>
|
|
<button class="helper-remove-row" type="button" data-kind="edge" data-index="${index}">Remove</button>
|
|
</div>
|
|
`;
|
|
})
|
|
.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(`
|
|
<tr data-session-id="${escapeHtml(session.sessionId)}">
|
|
<td>${escapeHtml(session.sessionId)}</td>
|
|
<td><span class="${statusChipClass(sessionStatus)}">${escapeHtml(sessionStatus)}</span></td>
|
|
<td>${Number(session.nodeAttemptCount || 0)}</td>
|
|
<td>${fmtMs(session.durationMs)}</td>
|
|
<td>${fmtMoney(session.costUsd)}</td>
|
|
<td>${escapeHtml(session.endedAt || session.startedAt || "-")}</td>
|
|
</tr>
|
|
`);
|
|
}
|
|
|
|
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 = `
|
|
<pre>${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}`,
|
|
)}</pre>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="event-row ${escapeHtml(event.severity)}">
|
|
<div class="event-time">${escapeHtml(ts)}</div>
|
|
<div class="event-type">${escapeHtml(event.type)}</div>
|
|
<div>${escapeHtml(event.message)}</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
dom.eventFeed.innerHTML = rows || '<div class="event-row"><div class="event-time">-</div><div class="event-type">-</div><div>No runtime events.</div></div>';
|
|
}
|
|
|
|
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";
|
|
});
|