diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs index 76b8321a..58eeffaf 100644 --- a/src/channels/web/handlers/extensions.rs +++ b/src/channels/web/handlers/extensions.rs @@ -24,17 +24,43 @@ pub async fn extensions_list_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let pairing_store = crate::pairing::PairingStore::new(); let extensions = installed .into_iter() - .map(|ext| ExtensionInfo { - name: ext.name, - kind: ext.kind.to_string(), - description: ext.description, - url: ext.url, - authenticated: ext.authenticated, - active: ext.active, - tools: ext.tools, - needs_setup: ext.needs_setup, + .map(|ext| { + let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { + Some(if ext.activation_error.is_some() { + "failed".to_string() + } else if !ext.authenticated { + "installed".to_string() + } else if ext.active && ext.name == "telegram" { + let has_paired = pairing_store + .read_allow_from(&ext.name) + .map(|list| !list.is_empty()) + .unwrap_or(false); + if has_paired { + "active".to_string() + } else { + "pairing".to_string() + } + } else { + "configured".to_string() + }) + } else { + None + }; + ExtensionInfo { + name: ext.name, + kind: ext.kind.to_string(), + description: ext.description, + url: ext.url, + authenticated: ext.authenticated, + active: ext.active, + tools: ext.tools, + needs_setup: ext.needs_setup, + activation_status, + activation_error: ext.activation_error, + } }) .collect(); diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 0c766b98..733c8a60 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -93,6 +93,7 @@ impl GatewayChannel { registry_entries: Vec::new(), cost_guard: None, startup_time: std::time::Instant::now(), + restart_requested: std::sync::atomic::AtomicBool::new(false), }); Self { @@ -126,6 +127,7 @@ impl GatewayChannel { registry_entries: self.state.registry_entries.clone(), cost_guard: self.state.cost_guard.clone(), startup_time: self.state.startup_time, + restart_requested: std::sync::atomic::AtomicBool::new(false), }; mutate(&mut new_state); self.state = Arc::new(new_state); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index b6bcb74c..40c5ca4b 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -158,6 +158,8 @@ pub struct GatewayState { pub cost_guard: Option>, /// Server startup time for uptime calculation. pub startup_time: std::time::Instant, + /// Flag set when a restart has been requested via the API. + pub restart_requested: std::sync::atomic::AtomicBool, } /// Start the gateway HTTP server. @@ -238,6 +240,8 @@ pub async fn start_server( "/api/extensions/{name}/setup", get(extensions_setup_handler).post(extensions_setup_submit_handler), ) + // Gateway management + .route("/api/gateway/restart", post(gateway_restart_handler)) // Pairing .route("/api/pairing/{channel}", get(pairing_list_handler)) .route( @@ -1722,17 +1726,46 @@ async fn extensions_list_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let pairing_store = crate::pairing::PairingStore::new(); let extensions = installed .into_iter() - .map(|ext| ExtensionInfo { - name: ext.name, - kind: ext.kind.to_string(), - description: ext.description, - url: ext.url, - authenticated: ext.authenticated, - active: ext.active, - tools: ext.tools, - needs_setup: ext.needs_setup, + .map(|ext| { + let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { + Some(if ext.activation_error.is_some() { + "failed".to_string() + } else if !ext.authenticated { + // No credentials configured yet. + "installed".to_string() + } else if ext.active && ext.name == "telegram" { + // Telegram: check pairing status (end-to-end setup via web UI). + let has_paired = pairing_store + .read_allow_from(&ext.name) + .map(|list| !list.is_empty()) + .unwrap_or(false); + if has_paired { + "active".to_string() + } else { + "pairing".to_string() + } + } else { + // Authenticated but not fully active (or non-Telegram). + "configured".to_string() + }) + } else { + None + }; + ExtensionInfo { + name: ext.name, + kind: ext.kind.to_string(), + description: ext.description, + url: ext.url, + authenticated: ext.authenticated, + active: ext.active, + tools: ext.tools, + needs_setup: ext.needs_setup, + activation_status, + activation_error: ext.activation_error, + } }) .collect(); @@ -2037,11 +2070,44 @@ async fn extensions_setup_submit_handler( ))?; match ext_mgr.save_setup_secrets(&name, &req.secrets).await { - Ok(message) => Ok(Json(ActionResponse::ok(message))), + Ok(result) => { + let mut resp = ActionResponse::ok(result.message); + resp.activated = Some(result.activated); + if !result.activated { + resp.needs_restart = Some(true); + } + Ok(Json(resp)) + } Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } +// --- Gateway management handlers --- + +async fn gateway_restart_handler(State(state): State>) -> Json { + // Idempotency guard: only allow one restart at a time. + if state + .restart_requested + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + ) + .is_err() + { + return Json(ActionResponse::ok("Restart already in progress")); + } + + // Take the shutdown sender and trigger graceful shutdown. + if let Some(tx) = state.shutdown_tx.write().await.take() { + let _ = tx.send(()); + tracing::info!("Gateway restart requested via API"); + } + + Json(ActionResponse::ok("Restarting...")) +} + // --- Pairing handlers --- async fn pairing_list_handler( diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 120a7103..0d5cf39a 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -42,6 +42,11 @@ impl SseManager { let _ = self.tx.send(event); } + /// Get a clone of the broadcast sender for use by other components. + pub fn sender(&self) -> broadcast::Sender { + self.tx.clone() + } + /// Get current number of active connections. pub fn connection_count(&self) -> u64 { self.connection_count.load(Ordering::Relaxed) @@ -120,6 +125,7 @@ impl SseManager { SseEvent::JobStatus { .. } => "job_status", SseEvent::JobResult { .. } => "job_result", SseEvent::Heartbeat => "heartbeat", + SseEvent::ExtensionStatus { .. } => "extension_status", }; Ok(Event::default().event(event_type).data(data)) }); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 351678fc..d9253a91 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -12,6 +12,7 @@ let loadingOlder = false; let sseHasConnectedBefore = false; let jobEvents = new Map(); // job_id -> Array of events let jobListRefreshTimer = null; +let pairingPollInterval = null; const JOB_EVENTS_CAP = 500; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; @@ -207,6 +208,10 @@ function connectSSE() { enableChatInput(); }); + eventSource.addEventListener('extension_status', (e) => { + if (currentTab === 'extensions') loadExtensions(); + }); + eventSource.addEventListener('error', (e) => { if (e.data) { const data = JSON.parse(e.data); @@ -1090,7 +1095,12 @@ function switchTab(tab) { if (tab === 'jobs') loadJobs(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); - if (tab === 'extensions') loadExtensions(); + if (tab === 'extensions') { + loadExtensions(); + startPairingPoll(); + } else { + stopPairingPoll(); + } if (tab === 'skills') loadSkills(); } @@ -1576,10 +1586,15 @@ function renderAvailableExtensionCard(entry) { }).then(function(res) { if (res.success) { showToast('Installed ' + entry.display_name, 'success'); + loadExtensions(); + // Auto-open configure for WASM channels + if (entry.kind === 'wasm_channel') { + showConfigureModal(entry.name); + } } else { showToast('Install: ' + (res.message || 'unknown error'), 'error'); + loadExtensions(); } - loadExtensions(); }).catch(function(err) { showToast('Install failed: ' + err.message, 'error'); loadExtensions(); @@ -1672,6 +1687,14 @@ function renderMcpServerCard(entry, installedExt) { return card; } +function createReconfigureButton(extName) { + var btn = document.createElement('button'); + btn.className = 'btn-ext configure'; + btn.textContent = 'Reconfigure'; + btn.addEventListener('click', function() { showConfigureModal(extName); }); + return btn; +} + function renderExtensionCard(ext) { const card = document.createElement('div'); card.className = 'ext-card'; @@ -1689,13 +1712,21 @@ function renderExtensionCard(ext) { kind.textContent = ext.kind; header.appendChild(kind); - const authDot = document.createElement('span'); - authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed'); - authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated'; - header.appendChild(authDot); + // Auth dot only for non-WASM-channel extensions (channels use the stepper instead) + if (ext.kind !== 'wasm_channel') { + const authDot = document.createElement('span'); + authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed'); + authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated'; + header.appendChild(authDot); + } card.appendChild(header); + // WASM channels get a progress stepper + if (ext.kind === 'wasm_channel') { + card.appendChild(renderWasmChannelStepper(ext)); + } + if (ext.description) { const desc = document.createElement('div'); desc.className = 'ext-desc'; @@ -1718,28 +1749,78 @@ function renderExtensionCard(ext) { card.appendChild(tools); } + // Show activation error for WASM channels + if (ext.kind === 'wasm_channel' && ext.activation_error) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'ext-error'; + errorDiv.textContent = ext.activation_error; + card.appendChild(errorDiv); + } + + // Show "coming soon" note for non-Telegram channels that are configured but not fully supported yet + if (ext.kind === 'wasm_channel' && ext.name !== 'telegram' + && (ext.activation_status === 'configured' || ext.active)) { + const noteDiv = document.createElement('div'); + noteDiv.className = 'ext-note'; + noteDiv.textContent = 'Full integration coming soon. Use the CLI to complete setup.'; + card.appendChild(noteDiv); + } + const actions = document.createElement('div'); actions.className = 'ext-actions'; - if (!ext.active) { - const activateBtn = document.createElement('button'); - activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; - activateBtn.addEventListener('click', () => activateExtension(ext.name)); - actions.appendChild(activateBtn); + if (ext.kind === 'wasm_channel') { + // WASM channels: state-based buttons (no generic Activate) + var status = ext.activation_status || 'installed'; + if (status === 'active') { + var activeLabel = document.createElement('span'); + activeLabel.className = 'ext-active-label'; + activeLabel.textContent = 'Active'; + actions.appendChild(activeLabel); + actions.appendChild(createReconfigureButton(ext.name)); + } else if (status === 'pairing') { + var pairingLabel = document.createElement('span'); + pairingLabel.className = 'ext-pairing-label'; + pairingLabel.textContent = 'Awaiting Pairing'; + actions.appendChild(pairingLabel); + actions.appendChild(createReconfigureButton(ext.name)); + } else if (status === 'failed') { + var restartBtn = document.createElement('button'); + restartBtn.className = 'btn-ext activate'; + restartBtn.textContent = 'Restart'; + restartBtn.addEventListener('click', restartGateway); + actions.appendChild(restartBtn); + actions.appendChild(createReconfigureButton(ext.name)); + } else { + // installed or configured: show Setup button + var setupBtn = document.createElement('button'); + setupBtn.className = 'btn-ext configure'; + setupBtn.textContent = 'Setup'; + setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); }); + actions.appendChild(setupBtn); + } } else { - const activeLabel = document.createElement('span'); - activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; - actions.appendChild(activeLabel); - } + // Non-WASM-channel extensions: original behavior + if (!ext.active) { + const activateBtn = document.createElement('button'); + activateBtn.className = 'btn-ext activate'; + activateBtn.textContent = 'Activate'; + activateBtn.addEventListener('click', () => activateExtension(ext.name)); + actions.appendChild(activateBtn); + } else { + const activeLabel = document.createElement('span'); + activeLabel.className = 'ext-active-label'; + activeLabel.textContent = 'Active'; + actions.appendChild(activeLabel); + } - if (ext.needs_setup) { - const configBtn = document.createElement('button'); - configBtn.className = 'btn-ext configure'; - configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; - configBtn.addEventListener('click', () => showConfigureModal(ext.name)); - actions.appendChild(configBtn); + if (ext.needs_setup) { + const configBtn = document.createElement('button'); + configBtn.className = 'btn-ext configure'; + configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; + configBtn.addEventListener('click', () => showConfigureModal(ext.name)); + actions.appendChild(configBtn); + } } const removeBtn = document.createElement('button'); @@ -1751,11 +1832,10 @@ function renderExtensionCard(ext) { card.appendChild(actions); // For WASM channels, check for pending pairing requests. - // Show even when inactive — pairing requests can arrive via webhooks - // before the channel is fully activated. if (ext.kind === 'wasm_channel') { const pairingSection = document.createElement('div'); pairingSection.className = 'ext-pairing'; + pairingSection.setAttribute('data-channel', ext.name); card.appendChild(pairingSection); loadPairingRequests(ext.name, pairingSection); } @@ -1905,6 +1985,10 @@ function submitConfigureModal(name, fields) { } } + // Disable buttons to prevent double-submit + var btns = document.querySelectorAll('.configure-actions button'); + btns.forEach(function(b) { b.disabled = true; }); + apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', { method: 'POST', body: { secrets }, @@ -1912,13 +1996,22 @@ function submitConfigureModal(name, fields) { .then((res) => { closeConfigureModal(); if (res.success) { - showToast(res.message, 'success'); + if (res.activated && name === 'telegram') { + showToast('Configured and activated ' + name, 'success'); + } else if (res.activated) { + showToast('Configured ' + name + ' successfully', 'success'); + } else if (res.needs_restart) { + showToast('Configured ' + name + '. Restart required to activate.', 'info'); + } else { + showToast(res.message, 'success'); + } } else { showToast(res.message || 'Configuration failed', 'error'); } loadExtensions(); }) .catch((err) => { + btns.forEach(function(b) { b.disabled = false; }); showToast('Configuration failed: ' + err.message, 'error'); }); } @@ -1981,6 +2074,139 @@ function approvePairing(channel, code, container) { }).catch(err => showToast('Error: ' + err.message, 'error')); } +function startPairingPoll() { + stopPairingPoll(); + pairingPollInterval = setInterval(function() { + document.querySelectorAll('.ext-pairing[data-channel]').forEach(function(el) { + loadPairingRequests(el.getAttribute('data-channel'), el); + }); + }, 10000); +} + +function stopPairingPoll() { + if (pairingPollInterval) { + clearInterval(pairingPollInterval); + pairingPollInterval = null; + } +} + +// --- Gateway restart --- + +function restartGateway() { + if (!confirm('Restart IronClaw gateway? Active connections will be dropped.')) return; + + apiFetch('/api/gateway/restart', { method: 'POST' }) + .then(function() { + showRestartOverlay(); + }) + .catch(function() { + showRestartOverlay(); + }); +} + +function showRestartOverlay() { + var overlay = document.createElement('div'); + overlay.className = 'restart-overlay'; + overlay.innerHTML = '
' + + '
' + + '

Restarting IronClaw...

' + + '

Waiting for server to come back online

' + + '
'; + document.body.appendChild(overlay); + + var pollCount = 0; + var pollTimer = setInterval(function() { + pollCount++; + if (pollCount > 30) { // 60 seconds + clearInterval(pollTimer); + overlay.querySelector('h2').textContent = 'Restart timed out'; + overlay.querySelector('p').textContent = 'Server did not come back within 60 seconds. Check logs.'; + overlay.querySelector('.restart-spinner').style.display = 'none'; + return; + } + fetch('/api/gateway/status', { + headers: { 'Authorization': 'Bearer ' + token }, + }) + .then(function(r) { + if (r.ok) { + clearInterval(pollTimer); + window.location.reload(); + } + }) + .catch(function() { /* still restarting */ }); + }, 2000); +} + +// --- WASM channel stepper --- + +function renderWasmChannelStepper(ext) { + var stepper = document.createElement('div'); + stepper.className = 'ext-stepper'; + + var status = ext.activation_status || 'installed'; + var isTelegram = ext.name === 'telegram'; + + // Telegram gets a 3-step stepper (Installed → Configured → Active/Pairing). + // Other channels only get 2 steps (Installed → Configured) since full + // integration isn't available in the web UI yet. + var steps = [ + { label: 'Installed', key: 'installed' }, + { label: 'Configured', key: 'configured' }, + ]; + if (isTelegram) { + steps.push({ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' }); + } + + var reachedIdx; + if (status === 'active') reachedIdx = isTelegram ? 2 : 1; + else if (status === 'pairing') reachedIdx = 2; + else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1; + else if (status === 'configured') reachedIdx = 1; + else reachedIdx = 0; + + for (var i = 0; i < steps.length; i++) { + if (i > 0) { + var connector = document.createElement('div'); + connector.className = 'stepper-connector' + (i <= reachedIdx ? ' completed' : ''); + stepper.appendChild(connector); + } + + var step = document.createElement('div'); + var stepState; + if (i < reachedIdx) { + stepState = 'completed'; + } else if (i === reachedIdx) { + if (status === 'failed') { + stepState = 'failed'; + } else if (status === 'pairing') { + stepState = 'in-progress'; + } else if (status === 'active' || status === 'configured' || status === 'installed') { + stepState = 'completed'; + } else { + stepState = 'pending'; + } + } else { + stepState = 'pending'; + } + step.className = 'stepper-step ' + stepState; + + var circle = document.createElement('span'); + circle.className = 'stepper-circle'; + if (stepState === 'completed') circle.textContent = '\u2713'; + else if (stepState === 'failed') circle.textContent = '\u2717'; + step.appendChild(circle); + + var label = document.createElement('span'); + label.className = 'stepper-label'; + label.textContent = steps[i].label; + step.appendChild(label); + + stepper.appendChild(step); + } + + return stepper; +} + // --- Jobs --- let currentJobId = null; diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index d9ffcd0f..a3adae3d 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -2156,6 +2156,159 @@ body { font-weight: 500; } +/* WASM channel setup stepper */ +.ext-stepper { + display: flex; + align-items: center; + gap: 0; + margin: 8px 0 4px; +} + +.stepper-step { + display: flex; + align-items: center; + gap: 4px; +} + +.stepper-circle { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.stepper-label { + font-size: 11px; + white-space: nowrap; +} + +.stepper-step.completed .stepper-circle { + background: var(--success); + color: #000; +} + +.stepper-step.completed .stepper-label { + color: var(--success); +} + +.stepper-step.failed .stepper-circle { + background: var(--danger); + color: #fff; +} + +.stepper-step.failed .stepper-label { + color: var(--danger); +} + +.stepper-step.in-progress .stepper-circle { + background: var(--warning); + color: #000; + animation: pulse-glow 1.5s ease-in-out infinite; +} + +.stepper-step.in-progress .stepper-label { + color: var(--warning); +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.stepper-step.pending .stepper-circle { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.stepper-step.pending .stepper-label { + color: var(--text-secondary); +} + +.ext-pairing-label { + font-size: 12px; + color: var(--warning); + font-weight: 500; +} + +.stepper-connector { + width: 20px; + height: 2px; + background: var(--border); + margin: 0 4px; + flex-shrink: 0; +} + +.stepper-connector.completed { + background: var(--success); +} + +.ext-error { + font-size: 11px; + color: var(--danger); + background: rgba(230, 76, 76, 0.1); + border: 1px solid rgba(230, 76, 76, 0.2); + border-radius: var(--radius); + padding: 6px 8px; + margin-top: 6px; +} + +.ext-note { + font-size: 11px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 8px; + margin-top: 6px; +} + +/* Restart overlay */ +.restart-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.restart-message { + text-align: center; + color: var(--text); +} + +.restart-message h2 { + margin: 16px 0 8px; +} + +.restart-message p { + color: var(--text-secondary); +} + +.restart-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + .btn-ext { padding: 4px 10px; border-radius: var(--radius); diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 45af9924..d79f7513 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -193,6 +193,15 @@ pub enum SseEvent { #[serde(skip_serializing_if = "Option::is_none")] session_id: Option, }, + + /// Extension activation status change (WASM channels). + #[serde(rename = "extension_status")] + ExtensionStatus { + extension_name: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, } // --- Memory --- @@ -349,6 +358,12 @@ pub struct ExtensionInfo { /// Whether this extension has configurable secrets (setup schema). #[serde(default)] pub needs_setup: bool, + /// WASM channel activation status: "installed", "configured", "active", "failed". + #[serde(skip_serializing_if = "Option::is_none")] + pub activation_status: Option, + /// Human-readable error when activation_status is "failed". + #[serde(skip_serializing_if = "Option::is_none")] + pub activation_error: Option, } #[derive(Debug, Serialize)] @@ -412,6 +427,12 @@ pub struct ActionResponse { /// Instructions for manual token entry. #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, + /// Whether the channel was successfully activated after setup. + #[serde(skip_serializing_if = "Option::is_none")] + pub activated: Option, + /// Whether a gateway restart is needed (activation failed). + #[serde(skip_serializing_if = "Option::is_none")] + pub needs_restart: Option, } impl ActionResponse { @@ -422,6 +443,8 @@ impl ActionResponse { auth_url: None, awaiting_token: None, instructions: None, + activated: None, + needs_restart: None, } } @@ -432,6 +455,8 @@ impl ActionResponse { auth_url: None, awaiting_token: None, instructions: None, + activated: None, + needs_restart: None, } } } @@ -612,6 +637,7 @@ impl WsServerMessage { SseEvent::JobToolResult { .. } => "job_tool_result", SseEvent::JobStatus { .. } => "job_status", SseEvent::JobResult { .. } => "job_result", + SseEvent::ExtensionStatus { .. } => "extension_status", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); WsServerMessage::Event { diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 96c6f783..e0b7eb35 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -493,6 +493,7 @@ mod tests { registry_entries: Vec::new(), cost_guard: None, startup_time: std::time::Instant::now(), + restart_requested: std::sync::atomic::AtomicBool::new(false), } } } diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 7c2ef0a3..b5198eac 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -52,6 +52,14 @@ struct ChannelRuntimeState { telegram_owner_id: Option, } +/// Result of saving setup secrets and attempting activation. +pub struct SetupResult { + /// Human-readable status message. + pub message: String, + /// Whether the channel was successfully activated after saving secrets. + pub activated: bool, +} + /// Central manager for extension lifecycle operations. pub struct ExtensionManager { registry: ExtensionRegistry, @@ -82,6 +90,11 @@ pub struct ExtensionManager { store: Option>, /// Names of WASM channels that were successfully loaded at startup. active_channel_names: RwLock>, + /// Last activation error for each WASM channel (ephemeral, cleared on success). + activation_errors: RwLock>, + /// SSE broadcast sender (set post-construction via `set_sse_sender()`). + sse_sender: + RwLock>>, } impl ExtensionManager { @@ -121,6 +134,8 @@ impl ExtensionManager { user_id, store, active_channel_names: RwLock::new(HashSet::new()), + activation_errors: RwLock::new(HashMap::new()), + sse_sender: RwLock::new(None), } } @@ -153,6 +168,25 @@ impl ExtensionManager { active.extend(names); } + /// Set the SSE broadcast sender for pushing extension status events to the web UI. + pub async fn set_sse_sender( + &self, + sender: tokio::sync::broadcast::Sender, + ) { + *self.sse_sender.write().await = Some(sender); + } + + /// Broadcast an extension status change to the web UI via SSE. + async fn broadcast_extension_status(&self, name: &str, status: &str, message: Option<&str>) { + if let Some(ref sender) = *self.sse_sender.read().await { + let _ = sender.send(crate::channels::web::types::SseEvent::ExtensionStatus { + extension_name: name.to_string(), + status: status.to_string(), + message: message.map(|m| m.to_string()), + }); + } + } + /// Search for extensions. If `discover` is true, also searches online. pub async fn search( &self, @@ -299,6 +333,7 @@ impl ExtensionManager { tools, needs_setup: false, installed: true, + activation_error: None, }); } } @@ -327,6 +362,7 @@ impl ExtensionManager { tools: if active { vec![name] } else { Vec::new() }, needs_setup: false, installed: true, + activation_error: None, }); } } @@ -343,10 +379,12 @@ impl ExtensionManager { match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await { Ok(channels) => { let active_names = self.active_channel_names.read().await; + let errors = self.activation_errors.read().await; for (name, _discovered) in channels { let active = active_names.contains(&name); let (authenticated, needs_setup) = self.check_channel_auth_status(&name).await; + let activation_error = errors.get(&name).cloned(); extensions.push(InstalledExtension { name, kind: ExtensionKind::WasmChannel, @@ -357,6 +395,7 @@ impl ExtensionManager { tools: Vec::new(), needs_setup, installed: true, + activation_error, }); } } @@ -392,6 +431,7 @@ impl ExtensionManager { tools: Vec::new(), needs_setup: false, installed: false, + activation_error: None, }); } } @@ -2087,11 +2127,14 @@ impl ExtensionManager { } /// Save setup secrets for an extension, validating names against the capabilities schema. + /// + /// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`] + /// indicating whether activation succeeded (so the frontend can show appropriate UI). pub async fn save_setup_secrets( &self, name: &str, secrets: &std::collections::HashMap, - ) -> Result { + ) -> Result { let kind = self.determine_installed_kind(name).await?; if kind != ExtensionKind::WasmChannel { return Err(ExtensionError::Other( @@ -2174,21 +2217,38 @@ impl ExtensionManager { // Try to hot-activate the channel now that secrets are saved match self.activate_wasm_channel(name).await { - Ok(result) => Ok(format!( - "Configuration saved and channel '{}' activated. {}", - name, result.message - )), + Ok(result) => { + self.activation_errors.write().await.remove(name); + self.broadcast_extension_status(name, "active", None).await; + Ok(SetupResult { + message: format!( + "Configuration saved and channel '{}' activated. {}", + name, result.message + ), + activated: true, + }) + } Err(e) => { + let error_msg = e.to_string(); tracing::warn!( channel = name, error = %e, "Saved configuration but hot-activation failed, restart may be needed" ); - Ok(format!( - "Configuration saved for '{}'. \ - Automatic activation failed ({}), restart IronClaw to activate.", - name, e - )) + self.activation_errors + .write() + .await + .insert(name.to_string(), error_msg.clone()); + self.broadcast_extension_status(name, "failed", Some(&error_msg)) + .await; + Ok(SetupResult { + message: format!( + "Configuration saved for '{}'. \ + Automatic activation failed ({}), restart IronClaw to activate.", + name, e + ), + activated: false, + }) } } } diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index cb45ed02..0d7828e3 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -203,6 +203,9 @@ pub struct InstalledExtension { /// Whether this extension is installed locally (false = available in registry but not installed). #[serde(default = "default_true")] pub installed: bool, + /// Last activation error for WASM channels. + #[serde(skip_serializing_if = "Option::is_none")] + pub activation_error: Option, } /// Error type for extension operations. diff --git a/src/main.rs b/src/main.rs index 3743fea1..1240f1b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -474,6 +474,11 @@ async fn main() -> anyhow::Result<()> { // ── Gateway channel ──────────────────────────────────────────────── let mut gateway_url: Option = None; + let mut sse_sender: Option< + tokio::sync::broadcast::Sender, + > = None; + let mut gateway_state: Option> = + None; if let Some(ref gw_config) = config.channels.gateway { let mut gw = GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm)); @@ -526,6 +531,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); + // Capture SSE sender before moving gw into channels. + // IMPORTANT: This must come after all `with_*` calls since `rebuild_state` + // creates a new SseManager, which would orphan this sender. + sse_sender = Some(gw.state().sse.sender()); + gateway_state = Some(Arc::clone(gw.state())); + channel_names.push("gateway".to_string()); channels.add(Box::new(gw)).await; } @@ -597,6 +608,13 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Channel runtime wired into extension manager for hot-activation"); } + // Wire SSE sender into extension manager for broadcasting status events. + if let Some(ref ext_mgr) = components.extension_manager + && let Some(sender) = sse_sender + { + ext_mgr.set_sse_sender(sender).await; + } + let deps = AgentDeps { store: components.db, llm: components.llm, @@ -639,6 +657,17 @@ async fn main() -> anyhow::Result<()> { } tracing::info!("Agent shutdown complete"); + + // Check if a restart was requested via the gateway API. + if let Some(ref gw_state) = gateway_state + && gw_state + .restart_requested + .load(std::sync::atomic::Ordering::Relaxed) + { + eprintln!("Restarting IronClaw (exit code 75)..."); + std::process::exit(75); + } + Ok(()) } @@ -851,7 +880,6 @@ async fn setup_wasm_channels( }; let wasm_router = Arc::new(WasmChannelRouter::new()); - let mut has_webhook_channels = false; let mut channels: Vec<(String, Box)> = Vec::new(); let mut channel_names: Vec = Vec::new(); @@ -934,8 +962,6 @@ async fn setup_wasm_channels( secret_header, ) .await; - has_webhook_channels = true; - if let Some(secrets) = secrets_store { match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await { Ok(count) => { @@ -964,13 +990,13 @@ async fn setup_wasm_channels( tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err); } - let webhook_routes = if has_webhook_channels { + // Always create webhook routes (even with no channels loaded) so that + // channels hot-added at runtime can receive webhooks without a restart. + let webhook_routes = { Some(create_wasm_channel_router( Arc::clone(&wasm_router), extension_manager.map(Arc::clone), )) - } else { - None }; Some(WasmChannelSetup { diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index f8b8631a..d788a93d 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -201,6 +201,7 @@ async fn start_test_server_with_provider( registry_entries: Vec::new(), cost_guard: None, startup_time: std::time::Instant::now(), + restart_requested: std::sync::atomic::AtomicBool::new(false), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); @@ -689,6 +690,7 @@ async fn test_no_llm_provider_returns_503() { registry_entries: Vec::new(), cost_guard: None, startup_time: std::time::Instant::now(), + restart_requested: std::sync::atomic::AtomicBool::new(false), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index beb01859..7a4eb440 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -59,6 +59,7 @@ async fn start_test_server() -> ( registry_entries: Vec::new(), cost_guard: None, startup_time: std::time::Instant::now(), + restart_requested: std::sync::atomic::AtomicBool::new(false), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();