htmlgraph 0.27.7__py3-none-any.whl → 0.28.1__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/broadcast.py +316 -0
- htmlgraph/api/broadcast_routes.py +357 -0
- htmlgraph/api/broadcast_websocket.py +115 -0
- htmlgraph/api/cost_alerts_websocket.py +7 -16
- htmlgraph/api/main.py +135 -1
- htmlgraph/api/offline.py +776 -0
- htmlgraph/api/presence.py +446 -0
- htmlgraph/api/reactive.py +455 -0
- htmlgraph/api/reactive_routes.py +195 -0
- htmlgraph/api/static/broadcast-demo.html +393 -0
- htmlgraph/api/static/presence-widget-demo.html +785 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/templates/partials/agents.html +308 -80
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +226 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/models.py +1 -0
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/session_manager.py +7 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/RECORD +32 -19
- htmlgraph/dashboard.html +0 -6592
- htmlgraph-0.27.7.data/data/htmlgraph/dashboard.html +0 -6592
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.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
|