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
htmlgraph/__init__.py
CHANGED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-Session Broadcast Sync - Phase 2
|
|
3
|
+
|
|
4
|
+
Enables real-time feature updates across multiple Claude Code sessions.
|
|
5
|
+
Session A updates a feature → immediately visible in Session B without manual git pull.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Real-time feature/track/spike updates (<100ms latency)
|
|
9
|
+
- Cross-session coordination
|
|
10
|
+
- Automatic synchronization
|
|
11
|
+
- Audit trail for all changes
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BroadcastEventType(str, Enum):
|
|
24
|
+
"""Types of broadcast events for cross-session sync."""
|
|
25
|
+
|
|
26
|
+
FEATURE_UPDATED = "feature_updated"
|
|
27
|
+
FEATURE_CREATED = "feature_created"
|
|
28
|
+
FEATURE_DELETED = "feature_deleted"
|
|
29
|
+
TRACK_UPDATED = "track_updated"
|
|
30
|
+
SPIKE_UPDATED = "spike_updated"
|
|
31
|
+
STATUS_CHANGED = "status_changed"
|
|
32
|
+
LINK_ADDED = "link_added"
|
|
33
|
+
COMMENT_ADDED = "comment_added"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BroadcastEvent:
|
|
38
|
+
"""
|
|
39
|
+
Event for cross-session broadcasting.
|
|
40
|
+
|
|
41
|
+
Contains all information needed to synchronize changes across
|
|
42
|
+
multiple active sessions.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
event_type: BroadcastEventType
|
|
46
|
+
resource_id: str # feature_id, track_id, etc.
|
|
47
|
+
resource_type: str # "feature", "track", "spike"
|
|
48
|
+
agent_id: str
|
|
49
|
+
session_id: str
|
|
50
|
+
payload: dict[str, Any]
|
|
51
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""Convert to dictionary for JSON serialization."""
|
|
55
|
+
return {
|
|
56
|
+
"type": "broadcast_event",
|
|
57
|
+
"event_type": self.event_type.value,
|
|
58
|
+
"resource_id": self.resource_id,
|
|
59
|
+
"resource_type": self.resource_type,
|
|
60
|
+
"agent_id": self.agent_id,
|
|
61
|
+
"session_id": self.session_id,
|
|
62
|
+
"payload": self.payload,
|
|
63
|
+
"timestamp": self.timestamp.isoformat(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CrossSessionBroadcaster:
|
|
68
|
+
"""
|
|
69
|
+
Broadcasts feature/track/spike updates across all active sessions.
|
|
70
|
+
|
|
71
|
+
Enables real-time coordination between multiple agents working
|
|
72
|
+
on the same project simultaneously.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
|
|
76
|
+
>>> await broadcaster.broadcast_feature_update(
|
|
77
|
+
... feature_id="feat-123",
|
|
78
|
+
... agent_id="claude-1",
|
|
79
|
+
... session_id="sess-1",
|
|
80
|
+
... payload={"status": "in_progress"}
|
|
81
|
+
... )
|
|
82
|
+
>>> # All other sessions immediately see the update
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, websocket_manager: Any, db_path: str):
|
|
86
|
+
"""
|
|
87
|
+
Initialize broadcaster.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
websocket_manager: WebSocketManager instance for distribution
|
|
91
|
+
db_path: Path to SQLite database for audit trail
|
|
92
|
+
"""
|
|
93
|
+
self.websocket_manager = websocket_manager
|
|
94
|
+
self.db_path = db_path
|
|
95
|
+
self.event_cache: dict[str, float] = {} # Simple deduplication cache
|
|
96
|
+
|
|
97
|
+
async def broadcast_feature_update(
|
|
98
|
+
self,
|
|
99
|
+
feature_id: str,
|
|
100
|
+
agent_id: str,
|
|
101
|
+
session_id: str,
|
|
102
|
+
payload: dict[str, Any],
|
|
103
|
+
) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Broadcast feature update to all sessions.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
feature_id: Feature being updated
|
|
109
|
+
agent_id: Agent making the change
|
|
110
|
+
session_id: Source session
|
|
111
|
+
payload: Update details (title, status, description, etc.)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Number of clients that received the broadcast
|
|
115
|
+
"""
|
|
116
|
+
event = BroadcastEvent(
|
|
117
|
+
event_type=BroadcastEventType.FEATURE_UPDATED,
|
|
118
|
+
resource_id=feature_id,
|
|
119
|
+
resource_type="feature",
|
|
120
|
+
agent_id=agent_id,
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
payload=payload,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Broadcast to all sessions
|
|
126
|
+
clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
|
|
127
|
+
event.to_dict()
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
logger.info(
|
|
131
|
+
f"Broadcast feature {feature_id} update to {clients_notified} clients "
|
|
132
|
+
f"(agent={agent_id}, session={session_id})"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return int(clients_notified)
|
|
136
|
+
|
|
137
|
+
async def broadcast_feature_created(
|
|
138
|
+
self,
|
|
139
|
+
feature_id: str,
|
|
140
|
+
agent_id: str,
|
|
141
|
+
session_id: str,
|
|
142
|
+
payload: dict[str, Any],
|
|
143
|
+
) -> int:
|
|
144
|
+
"""
|
|
145
|
+
Broadcast new feature creation to all sessions.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
feature_id: New feature ID
|
|
149
|
+
agent_id: Agent creating the feature
|
|
150
|
+
session_id: Source session
|
|
151
|
+
payload: Feature details
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Number of clients notified
|
|
155
|
+
"""
|
|
156
|
+
event = BroadcastEvent(
|
|
157
|
+
event_type=BroadcastEventType.FEATURE_CREATED,
|
|
158
|
+
resource_id=feature_id,
|
|
159
|
+
resource_type="feature",
|
|
160
|
+
agent_id=agent_id,
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
payload=payload,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
|
|
166
|
+
event.to_dict()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
logger.info(
|
|
170
|
+
f"Broadcast new feature {feature_id} to {clients_notified} clients "
|
|
171
|
+
f"(agent={agent_id})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return int(clients_notified)
|
|
175
|
+
|
|
176
|
+
async def broadcast_status_change(
|
|
177
|
+
self,
|
|
178
|
+
feature_id: str,
|
|
179
|
+
old_status: str,
|
|
180
|
+
new_status: str,
|
|
181
|
+
agent_id: str,
|
|
182
|
+
session_id: str,
|
|
183
|
+
) -> int:
|
|
184
|
+
"""
|
|
185
|
+
Broadcast feature status change across sessions.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
feature_id: Feature being updated
|
|
189
|
+
old_status: Previous status
|
|
190
|
+
new_status: New status
|
|
191
|
+
agent_id: Agent making change
|
|
192
|
+
session_id: Source session
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Number of clients notified
|
|
196
|
+
"""
|
|
197
|
+
return await self.broadcast_feature_update(
|
|
198
|
+
feature_id=feature_id,
|
|
199
|
+
agent_id=agent_id,
|
|
200
|
+
session_id=session_id,
|
|
201
|
+
payload={
|
|
202
|
+
"change_type": "status",
|
|
203
|
+
"old_status": old_status,
|
|
204
|
+
"new_status": new_status,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def broadcast_link_added(
|
|
209
|
+
self,
|
|
210
|
+
feature_id: str,
|
|
211
|
+
linked_feature_id: str,
|
|
212
|
+
link_type: str, # "depends_on", "blocks", "related", etc.
|
|
213
|
+
agent_id: str,
|
|
214
|
+
session_id: str,
|
|
215
|
+
) -> int:
|
|
216
|
+
"""
|
|
217
|
+
Broadcast when feature links are added.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
feature_id: Source feature
|
|
221
|
+
linked_feature_id: Target feature
|
|
222
|
+
link_type: Type of relationship
|
|
223
|
+
agent_id: Agent making change
|
|
224
|
+
session_id: Source session
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Number of clients notified
|
|
228
|
+
"""
|
|
229
|
+
return await self.broadcast_feature_update(
|
|
230
|
+
feature_id=feature_id,
|
|
231
|
+
agent_id=agent_id,
|
|
232
|
+
session_id=session_id,
|
|
233
|
+
payload={
|
|
234
|
+
"change_type": "link_added",
|
|
235
|
+
"linked_feature_id": linked_feature_id,
|
|
236
|
+
"link_type": link_type,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def broadcast_track_update(
|
|
241
|
+
self,
|
|
242
|
+
track_id: str,
|
|
243
|
+
agent_id: str,
|
|
244
|
+
session_id: str,
|
|
245
|
+
payload: dict[str, Any],
|
|
246
|
+
) -> int:
|
|
247
|
+
"""
|
|
248
|
+
Broadcast track update to all sessions.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
track_id: Track being updated
|
|
252
|
+
agent_id: Agent making change
|
|
253
|
+
session_id: Source session
|
|
254
|
+
payload: Update details
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Number of clients notified
|
|
258
|
+
"""
|
|
259
|
+
event = BroadcastEvent(
|
|
260
|
+
event_type=BroadcastEventType.TRACK_UPDATED,
|
|
261
|
+
resource_id=track_id,
|
|
262
|
+
resource_type="track",
|
|
263
|
+
agent_id=agent_id,
|
|
264
|
+
session_id=session_id,
|
|
265
|
+
payload=payload,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
|
|
269
|
+
event.to_dict()
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
logger.info(
|
|
273
|
+
f"Broadcast track {track_id} update to {clients_notified} clients "
|
|
274
|
+
f"(agent={agent_id})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return int(clients_notified)
|
|
278
|
+
|
|
279
|
+
async def broadcast_spike_update(
|
|
280
|
+
self,
|
|
281
|
+
spike_id: str,
|
|
282
|
+
agent_id: str,
|
|
283
|
+
session_id: str,
|
|
284
|
+
payload: dict[str, Any],
|
|
285
|
+
) -> int:
|
|
286
|
+
"""
|
|
287
|
+
Broadcast spike update to all sessions.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
spike_id: Spike being updated
|
|
291
|
+
agent_id: Agent making change
|
|
292
|
+
session_id: Source session
|
|
293
|
+
payload: Update details
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Number of clients notified
|
|
297
|
+
"""
|
|
298
|
+
event = BroadcastEvent(
|
|
299
|
+
event_type=BroadcastEventType.SPIKE_UPDATED,
|
|
300
|
+
resource_id=spike_id,
|
|
301
|
+
resource_type="spike",
|
|
302
|
+
agent_id=agent_id,
|
|
303
|
+
session_id=session_id,
|
|
304
|
+
payload=payload,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
|
|
308
|
+
event.to_dict()
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
logger.info(
|
|
312
|
+
f"Broadcast spike {spike_id} update to {clients_notified} clients "
|
|
313
|
+
f"(agent={agent_id})"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return int(clients_notified)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broadcast API Routes - Cross-Session Synchronization
|
|
3
|
+
|
|
4
|
+
REST API endpoints for broadcasting feature/track/spike updates
|
|
5
|
+
across all active sessions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Header, HTTPException
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from htmlgraph.api.broadcast import CrossSessionBroadcaster
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FeatureUpdateRequest(BaseModel):
|
|
20
|
+
"""Request model for feature update broadcasts."""
|
|
21
|
+
|
|
22
|
+
title: str | None = None
|
|
23
|
+
status: str | None = None
|
|
24
|
+
description: str | None = None
|
|
25
|
+
tags: list[str] | None = None
|
|
26
|
+
priority: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StatusChangeRequest(BaseModel):
|
|
30
|
+
"""Request model for status change broadcasts."""
|
|
31
|
+
|
|
32
|
+
new_status: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LinkAddRequest(BaseModel):
|
|
36
|
+
"""Request model for link addition broadcasts."""
|
|
37
|
+
|
|
38
|
+
linked_feature_id: str
|
|
39
|
+
link_type: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_broadcast_router(
|
|
43
|
+
broadcaster: CrossSessionBroadcaster,
|
|
44
|
+
get_db: Any,
|
|
45
|
+
) -> APIRouter:
|
|
46
|
+
"""
|
|
47
|
+
Create broadcast API router with dependency injection.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
broadcaster: CrossSessionBroadcaster instance
|
|
51
|
+
get_db: Database connection factory
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Configured APIRouter
|
|
55
|
+
"""
|
|
56
|
+
router = APIRouter(prefix="/api/broadcast", tags=["broadcast"])
|
|
57
|
+
|
|
58
|
+
@router.post("/features/{feature_id}")
|
|
59
|
+
async def broadcast_feature_update(
|
|
60
|
+
feature_id: str,
|
|
61
|
+
update: FeatureUpdateRequest,
|
|
62
|
+
agent_id: str = Header(..., alias="X-Agent-ID"),
|
|
63
|
+
session_id: str = Header(..., alias="X-Session-ID"),
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
Broadcast feature update to all sessions.
|
|
67
|
+
|
|
68
|
+
Headers:
|
|
69
|
+
- X-Agent-ID: Agent making the change
|
|
70
|
+
- X-Session-ID: Source session ID
|
|
71
|
+
|
|
72
|
+
Body:
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"title": "new title",
|
|
76
|
+
"status": "in_progress",
|
|
77
|
+
"description": "updated description",
|
|
78
|
+
"tags": ["api", "backend"],
|
|
79
|
+
"priority": "high"
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"success": true,
|
|
87
|
+
"feature_id": "feat-123",
|
|
88
|
+
"clients_notified": 5,
|
|
89
|
+
"timestamp": "2025-01-01T12:00:00"
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
# Validate feature exists
|
|
94
|
+
try:
|
|
95
|
+
db = await get_db()
|
|
96
|
+
try:
|
|
97
|
+
cursor = await db.execute(
|
|
98
|
+
"SELECT id FROM nodes WHERE id = ? AND type = 'feature'",
|
|
99
|
+
[feature_id],
|
|
100
|
+
)
|
|
101
|
+
if not await cursor.fetchone():
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=404, detail=f"Feature {feature_id} not found"
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
await db.close()
|
|
107
|
+
except HTTPException:
|
|
108
|
+
raise
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Database error checking feature: {e}")
|
|
111
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
112
|
+
|
|
113
|
+
# Build payload from request
|
|
114
|
+
payload = {}
|
|
115
|
+
if update.title is not None:
|
|
116
|
+
payload["title"] = update.title
|
|
117
|
+
if update.status is not None:
|
|
118
|
+
payload["status"] = update.status
|
|
119
|
+
if update.description is not None:
|
|
120
|
+
payload["description"] = update.description
|
|
121
|
+
if update.tags is not None:
|
|
122
|
+
payload["tags"] = ",".join(update.tags) if update.tags else ""
|
|
123
|
+
if update.priority is not None:
|
|
124
|
+
payload["priority"] = update.priority
|
|
125
|
+
|
|
126
|
+
# Broadcast update
|
|
127
|
+
clients_notified = await broadcaster.broadcast_feature_update(
|
|
128
|
+
feature_id=feature_id,
|
|
129
|
+
agent_id=agent_id,
|
|
130
|
+
session_id=session_id,
|
|
131
|
+
payload=payload,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
from datetime import datetime
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"success": True,
|
|
138
|
+
"feature_id": feature_id,
|
|
139
|
+
"clients_notified": clients_notified,
|
|
140
|
+
"timestamp": datetime.now().isoformat(),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@router.post("/features/{feature_id}/status")
|
|
144
|
+
async def update_feature_status(
|
|
145
|
+
feature_id: str,
|
|
146
|
+
request: StatusChangeRequest,
|
|
147
|
+
agent_id: str = Header(..., alias="X-Agent-ID"),
|
|
148
|
+
session_id: str = Header(..., alias="X-Session-ID"),
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""
|
|
151
|
+
Update feature status and broadcast to all sessions.
|
|
152
|
+
|
|
153
|
+
Valid statuses: todo, in_progress, blocked, done
|
|
154
|
+
"""
|
|
155
|
+
# Validate status
|
|
156
|
+
valid_statuses = ["todo", "in_progress", "blocked", "done"]
|
|
157
|
+
if request.new_status not in valid_statuses:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=400,
|
|
160
|
+
detail=f"Invalid status: {request.new_status}. Must be one of {valid_statuses}",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Get current status
|
|
164
|
+
try:
|
|
165
|
+
db = await get_db()
|
|
166
|
+
try:
|
|
167
|
+
cursor = await db.execute(
|
|
168
|
+
"SELECT status FROM nodes WHERE id = ? AND type = 'feature'",
|
|
169
|
+
[feature_id],
|
|
170
|
+
)
|
|
171
|
+
row = await cursor.fetchone()
|
|
172
|
+
if not row:
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=404, detail=f"Feature {feature_id} not found"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
old_status = row[0] if row[0] else "todo"
|
|
178
|
+
finally:
|
|
179
|
+
await db.close()
|
|
180
|
+
except HTTPException:
|
|
181
|
+
raise
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Database error fetching feature status: {e}")
|
|
184
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
185
|
+
|
|
186
|
+
# Broadcast status change
|
|
187
|
+
clients_notified = await broadcaster.broadcast_status_change(
|
|
188
|
+
feature_id=feature_id,
|
|
189
|
+
old_status=old_status,
|
|
190
|
+
new_status=request.new_status,
|
|
191
|
+
agent_id=agent_id,
|
|
192
|
+
session_id=session_id,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
from datetime import datetime
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"success": True,
|
|
199
|
+
"feature_id": feature_id,
|
|
200
|
+
"old_status": old_status,
|
|
201
|
+
"new_status": request.new_status,
|
|
202
|
+
"clients_notified": clients_notified,
|
|
203
|
+
"timestamp": datetime.now().isoformat(),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@router.post("/features/{feature_id}/links")
|
|
207
|
+
async def add_feature_link(
|
|
208
|
+
feature_id: str,
|
|
209
|
+
request: LinkAddRequest,
|
|
210
|
+
agent_id: str = Header(..., alias="X-Agent-ID"),
|
|
211
|
+
session_id: str = Header(..., alias="X-Session-ID"),
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""
|
|
214
|
+
Add feature link and broadcast to all sessions.
|
|
215
|
+
|
|
216
|
+
Valid link types: depends_on, blocks, related
|
|
217
|
+
"""
|
|
218
|
+
# Validate link type
|
|
219
|
+
valid_link_types = ["depends_on", "blocks", "related"]
|
|
220
|
+
if request.link_type not in valid_link_types:
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=400,
|
|
223
|
+
detail=f"Invalid link type: {request.link_type}. Must be one of {valid_link_types}",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Validate both features exist
|
|
227
|
+
try:
|
|
228
|
+
db = await get_db()
|
|
229
|
+
try:
|
|
230
|
+
cursor = await db.execute(
|
|
231
|
+
"SELECT id FROM nodes WHERE id IN (?, ?) AND type = 'feature'",
|
|
232
|
+
[feature_id, request.linked_feature_id],
|
|
233
|
+
)
|
|
234
|
+
rows = await cursor.fetchall()
|
|
235
|
+
if len(rows) != 2:
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=404, detail="One or both features not found"
|
|
238
|
+
)
|
|
239
|
+
finally:
|
|
240
|
+
await db.close()
|
|
241
|
+
except HTTPException:
|
|
242
|
+
raise
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.error(f"Database error validating features: {e}")
|
|
245
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
246
|
+
|
|
247
|
+
# Broadcast link addition
|
|
248
|
+
clients_notified = await broadcaster.broadcast_link_added(
|
|
249
|
+
feature_id=feature_id,
|
|
250
|
+
linked_feature_id=request.linked_feature_id,
|
|
251
|
+
link_type=request.link_type,
|
|
252
|
+
agent_id=agent_id,
|
|
253
|
+
session_id=session_id,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
from datetime import datetime
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"success": True,
|
|
260
|
+
"feature_id": feature_id,
|
|
261
|
+
"linked_feature_id": request.linked_feature_id,
|
|
262
|
+
"link_type": request.link_type,
|
|
263
|
+
"clients_notified": clients_notified,
|
|
264
|
+
"timestamp": datetime.now().isoformat(),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@router.post("/tracks/{track_id}")
|
|
268
|
+
async def broadcast_track_update(
|
|
269
|
+
track_id: str,
|
|
270
|
+
update: dict[str, Any],
|
|
271
|
+
agent_id: str = Header(..., alias="X-Agent-ID"),
|
|
272
|
+
session_id: str = Header(..., alias="X-Session-ID"),
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""Broadcast track update to all sessions."""
|
|
275
|
+
# Validate track exists
|
|
276
|
+
try:
|
|
277
|
+
db = await get_db()
|
|
278
|
+
try:
|
|
279
|
+
cursor = await db.execute(
|
|
280
|
+
"SELECT id FROM nodes WHERE id = ? AND type = 'track'",
|
|
281
|
+
[track_id],
|
|
282
|
+
)
|
|
283
|
+
if not await cursor.fetchone():
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=404, detail=f"Track {track_id} not found"
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
await db.close()
|
|
289
|
+
except HTTPException:
|
|
290
|
+
raise
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Database error checking track: {e}")
|
|
293
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
294
|
+
|
|
295
|
+
# Broadcast update
|
|
296
|
+
clients_notified = await broadcaster.broadcast_track_update(
|
|
297
|
+
track_id=track_id,
|
|
298
|
+
agent_id=agent_id,
|
|
299
|
+
session_id=session_id,
|
|
300
|
+
payload=update,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
from datetime import datetime
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"success": True,
|
|
307
|
+
"track_id": track_id,
|
|
308
|
+
"clients_notified": clients_notified,
|
|
309
|
+
"timestamp": datetime.now().isoformat(),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@router.post("/spikes/{spike_id}")
|
|
313
|
+
async def broadcast_spike_update(
|
|
314
|
+
spike_id: str,
|
|
315
|
+
update: dict[str, Any],
|
|
316
|
+
agent_id: str = Header(..., alias="X-Agent-ID"),
|
|
317
|
+
session_id: str = Header(..., alias="X-Session-ID"),
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Broadcast spike update to all sessions."""
|
|
320
|
+
# Validate spike exists
|
|
321
|
+
try:
|
|
322
|
+
db = await get_db()
|
|
323
|
+
try:
|
|
324
|
+
cursor = await db.execute(
|
|
325
|
+
"SELECT id FROM nodes WHERE id = ? AND type = 'spike'",
|
|
326
|
+
[spike_id],
|
|
327
|
+
)
|
|
328
|
+
if not await cursor.fetchone():
|
|
329
|
+
raise HTTPException(
|
|
330
|
+
status_code=404, detail=f"Spike {spike_id} not found"
|
|
331
|
+
)
|
|
332
|
+
finally:
|
|
333
|
+
await db.close()
|
|
334
|
+
except HTTPException:
|
|
335
|
+
raise
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Database error checking spike: {e}")
|
|
338
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
339
|
+
|
|
340
|
+
# Broadcast update
|
|
341
|
+
clients_notified = await broadcaster.broadcast_spike_update(
|
|
342
|
+
spike_id=spike_id,
|
|
343
|
+
agent_id=agent_id,
|
|
344
|
+
session_id=session_id,
|
|
345
|
+
payload=update,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
from datetime import datetime
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"success": True,
|
|
352
|
+
"spike_id": spike_id,
|
|
353
|
+
"clients_notified": clients_notified,
|
|
354
|
+
"timestamp": datetime.now().isoformat(),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return router
|