diff --git a/CLAUDE.md b/CLAUDE.md index 99350058e..e8703c02e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,6 +373,7 @@ Key updates from recent commits (cd9ebcd to current): - **Unit Tests**: All unit tests live in `__tests__/` folders co-located with the code they test. Use Jest with TypeScript support. - **Runtime Platform Differences**: V8 runtime is desktop-only. Mobile platforms use feature detection and graceful degradation. Skills with platform restrictions are filtered during discovery. - **Socket Management**: Rust-native Socket.io client provides persistent connections with automatic reconnection. Use `connect_to_socket` command instead of frontend-only socket connections for reliability. +- **Dual Socket Codebase**: Socket event handling exists in **both** the TypeScript frontend (`src/services/socketService.ts`, `src/utils/tauriSocket.ts`) and the Rust backend (`src-tauri/src/runtime/socket_manager.rs`). **Any new socket event or protocol change must be implemented in both codebases.** The web frontend handles events directly via Socket.io; the Rust backend handles them over raw WebSocket with Engine.IO/Socket.IO framing. Example: `tool:sync` is emitted from both `src/lib/skills/sync.ts` (web mode) and `socket_manager.rs` (Rust mode, on connect + skill lifecycle changes). ## Platform Gotchas diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5076b7e2..02c56f1db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -307,6 +307,8 @@ pub fn run() { // Wire the SkillRegistry into the SocketManager for MCP socket_mgr.set_registry(engine.registry()); + // Wire the SocketManager into the engine for tool:sync + engine.set_socket_manager(socket_mgr.clone()); app.manage(engine.clone()); diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index 43d7ae1c8..a8981023f 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -13,6 +13,7 @@ use crate::runtime::cron_scheduler::CronScheduler; use crate::runtime::manifest::SkillManifest; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; +use crate::runtime::socket_manager::SocketManager; use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; use crate::services::quickjs_libs::storage::IdbStorage; @@ -33,6 +34,8 @@ pub struct RuntimeEngine { resource_dir: RwLock>, /// Tauri app handle for emitting events. app_handle: RwLock>, + /// Socket manager for emitting tool:sync events. + socket_manager: RwLock>>, } impl RuntimeEngine { @@ -53,6 +56,7 @@ impl RuntimeEngine { skills_source_dir: RwLock::new(None), resource_dir: RwLock::new(None), app_handle: RwLock::new(None), + socket_manager: RwLock::new(None), }) } @@ -83,6 +87,19 @@ impl RuntimeEngine { *self.resource_dir.write() = Some(dir); } + /// Set the socket manager for emitting `tool:sync` events. + pub fn set_socket_manager(&self, mgr: Arc) { + *self.socket_manager.write() = Some(mgr); + } + + /// Notify the backend of the current tool state via `tool:sync`. + async fn sync_tools(&self) { + let mgr = { self.socket_manager.read().clone() }; + if let Some(mgr) = mgr { + mgr.sync_tools().await; + } + } + /// Get the skills source directory. fn get_skills_source_dir(&self) -> Result { // 1. Explicitly set source dir (highest priority) @@ -274,6 +291,7 @@ impl RuntimeEngine { match current_status { SkillStatus::Running => { self.emit_status_change(&skill_id_owned); + self.sync_tools().await; return Ok(instance.snapshot()); } SkillStatus::Error => { @@ -321,6 +339,7 @@ impl RuntimeEngine { self.registry.stop_skill(skill_id).await?; self.cron_scheduler.unregister_all_for_skill(skill_id); self.emit_status_change(skill_id); + self.sync_tools().await; Ok(()) } @@ -391,6 +410,8 @@ impl RuntimeEngine { log::warn!("[runtime] Failed to discover skills for auto-start: {e}"); } } + // Sync all tool state to backend after auto-start completes + self.sync_tools().await; } /// Enable a skill. diff --git a/src-tauri/src/runtime/socket_manager.rs b/src-tauri/src/runtime/socket_manager.rs index ba6250161..ebdd80d5a 100644 --- a/src-tauri/src/runtime/socket_manager.rs +++ b/src-tauri/src/runtime/socket_manager.rs @@ -32,6 +32,8 @@ use { // SkillRegistry only available on desktop #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::runtime::skill_registry::SkillRegistry; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::runtime::types::{SkillSnapshot, SkillStatus}; /// Events emitted to the frontend via Tauri. #[allow(dead_code)] @@ -224,6 +226,27 @@ impl SocketManager { Err("Rust Socket.io not available on Android".to_string()) } + // ----------------------------------------------------------------------- + // Tool sync — notify backend of current skill/tool state + // ----------------------------------------------------------------------- + + /// Emit `tool:sync` with the current skill/tool state. + /// Called on socket reconnect and after skill lifecycle changes. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub async fn sync_tools(&self) { + let payload = build_tool_sync_payload(&self.shared); + if let Some(payload) = payload { + if let Err(e) = self.emit("tool:sync", payload).await { + log::debug!("[socket-mgr] tool:sync emit failed: {e}"); + } + } + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + pub async fn sync_tools(&self) { + // No-op on mobile — skill runtime not available + } + // ----------------------------------------------------------------------- // Tauri event helpers // ----------------------------------------------------------------------- @@ -663,6 +686,10 @@ fn handle_sio_event( log::info!("[socket-mgr] Server ready — auth successful"); *shared.status.write() = ConnectionStatus::Connected; SocketManager::emit_state_change(shared); + + // Sync current tool state to backend on connect/reconnect + #[cfg(not(any(target_os = "android", target_os = "ios")))] + sync_tools_via_channel(emit_tx, shared); } "error" => { log::error!("[socket-mgr] Server error event: {}", data); @@ -869,6 +896,70 @@ fn emit_via_channel( } } +// --------------------------------------------------------------------------- +// Tool sync helpers (desktop only — requires SkillRegistry) +// --------------------------------------------------------------------------- + +/// Derive a unified connection status string from a Rust-side SkillSnapshot. +/// Mirrors the frontend's `deriveConnectionStatus()` logic in `src/lib/skills/hooks.ts`. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn derive_connection_status(snap: &SkillSnapshot) -> &'static str { + match snap.status { + SkillStatus::Error => "error", + SkillStatus::Pending | SkillStatus::Stopped => "offline", + SkillStatus::Initializing => "connecting", + SkillStatus::Stopping => "disconnected", + SkillStatus::Running => { + // Check the skill's self-reported connection/auth state + let conn = snap.state.get("connection_status").and_then(|v| v.as_str()); + let auth = snap.state.get("auth_status").and_then(|v| v.as_str()); + + match (conn, auth) { + (Some("error"), _) | (_, Some("error")) => "error", + (Some("connected"), Some("authenticated")) => "connected", + (Some("connecting"), _) | (_, Some("authenticating")) => "connecting", + (Some("connected"), Some("not_authenticated")) => "not_authenticated", + (Some("disconnected"), _) => "disconnected", + // Running with no explicit connection state = connected + _ => "connected", + } + } + } +} + +/// Build the `tool:sync` payload from the current registry state. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn build_tool_sync_payload(shared: &SharedState) -> Option { + let registry = shared.registry.read().clone()?; + let skills = registry.list_skills(); + let tools: Vec = skills + .iter() + .map(|snap| { + let status = derive_connection_status(snap); + let tool_names: Vec = snap.tools.iter().map(|t| t.name.clone()).collect(); + json!({ + "skillId": snap.skill_id, + "name": snap.name, + "status": status, + "tools": tool_names, + }) + }) + .collect(); + Some(json!({ "tools": tools })) +} + +/// Emit `tool:sync` synchronously via the emit channel (for use from event handlers). +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn sync_tools_via_channel(emit_tx: &mpsc::UnboundedSender, shared: &SharedState) { + if let Some(payload) = build_tool_sync_payload(shared) { + emit_via_channel(emit_tx, "tool:sync", payload); + } +} + +// --------------------------------------------------------------------------- +// SIO event parsing +// --------------------------------------------------------------------------- + /// Parse a Socket.IO EVENT payload: `["eventName", data]` or `["eventName", data]`. #[cfg(not(target_os = "android"))] fn parse_sio_event(text: &str) -> Option<(String, serde_json::Value)> { diff --git a/src/lib/skills/hooks.ts b/src/lib/skills/hooks.ts index d79728254..c87e7f6e1 100644 --- a/src/lib/skills/hooks.ts +++ b/src/lib/skills/hooks.ts @@ -13,7 +13,7 @@ import type { * Derive a unified connection status from the skill's lifecycle status * and its self-reported connection/auth state. */ -function deriveConnectionStatus( +export function deriveConnectionStatus( lifecycleStatus: string | undefined, setupComplete: boolean | undefined, skillState: Record | undefined, diff --git a/src/lib/skills/index.ts b/src/lib/skills/index.ts index 95bcb1153..9dbdf336e 100644 --- a/src/lib/skills/index.ts +++ b/src/lib/skills/index.ts @@ -1,6 +1,7 @@ export { SkillTransport } from "./transport"; export { SkillRuntime } from "./runtime"; export { skillManager } from "./manager"; +export { syncToolsToBackend } from "./sync"; export * from "./types"; export * from "./paths"; export * from "./hooks"; diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 23938cb43..4f4ff2ad3 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -8,6 +8,7 @@ import { invoke } from "@tauri-apps/api/core"; import { SkillRuntime } from "./runtime"; +import { syncToolsToBackend } from "./sync"; import type { SkillManifest, SkillStatus, @@ -130,6 +131,7 @@ class SkillManager { const tools = await runtime.listTools(); store.dispatch(setSkillTools({ skillId, tools })); store.dispatch(setSkillStatus({ skillId, status: "ready" })); + syncToolsToBackend(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); store.dispatch(setSkillError({ skillId, error: msg })); @@ -314,6 +316,7 @@ class SkillManager { store.dispatch(setSkillSetupComplete({ skillId, complete: false })); store.dispatch(setSkillOAuthCredential({ skillId, credential: undefined })); store.dispatch(setSkillState({ skillId, state: {} })); + syncToolsToBackend(); } /** @@ -331,6 +334,7 @@ class SkillManager { } this.runtimes.delete(skillId); store.dispatch(setSkillStatus({ skillId, status: "installed" })); + syncToolsToBackend(); } /** @@ -407,6 +411,7 @@ class SkillManager { type: "skills/setSkillState", payload: { skillId, state: newState }, }); + syncToolsToBackend(); return { ok: true }; } diff --git a/src/lib/skills/sync.ts b/src/lib/skills/sync.ts new file mode 100644 index 000000000..408da9299 --- /dev/null +++ b/src/lib/skills/sync.ts @@ -0,0 +1,64 @@ +/** + * Sync tool/skill state to the backend via `tool:sync` socket event. + * + * Called whenever skill connection state changes or the socket reconnects, + * so the backend always has an up-to-date picture of connected tools. + */ + +import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; + +import { socketService } from '../../services/socketService'; +import { store } from '../../store'; +import { emitViaRustSocket } from '../../utils/tauriSocket'; +import { deriveConnectionStatus } from './hooks'; +import type { SkillConnectionStatus } from './types'; + +interface ToolSyncEntry { + skillId: string; + name: string; + status: SkillConnectionStatus; + tools: string[]; +} + +function isTauri(): boolean { + try { + return coreIsTauri(); + } catch { + return false; + } +} + +/** + * Read all skills from Redux, derive their connection status, + * and emit a `tool:sync` event with the full list. + */ +export function syncToolsToBackend(): void { + const state = store.getState(); + const skills = state.skills.skills; + const skillStates = state.skills.skillStates; + + const tools: ToolSyncEntry[] = []; + + for (const [skillId, skill] of Object.entries(skills)) { + const connectionStatus = deriveConnectionStatus( + skill.status, + skill.setupComplete, + skillStates[skillId], + ); + + tools.push({ + skillId, + name: skill.manifest.name, + status: connectionStatus, + tools: (skill.tools ?? []).map(t => t.name), + }); + } + + const payload = { tools }; + + if (isTauri()) { + emitViaRustSocket('tool:sync', payload); + } else { + socketService.emit('tool:sync', payload); + } +} diff --git a/src/services/socketService.ts b/src/services/socketService.ts index bb3a1bfc3..4030d489d 100644 --- a/src/services/socketService.ts +++ b/src/services/socketService.ts @@ -3,7 +3,7 @@ import debug from 'debug'; import { io, Socket } from 'socket.io-client'; import { MCPTool, MCPToolCall, SocketIOMCPTransportImpl } from '../lib/mcp'; -import { skillManager } from '../lib/skills'; +import { skillManager, syncToolsToBackend } from '../lib/skills'; import { store } from '../store'; import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from '../utils/config'; @@ -134,6 +134,7 @@ class SocketService { socketLog('Connected', { socketId, userId: uid }); store.dispatch(setStatusForUser({ userId: uid, status: 'connected' })); store.dispatch(setSocketIdForUser({ userId: uid, socketId })); + syncToolsToBackend(); }); this.socket.on('ready', () => { diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index b78e22795..393296b70 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -16,6 +16,7 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { syncToolsToBackend } from '../lib/skills/sync'; import { store } from '../store'; import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from './config'; @@ -149,6 +150,10 @@ export async function setupTauriSocketListeners(): Promise { const mappedStatus: ReduxStatus = statusMap[status] || 'disconnected'; store.dispatch(setStatusForUser({ userId: uid, status: mappedStatus })); store.dispatch(setSocketIdForUser({ userId: uid, socketId: socket_id ?? null })); + + if (mappedStatus === 'connected') { + syncToolsToBackend(); + } } );