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; };