diff --git a/.gitignore b/.gitignore index 7abd04c4..a951ea97 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ MagicMock/ # Second Repos Inline/ + +# Local git worktrees +.worktrees/ diff --git a/frontend/src/lib/useAgentEvents.ts b/frontend/src/lib/useAgentEvents.ts new file mode 100644 index 00000000..5232239f --- /dev/null +++ b/frontend/src/lib/useAgentEvents.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react'; +import { getBase } from './api'; + +export interface AgentEvent { + type: string; + timestamp: number; + data: Record; +} + +function buildWsUrl(agentId?: string): string { + const base = getBase(); + let origin: string; + if (base) { + origin = base.replace(/^http/, 'ws'); + } else { + const loc = window.location; + origin = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`; + } + const path = '/v1/agents/events'; + return agentId + ? `${origin}${path}?agent_id=${encodeURIComponent(agentId)}` + : `${origin}${path}`; +} + +/** + * Subscribe to agent events over WebSocket. + * Auto-reconnects with backoff when the socket drops. + */ +export function useAgentEvents( + agentId: string | undefined, + onEvent: (event: AgentEvent) => void, + eventTypes?: readonly string[], +): void { + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + const typesRef = useRef(eventTypes); + typesRef.current = eventTypes; + + useEffect(() => { + if (!agentId) return; + let ws: WebSocket | null = null; + let closed = false; + let retry = 0; + let reconnectTimer: ReturnType | null = null; + + const connect = () => { + if (closed) return; + try { + ws = new WebSocket(buildWsUrl(agentId)); + } catch { + schedule(); + return; + } + ws.onopen = () => { + retry = 0; + }; + ws.onmessage = (msg) => { + try { + const payload = JSON.parse(msg.data) as AgentEvent; + const allowed = typesRef.current; + if (allowed && !allowed.includes(payload.type)) return; + onEventRef.current(payload); + } catch { + // ignore malformed payload + } + }; + ws.onclose = () => { + if (!closed) schedule(); + }; + ws.onerror = () => { + ws?.close(); + }; + }; + + const schedule = () => { + if (closed) return; + const delay = Math.min(30000, 1000 * 2 ** Math.min(retry, 5)); + retry += 1; + reconnectTimer = setTimeout(connect, delay); + }; + + connect(); + + return () => { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + ws?.close(); + }; + }, [agentId]); +} diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index c68848dd..5504c8ef 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -32,6 +32,7 @@ import { sendblueHealth, } from '../lib/api'; import type { AgentTask, ChannelBinding, AgentTemplate, AgentMessage, ManagedAgent, LearningLogEntry, AgentTrace, ToolInfo } from '../lib/api'; +import { useAgentEvents } from '../lib/useAgentEvents'; import { Plus, Bot, @@ -1332,10 +1333,21 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s useEffect(() => { loadData(); - const interval = setInterval(loadData, 2000); + // Fallback slow poll — WS is primary, this catches missed events / dropped sockets + const interval = setInterval(loadData, 30000); return () => clearInterval(interval); }, [loadData]); + // Event-driven refresh — fires when the server reports agent activity + useAgentEvents(agentId, loadData, [ + 'agent_tick_start', + 'agent_tick_end', + 'agent_tick_error', + 'agent_message_received', + 'tool_call_end', + 'inference_end', + ]); + useEffect(() => { setLiveStatus(agentStatus); }, [agentStatus]); // Clean up elapsed-time timer on unmount @@ -2854,10 +2866,20 @@ function LogsTab({ agentId }: { agentId: string }) { useEffect(() => { loadData(); - const interval = setInterval(loadData, 5000); + // Fallback slow poll — WS is primary, this catches missed events + const interval = setInterval(loadData, 30000); return () => clearInterval(interval); }, [loadData]); + // Event-driven refresh — trace/learning entries are created by tick + tool events + useAgentEvents(agentId, loadData, [ + 'agent_tick_end', + 'agent_tick_error', + 'tool_call_end', + 'inference_end', + 'agent_learning_completed', + ]); + // Merge traces and learning entries into a unified timeline type TimelineEntry = | { kind: 'trace'; data: AgentTrace; ts: number } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e0178f24..cccf520f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -30,7 +30,6 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/], - mode: 'development', }, }), ], diff --git a/rust/crates/openjarvis-learning/src/icl_updater.rs b/rust/crates/openjarvis-learning/src/icl_updater.rs index dc9706d4..cf2fb544 100644 --- a/rust/crates/openjarvis-learning/src/icl_updater.rs +++ b/rust/crates/openjarvis-learning/src/icl_updater.rs @@ -141,7 +141,7 @@ impl ICLUpdaterPolicy { } } - skills.sort_by(|a, b| b.occurrences.cmp(&a.occurrences)); + skills.sort_by_key(|s| std::cmp::Reverse(s.occurrences)); self.discovered_skills = skills; &self.discovered_skills } diff --git a/rust/crates/openjarvis-learning/src/training_data.rs b/rust/crates/openjarvis-learning/src/training_data.rs index 8e3bcfdb..1dbd32ed 100644 --- a/rust/crates/openjarvis-learning/src/training_data.rs +++ b/rust/crates/openjarvis-learning/src/training_data.rs @@ -219,7 +219,7 @@ impl TrainingDataMiner { } } let mut ranked: Vec<_> = tool_freq.into_iter().collect(); - ranked.sort_by(|a, b| b.1.cmp(&a.1)); + ranked.sort_by_key(|r| std::cmp::Reverse(r.1)); let best_tools: Vec = ranked.into_iter().map(|(name, _)| name).collect(); let all_scores: Vec = agent_scores.values().flat_map(|v| v.iter().copied()).collect(); diff --git a/rust/crates/openjarvis-workflow/src/lib.rs b/rust/crates/openjarvis-workflow/src/lib.rs index 22f45a4e..13ac06f5 100644 --- a/rust/crates/openjarvis-workflow/src/lib.rs +++ b/rust/crates/openjarvis-workflow/src/lib.rs @@ -136,13 +136,10 @@ impl WorkflowGraph { continue; } if let Some(&vi) = self.node_index.get(&edge.target) { - match color[vi] { + let c = color[vi]; + match c { 1 => return true, - 0 => { - if self.dfs_has_cycle(vi, color) { - return true; - } - } + 0 if self.dfs_has_cycle(vi, color) => return true, _ => {} } }