mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
abda94d44f
commit
996c6a8cc9
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user