feat(web): improve WASM channel setup flow (#380)

* feat(web): improve WASM channel setup flow with stepper UI and auto-configure

Streamline the WASM channel setup experience in the web gateway:

- Auto-open configure modal after installing a WASM channel
- Add progress stepper (Installed → Configured → Active) on channel cards
- Replace generic Activate button with state-specific actions (Setup, Reconfigure, Restart)
- Show "Awaiting Pairing" status for Telegram until first user is paired
- Add SSE extension_status events for real-time status updates
- Add gateway restart endpoint (POST /api/gateway/restart) with idempotency guard
- Always mount webhook routes at startup so hot-added channels work without restart
- Add pairing request polling (10s interval) on extensions tab
- Track activation errors per channel with inline error display

Includes review fixes: activation_error priority over active status, stepper
failed state rendering, restart poll timeout, configure modal double-submit
guard, and SSE sender ordering constraint documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: address PR review comments

- Move PairingStore construction outside .map() loop
- Extract createReconfigureButton() helper to reduce duplication

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-02-25 22:27:13 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent abda94d44f
commit 996c6a8cc9
13 changed files with 659 additions and 61 deletions
+35 -9
View File
@@ -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();
+2
View File
@@ -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);
+76 -10
View File
@@ -158,6 +158,8 @@ pub struct GatewayState {
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
/// 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<Arc<GatewayState>>) -> Json<ActionResponse> {
// 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(
+6
View File
@@ -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<SseEvent> {
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))
});
+252 -26
View File
@@ -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 = '<div class="restart-message">'
+ '<div class="restart-spinner"></div>'
+ '<h2>Restarting IronClaw...</h2>'
+ '<p>Waiting for server to come back online</p>'
+ '</div>';
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;
+153
View File
@@ -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);
+26
View File
@@ -193,6 +193,15 @@ pub enum SseEvent {
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
/// 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<String>,
},
}
// --- 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<String>,
/// Human-readable error when activation_status is "failed".
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_error: Option<String>,
}
#[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<String>,
/// Whether the channel was successfully activated after setup.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
/// Whether a gateway restart is needed (activation failed).
#[serde(skip_serializing_if = "Option::is_none")]
pub needs_restart: Option<bool>,
}
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 {
+1
View File
@@ -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),
}
}
}
+70 -10
View File
@@ -52,6 +52,14 @@ struct ChannelRuntimeState {
telegram_owner_id: Option<i64>,
}
/// 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<Arc<dyn crate::db::Database>>,
/// Names of WASM channels that were successfully loaded at startup.
active_channel_names: RwLock<HashSet<String>>,
/// Last activation error for each WASM channel (ephemeral, cleared on success).
activation_errors: RwLock<HashMap<String, String>>,
/// SSE broadcast sender (set post-construction via `set_sse_sender()`).
sse_sender:
RwLock<Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>>,
}
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<crate::channels::web::types::SseEvent>,
) {
*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<String, String>,
) -> Result<String, ExtensionError> {
) -> Result<SetupResult, ExtensionError> {
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,
})
}
}
}
+3
View File
@@ -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<String>,
}
/// Error type for extension operations.
+32 -6
View File
@@ -474,6 +474,11 @@ async fn main() -> anyhow::Result<()> {
// ── Gateway channel ────────────────────────────────────────────────
let mut gateway_url: Option<String> = None;
let mut sse_sender: Option<
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
> = None;
let mut gateway_state: Option<std::sync::Arc<ironclaw::channels::web::server::GatewayState>> =
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<dyn ironclaw::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = 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 {
+2
View File
@@ -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();
+1
View File
@@ -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();