htmlgraph 0.27.6__py3-none-any.whl → 0.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. htmlgraph/__init__.py +9 -1
  2. htmlgraph/api/broadcast.py +316 -0
  3. htmlgraph/api/broadcast_routes.py +357 -0
  4. htmlgraph/api/broadcast_websocket.py +115 -0
  5. htmlgraph/api/cost_alerts_websocket.py +7 -16
  6. htmlgraph/api/main.py +110 -1
  7. htmlgraph/api/offline.py +776 -0
  8. htmlgraph/api/presence.py +446 -0
  9. htmlgraph/api/reactive.py +455 -0
  10. htmlgraph/api/reactive_routes.py +195 -0
  11. htmlgraph/api/static/broadcast-demo.html +393 -0
  12. htmlgraph/api/sync_routes.py +184 -0
  13. htmlgraph/api/websocket.py +112 -37
  14. htmlgraph/broadcast_integration.py +227 -0
  15. htmlgraph/cli_commands/sync.py +207 -0
  16. htmlgraph/db/schema.py +214 -0
  17. htmlgraph/hooks/event_tracker.py +53 -2
  18. htmlgraph/reactive_integration.py +148 -0
  19. htmlgraph/session_context.py +1669 -0
  20. htmlgraph/session_manager.py +70 -0
  21. htmlgraph/sync/__init__.py +21 -0
  22. htmlgraph/sync/git_sync.py +458 -0
  23. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
  24. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +31 -16
  25. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
  26. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
  27. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  28. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  29. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  30. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
  31. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,446 @@
