From 1aa7c077d6e1fae3b43793dd73f8ec47f04c7b29 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 11:09:20 +0530 Subject: [PATCH 1/7] Add skill ping/health-check system for connection monitoring Periodic (5-min) onPing() calls verify skill service connections are healthy. Auth errors stop the skill; network errors update connection status while keeping the skill running for retry. Skills without onPing are treated as healthy (backward compatible). Co-Authored-By: Claude Opus 4.6 --- skills | 2 +- src-tauri/src/runtime/qjs_skill_instance.rs | 3 + src/lib/skills/manager.ts | 99 +++++++++++++++++++++ src/lib/skills/runtime.ts | 9 ++ src/lib/skills/types.ts | 10 +++ src/providers/SkillProvider.tsx | 2 + 6 files changed, 124 insertions(+), 1 deletion(-) diff --git a/skills b/skills index 26341b529..ceb8d2485 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 26341b529751b46e9be28bd7c46b1a978b5bbc7d +Subproject commit ceb8d248550b8f296a245743d8d3a08efc365759 diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index bbc6e2973..e57d5c234 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -482,6 +482,9 @@ async fn handle_message( let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await } + "skill/ping" => { + handle_js_call(ctx, "onPing", "{}").await + } "oauth/revoked" => { // Clear credential: set to empty string so it's clearly "disconnected" let clear_code = r#"(function() { diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 23938cb43..fc6304ce7 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -15,6 +15,7 @@ import type { SetupResult, SkillToolDefinition, SkillOptionDefinition, + PingResult, } from "./types"; import { store } from "../../store"; import { @@ -31,6 +32,8 @@ import { class SkillManager { private runtimes = new Map(); + private pingIntervalId: ReturnType | null = null; + static PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes /** * Get skill-specific load parameters (e.g., session data for Telegram) @@ -130,6 +133,10 @@ class SkillManager { const tools = await runtime.listTools(); store.dispatch(setSkillTools({ skillId, tools })); store.dispatch(setSkillStatus({ skillId, status: "ready" })); + // Fire an initial ping (non-blocking) + this.pingSkill(skillId).catch((err) => { + console.warn(`[SkillManager] Initial ping failed for ${skillId}:`, err); + }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); store.dispatch(setSkillError({ skillId, error: msg })); @@ -337,6 +344,7 @@ class SkillManager { * Stop all running skills. */ async stopAll(): Promise { + this.stopPingLoop(); const ids = Array.from(this.runtimes.keys()); await Promise.all(ids.map((id) => this.stopSkill(id))); } @@ -382,6 +390,97 @@ class SkillManager { } } + // ----------------------------------------------------------------------- + // Ping / health-check + // ----------------------------------------------------------------------- + + /** + * Start the periodic ping loop. Call once after skills are started. + */ + startPingLoop(): void { + if (this.pingIntervalId) return; + this.pingIntervalId = setInterval(() => { + this.pingAllSkills().catch((err) => { + console.error("[SkillManager] Ping loop error:", err); + }); + }, SkillManager.PING_INTERVAL_MS); + } + + /** + * Stop the periodic ping loop. + */ + stopPingLoop(): void { + if (this.pingIntervalId) { + clearInterval(this.pingIntervalId); + this.pingIntervalId = null; + } + } + + /** + * Ping all ready skills in parallel and handle results. + */ + private async pingAllSkills(): Promise { + const entries: [string, SkillRuntime][] = []; + for (const [id, runtime] of this.runtimes) { + const status = this.getSkillStatus(id); + if (status === "ready" && runtime.isRunning) { + entries.push([id, runtime]); + } + } + if (entries.length === 0) return; + + await Promise.allSettled( + entries.map(([id]) => this.pingSkill(id)), + ); + } + + /** + * Ping a single skill and interpret the result. + * - null / ok:true → healthy, no-op + * - ok:false, errorType:"auth" → stop skill + set error + * - ok:false, errorType:"network" → update connection_status to error (keep running) + */ + private async pingSkill(skillId: string): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime || !runtime.isRunning) return; + + let result: PingResult | null; + try { + result = await runtime.ping(); + } catch (err) { + console.warn(`[SkillManager] Ping RPC failed for ${skillId}:`, err); + return; + } + + // null means onPing is not implemented → treat as healthy + if (!result || result.ok) return; + + console.warn( + `[SkillManager] Ping failed for ${skillId}: ${result.errorType} — ${result.errorMessage}`, + ); + + if (result.errorType === "auth") { + store.dispatch( + setSkillError({ skillId, error: result.errorMessage ?? "Auth error" }), + ); + await this.stopSkill(skillId); + } else { + // Network error — update state but keep skill running + const currentState = + store.getState().skills.skillStates[skillId] ?? {}; + store.dispatch( + setSkillState({ + skillId, + state: { + ...currentState, + connection_status: "error", + connection_error: result.errorMessage ?? "Connection error", + }, + }), + ); + } + } + // ----------------------------------------------------------------------- // Reverse RPC handling // ----------------------------------------------------------------------- diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index 6fb8aeeaa..35d1c6306 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -15,6 +15,7 @@ import type { SetupResult, SkillToolDefinition, SkillOptionDefinition, + PingResult, } from "./types"; export class SkillRuntime { @@ -143,6 +144,14 @@ export class SkillRuntime { return this.transport.request("tools/call", { name, arguments: args }); } + /** + * Ping the skill to verify its external service connection is healthy. + * Returns null if the skill doesn't implement onPing (backward compatible). + */ + async ping(): Promise { + return this.transport.request("skill/ping"); + } + /** * Trigger periodic tick. */ diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 16f6ab73d..00415be48 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -188,6 +188,16 @@ export interface OAuthCredential { grantedScopes?: string[]; } +// --------------------------------------------------------------------------- +// Ping / Health-Check +// --------------------------------------------------------------------------- + +export interface PingResult { + ok: boolean; + errorType?: "network" | "auth"; + errorMessage?: string; +} + export interface SkillState { manifest: SkillManifest; status: SkillStatus; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index f9c1f5f80..52e9d75c9 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -160,6 +160,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) { // Discover skills from the QuickJS runtime engine const manifests = await discoverSkills(); await registerAndStart(manifests); + skillManager.startPingLoop(); } catch (err) { console.error('[SkillProvider] Failed to discover skills:', err); } @@ -168,6 +169,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) { init(); return () => { + skillManager.stopPingLoop(); skillManager.stopAll().catch(console.error); initRef.current = false; }; From f372d5ce292a3ddf24e95f1c2452834500e33a0f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 11:21:39 +0530 Subject: [PATCH 2/7] Move skill ping/health-check from frontend to Rust backend The frontend ping loop only runs while React is active, but the Rust backend keeps skills running independently. This adds a PingScheduler (modeled after CronScheduler) that pings all running skills every 5 minutes from a background Tokio task and acts on auth/network failures. The redundant frontend ping loop is removed since SkillProvider already listens for the Tauri events the Rust scheduler emits. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 6 + src-tauri/src/runtime/mod.rs | 2 + src-tauri/src/runtime/ping_scheduler.rs | 302 ++++++++++++++++++++++++ src-tauri/src/runtime/qjs_engine.rs | 12 + src/lib/skills/manager.ts | 99 -------- src/providers/SkillProvider.tsx | 2 - 6 files changed, 322 insertions(+), 101 deletions(-) create mode 100644 src-tauri/src/runtime/ping_scheduler.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02c56f1db..508456b5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -318,6 +318,12 @@ pub fn run() { cron.start(); }); + // Start the ping scheduler (health-checks running skills) + let ping = engine.ping_scheduler(); + tauri::async_runtime::spawn(async move { + ping.start(); + }); + // Auto-start skills in background (no delay needed for QuickJS - // lightweight contexts don't have V8's memory reservation issue) let engine_clone = engine.clone(); diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index c4acfb5f1..337d494e8 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -19,6 +19,8 @@ pub mod bridge; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod cron_scheduler; #[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod ping_scheduler; +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod skill_registry; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod qjs_engine; diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs new file mode 100644 index 000000000..ad93036d1 --- /dev/null +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -0,0 +1,302 @@ +//! PingScheduler — background Tokio task that periodically health-checks running skills. +//! +//! Every 5 minutes the scheduler pings all skills whose status is `Running` by +//! sending an RPC `skill/ping` message. The response is interpreted as follows: +//! +//! - `null` / `{ ok: true }` → healthy, no action +//! - `{ ok: false, errorType: "auth" }` → stop the skill and set an error status +//! - `{ ok: false, errorType: "network" }` → update `connection_status` in the +//! skill's published state to `"error"` but keep the skill running +//! +//! Architecture follows the same pattern as `CronScheduler`: a background Tokio +//! task with `tokio::select!` for a tick interval + a stop signal via a watch channel. + +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::RwLock; +use serde::Deserialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::watch; + +use crate::runtime::skill_registry::SkillRegistry; +use crate::runtime::types::{events, SkillMessage, SkillStatus}; + +/// Interval between ping sweeps. +const PING_INTERVAL: Duration = Duration::from_secs(5 * 60); + +/// Per-skill timeout when waiting for a ping reply. +const PING_TIMEOUT: Duration = Duration::from_secs(30); + +/// Deserialized result from a skill's `onPing()` handler. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PingResult { + ok: bool, + #[serde(default)] + error_type: Option, + #[serde(default)] + error_message: Option, +} + +/// Background ping scheduler that health-checks running skills. +pub struct PingScheduler { + /// Reference to the skill registry (set after engine initialisation). + registry: Arc>>>, + /// Tauri app handle for emitting events to the frontend. + app_handle: Arc>>, + /// Watch channel to signal the tick loop to stop. + stop_tx: watch::Sender, +} + +impl PingScheduler { + pub fn new() -> Self { + let (stop_tx, _) = watch::channel(false); + Self { + registry: Arc::new(RwLock::new(None)), + app_handle: Arc::new(RwLock::new(None)), + stop_tx, + } + } + + /// Set the skill registry (called after engine initialisation). + pub fn set_registry(&self, registry: Arc) { + *self.registry.write() = Some(registry); + } + + /// Set the Tauri app handle for emitting events. + pub fn set_app_handle(&self, handle: AppHandle) { + *self.app_handle.write() = Some(handle); + } + + /// Start the background ping loop. Returns the Tokio task handle. + /// + /// Must be called from within a Tokio runtime context. + pub fn start(&self) -> tokio::task::JoinHandle<()> { + let registry = self.registry.clone(); + let app_handle = self.app_handle.clone(); + let mut stop_rx = self.stop_tx.subscribe(); + + tokio::spawn(async move { + log::info!("[ping] Scheduler started ({}s interval)", PING_INTERVAL.as_secs()); + + loop { + tokio::select! { + _ = tokio::time::sleep(PING_INTERVAL) => { + let reg = registry.read().clone(); + let handle = app_handle.read().clone(); + Self::tick(®, &handle).await; + } + _ = stop_rx.changed() => { + log::info!("[ping] Scheduler stopped"); + break; + } + } + } + }) + } + + /// Stop the scheduler's tick loop. + #[allow(dead_code)] + pub fn stop(&self) { + let _ = self.stop_tx.send(true); + } + + /// Ping all running skills concurrently and act on failures. + async fn tick(registry: &Option>, app_handle: &Option) { + let registry = match registry { + Some(r) => r, + None => return, + }; + + // Collect running skill IDs + let running: Vec = registry + .list_skills() + .into_iter() + .filter(|s| s.status == SkillStatus::Running) + .map(|s| s.skill_id) + .collect(); + + if running.is_empty() { + return; + } + + log::debug!("[ping] Pinging {} running skill(s)", running.len()); + + // Ping all skills concurrently + let futures: Vec<_> = running + .into_iter() + .map(|skill_id| { + let registry = Arc::clone(registry); + let app_handle = app_handle.clone(); + async move { + Self::ping_skill(&skill_id, ®istry, &app_handle).await; + } + }) + .collect(); + + futures::future::join_all(futures).await; + } + + /// Ping a single skill and handle the result. + async fn ping_skill( + skill_id: &str, + registry: &Arc, + app_handle: &Option, + ) { + log::debug!("[ping] Pinging skill '{}'", skill_id); + + // Send the RPC message + let (tx, rx) = tokio::sync::oneshot::channel(); + if let Err(e) = registry.send_message( + skill_id, + SkillMessage::Rpc { + method: "skill/ping".to_string(), + params: serde_json::json!({}), + reply: tx, + }, + ) { + log::warn!("[ping] Failed to send ping to '{}': {}", skill_id, e); + return; + } + + // Wait for the reply with a timeout + let reply = match tokio::time::timeout(PING_TIMEOUT, rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + log::warn!("[ping] Ping channel closed for '{}'", skill_id); + return; + } + Err(_) => { + log::warn!("[ping] Ping timed out for '{}'", skill_id); + return; + } + }; + + // Parse the result + let value = match reply { + Ok(v) => v, + Err(e) => { + log::warn!("[ping] Ping RPC error for '{}': {}", skill_id, e); + return; + } + }; + + // null / { ok: true } → healthy + if value.is_null() { + return; + } + + let ping_result: PingResult = match serde_json::from_value(value) { + Ok(r) => r, + Err(e) => { + log::debug!( + "[ping] Could not parse ping result for '{}': {} — treating as healthy", + skill_id, + e + ); + return; + } + }; + + if ping_result.ok { + return; + } + + // ----- Handle failure ----- + let error_type = ping_result.error_type.as_deref().unwrap_or("unknown"); + let error_message = ping_result + .error_message + .as_deref() + .unwrap_or("Ping failed"); + + log::warn!( + "[ping] Skill '{}' ping failed: type={}, message={}", + skill_id, + error_type, + error_message + ); + + match error_type { + "auth" => { + // Auth failure: stop the skill and emit error event + log::info!("[ping] Stopping skill '{}' due to auth failure", skill_id); + + if let Err(e) = registry.stop_skill(skill_id).await { + log::error!("[ping] Failed to stop skill '{}': {}", skill_id, e); + } + + if let Some(handle) = app_handle { + let payload = serde_json::json!({ + "skill_id": skill_id, + "status": "error", + "error": error_message, + }); + let _ = handle.emit(events::SKILL_STATUS_CHANGED, &payload); + } + } + _ => { + // Network or other error: update published state, keep running + if let Some(snap) = registry.get_skill(skill_id) { + // We need to update the skill's published_state through the + // registry. The SkillState is behind an Arc>, which + // we can reach via the snapshot's backing state. However, the + // registry only exposes snapshots (copies). We use an RPC + // message to let the skill instance update its own state. + // + // A simpler approach: directly update published_state via the + // SkillState Arc that the registry entry holds. Since + // SkillRegistry doesn't expose the Arc directly, we send a + // state/set RPC to the skill, which is the same mechanism + // the frontend uses. + let _ = snap; // used for logging context + + // Send a state update via RPC (skills handle "state/set" + // in their reverse-RPC handler, but here we update the + // published_state directly through the skill message loop). + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = registry.send_message( + skill_id, + SkillMessage::Rpc { + method: "state/set".to_string(), + params: serde_json::json!({ + "partial": { + "connection_status": "error", + "connection_error": error_message, + } + }), + reply: tx, + }, + ); + // Don't block on the reply — fire-and-forget + let _ = tokio::time::timeout(Duration::from_secs(5), rx).await; + } + + // Also emit a state-changed event so the frontend picks it up + if let Some(handle) = app_handle { + let mut state_map = std::collections::HashMap::new(); + state_map.insert( + "connection_status".to_string(), + serde_json::Value::String("error".to_string()), + ); + state_map.insert( + "connection_error".to_string(), + serde_json::Value::String(error_message.to_string()), + ); + + let payload = serde_json::json!({ + "skillId": skill_id, + "state": state_map, + }); + let _ = handle.emit("skill-state-changed", &payload); + } + } + } + } +} + +impl Default for PingScheduler { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index a8981023f..15ec60382 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -11,6 +11,7 @@ use tauri::{AppHandle, Emitter}; use crate::runtime::cron_scheduler::CronScheduler; use crate::runtime::manifest::SkillManifest; +use crate::runtime::ping_scheduler::PingScheduler; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::socket_manager::SocketManager; @@ -24,6 +25,8 @@ pub struct RuntimeEngine { registry: Arc, /// Global cron scheduler for timed skill triggers. cron_scheduler: Arc, + /// Background ping scheduler for skill health checks. + ping_scheduler: Arc, /// Persistent user enable/disable preferences for skills. preferences: Arc, /// Base data directory for skills (platform-aware). @@ -44,6 +47,8 @@ impl RuntimeEngine { let registry = Arc::new(SkillRegistry::new()); let cron_scheduler = Arc::new(CronScheduler::new()); cron_scheduler.set_registry(Arc::clone(®istry)); + let ping_scheduler = Arc::new(PingScheduler::new()); + ping_scheduler.set_registry(Arc::clone(®istry)); let preferences = Arc::new(PreferencesStore::new(&skills_data_dir)); log::info!("[runtime] QuickJS RuntimeEngine created"); @@ -51,6 +56,7 @@ impl RuntimeEngine { Ok(Self { registry, cron_scheduler, + ping_scheduler, preferences, skills_data_dir, skills_source_dir: RwLock::new(None), @@ -70,8 +76,14 @@ impl RuntimeEngine { Arc::clone(&self.cron_scheduler) } + /// Get a clone of the ping scheduler Arc. + pub fn ping_scheduler(&self) -> Arc { + Arc::clone(&self.ping_scheduler) + } + /// Set the Tauri app handle for emitting events to the frontend. pub fn set_app_handle(&self, handle: AppHandle) { + self.ping_scheduler.set_app_handle(handle.clone()); *self.app_handle.write() = Some(handle); } diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 3357dd10e..4f4ff2ad3 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -16,7 +16,6 @@ import type { SetupResult, SkillToolDefinition, SkillOptionDefinition, - PingResult, } from "./types"; import { store } from "../../store"; import { @@ -33,8 +32,6 @@ import { class SkillManager { private runtimes = new Map(); - private pingIntervalId: ReturnType | null = null; - static PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes /** * Get skill-specific load parameters (e.g., session data for Telegram) @@ -134,10 +131,6 @@ class SkillManager { const tools = await runtime.listTools(); store.dispatch(setSkillTools({ skillId, tools })); store.dispatch(setSkillStatus({ skillId, status: "ready" })); - // Fire an initial ping (non-blocking) - this.pingSkill(skillId).catch((err) => { - console.warn(`[SkillManager] Initial ping failed for ${skillId}:`, err); - }); syncToolsToBackend(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -348,7 +341,6 @@ class SkillManager { * Stop all running skills. */ async stopAll(): Promise { - this.stopPingLoop(); const ids = Array.from(this.runtimes.keys()); await Promise.all(ids.map((id) => this.stopSkill(id))); } @@ -394,97 +386,6 @@ class SkillManager { } } - // ----------------------------------------------------------------------- - // Ping / health-check - // ----------------------------------------------------------------------- - - /** - * Start the periodic ping loop. Call once after skills are started. - */ - startPingLoop(): void { - if (this.pingIntervalId) return; - this.pingIntervalId = setInterval(() => { - this.pingAllSkills().catch((err) => { - console.error("[SkillManager] Ping loop error:", err); - }); - }, SkillManager.PING_INTERVAL_MS); - } - - /** - * Stop the periodic ping loop. - */ - stopPingLoop(): void { - if (this.pingIntervalId) { - clearInterval(this.pingIntervalId); - this.pingIntervalId = null; - } - } - - /** - * Ping all ready skills in parallel and handle results. - */ - private async pingAllSkills(): Promise { - const entries: [string, SkillRuntime][] = []; - for (const [id, runtime] of this.runtimes) { - const status = this.getSkillStatus(id); - if (status === "ready" && runtime.isRunning) { - entries.push([id, runtime]); - } - } - if (entries.length === 0) return; - - await Promise.allSettled( - entries.map(([id]) => this.pingSkill(id)), - ); - } - - /** - * Ping a single skill and interpret the result. - * - null / ok:true → healthy, no-op - * - ok:false, errorType:"auth" → stop skill + set error - * - ok:false, errorType:"network" → update connection_status to error (keep running) - */ - private async pingSkill(skillId: string): Promise { - const runtime = this.runtimes.get(skillId); - if (!runtime || !runtime.isRunning) return; - - let result: PingResult | null; - try { - result = await runtime.ping(); - } catch (err) { - console.warn(`[SkillManager] Ping RPC failed for ${skillId}:`, err); - return; - } - - // null means onPing is not implemented → treat as healthy - if (!result || result.ok) return; - - console.warn( - `[SkillManager] Ping failed for ${skillId}: ${result.errorType} — ${result.errorMessage}`, - ); - - if (result.errorType === "auth") { - store.dispatch( - setSkillError({ skillId, error: result.errorMessage ?? "Auth error" }), - ); - await this.stopSkill(skillId); - } else { - // Network error — update state but keep skill running - const currentState = - store.getState().skills.skillStates[skillId] ?? {}; - store.dispatch( - setSkillState({ - skillId, - state: { - ...currentState, - connection_status: "error", - connection_error: result.errorMessage ?? "Connection error", - }, - }), - ); - } - } - // ----------------------------------------------------------------------- // Reverse RPC handling // ----------------------------------------------------------------------- diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 52e9d75c9..f9c1f5f80 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -160,7 +160,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) { // Discover skills from the QuickJS runtime engine const manifests = await discoverSkills(); await registerAndStart(manifests); - skillManager.startPingLoop(); } catch (err) { console.error('[SkillProvider] Failed to discover skills:', err); } @@ -169,7 +168,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) { init(); return () => { - skillManager.stopPingLoop(); skillManager.stopAll().catch(console.error); initRef.current = false; }; From d191deb0bf2d709e75aeb0135e569ecac2a0558b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 11:22:28 +0530 Subject: [PATCH 3/7] Refactor package-and-publish workflow for consistency Updated formatting in the package-and-publish.yml file to use consistent quotation styles for strings. Removed unnecessary branch entry from the pull_request section to streamline the workflow configuration. --- .github/workflows/package-and-publish.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index c68f5692a..fa2b0a5c0 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -14,7 +14,7 @@ on: branches: - develop workflow_run: - workflows: ['Version Bump'] + workflows: ["Version Bump"] types: - completed branches: @@ -22,7 +22,6 @@ on: pull_request: branches: - main - - develop env: IS_PR: ${{ github.event_name == 'pull_request' }} @@ -170,15 +169,15 @@ jobs: fail-fast: false matrix: settings: - - platform: 'macos-latest' - args: '--target aarch64-apple-darwin' - target: 'aarch64-apple-darwin' - - platform: 'macos-latest' - args: '--target x86_64-apple-darwin' - target: 'x86_64-apple-darwin' - - platform: 'ubuntu-22.04' - args: '' - target: '' + - platform: "macos-latest" + args: "--target aarch64-apple-darwin" + target: "aarch64-apple-darwin" + - platform: "macos-latest" + args: "--target x86_64-apple-darwin" + target: "x86_64-apple-darwin" + - platform: "ubuntu-22.04" + args: "" + target: "" runs-on: ${{ matrix.settings.platform }} steps: - name: Checkout @@ -198,7 +197,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: 'yarn' + cache: "yarn" - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -321,7 +320,7 @@ jobs: # macOS 10.15+ required for std::filesystem used by llama.cpp MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} with: - args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}' + args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}" includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }} includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }} # TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks From 81c61fac1291223f653c964bb0279f94904eeecc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 15:30:57 +0530 Subject: [PATCH 4/7] Update logging configuration and adjust ping interval for improved performance - Enhanced logging setup in `lib.rs` to allow customizable verbosity and color-coded output based on log levels and tags. - Reduced ping interval in `ping_scheduler.rs` from 5 minutes to 1 minute for more frequent health checks on skills. - Added functionality to suppress TDLib's native C++ logs based on environment variable settings in `manager.rs`. --- skills | 2 +- src-tauri/src/lib.rs | 90 ++++++++++++++++++++++++- src-tauri/src/runtime/ping_scheduler.rs | 2 +- src-tauri/src/services/tdlib/manager.rs | 13 ++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/skills b/skills index ceb8d2485..db1865622 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit ceb8d248550b8f296a245743d8d3a08efc365759 +Subproject commit db1865622e1705309e6550da2df4d2d255bf2cb0 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 508456b5f..17b547f0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -219,7 +219,95 @@ pub fn run() { } #[cfg(not(target_os = "android"))] { - let _ = env_logger::try_init(); + use env_logger::fmt::style::{AnsiColor, Style}; + use std::io::Write; + + let default_filter = std::env::var("RUST_LOG") + .unwrap_or_else(|_| "info,tungstenite=warn,tokio_tungstenite=warn,reqwest=warn,rusqlite=warn,hyper=warn,h2=warn".to_string()); + + let write_style = std::env::var("RUST_LOG_STYLE") + .map(|v| match v.as_str() { + "never" => env_logger::fmt::WriteStyle::Never, + _ => env_logger::fmt::WriteStyle::Always, + }) + .unwrap_or(env_logger::fmt::WriteStyle::Always); + + let _ = env_logger::Builder::new() + .parse_filters(&default_filter) + .write_style(write_style) + .format(|buf, record| { + let timestamp = buf.timestamp_millis() + .to_string(); + // Strip the date prefix, keep only HH:MM:SS.mmm + let time_only = timestamp.split('T') + .nth(1) + .and_then(|t| t.strip_suffix('Z')) + .unwrap_or(×tamp); + let level = record.level(); + + // Level colors + let level_style = match level { + log::Level::Error => Style::new().fg_color(Some(AnsiColor::Red.into())).bold(), + log::Level::Warn => Style::new().fg_color(Some(AnsiColor::Yellow.into())).bold(), + log::Level::Info => Style::new().fg_color(Some(AnsiColor::Green.into())), + log::Level::Debug => Style::new().fg_color(Some(AnsiColor::BrightBlack.into())), + log::Level::Trace => Style::new().fg_color(Some(AnsiColor::BrightBlack.into())), + }; + + let msg = format!("{}", record.args()); + + // Extract tag from message (e.g. "[tdlib]", "[socket-mgr]", "[skill:x]") + let (tag, rest) = if msg.starts_with('[') { + if let Some(end) = msg.find(']') { + let tag = &msg[..=end]; + let rest = msg[end + 1..].trim_start(); + (Some(tag.to_string()), rest.to_string()) + } else { + (None, msg) + } + } else { + (None, msg) + }; + + // Tag-based colors + let tag_style = if let Some(ref t) = tag { + let t_lower = t.to_lowercase(); + if t_lower.contains("tdlib") { + Style::new().fg_color(Some(AnsiColor::Magenta.into())).bold() + } else if t_lower.contains("socket") { + Style::new().fg_color(Some(AnsiColor::Blue.into())).bold() + } else if t_lower.contains("runtime") { + Style::new().fg_color(Some(AnsiColor::Cyan.into())).bold() + } else if t_lower.contains("skill") { + Style::new().fg_color(Some(AnsiColor::Green.into())).bold() + } else if t_lower.contains("ping") || t_lower.contains("cron") { + Style::new().fg_color(Some(AnsiColor::Yellow.into())).bold() + } else if t_lower.contains("app") { + Style::new().fg_color(Some(AnsiColor::White.into())).bold() + } else if t_lower.contains("ai") { + Style::new().fg_color(Some(AnsiColor::BrightMagenta.into())).bold() + } else { + Style::new().fg_color(Some(AnsiColor::BrightBlack.into())) + } + } else { + Style::new() + }; + + let dim = Style::new().fg_color(Some(AnsiColor::BrightBlack.into())); + + if let Some(ref t) = tag { + writeln!( + buf, + "{dim}{time_only}{dim:#} {level_style}{level:<5}{level_style:#} {tag_style}{t}{tag_style:#} {rest}" + ) + } else { + writeln!( + buf, + "{dim}{time_only}{dim:#} {level_style}{level:<5}{level_style:#} {rest}" + ) + } + }) + .try_init(); } let mut builder = tauri::Builder::default() diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs index ad93036d1..a6fa681ca 100644 --- a/src-tauri/src/runtime/ping_scheduler.rs +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -23,7 +23,7 @@ use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::types::{events, SkillMessage, SkillStatus}; /// Interval between ping sweeps. -const PING_INTERVAL: Duration = Duration::from_secs(5 * 60); +const PING_INTERVAL: Duration = Duration::from_secs(60); /// Per-skill timeout when waiting for a ping reply. const PING_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs index 434391158..1edd72614 100644 --- a/src-tauri/src/services/tdlib/manager.rs +++ b/src-tauri/src/services/tdlib/manager.rs @@ -155,6 +155,19 @@ impl TdLibManager { self.state.is_active.store(true, Ordering::SeqCst); log::info!("[tdlib] Client created with ID: {}", client_id); + // Suppress TDLib's native C++ logs (Client.cpp, etc.) by default. + // Level 0 = fatal only. Override with TDLIB_LOG_LEVEL env var (0-5). + let tdlib_verbosity: i32 = std::env::var("TDLIB_LOG_LEVEL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let cid_for_log = client_id; + tauri::async_runtime::spawn(async move { + if let Err(e) = tdlib_rs::functions::set_log_verbosity_level(tdlib_verbosity, cid_for_log).await { + log::warn!("[tdlib] Failed to set log verbosity: {:?}", e); + } + }); + Ok(client_id) } From a7e208a7cbbcac8f49169f691b5cbeb0e92fc2e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 16:22:20 +0530 Subject: [PATCH 5/7] Update subproject commit reference in skills directory and remove logging from get_session_token function for improved security --- skills | 2 +- src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/skills b/skills index db1865622..1aa59d848 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit db1865622e1705309e6550da2df4d2d255bf2cb0 +Subproject commit 1aa59d8483918e467eb416270c4e1c8f6ef73fe2 diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs index 9d6c17b7f..1cd669920 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_core.rs @@ -83,7 +83,6 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc String { let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default(); - log::info!("[js] get_session_token: {}", token); return token; }))?; From 95b7e2acce1a397ac1b501daa2b32015c8209f32 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 16:24:17 +0530 Subject: [PATCH 6/7] Enhance error handling in skill message processing - Added a new `Error` variant to the `SkillMessage` enum to notify skills of errors from async operations, including error type, message, source, and recoverability. - Updated the `handle_message` function to handle `SkillMessage::Error`, invoking the `onError` JavaScript handler and logging any failures in the handler execution. - Updated subproject commit reference in the skills directory. --- skills | 2 +- src-tauri/src/runtime/qjs_skill_instance.rs | 11 +++++++++++ src-tauri/src/runtime/types.rs | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/skills b/skills index 1aa59d848..8030aef02 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 1aa59d8483918e467eb416270c4e1c8f6ef73fe2 +Subproject commit 8030aef027b7227b2fd63bf1e31bdf9d8e806383 diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index e57d5c234..795752dcd 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -459,6 +459,17 @@ async fn handle_message( let result = handle_js_void_call(ctx, "onTick", "{}").await; let _ = reply.send(result); } + SkillMessage::Error { error_type, message, source, recoverable } => { + let args = serde_json::json!({ + "type": error_type, + "message": message, + "source": source, + "recoverable": recoverable, + }); + if let Err(e) = handle_js_void_call(ctx, "onError", &args.to_string()).await { + log::warn!("[skill:{}] onError() handler failed: {e}", skill_id); + } + } SkillMessage::Rpc { method, params, reply } => { let result = match method.as_str() { "oauth/complete" => { diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 2abe6945c..7616e6065 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -86,6 +86,13 @@ pub enum SkillMessage { Tick { reply: tokio::sync::oneshot::Sender>, }, + /// Notify the skill of an error from an async operation. + Error { + error_type: String, + message: String, + source: Option, + recoverable: bool, + }, /// Generic JSON-RPC call (for methods not covered by specific variants). Rpc { method: String, From fa41d90c2d14fdb69dea2329752ef75e087e51f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 16:41:29 +0530 Subject: [PATCH 7/7] Update subproject commit reference and enhance GitHub workflows - Updated the subproject commit reference in the skills directory. - Modified the build workflow to enable submodule fetching. - Standardized quotation styles in the package-and-publish workflow for consistency. - Refactored the `setSkillOAuthCredential` function for improved readability. - Added an attribute to the `Error` variant in the `SkillMessage` enum to suppress warnings for unused code. --- .github/workflows/build.yml | 1 + .github/workflows/package-and-publish.yml | 24 +++++++++++------------ skills | 2 +- src-tauri/src/runtime/types.rs | 1 + src/store/skillsSlice.ts | 5 ++++- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20b74bb96..e782a7e3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 1 + submodules: true - name: Setup Node.js 24.x uses: actions/setup-node@v4 diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index fa2b0a5c0..c10f56289 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -14,7 +14,7 @@ on: branches: - develop workflow_run: - workflows: ["Version Bump"] + workflows: ['Version Bump'] types: - completed branches: @@ -169,15 +169,15 @@ jobs: fail-fast: false matrix: settings: - - platform: "macos-latest" - args: "--target aarch64-apple-darwin" - target: "aarch64-apple-darwin" - - platform: "macos-latest" - args: "--target x86_64-apple-darwin" - target: "x86_64-apple-darwin" - - platform: "ubuntu-22.04" - args: "" - target: "" + - platform: 'macos-latest' + args: '--target aarch64-apple-darwin' + target: 'aarch64-apple-darwin' + - platform: 'macos-latest' + args: '--target x86_64-apple-darwin' + target: 'x86_64-apple-darwin' + - platform: 'ubuntu-22.04' + args: '' + target: '' runs-on: ${{ matrix.settings.platform }} steps: - name: Checkout @@ -197,7 +197,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: "yarn" + cache: 'yarn' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -320,7 +320,7 @@ jobs: # macOS 10.15+ required for std::filesystem used by llama.cpp MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} with: - args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}" + args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}' includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }} includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }} # TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks diff --git a/skills b/skills index 8030aef02..2dbb87011 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 8030aef027b7227b2fd63bf1e31bdf9d8e806383 +Subproject commit 2dbb87011a097086fa82f79d6d559a315b0b3c19 diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 7616e6065..3beb3d646 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -87,6 +87,7 @@ pub enum SkillMessage { reply: tokio::sync::oneshot::Sender>, }, /// Notify the skill of an error from an async operation. + #[allow(dead_code)] Error { error_type: String, message: String, diff --git a/src/store/skillsSlice.ts b/src/store/skillsSlice.ts index b1d7f1f8a..fcf3b2c7a 100644 --- a/src/store/skillsSlice.ts +++ b/src/store/skillsSlice.ts @@ -58,7 +58,10 @@ const skillsSlice = createSlice({ } }, - setSkillOAuthCredential(state, action: PayloadAction<{ skillId: string; credential: OAuthCredential | undefined }>) { + setSkillOAuthCredential( + state, + action: PayloadAction<{ skillId: string; credential: OAuthCredential | undefined }> + ) { const { skillId, credential } = action.payload; if (state.skills[skillId]) { state.skills[skillId].oauthCredential = credential;