From 5b7fea853308009d41133d7b183edf001fa8c5e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 9 Feb 2026 19:56:32 +0530 Subject: [PATCH 01/11] Refactor BillingPanel to use updated user usage structure - Changed the usage data source in BillingPanel from activeTeam to user, aligning with the new IUserUsage interface. - Updated the display of token usage percentage and progress bar to reflect the new usage metrics (spentThisCycleUsd and cycleBudgetUsd). - Cleaned up commented-out code and improved layout for better readability and user experience. --- .../settings/panels/BillingPanel.tsx | 115 +++++++++--------- src/types/api.ts | 12 +- 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index 58b1da445..6ec781155 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -31,7 +31,7 @@ const BillingPanel = () => { const currentTier: PlanTier = activeTeam?.team.subscription?.plan ?? 'FREE'; const hasActive = activeTeam?.team.subscription?.hasActiveSubscription ?? false; const planExpiry = activeTeam?.team.subscription?.planExpiry; - const usage = activeTeam?.team.usage; + const usage = user?.usage; // Local state const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); @@ -135,69 +135,66 @@ const BillingPanel = () => { onBack={navigateBack} /> + {/*
*/}
- {/* ── Current plan banner ──────────────────────────────── */} -
-
-

Your Current Plan {currentTier}

- {usage && ( - - {Math.round( - ((usage.dailyTokenLimit - usage.remainingTokens) / usage.dailyTokenLimit) * 100 +
+
+
+

+ Your Current Plan {currentTier} +

+ {usage && ( + + {Math.round((usage.spentThisCycleUsd / usage.cycleBudgetUsd) * 100)}% used + + )} +
+ + {hasActive && ( +
+ {planExpiry && ( +

+ Renews{' '} + {new Date(planExpiry).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +

)} - % used - + +
+ )} + {/* Renewal date (for non-active subscriptions) */} + {!hasActive && planExpiry && ( +

+ Renews{' '} + {new Date(planExpiry).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +

+ )} + {usage && ( +
+
+
)}
- - {hasActive && ( -
- {planExpiry && ( -

- Renews{' '} - {new Date(planExpiry).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -

- )} - -
- )} - - {/* Renewal date (for non-active subscriptions) */} - {!hasActive && planExpiry && ( -

- Renews{' '} - {new Date(planExpiry).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -

- )} - - {/* Token usage progress bar */} - {usage && ( -
-
-
- )}
{/* ── Interval toggle ──────────────────────────────────── */} diff --git a/src/types/api.ts b/src/types/api.ts index 2fc00557c..ecb28d249 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -21,11 +21,11 @@ export interface UserSubscription { stripeCustomerId?: string; } -export interface UserUsage { - dailyTokenLimit: number; - remainingTokens: number; - activeSessionCount: number; - lastTokenResetAt?: string; +export interface IUserUsage { + cycleBudgetUsd: number; + spentThisCycleUsd: number; + spentTodayUsd: number; + cycleStartDate: Date; } export interface UserReferral { @@ -54,7 +54,6 @@ export interface User { magicWord: string; referral: UserReferral; subscription: UserSubscription; - usage: UserUsage; role: 'admin' | 'team' | 'user'; settings: UserSettings; autoDeleteTelegramMessagesAfterDays: number; @@ -62,6 +61,7 @@ export interface User { firstName?: string; lastName?: string; username?: string; + usage: IUserUsage; languageCode?: string; waitlist?: string; activeTeamId: string; From 1aa7c077d6e1fae3b43793dd73f8ec47f04c7b29 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 11:09:20 +0530 Subject: [PATCH 02/11] 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 03/11] 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 04/11] 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 fb542e1320acb36c4b6fa84f5680e93548131490 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:57:13 +0530 Subject: [PATCH 05/11] Feat/docs (#89) * Refactor BillingPanel to use updated user usage structure - Changed the usage data source in BillingPanel from activeTeam to user, aligning with the new IUserUsage interface. - Updated the display of token usage percentage and progress bar to reflect the new usage metrics (spentThisCycleUsd and cycleBudgetUsd). - Cleaned up commented-out code and improved layout for better readability and user experience. * Add architecture documentation for AlphaHuman platform - Introduced a comprehensive `ARCHITECTURE.md` file detailing the architecture of AlphaHuman, an AI-powered assistant for crypto communities. - Documented platform reach, high-level architecture, performance metrics, real-time socket infrastructure, and skills runtime engine. - Highlighted the advantages of using Tauri and Rust over Electron for performance and security. - Included diagrams to illustrate the architecture and socket communication flow. Updated subproject commit reference in the skills directory. --- ARCHITECTURE.md | 335 ++++++++++++++++++++++++++++++++++++++++++++++++ skills | 2 +- 2 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..ad2680baf --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,335 @@ +# AlphaHuman Architecture + +**AI-powered super assistant for crypto communities, built on Rust.** + +AlphaHuman is a cross-platform communication and automation platform purpose-built for the cryptocurrency ecosystem. A single React + Rust codebase compiles to native apps across six platforms — Windows, macOS, Linux, Android, iOS, and Web — with a sandboxed JavaScript skills engine, persistent Rust-native WebSocket infrastructure, and an AI tool protocol that lets language models invoke any connected service in real time. + +--- + +## Platform Reach + +One codebase, six platforms, architecture-aware distribution: + +``` + AlphaHuman + | + +---------------+---------------+ + | | | + Desktop Mobile Web + / | \ / \ | + Windows macOS Linux Android iOS Browser + x64 x64 x64 ARM ARM64 Any + ARM64 ARM64 ARM64 +``` + +Tauri v2 compiles the Rust core into native binaries per platform, embedding the React frontend as a lightweight WebView. Desktop builds produce `.dmg`, `.msi`, `.AppImage`, and `.deb` installers. Mobile builds produce `.apk` and `.ipa` packages. The web target runs the React frontend directly with a JavaScript Socket.io client replacing the Rust networking layer. + +--- + +## High-Level Architecture + +``` ++------------------------------------------------------------------+ +| React Frontend | +| Redux Toolkit | Socket.io Client | MCP Transport | UI | ++------------------------------------------------------------------+ + | Tauri IPC Bridge | ++------------------------------------------------------------------+ +| Rust Core Engine | +| | +| +------------------+ +------------------+ +-----------------+ | +| | QuickJS Skills | | Socket Manager | | AI Encryption | | +| | Runtime Engine | | (Persistent WS) | | & Memory Store | | +| +------------------+ +------------------+ +-----------------+ | +| | +| +------------------+ +------------------+ +-----------------+ | +| | Skill Registry | | Cron Scheduler | | Session & Auth | | +| | & Bridge APIs | | (5s tick loop) | | Management | | +| +------------------+ +------------------+ +-----------------+ | +| | +| +------------------+ +------------------+ +-----------------+ | +| | TDLib Telegram | | SQLite Storage | | OS Keychain | | +| | Client (Desktop)| | (rusqlite) | | Integration | | +| +------------------+ +------------------+ +-----------------+ | ++------------------------------------------------------------------+ + | + +-----------+-----------+ + | | + Backend Services External APIs + (Socket.io Server) (Telegram, etc.) +``` + +The frontend communicates with the Rust core through Tauri's IPC bridge — 47+ registered commands covering auth, socket management, AI encryption, skill lifecycle, and platform operations. The Rust core owns all persistent connections, cryptographic operations, and sandboxed skill execution. + +--- + +## Rust-Powered Performance + +AlphaHuman chose Tauri + Rust over Electron for fundamental performance and security reasons: + +| Metric | AlphaHuman (Tauri + Rust) | Typical Electron App | +| ------------------------- | ------------------------------ | ---------------------------- | +| Binary size | ~30 MB | ~150 MB+ | +| Memory per skill context | ~1-2 MB (QuickJS) | ~150 MB+ (Chromium renderer) | +| Cold startup | Sub-500ms | 2-5 seconds | +| Garbage collection pauses | None (Rust ownership model) | V8 GC pauses | +| Memory safety | Compile-time guaranteed | Runtime exceptions | +| TLS implementation | rustls (no OpenSSL dependency) | Chromium's BoringSSL | + +**Why this matters for a crypto platform**: Traders and analysts run AlphaHuman alongside resource-intensive tools — charting software, multiple browser tabs, trading terminals. A 30 MB footprint with sub-500ms startup means the app feels native and stays out of the way. Zero GC pauses means real-time price feeds and alerts are never delayed by memory management. + +The **Tokio async runtime** drives all I/O — WebSocket connections, HTTP requests, file operations, and inter-skill communication — as non-blocking tasks on a thread pool. Thousands of concurrent operations (skill executions, cron jobs, socket events) share a small fixed set of OS threads. + +--- + +## Real-Time Socket Infrastructure + +AlphaHuman implements a **dual-socket architecture**: a Rust-native WebSocket client on desktop and a JavaScript Socket.io client on web. The Rust implementation survives app backgrounding, operates independently of the WebView, and handles TLS via rustls. + +``` +Desktop Mode: Web Mode: + ++-------------+ +-------------+ +| React UI | | React UI | ++------+------+ +------+------+ + | Tauri IPC | Direct ++------+------+ +------+------+ +| Rust Socket | | JS Socket | +| Manager | | .io Client | ++------+------+ +------+------+ + | tokio-tungstenite | Socket.io + | + rustls TLS | (websocket/polling) ++------+------+ +------+------+ +| Backend | | Backend | ++-------------+ +-------------+ +``` + +**Rust Socket Manager** implements Engine.IO v4 + Socket.IO v4 framing over raw WebSocket: + +- **Handshake**: WebSocket connect, Engine.IO OPEN (extracts `sid`, `pingInterval`, `pingTimeout`), Socket.IO CONNECT with JWT auth, CONNECT ACK +- **Keep-alive**: Responds to Engine.IO PING with PONG; timeout threshold = `pingInterval + pingTimeout + 5s` (default: 50 seconds) +- **Reconnection**: Exponential backoff from 1 second to 30 seconds max. Resets to 1s after a successful connection is lost; keeps growing if connection was never established +- **CORS bypass**: The Rust `reqwest` HTTP client makes external API calls directly — no browser CORS restrictions apply + +The socket connection is **shared across all skills**. When events arrive, the socket manager routes them to the appropriate skill via async message channels. This eliminates per-skill connection overhead entirely. + +**`tool:sync` protocol**: On every socket connect and skill lifecycle change, the client emits a `tool:sync` event containing the full list of available tools with their connection status. This keeps the backend AI system aware of all capabilities in real time. + +--- + +## Skills Runtime Engine + +AlphaHuman's defining capability is its **sandboxed JavaScript execution engine** running inside the Rust process. Skills are lightweight automation scripts that extend the platform with custom tools, integrations, and scheduled tasks. + +``` ++---------------------------------------------------------------+ +| RuntimeEngine | +| | +| +-------------------+ +-------------------+ | +| | SkillRegistry | | CronScheduler | | +| | (HashMap + MPSC) | | (5s tick loop) | | +| +--------+----------+ +--------+----------+ | +| | | | +| +--------v----------+ +--------v----------+ +----------+ | +| | QuickJS Instance | | QuickJS Instance | | Bridge | | +| | Skill A | | Skill B | | APIs | | +| | 64 MB memory cap | | 64 MB memory cap | +----+-----+ | +| | 512 KB stack | | 512 KB stack | | | +| +-------------------+ +-------------------+ | | +| | | +| +---------------------------------------------------v-----+ | +| | net | db | store | cron | log | tauri | | | +| | HTTP SQLite KV Schedule Log Platform| | | +| +------------------------------------------------------+ | | ++---------------------------------------------------------------+ +``` + +**QuickJS Runtime** (`rquickjs`): Each skill gets its own QuickJS `AsyncRuntime` and `AsyncContext` — fully isolated memory spaces with no cross-skill access. + +| Parameter | Value | +| ------------------------------ | ----------- | +| Default memory limit per skill | 64 MB | +| Stack size | 512 KB | +| Initialization timeout | 10 seconds | +| Graceful stop timeout | 5 seconds | +| Message channel buffer | 64 messages | + +**Message-passing architecture**: Skills communicate with the core engine through async MPSC channels — no shared mutable state. The registry routes tool calls, server events, cron triggers, and lifecycle commands to the correct skill instance via its channel sender. + +**Bridge APIs** expose platform capabilities to skill JavaScript code: + +| Bridge | Capability | +| --------- | ----------------------------------------------------------- | +| **net** | HTTP fetch via `reqwest` (30s default timeout, all methods) | +| **db** | SQLite database per skill via `rusqlite` | +| **store** | Key-value persistence | +| **cron** | Schedule registration (6-field cron expressions) | +| **log** | Structured logging routed through Rust `log` crate | +| **tauri** | Platform detection, notifications, whitelisted env vars | + +**Skill discovery** uses a manifest system. Each skill declares its metadata in a JSON manifest: + +| Field | Purpose | +| ----------------- | ----------------------------------------- | +| `id` | Unique identifier | +| `name` | Human-readable display name | +| `runtime` | Execution engine (`quickjs`) | +| `entry` | Entry point file (default: `index.js`) | +| `memory_limit_mb` | Per-skill memory cap (default: 64) | +| `platforms` | Supported platforms (default: all) | +| `setup` | OAuth and configuration wizard definition | +| `auto_start` | Start on app launch | + +Skills are synced from a GitHub repository and discovered at runtime. Platform filtering ensures skills only run where they're supported. + +**Cron scheduler**: A 5-second tick loop checks all registered schedules against UTC time, using the `cron` crate for expression parsing. When a schedule fires, the scheduler sends a `CronTrigger` message to the skill's channel, invoking the skill's `onCronTrigger()` handler. + +--- + +## AI & Tool Protocol (MCP) + +AlphaHuman implements the **Model Context Protocol** — a JSON-RPC 2.0 layer over Socket.io that lets AI models discover and invoke tools exposed by skills. + +``` +User Prompt + | + v +AI Model (Backend) + | + | 1. mcp:listTools --> Frontend/Rust aggregates all skill tools + | <-- tool catalog + | + | 2. Decides which tool to call + | + | 3. mcp:toolCall { skillId__toolName, arguments } + | | + | v + | Socket Manager routes to Skill Registry + | | + | v + | QuickJS Skill Instance executes tool + | | + | v + | Bridge API call (HTTP, DB, etc.) + | | + | <-- mcp:toolCallResponse { result } + | + v +AI Response to User +``` + +**Transport**: 30-second timeout per request, `mcp:` event prefix, request IDs tracked in a pending response map. Tool names are namespaced as `skillId__toolName` for unambiguous routing. + +**Tool sync**: The `tool:sync` event broadcasts the complete tool inventory — skill ID, name, connection status, and tool list — on every socket connect and skill state change. The backend AI system always has an up-to-date view of available capabilities. + +**AI Memory System**: + +| Feature | Implementation | +| ------------------ | ------------------------------------------------------ | +| Encryption at rest | AES-256-GCM with Argon2id key derivation | +| Chunking | 512 tokens per chunk, 64-token overlap | +| Search | Hybrid: 70% vector similarity + 30% FTS5 full-text | +| Embeddings | OpenAI `text-embedding-3-small` | +| Knowledge graph | Neo4j via REST API for entity relationships | +| Sessions | JSONL transcripts with compaction and tool compression | + +Memory encryption keys derive from user credentials via Argon2id, ensuring memory files are unreadable without authentication. The hybrid search combines semantic understanding (vector similarity) with keyword precision (SQLite FTS5) for reliable recall. + +--- + +## Security Architecture + +``` ++-------------------------------------------------------------------+ +| Security Layers | +| | +| +------------------+ +------------------+ +------------------+ | +| | OS Keychain | | AES-256-GCM | | Sandboxed | | +| | (macOS/Win/Lin) | | Memory Encrypt | | QuickJS per | | +| | for credentials | | + Argon2id KDF | | skill (64 MB) | | +| +------------------+ +------------------+ +------------------+ | +| | +| +------------------+ +------------------+ +------------------+ | +| | Single-Use | | rustls TLS | | No localStorage | | +| | Login Tokens | | for all network | | for sensitive | | +| | (5-min TTL) | | connections | | data | | +| +------------------+ +------------------+ +------------------+ | ++-------------------------------------------------------------------+ +``` + +- **Credential storage**: OS keychain integration via the `keyring` crate (macOS Keychain, Windows Credential Manager, Linux Secret Service) — desktop only +- **Memory encryption**: AES-256-GCM with Argon2id key derivation. All AI memory is encrypted at rest +- **Skill sandboxing**: Each QuickJS instance has enforced memory limits (64 MB default) and stack limits (512 KB). No cross-skill memory access +- **Auth handoff**: Web-to-desktop authentication uses single-use login tokens with 5-minute TTL, exchanged via Rust HTTP client (bypasses CORS) +- **Network TLS**: All WebSocket and HTTP connections use rustls — no dependency on platform OpenSSL +- **State management**: Sensitive data lives in Redux (memory) and OS keychain (persistent). No localStorage for credentials or tokens + +--- + +## End-to-End Data Flow + +A complete flow from user action to external service and back: + +``` +User types a command in the chat UI + | + v +React Frontend dispatches to AI provider + | + v +AI model receives prompt + tool catalog (via tool:sync) + | + v +AI decides to invoke a skill tool (e.g., send Telegram message) + | + v +mcp:toolCall event sent over Socket.io + | + v +Socket Manager (Rust) receives event, parses skillId__toolName + | + v +Skill Registry routes message to correct QuickJS instance via MPSC channel + | + v +QuickJS skill executes tool handler + | + v +Bridge API: net.rs makes HTTP request via reqwest (CORS-free, rustls TLS) + | + v +External service responds (e.g., Telegram API) + | + v +Result flows back: Bridge -> QuickJS -> Registry -> Socket -> MCP -> AI -> UI + | + v +User sees the result in the chat interface +``` + +Every layer is async and non-blocking. The Rust core processes thousands of concurrent skill executions, cron triggers, and socket events on a fixed Tokio thread pool. + +--- + +## Technology Stack + +| Layer | Technology | Why | +| -------------- | ------------------------------- | -------------------------------------------------------- | +| **Frontend** | React 19, TypeScript 5.8 | Modern component model, type safety | +| **State** | Redux Toolkit + Persist | Predictable state with offline persistence | +| **Build** | Vite 7 | Sub-second HMR, optimized production builds | +| **Styling** | Tailwind CSS | Utility-first, consistent design system | +| **Framework** | Tauri v2 | Native cross-platform with minimal overhead | +| **Language** | Rust (2021 edition) | Memory safety, zero-cost abstractions | +| **Async** | Tokio | High-performance async I/O runtime | +| **JS Engine** | QuickJS (rquickjs) | Lightweight sandboxed JS execution (~1-2 MB per context) | +| **Database** | SQLite (rusqlite) | Embedded, zero-config, per-skill isolation | +| **WebSocket** | tokio-tungstenite + rustls | Persistent connections with native TLS | +| **HTTP** | reqwest | Async HTTP with rustls + native-tLS dual support | +| **Encryption** | aes-gcm + argon2 | AES-256-GCM encryption, Argon2id key derivation | +| **Scheduling** | cron crate + custom scheduler | Standard cron expressions, 5-second resolution | +| **Telegram** | TDLib (tdlib-rs) | Official Telegram client library, desktop only | +| **Realtime** | Socket.io (client) | Bidirectional event-based communication | +| **AI** | MCP (JSON-RPC 2.0) | Standardized tool protocol for LLM integration | +| **Search** | OpenAI embeddings + SQLite FTS5 | Hybrid semantic + keyword search | +| **Graph** | Neo4j | Entity relationship knowledge graph | diff --git a/skills b/skills index 26341b529..ceb8d2485 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 26341b529751b46e9be28bd7c46b1a978b5bbc7d +Subproject commit ceb8d248550b8f296a245743d8d3a08efc365759 From 81c61fac1291223f653c964bb0279f94904eeecc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 15:30:57 +0530 Subject: [PATCH 06/11] 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 07/11] 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 08/11] 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 6df6877b96c1f4f68db5ddc82415a5a450b0066b Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:24:47 +0530 Subject: [PATCH 09/11] Add skill ping/health-check system (#85) * Refactor BillingPanel to use updated user usage structure - Changed the usage data source in BillingPanel from activeTeam to user, aligning with the new IUserUsage interface. - Updated the display of token usage percentage and progress bar to reflect the new usage metrics (spentThisCycleUsd and cycleBudgetUsd). - Cleaned up commented-out code and improved layout for better readability and user experience. * 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 * 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 * 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. * 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`. * Update subproject commit reference in skills directory and remove logging from get_session_token function for improved security * 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. --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/package-and-publish.yml | 25 +- skills | 2 +- src-tauri/src/lib.rs | 96 +++++- src-tauri/src/runtime/mod.rs | 2 + src-tauri/src/runtime/ping_scheduler.rs | 302 ++++++++++++++++++ src-tauri/src/runtime/qjs_engine.rs | 12 + src-tauri/src/runtime/qjs_skill_instance.rs | 14 + src-tauri/src/runtime/types.rs | 7 + .../services/quickjs-libs/qjs_ops/ops_core.rs | 1 - src-tauri/src/services/tdlib/manager.rs | 13 + src/lib/skills/runtime.ts | 9 + src/lib/skills/types.ts | 10 + 12 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/runtime/ping_scheduler.rs 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 diff --git a/skills b/skills index ceb8d2485..8030aef02 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit ceb8d248550b8f296a245743d8d3a08efc365759 +Subproject commit 8030aef027b7227b2fd63bf1e31bdf9d8e806383 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02c56f1db..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() @@ -318,6 +406,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..a6fa681ca --- /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(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-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index bbc6e2973..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" => { @@ -482,6 +493,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-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, 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; }))?; 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) } 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; From fa41d90c2d14fdb69dea2329752ef75e087e51f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 10 Feb 2026 16:41:29 +0530 Subject: [PATCH 10/11] 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; From 1617416700ca4199629b052275bc80609f40becd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 11:14:15 +0000 Subject: [PATCH 11/11] chore: bump version to 0.37.0 [skip ci] --- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f7f7571b1..76eb4a44a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "alphahuman", "private": true, - "version": "0.36.0", + "version": "0.37.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6c0f1940b..1b644b3cf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "AlphaHuman", - "version": "0.36.0", + "version": "0.37.0", "identifier": "com.alphahuman.app", "build": { "beforeDevCommand": "npm run dev",