diff --git a/src/ui/config-store.ts b/src/ui/config-store.ts index 109af78..222eab9 100644 --- a/src/ui/config-store.ts +++ b/src/ui/config-store.ts @@ -23,6 +23,7 @@ export type LimitSettings = { topologyMaxDepth: number; topologyMaxRetries: number; relationshipMaxChildren: number; + mergeConflictMaxAttempts: number; portBase: number; portBlockSize: number; portBlockCount: number; @@ -88,6 +89,7 @@ function toLimits(config: Readonly): LimitSettings { topologyMaxDepth: config.orchestration.maxDepth, topologyMaxRetries: config.orchestration.maxRetries, relationshipMaxChildren: config.orchestration.maxChildren, + mergeConflictMaxAttempts: config.orchestration.mergeConflictMaxAttempts, portBase: config.provisioning.portRange.basePort, portBlockSize: config.provisioning.portRange.blockSize, portBlockCount: config.provisioning.portRange.blockCount, @@ -170,6 +172,7 @@ export class UiConfigStore { AGENT_TOPOLOGY_MAX_DEPTH: String(input.topologyMaxDepth), AGENT_TOPOLOGY_MAX_RETRIES: String(input.topologyMaxRetries), AGENT_RELATIONSHIP_MAX_CHILDREN: String(input.relationshipMaxChildren), + AGENT_MERGE_CONFLICT_MAX_ATTEMPTS: String(input.mergeConflictMaxAttempts), AGENT_PORT_BASE: String(input.portBase), AGENT_PORT_BLOCK_SIZE: String(input.portBlockSize), AGENT_PORT_BLOCK_COUNT: String(input.portBlockCount), diff --git a/src/ui/public/app.js b/src/ui/public/app.js index a21b54b..f52e67d 100644 --- a/src/ui/public/app.js +++ b/src/ui/public/app.js @@ -2,6 +2,7 @@ const state = { config: null, manifests: [], sessions: [], + sessionMetadata: [], runs: [], selectedSessionId: "", selectedManifestPath: "", @@ -29,6 +30,11 @@ const dom = { 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"), @@ -78,6 +84,7 @@ const dom = { 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"), @@ -137,6 +144,8 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({ "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.", @@ -152,6 +161,7 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({ "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.", @@ -1036,6 +1046,7 @@ async function loadConfig() { 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); @@ -1067,11 +1078,28 @@ 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 sessionStatus = session.status || "unknown"; + const lifecycleStatus = getSessionLifecycleStatus(session.sessionId); + const sessionStatus = + lifecycleStatus === "closed" || lifecycleStatus === "closed_with_conflicts" + ? lifecycleStatus + : session.status || lifecycleStatus || "unknown"; rows.push(` ${escapeHtml(session.sessionId)} @@ -1099,6 +1127,7 @@ function renderRunsAndSessionsTable() { 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) { @@ -1511,6 +1540,17 @@ async function startRun(event) { 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) { @@ -1566,6 +1606,64 @@ async function cancelActiveRun() { } } +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 = { @@ -1611,6 +1709,7 @@ async function saveLimits(event) { 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), @@ -1737,6 +1836,12 @@ function bindUiEvents() { 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); diff --git a/src/ui/public/index.html b/src/ui/public/index.html index 27efdd2..f701f0d 100644 --- a/src/ui/public/index.html +++ b/src/ui/public/index.html @@ -90,6 +90,23 @@
+
+

Session Controls

+
+ + +
+ + +
+
+

Node Inspector

Select a graph node.
@@ -196,6 +213,7 @@ + diff --git a/src/ui/public/styles.css b/src/ui/public/styles.css index c3c41c1..eb9012d 100644 --- a/src/ui/public/styles.css +++ b/src/ui/public/styles.css @@ -142,6 +142,12 @@ label { letter-spacing: 0.015em; } +label.inline-checkbox { + flex-direction: row; + align-items: center; + gap: 0.45rem; +} + input, select, textarea, @@ -353,6 +359,22 @@ button.danger { border-color: rgba(255, 201, 74, 0.6); } +.status-active { + color: var(--accent-cool); + border-color: rgba(86, 195, 255, 0.6); +} + +.status-suspended, +.status-closed_with_conflicts { + color: var(--warn); + border-color: rgba(255, 201, 74, 0.6); +} + +.status-closed { + color: var(--muted); + border-color: rgba(155, 184, 207, 0.45); +} + .status-unknown { color: var(--muted); border-color: rgba(155, 184, 207, 0.45); diff --git a/workspace/.gitkeep b/workspace/.gitkeep deleted file mode 100644 index e69de29..0000000