1
+ """
2
+ Cross-Agent Presence Tracking - Phase 1
3
+
4
+ Real-time visibility of which agents are active, what they're working on,
5
+ and their recent activity. Foundation for multi-AI coordination observability.
6
+
7
+ Features:
8
+ - Track agent status (active, idle, offline)
9
+ - Monitor current feature each agent is working on
10
+ - Display last tool executed
11
+ - <500ms latency from activity to UI update
12
+ - WebSocket broadcasting for real-time updates
13
+
14
+ Architecture:
15
+ - AgentPresence: Data model for agent state
16
+ - PresenceManager: In-memory presence tracking with persistence
17
+ - Integrates with event pipeline for automatic updates
18
+ """
19
+
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timedelta
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class PresenceStatus(str, Enum):
31
+ """Agent presence status."""
32
+
33
+ ACTIVE = "active"
34
+ IDLE = "idle"
35
+ OFFLINE = "offline"
36
+
37
+
38
+ @dataclass
39
+ class AgentPresence:
40
+ """
41
+ Agent presence state.
42
+
43
+ Tracks real-time information about an agent's activity:
44
+ - Current status (active/idle/offline)
45
+ - Feature being worked on
46
+ - Last tool executed
47
+ - Activity metrics
48
+ """
49
+
50
+ agent_id: str
51
+ status: PresenceStatus = PresenceStatus.OFFLINE
52
+ current_feature_id: str | None = None
53
+ last_tool_name: str | None = None
54
+ last_activity: datetime = field(default_factory=datetime.now)
55
+ total_tools_executed: int = 0
56
+ total_cost_tokens: int = 0
57
+ session_id: str | None = None
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ """Convert to dictionary for JSON serialization."""
61
+ return {
62
+ "agent_id": self.agent_id,
63
+ "status": self.status.value,
64
+ "current_feature_id": self.current_feature_id,
65
+ "last_tool_name": self.last_tool_name,
66
+ "last_activity": self.last_activity.isoformat(),
67
+ "total_tools_executed": self.total_tools_executed,
68
+ "total_cost_tokens": self.total_cost_tokens,
69
+ "session_id": self.session_id,
70
+ }
71
+
72
+ @staticmethod
73
+ def from_dict(data: dict[str, Any]) -> "AgentPresence":
74
+ """Create AgentPresence from dictionary."""
75
+ return AgentPresence(
76
+ agent_id=data["agent_id"],
77
+ status=PresenceStatus(data.get("status", "offline")),
78
+ current_feature_id=data.get("current_feature_id"),
79
+ last_tool_name=data.get("last_tool_name"),
80
+ last_activity=datetime.fromisoformat(data["last_activity"]),
81
+ total_tools_executed=data.get("total_tools_executed", 0),
82
+ total_cost_tokens=data.get("total_cost_tokens", 0),
83
+ session_id=data.get("session_id"),
84
+ )
85
+
86
+
87
+ class PresenceManager:
88
+ """
89
+ Manages agent presence tracking.
90
+
91
+ Features:
92
+ - In-memory state for fast access
93
+ - Periodic persistence to database
94
+ - Idle detection based on inactivity
95
+ - Real-time updates via WebSocket
96
+ """
97
+
98
+ def __init__(self, db_path: str | None = None, idle_timeout_seconds: int = 300):
99
+ """
100
+ Initialize presence manager.
101
+
102
+ Args:
103
+ db_path: Path to SQLite database for persistence
104
+ idle_timeout_seconds: Seconds of inactivity before marking idle (default: 5 minutes)
105
+ """
106
+ self.db_path = db_path
107
+ self.idle_timeout_seconds = idle_timeout_seconds
108
+
109
+ # In-memory presence state: {agent_id: AgentPresence}
110
+ self.agents: dict[str, AgentPresence] = {}
111
+
112
+ # Ensure schema exists before loading
113
+ if self.db_path:
114
+ self._ensure_schema()
115
+
116
+ # Load persisted state if available
117
+ self._load_from_db()
118
+
119
+ def update_presence(
120
+ self,
121
+ agent_id: str,
122
+ event: dict[str, Any],
123
+ websocket_manager: Any | None = None,
124
+ ) -> AgentPresence:
125
+ """
126
+ Update agent presence based on event.
127
+
128
+ Args:
129
+ agent_id: Agent identifier
130
+ event: Event data (tool_call, completion, error)
131
+ websocket_manager: WebSocket manager for broadcasting updates
132
+
133
+ Returns:
134
+ Updated AgentPresence
135
+ """
136
+ # Get or create presence
137
+ if agent_id not in self.agents:
138
+ self.agents[agent_id] = AgentPresence(agent_id=agent_id)
139
+
140
+ presence = self.agents[agent_id]
141
+
142
+ # Update fields
143
+ presence.status = PresenceStatus.ACTIVE
144
+ presence.last_activity = datetime.now()
145
+
146
+ # Extract tool name
147
+ if tool_name := event.get("tool_name"):
148
+ presence.last_tool_name = tool_name
149
+
150
+ # Extract feature
151
+ if feature_id := event.get("feature_id"):
152
+ presence.current_feature_id = feature_id
153
+
154
+ # Extract session
155
+ if session_id := event.get("session_id"):
156
+ presence.session_id = session_id
157
+
158
+ # Update metrics
159
+ if cost_tokens := event.get("cost_tokens"):
160
+ presence.total_cost_tokens += cost_tokens
161
+
162
+ presence.total_tools_executed += 1
163
+
164
+ # Persist to database
165
+ self._save_to_db(agent_id)
166
+
167
+ # Broadcast update via WebSocket if manager provided
168
+ if websocket_manager:
169
+ import asyncio
170
+
171
+ try:
172
+ loop = asyncio.get_event_loop()
173
+ if loop.is_running():
174
+ # Schedule broadcast as task
175
+ asyncio.create_task(
176
+ self._broadcast_update(websocket_manager, presence)
177
+ )
178
+ except RuntimeError:
179
+ # No event loop - skip broadcast
180
+ pass
181
+
182
+ return presence
183
+
184
+ async def _broadcast_update(
185
+ self, websocket_manager: Any, presence: AgentPresence
186
+ ) -> None:
187
+ """Broadcast presence update via WebSocket."""
188
+ try:
189
+ await websocket_manager.broadcast_to_all_sessions(
190
+ {
191
+ "type": "presence_update",
192
+ "event_type": "presence_update",
193
+ "agent_id": presence.agent_id,
194
+ "presence": presence.to_dict(),
195
+ "timestamp": datetime.now().isoformat(),
196
+ }
197
+ )
198
+ except Exception as e:
199
+ logger.error(f"Error broadcasting presence update: {e}")
200
+
201
+ def mark_idle(self, agent_id: str | None = None) -> list[str]:
202
+ """
203
+ Mark agents as idle if no activity for idle_timeout_seconds.
204
+
205
+ Args:
206
+ agent_id: Specific agent to check, or None to check all
207
+
208
+ Returns:
209
+ List of agent IDs marked as idle
210
+ """
211
+ marked_idle = []
212
+ agents_to_check = [agent_id] if agent_id else list(self.agents.keys())
213
+
214
+ for aid in agents_to_check:
215
+ if aid not in self.agents:
216
+ continue
217
+
218
+ presence = self.agents[aid]
219
+ elapsed = (datetime.now() - presence.last_activity).total_seconds()
220
+
221
+ if (
222
+ elapsed > self.idle_timeout_seconds
223
+ and presence.status != PresenceStatus.IDLE
224
+ ):
225
+ presence.status = PresenceStatus.IDLE
226
+ marked_idle.append(aid)
227
+ self._save_to_db(aid)
228
+
229
+ return marked_idle
230
+
231
+ def mark_offline(self, agent_id: str) -> bool:
232
+ """
233
+ Mark agent as offline.
234
+
235
+ Args:
236
+ agent_id: Agent to mark offline
237
+
238
+ Returns:
239
+ True if agent was marked offline, False if not found
240
+ """
241
+ if agent_id not in self.agents:
242
+ return False
243
+
244
+ self.agents[agent_id].status = PresenceStatus.OFFLINE
245
+ self._save_to_db(agent_id)
246
+ return True
247
+
248
+ def get_all_presence(self) -> list[AgentPresence]:
249
+ """
250
+ Get presence info for all agents.
251
+
252
+ Returns:
253
+ List of AgentPresence objects
254
+ """
255
+ # Mark idle agents before returning
256
+ self.mark_idle()
257
+ return list(self.agents.values())
258
+
259
+ def get_agent_presence(self, agent_id: str) -> AgentPresence | None:
260
+ """
261
+ Get presence info for specific agent.
262
+
263
+ Args:
264
+ agent_id: Agent identifier
265
+
266
+ Returns:
267
+ AgentPresence or None if not found
268
+ """
269
+ # Check if idle before returning
270
+ self.mark_idle(agent_id)
271
+ return self.agents.get(agent_id)
272
+
273
+ def _ensure_schema(self) -> None:
274
+ """Ensure database schema exists."""
275
+ if not self.db_path:
276
+ return
277
+
278
+ try:
279
+ import sqlite3
280
+
281
+ db_path = Path(self.db_path)
282
+ if not db_path.exists():
283
+ db_path.parent.mkdir(parents=True, exist_ok=True)
284
+
285
+ conn = sqlite3.connect(str(db_path))
286
+ cursor = conn.cursor()
287
+
288
+ # Check if table exists
289
+ cursor.execute(
290
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='agent_presence'"
291
+ )
292
+ if not cursor.fetchone():
293
+ conn.close()
294
+ # Initialize schema using HtmlGraphDB
295
+ try:
296
+ from htmlgraph.db.schema import HtmlGraphDB
297
+
298
+ db = HtmlGraphDB(str(db_path))
299
+ db.create_tables()
300
+ db.disconnect()
301
+ except Exception as e:
302
+ logger.debug(f"Could not create schema: {e}")
303
+ else:
304
+ conn.close()
305
+
306
+ except Exception as e:
307
+ logger.warning(f"Could not ensure schema: {e}")
308
+
309
+ def _load_from_db(self) -> None:
310
+ """Load presence state from database."""
311
+ if not self.db_path:
312
+ return
313
+
314
+ try:
315
+ import sqlite3
316
+
317
+ db_path = Path(self.db_path)
318
+ if not db_path.exists():
319
+ return
320
+
321
+ conn = sqlite3.connect(str(db_path))
322
+ cursor = conn.cursor()
323
+
324
+ # Check if table exists
325
+ cursor.execute(
326
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='agent_presence'"
327
+ )
328
+ if not cursor.fetchone():
329
+ conn.close()
330
+ return
331
+
332
+ # Load all presence records
333
+ cursor.execute(
334
+ """
335
+ SELECT agent_id, status, current_feature_id, last_tool_name,
336
+ last_activity, total_tools_executed, total_cost_tokens,
337
+ session_id
338
+ FROM agent_presence
339
+ """
340
+ )
341
+
342
+ rows = cursor.fetchall()
343
+ for row in rows:
344
+ try:
345
+ presence = AgentPresence(
346
+ agent_id=row[0],
347
+ status=PresenceStatus(row[1]),
348
+ current_feature_id=row[2],
349
+ last_tool_name=row[3],
350
+ last_activity=datetime.fromisoformat(row[4]),
351
+ total_tools_executed=row[5] or 0,
352
+ total_cost_tokens=row[6] or 0,
353
+ session_id=row[7],
354
+ )
355
+ self.agents[presence.agent_id] = presence
356
+ except (ValueError, TypeError) as e:
357
+ logger.warning(f"Error loading presence for {row[0]}: {e}")
358
+
359
+ conn.close()
360
+ if rows:
361
+ logger.info(
362
+ f"Loaded {len(self.agents)} agent presence records from database"
363
+ )
364
+
365
+ except Exception as e:
366
+ logger.warning(f"Could not load presence from database: {e}")
367
+
368
+ def _save_to_db(self, agent_id: str) -> None:
369
+ """Save presence state to database."""
370
+ if not self.db_path or agent_id not in self.agents:
371
+ return
372
+
373
+ try:
374
+ import sqlite3
375
+
376
+ presence = self.agents[agent_id]
377
+
378
+ conn = sqlite3.connect(str(self.db_path))
379
+ cursor = conn.cursor()
380
+
381
+ # Ensure table exists (will be created by schema.py, but check anyway)
382
+ cursor.execute(
383
+ """
384
+ INSERT OR REPLACE INTO agent_presence
385
+ (agent_id, status, current_feature_id, last_tool_name,
386
+ last_activity, total_tools_executed, total_cost_tokens,
387
+ session_id, updated_at)
388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
389
+ """,
390
+ (
391
+ presence.agent_id,
392
+ presence.status.value,
393
+ presence.current_feature_id,
394
+ presence.last_tool_name,
395
+ presence.last_activity.isoformat(),
396
+ presence.total_tools_executed,
397
+ presence.total_cost_tokens,
398
+ presence.session_id,
399
+ datetime.now().isoformat(),
400
+ ),
401
+ )
402
+
403
+ conn.commit()
404
+ conn.close()
405
+
406
+ except Exception as e:
407
+ logger.debug(f"Could not save presence to database: {e}")
408
+
409
+ def cleanup_stale_agents(self, max_age_hours: int = 24) -> list[str]:
410
+ """
411
+ Remove agents that haven't been active for max_age_hours.
412
+
413
+ Args:
414
+ max_age_hours: Maximum age in hours before removal
415
+
416
+ Returns:
417
+ List of removed agent IDs
418
+ """
419
+ cutoff = datetime.now() - timedelta(hours=max_age_hours)
420
+ removed = []
421
+
422
+ for agent_id, presence in list(self.agents.items()):
423
+ if presence.last_activity < cutoff:
424
+ del self.agents[agent_id]
425
+ removed.append(agent_id)
426
+
427
+ # Remove from database too
428
+ if self.db_path:
429
+ try:
430
+ import sqlite3
431
+
432
+ conn = sqlite3.connect(str(self.db_path))
433
+ cursor = conn.cursor()
434
+ cursor.execute(
435
+ "DELETE FROM agent_presence WHERE agent_id = ?",
436
+ (agent_id,),
437
+ )
438
+ conn.commit()
439
+ conn.close()
440
+ except Exception as e:
441
+ logger.debug(f"Could not remove {agent_id} from database: {e}")
442
+
443
+ if removed:
444
+ logger.info(f"Cleaned up {len(removed)} stale agent presence records")
445
+
446
+ return removed