autoforge-ai 0.1.0
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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Handlers
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Real-time updates for project progress, agent output, and dev server output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Set
|
|
15
|
+
|
|
16
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from .schemas import AGENT_MASCOTS
|
|
19
|
+
from .services.chat_constants import ROOT_DIR
|
|
20
|
+
from .services.dev_server_manager import get_devserver_manager
|
|
21
|
+
from .services.process_manager import get_manager
|
|
22
|
+
from .utils.project_helpers import get_project_path as _get_project_path
|
|
23
|
+
from .utils.validation import is_valid_project_name as validate_project_name
|
|
24
|
+
|
|
25
|
+
# Lazy imports
|
|
26
|
+
_count_passing_tests = None
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Pattern to extract feature ID from parallel orchestrator output
|
|
31
|
+
# Both coding and testing agents now use the same [Feature #X] format
|
|
32
|
+
FEATURE_ID_PATTERN = re.compile(r'\[Feature #(\d+)\]\s*(.*)')
|
|
33
|
+
|
|
34
|
+
# Pattern to detect testing agent start message (includes feature ID)
|
|
35
|
+
# Matches: "Started testing agent for feature #123 (PID xxx)"
|
|
36
|
+
TESTING_AGENT_START_PATTERN = re.compile(r'Started testing agent for feature #(\d+)')
|
|
37
|
+
|
|
38
|
+
# Pattern to detect testing agent completion
|
|
39
|
+
# Matches: "Feature #123 testing completed" or "Feature #123 testing failed"
|
|
40
|
+
TESTING_AGENT_COMPLETE_PATTERN = re.compile(r'Feature #(\d+) testing (completed|failed)')
|
|
41
|
+
|
|
42
|
+
# Pattern to detect batch coding agent start message
|
|
43
|
+
# Matches: "Started coding agent for features #5, #8, #12"
|
|
44
|
+
BATCH_CODING_AGENT_START_PATTERN = re.compile(r'Started coding agent for features (#\d+(?:,\s*#\d+)*)')
|
|
45
|
+
|
|
46
|
+
# Pattern to detect batch completion
|
|
47
|
+
# Matches: "Features #5, #8, #12 completed" or "Features #5, #8, #12 failed"
|
|
48
|
+
BATCH_FEATURES_COMPLETE_PATTERN = re.compile(r'Features (#\d+(?:,\s*#\d+)*)\s+(completed|failed)')
|
|
49
|
+
|
|
50
|
+
# Patterns for detecting agent activity and thoughts
|
|
51
|
+
THOUGHT_PATTERNS = [
|
|
52
|
+
# Claude's tool usage patterns (actual format: [Tool: name])
|
|
53
|
+
(re.compile(r'\[Tool:\s*Read\]', re.I), 'thinking'),
|
|
54
|
+
(re.compile(r'\[Tool:\s*(?:Write|Edit|NotebookEdit)\]', re.I), 'working'),
|
|
55
|
+
(re.compile(r'\[Tool:\s*Bash\]', re.I), 'testing'),
|
|
56
|
+
(re.compile(r'\[Tool:\s*(?:Glob|Grep)\]', re.I), 'thinking'),
|
|
57
|
+
(re.compile(r'\[Tool:\s*(\w+)\]', re.I), 'working'), # Fallback for other tools
|
|
58
|
+
# Claude's internal thoughts
|
|
59
|
+
(re.compile(r'(?:Reading|Analyzing|Checking|Looking at|Examining)\s+(.+)', re.I), 'thinking'),
|
|
60
|
+
(re.compile(r'(?:Creating|Writing|Adding|Implementing|Building)\s+(.+)', re.I), 'working'),
|
|
61
|
+
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
|
62
|
+
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
|
63
|
+
# Test results
|
|
64
|
+
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
|
|
65
|
+
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Orchestrator event patterns for Mission Control observability
|
|
69
|
+
ORCHESTRATOR_PATTERNS = {
|
|
70
|
+
'init_start': re.compile(r'Running initializer agent'),
|
|
71
|
+
'init_complete': re.compile(r'INITIALIZATION COMPLETE'),
|
|
72
|
+
'capacity_check': re.compile(r'\[DEBUG\] Spawning loop: (\d+) ready, (\d+) slots'),
|
|
73
|
+
'at_capacity': re.compile(r'At max capacity|at max testing agents|At max total agents'),
|
|
74
|
+
'feature_start': re.compile(r'Starting feature \d+/\d+: #(\d+) - (.+)'),
|
|
75
|
+
'coding_spawn': re.compile(r'Started coding agent for features? #(\d+)'),
|
|
76
|
+
'testing_spawn': re.compile(r'Started testing agent for feature #(\d+)'),
|
|
77
|
+
'coding_complete': re.compile(r'Features? #(\d+)(?:,\s*#\d+)* (completed|failed)'),
|
|
78
|
+
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
|
79
|
+
'all_complete': re.compile(r'All features complete'),
|
|
80
|
+
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AgentTracker:
|
|
85
|
+
"""Tracks active agents and their states for multi-agent mode.
|
|
86
|
+
|
|
87
|
+
Both coding and testing agents are tracked using a composite key of
|
|
88
|
+
(feature_id, agent_type) to allow simultaneous tracking of both agent
|
|
89
|
+
types for the same feature.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self):
|
|
93
|
+
# (feature_id, agent_type) -> {name, state, last_thought, agent_index, agent_type}
|
|
94
|
+
self.active_agents: dict[tuple[int, str], dict] = {}
|
|
95
|
+
self._next_agent_index = 0
|
|
96
|
+
self._lock = asyncio.Lock()
|
|
97
|
+
|
|
98
|
+
async def process_line(self, line: str) -> dict | None:
|
|
99
|
+
"""
|
|
100
|
+
Process an output line and return an agent_update message if relevant.
|
|
101
|
+
|
|
102
|
+
Returns None if no update should be emitted.
|
|
103
|
+
"""
|
|
104
|
+
# Check for orchestrator status messages first
|
|
105
|
+
# These don't have [Feature #X] prefix
|
|
106
|
+
|
|
107
|
+
# Batch coding agent start: "Started coding agent for features #5, #8, #12"
|
|
108
|
+
batch_start_match = BATCH_CODING_AGENT_START_PATTERN.match(line)
|
|
109
|
+
if batch_start_match:
|
|
110
|
+
try:
|
|
111
|
+
feature_ids = [int(x.strip().lstrip('#')) for x in batch_start_match.group(1).split(',')]
|
|
112
|
+
if feature_ids:
|
|
113
|
+
return await self._handle_batch_agent_start(feature_ids, "coding")
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Single coding agent start: "Started coding agent for feature #X"
|
|
118
|
+
if line.startswith("Started coding agent for feature #"):
|
|
119
|
+
m = re.search(r'#(\d+)', line)
|
|
120
|
+
if m:
|
|
121
|
+
try:
|
|
122
|
+
feature_id = int(m.group(1))
|
|
123
|
+
return await self._handle_agent_start(feature_id, line, agent_type="coding")
|
|
124
|
+
except ValueError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# Testing agent start: "Started testing agent for feature #X (PID xxx)"
|
|
128
|
+
testing_start_match = TESTING_AGENT_START_PATTERN.match(line)
|
|
129
|
+
if testing_start_match:
|
|
130
|
+
feature_id = int(testing_start_match.group(1))
|
|
131
|
+
return await self._handle_agent_start(feature_id, line, agent_type="testing")
|
|
132
|
+
|
|
133
|
+
# Testing agent complete: "Feature #X testing completed/failed"
|
|
134
|
+
testing_complete_match = TESTING_AGENT_COMPLETE_PATTERN.match(line)
|
|
135
|
+
if testing_complete_match:
|
|
136
|
+
feature_id = int(testing_complete_match.group(1))
|
|
137
|
+
is_success = testing_complete_match.group(2) == "completed"
|
|
138
|
+
return await self._handle_agent_complete(feature_id, is_success, agent_type="testing")
|
|
139
|
+
|
|
140
|
+
# Batch features complete: "Features #5, #8, #12 completed/failed"
|
|
141
|
+
batch_complete_match = BATCH_FEATURES_COMPLETE_PATTERN.match(line)
|
|
142
|
+
if batch_complete_match:
|
|
143
|
+
try:
|
|
144
|
+
feature_ids = [int(x.strip().lstrip('#')) for x in batch_complete_match.group(1).split(',')]
|
|
145
|
+
is_success = batch_complete_match.group(2) == "completed"
|
|
146
|
+
if feature_ids:
|
|
147
|
+
return await self._handle_batch_agent_complete(feature_ids, is_success, "coding")
|
|
148
|
+
except ValueError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# Coding agent complete: "Feature #X completed/failed" (without "testing" keyword)
|
|
152
|
+
if line.startswith("Feature #") and ("completed" in line or "failed" in line) and "testing" not in line:
|
|
153
|
+
m = re.search(r'#(\d+)', line)
|
|
154
|
+
if m:
|
|
155
|
+
try:
|
|
156
|
+
feature_id = int(m.group(1))
|
|
157
|
+
is_success = "completed" in line
|
|
158
|
+
return await self._handle_agent_complete(feature_id, is_success, agent_type="coding")
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Check for feature-specific output lines: [Feature #X] content
|
|
163
|
+
# Both coding and testing agents use this format now
|
|
164
|
+
match = FEATURE_ID_PATTERN.match(line)
|
|
165
|
+
if not match:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
feature_id = int(match.group(1))
|
|
169
|
+
content = match.group(2)
|
|
170
|
+
|
|
171
|
+
async with self._lock:
|
|
172
|
+
# Check if either coding or testing agent exists for this feature
|
|
173
|
+
# This prevents creating ghost agents when a testing agent outputs [Feature #X] lines
|
|
174
|
+
coding_key = (feature_id, 'coding')
|
|
175
|
+
testing_key = (feature_id, 'testing')
|
|
176
|
+
|
|
177
|
+
if coding_key in self.active_agents:
|
|
178
|
+
key = coding_key
|
|
179
|
+
elif testing_key in self.active_agents:
|
|
180
|
+
key = testing_key
|
|
181
|
+
else:
|
|
182
|
+
# Neither exists, create a new coding agent entry (implicit tracking)
|
|
183
|
+
key = coding_key
|
|
184
|
+
agent_index = self._next_agent_index
|
|
185
|
+
self._next_agent_index += 1
|
|
186
|
+
self.active_agents[key] = {
|
|
187
|
+
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
|
188
|
+
'agent_index': agent_index,
|
|
189
|
+
'agent_type': 'coding',
|
|
190
|
+
'feature_ids': [feature_id],
|
|
191
|
+
'state': 'thinking',
|
|
192
|
+
'feature_name': f'Feature #{feature_id}',
|
|
193
|
+
'last_thought': None,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
agent = self.active_agents[key]
|
|
197
|
+
|
|
198
|
+
# Update current_feature_id for batch agents when output comes from a different feature
|
|
199
|
+
if 'current_feature_id' in agent and feature_id in agent.get('feature_ids', []):
|
|
200
|
+
agent['current_feature_id'] = feature_id
|
|
201
|
+
|
|
202
|
+
# Detect state and thought from content
|
|
203
|
+
state = 'working'
|
|
204
|
+
thought = None
|
|
205
|
+
|
|
206
|
+
for pattern, detected_state in THOUGHT_PATTERNS:
|
|
207
|
+
m = pattern.search(content)
|
|
208
|
+
if m:
|
|
209
|
+
state = detected_state
|
|
210
|
+
thought = m.group(1) if m.lastindex else content[:100]
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
# Only emit update if state changed or we have a new thought
|
|
214
|
+
if state != agent['state'] or thought != agent['last_thought']:
|
|
215
|
+
agent['state'] = state
|
|
216
|
+
if thought:
|
|
217
|
+
agent['last_thought'] = thought
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
'type': 'agent_update',
|
|
221
|
+
'agentIndex': agent['agent_index'],
|
|
222
|
+
'agentName': agent['name'],
|
|
223
|
+
'agentType': agent['agent_type'],
|
|
224
|
+
'featureId': feature_id,
|
|
225
|
+
'featureIds': agent.get('feature_ids', [feature_id]),
|
|
226
|
+
'featureName': agent['feature_name'],
|
|
227
|
+
'state': state,
|
|
228
|
+
'thought': thought,
|
|
229
|
+
'timestamp': datetime.now().isoformat(),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
async def get_agent_info(self, feature_id: int, agent_type: str = "coding") -> tuple[int | None, str | None]:
|
|
235
|
+
"""Get agent index and name for a feature ID and agent type.
|
|
236
|
+
|
|
237
|
+
Thread-safe method that acquires the lock before reading state.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
feature_id: The feature ID to look up.
|
|
241
|
+
agent_type: The agent type ("coding" or "testing"). Defaults to "coding".
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Tuple of (agentIndex, agentName) or (None, None) if not tracked.
|
|
245
|
+
"""
|
|
246
|
+
async with self._lock:
|
|
247
|
+
key = (feature_id, agent_type)
|
|
248
|
+
agent = self.active_agents.get(key)
|
|
249
|
+
if agent:
|
|
250
|
+
return agent['agent_index'], agent['name']
|
|
251
|
+
return None, None
|
|
252
|
+
|
|
253
|
+
async def reset(self):
|
|
254
|
+
"""Reset tracker state when orchestrator stops or crashes.
|
|
255
|
+
|
|
256
|
+
Clears all active agents and resets the index counter to prevent
|
|
257
|
+
ghost agents accumulating across start/stop cycles.
|
|
258
|
+
|
|
259
|
+
Must be called with await since it acquires the async lock.
|
|
260
|
+
"""
|
|
261
|
+
async with self._lock:
|
|
262
|
+
self.active_agents.clear()
|
|
263
|
+
self._next_agent_index = 0
|
|
264
|
+
|
|
265
|
+
async def _handle_agent_start(self, feature_id: int, line: str, agent_type: str = "coding") -> dict | None:
|
|
266
|
+
"""Handle agent start message from orchestrator."""
|
|
267
|
+
async with self._lock:
|
|
268
|
+
key = (feature_id, agent_type) # Composite key for separate tracking
|
|
269
|
+
agent_index = self._next_agent_index
|
|
270
|
+
self._next_agent_index += 1
|
|
271
|
+
|
|
272
|
+
# Try to extract feature name from line
|
|
273
|
+
feature_name = f'Feature #{feature_id}'
|
|
274
|
+
name_match = re.search(r'#\d+:\s*(.+)$', line)
|
|
275
|
+
if name_match:
|
|
276
|
+
feature_name = name_match.group(1)
|
|
277
|
+
|
|
278
|
+
self.active_agents[key] = {
|
|
279
|
+
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
|
280
|
+
'agent_index': agent_index,
|
|
281
|
+
'agent_type': agent_type,
|
|
282
|
+
'feature_ids': [feature_id],
|
|
283
|
+
'state': 'thinking',
|
|
284
|
+
'feature_name': feature_name,
|
|
285
|
+
'last_thought': 'Starting work...',
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
'type': 'agent_update',
|
|
290
|
+
'agentIndex': agent_index,
|
|
291
|
+
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
|
292
|
+
'agentType': agent_type,
|
|
293
|
+
'featureId': feature_id,
|
|
294
|
+
'featureIds': [feature_id],
|
|
295
|
+
'featureName': feature_name,
|
|
296
|
+
'state': 'thinking',
|
|
297
|
+
'thought': 'Starting work...',
|
|
298
|
+
'timestamp': datetime.now().isoformat(),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async def _handle_batch_agent_start(self, feature_ids: list[int], agent_type: str = "coding") -> dict | None:
|
|
302
|
+
"""Handle batch agent start message from orchestrator."""
|
|
303
|
+
if not feature_ids:
|
|
304
|
+
return None
|
|
305
|
+
primary_id = feature_ids[0]
|
|
306
|
+
async with self._lock:
|
|
307
|
+
key = (primary_id, agent_type)
|
|
308
|
+
agent_index = self._next_agent_index
|
|
309
|
+
self._next_agent_index += 1
|
|
310
|
+
|
|
311
|
+
feature_name = f'Features {", ".join(f"#{fid}" for fid in feature_ids)}'
|
|
312
|
+
|
|
313
|
+
self.active_agents[key] = {
|
|
314
|
+
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
|
315
|
+
'agent_index': agent_index,
|
|
316
|
+
'agent_type': agent_type,
|
|
317
|
+
'feature_ids': list(feature_ids),
|
|
318
|
+
'current_feature_id': primary_id,
|
|
319
|
+
'state': 'thinking',
|
|
320
|
+
'feature_name': feature_name,
|
|
321
|
+
'last_thought': 'Starting batch work...',
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Register all feature IDs so output lines can find this agent
|
|
325
|
+
for fid in feature_ids:
|
|
326
|
+
secondary_key = (fid, agent_type)
|
|
327
|
+
if secondary_key != key:
|
|
328
|
+
self.active_agents[secondary_key] = self.active_agents[key]
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
'type': 'agent_update',
|
|
332
|
+
'agentIndex': agent_index,
|
|
333
|
+
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
|
334
|
+
'agentType': agent_type,
|
|
335
|
+
'featureId': primary_id,
|
|
336
|
+
'featureIds': list(feature_ids),
|
|
337
|
+
'featureName': feature_name,
|
|
338
|
+
'state': 'thinking',
|
|
339
|
+
'thought': 'Starting batch work...',
|
|
340
|
+
'timestamp': datetime.now().isoformat(),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async def _handle_agent_complete(self, feature_id: int, is_success: bool, agent_type: str = "coding") -> dict | None:
|
|
344
|
+
"""Handle agent completion - ALWAYS emits a message, even if agent wasn't tracked.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
feature_id: The feature ID.
|
|
348
|
+
is_success: Whether the agent completed successfully.
|
|
349
|
+
agent_type: The agent type ("coding" or "testing"). Defaults to "coding".
|
|
350
|
+
"""
|
|
351
|
+
async with self._lock:
|
|
352
|
+
key = (feature_id, agent_type) # Composite key for correct agent lookup
|
|
353
|
+
state = 'success' if is_success else 'error'
|
|
354
|
+
|
|
355
|
+
if key in self.active_agents:
|
|
356
|
+
# Normal case: agent was tracked
|
|
357
|
+
agent = self.active_agents[key]
|
|
358
|
+
result = {
|
|
359
|
+
'type': 'agent_update',
|
|
360
|
+
'agentIndex': agent['agent_index'],
|
|
361
|
+
'agentName': agent['name'],
|
|
362
|
+
'agentType': agent.get('agent_type', agent_type),
|
|
363
|
+
'featureId': feature_id,
|
|
364
|
+
'featureIds': agent.get('feature_ids', [feature_id]),
|
|
365
|
+
'featureName': agent['feature_name'],
|
|
366
|
+
'state': state,
|
|
367
|
+
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
|
|
368
|
+
'timestamp': datetime.now().isoformat(),
|
|
369
|
+
}
|
|
370
|
+
del self.active_agents[key]
|
|
371
|
+
return result
|
|
372
|
+
else:
|
|
373
|
+
# Synthetic completion for untracked agent
|
|
374
|
+
# This ensures UI always receives completion messages
|
|
375
|
+
return {
|
|
376
|
+
'type': 'agent_update',
|
|
377
|
+
'agentIndex': -1, # Sentinel for untracked
|
|
378
|
+
'agentName': 'Unknown',
|
|
379
|
+
'agentType': agent_type,
|
|
380
|
+
'featureId': feature_id,
|
|
381
|
+
'featureIds': [feature_id],
|
|
382
|
+
'featureName': f'Feature #{feature_id}',
|
|
383
|
+
'state': state,
|
|
384
|
+
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
|
|
385
|
+
'timestamp': datetime.now().isoformat(),
|
|
386
|
+
'synthetic': True,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async def _handle_batch_agent_complete(self, feature_ids: list[int], is_success: bool, agent_type: str = "coding") -> dict | None:
|
|
390
|
+
"""Handle batch agent completion."""
|
|
391
|
+
if not feature_ids:
|
|
392
|
+
return None
|
|
393
|
+
primary_id = feature_ids[0]
|
|
394
|
+
async with self._lock:
|
|
395
|
+
state = 'success' if is_success else 'error'
|
|
396
|
+
key = (primary_id, agent_type)
|
|
397
|
+
|
|
398
|
+
if key in self.active_agents:
|
|
399
|
+
agent = self.active_agents[key]
|
|
400
|
+
result = {
|
|
401
|
+
'type': 'agent_update',
|
|
402
|
+
'agentIndex': agent['agent_index'],
|
|
403
|
+
'agentName': agent['name'],
|
|
404
|
+
'agentType': agent.get('agent_type', agent_type),
|
|
405
|
+
'featureId': primary_id,
|
|
406
|
+
'featureIds': agent.get('feature_ids', list(feature_ids)),
|
|
407
|
+
'featureName': agent['feature_name'],
|
|
408
|
+
'state': state,
|
|
409
|
+
'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
|
|
410
|
+
'timestamp': datetime.now().isoformat(),
|
|
411
|
+
}
|
|
412
|
+
# Clean up all keys for this batch
|
|
413
|
+
for fid in feature_ids:
|
|
414
|
+
self.active_agents.pop((fid, agent_type), None)
|
|
415
|
+
return result
|
|
416
|
+
else:
|
|
417
|
+
# Synthetic completion
|
|
418
|
+
return {
|
|
419
|
+
'type': 'agent_update',
|
|
420
|
+
'agentIndex': -1,
|
|
421
|
+
'agentName': 'Unknown',
|
|
422
|
+
'agentType': agent_type,
|
|
423
|
+
'featureId': primary_id,
|
|
424
|
+
'featureIds': list(feature_ids),
|
|
425
|
+
'featureName': f'Features {", ".join(f"#{fid}" for fid in feature_ids)}',
|
|
426
|
+
'state': state,
|
|
427
|
+
'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
|
|
428
|
+
'timestamp': datetime.now().isoformat(),
|
|
429
|
+
'synthetic': True,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class OrchestratorTracker:
|
|
434
|
+
"""Tracks orchestrator state for Mission Control observability.
|
|
435
|
+
|
|
436
|
+
Parses orchestrator stdout for key events and emits orchestrator_update
|
|
437
|
+
WebSocket messages showing what decisions the orchestrator is making.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
def __init__(self):
|
|
441
|
+
self.state = 'idle'
|
|
442
|
+
self.coding_agents = 0
|
|
443
|
+
self.testing_agents = 0
|
|
444
|
+
self.max_concurrency = 3 # Default, will be updated from output
|
|
445
|
+
self.ready_count = 0
|
|
446
|
+
self.blocked_count = 0
|
|
447
|
+
self.recent_events: list[dict] = []
|
|
448
|
+
self._lock = asyncio.Lock()
|
|
449
|
+
|
|
450
|
+
async def process_line(self, line: str) -> dict | None:
|
|
451
|
+
"""
|
|
452
|
+
Process an output line and return an orchestrator_update message if relevant.
|
|
453
|
+
|
|
454
|
+
Returns None if no update should be emitted.
|
|
455
|
+
"""
|
|
456
|
+
async with self._lock:
|
|
457
|
+
update = None
|
|
458
|
+
|
|
459
|
+
# Check for initializer start
|
|
460
|
+
if ORCHESTRATOR_PATTERNS['init_start'].search(line):
|
|
461
|
+
self.state = 'initializing'
|
|
462
|
+
update = self._create_update(
|
|
463
|
+
'init_start',
|
|
464
|
+
'Initializing project features...'
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Check for initializer complete
|
|
468
|
+
elif ORCHESTRATOR_PATTERNS['init_complete'].search(line):
|
|
469
|
+
self.state = 'scheduling'
|
|
470
|
+
update = self._create_update(
|
|
471
|
+
'init_complete',
|
|
472
|
+
'Initialization complete, preparing to schedule features'
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Check for capacity status
|
|
476
|
+
elif match := ORCHESTRATOR_PATTERNS['capacity_check'].search(line):
|
|
477
|
+
self.ready_count = int(match.group(1))
|
|
478
|
+
slots = int(match.group(2))
|
|
479
|
+
self.state = 'scheduling' if self.ready_count > 0 else 'monitoring'
|
|
480
|
+
update = self._create_update(
|
|
481
|
+
'capacity_check',
|
|
482
|
+
f'{self.ready_count} features ready, {slots} slots available'
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# Check for at capacity
|
|
486
|
+
elif ORCHESTRATOR_PATTERNS['at_capacity'].search(line):
|
|
487
|
+
self.state = 'monitoring'
|
|
488
|
+
update = self._create_update(
|
|
489
|
+
'at_capacity',
|
|
490
|
+
'At maximum capacity, monitoring active agents'
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Check for feature start
|
|
494
|
+
elif match := ORCHESTRATOR_PATTERNS['feature_start'].search(line):
|
|
495
|
+
feature_id = int(match.group(1))
|
|
496
|
+
feature_name = match.group(2).strip()
|
|
497
|
+
self.state = 'spawning'
|
|
498
|
+
update = self._create_update(
|
|
499
|
+
'feature_start',
|
|
500
|
+
f'Preparing Feature #{feature_id}: {feature_name}',
|
|
501
|
+
feature_id=feature_id,
|
|
502
|
+
feature_name=feature_name
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Check for coding agent spawn
|
|
506
|
+
elif match := ORCHESTRATOR_PATTERNS['coding_spawn'].search(line):
|
|
507
|
+
feature_id = int(match.group(1))
|
|
508
|
+
self.coding_agents += 1
|
|
509
|
+
self.state = 'spawning'
|
|
510
|
+
update = self._create_update(
|
|
511
|
+
'coding_spawn',
|
|
512
|
+
f'Spawned coding agent for Feature #{feature_id}',
|
|
513
|
+
feature_id=feature_id
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Check for testing agent spawn
|
|
517
|
+
elif match := ORCHESTRATOR_PATTERNS['testing_spawn'].search(line):
|
|
518
|
+
feature_id = int(match.group(1))
|
|
519
|
+
self.testing_agents += 1
|
|
520
|
+
self.state = 'spawning'
|
|
521
|
+
update = self._create_update(
|
|
522
|
+
'testing_spawn',
|
|
523
|
+
f'Spawned testing agent for Feature #{feature_id}',
|
|
524
|
+
feature_id=feature_id
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Check for coding agent complete
|
|
528
|
+
elif match := ORCHESTRATOR_PATTERNS['coding_complete'].search(line):
|
|
529
|
+
# Only match if "testing" is not in the line
|
|
530
|
+
if 'testing' not in line.lower():
|
|
531
|
+
feature_id = int(match.group(1))
|
|
532
|
+
self.coding_agents = max(0, self.coding_agents - 1)
|
|
533
|
+
self.state = 'monitoring'
|
|
534
|
+
update = self._create_update(
|
|
535
|
+
'coding_complete',
|
|
536
|
+
f'Coding agent finished Feature #{feature_id}',
|
|
537
|
+
feature_id=feature_id
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Check for testing agent complete
|
|
541
|
+
elif match := ORCHESTRATOR_PATTERNS['testing_complete'].search(line):
|
|
542
|
+
feature_id = int(match.group(1))
|
|
543
|
+
self.testing_agents = max(0, self.testing_agents - 1)
|
|
544
|
+
self.state = 'monitoring'
|
|
545
|
+
update = self._create_update(
|
|
546
|
+
'testing_complete',
|
|
547
|
+
f'Testing agent finished Feature #{feature_id}',
|
|
548
|
+
feature_id=feature_id
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Check for blocked features count
|
|
552
|
+
elif match := ORCHESTRATOR_PATTERNS['blocked_features'].search(line):
|
|
553
|
+
self.blocked_count = int(match.group(1))
|
|
554
|
+
|
|
555
|
+
# Check for all complete
|
|
556
|
+
elif ORCHESTRATOR_PATTERNS['all_complete'].search(line):
|
|
557
|
+
self.state = 'complete'
|
|
558
|
+
self.coding_agents = 0
|
|
559
|
+
self.testing_agents = 0
|
|
560
|
+
update = self._create_update(
|
|
561
|
+
'all_complete',
|
|
562
|
+
'All features complete!'
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return update
|
|
566
|
+
|
|
567
|
+
def _create_update(
|
|
568
|
+
self,
|
|
569
|
+
event_type: str,
|
|
570
|
+
message: str,
|
|
571
|
+
feature_id: int | None = None,
|
|
572
|
+
feature_name: str | None = None
|
|
573
|
+
) -> dict:
|
|
574
|
+
"""Create an orchestrator_update WebSocket message."""
|
|
575
|
+
timestamp = datetime.now().isoformat()
|
|
576
|
+
|
|
577
|
+
# Add to recent events (keep last 5)
|
|
578
|
+
event: dict[str, str | int] = {
|
|
579
|
+
'eventType': event_type,
|
|
580
|
+
'message': message,
|
|
581
|
+
'timestamp': timestamp,
|
|
582
|
+
}
|
|
583
|
+
if feature_id is not None:
|
|
584
|
+
event['featureId'] = feature_id
|
|
585
|
+
if feature_name is not None:
|
|
586
|
+
event['featureName'] = feature_name
|
|
587
|
+
|
|
588
|
+
self.recent_events = [event] + self.recent_events[:4]
|
|
589
|
+
|
|
590
|
+
update = {
|
|
591
|
+
'type': 'orchestrator_update',
|
|
592
|
+
'eventType': event_type,
|
|
593
|
+
'state': self.state,
|
|
594
|
+
'message': message,
|
|
595
|
+
'timestamp': timestamp,
|
|
596
|
+
'codingAgents': self.coding_agents,
|
|
597
|
+
'testingAgents': self.testing_agents,
|
|
598
|
+
'maxConcurrency': self.max_concurrency,
|
|
599
|
+
'readyCount': self.ready_count,
|
|
600
|
+
'blockedCount': self.blocked_count,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if feature_id is not None:
|
|
604
|
+
update['featureId'] = feature_id
|
|
605
|
+
if feature_name is not None:
|
|
606
|
+
update['featureName'] = feature_name
|
|
607
|
+
|
|
608
|
+
return update
|
|
609
|
+
|
|
610
|
+
async def reset(self):
|
|
611
|
+
"""Reset tracker state when orchestrator stops or crashes."""
|
|
612
|
+
async with self._lock:
|
|
613
|
+
self.state = 'idle'
|
|
614
|
+
self.coding_agents = 0
|
|
615
|
+
self.testing_agents = 0
|
|
616
|
+
self.ready_count = 0
|
|
617
|
+
self.blocked_count = 0
|
|
618
|
+
self.recent_events.clear()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _get_count_passing_tests():
|
|
622
|
+
"""Lazy import of count_passing_tests."""
|
|
623
|
+
global _count_passing_tests
|
|
624
|
+
if _count_passing_tests is None:
|
|
625
|
+
import sys
|
|
626
|
+
root = Path(__file__).parent.parent
|
|
627
|
+
if str(root) not in sys.path:
|
|
628
|
+
sys.path.insert(0, str(root))
|
|
629
|
+
from progress import count_passing_tests
|
|
630
|
+
_count_passing_tests = count_passing_tests
|
|
631
|
+
return _count_passing_tests
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class ConnectionManager:
|
|
635
|
+
"""Manages WebSocket connections per project."""
|
|
636
|
+
|
|
637
|
+
def __init__(self):
|
|
638
|
+
# project_name -> set of WebSocket connections
|
|
639
|
+
self.active_connections: dict[str, Set[WebSocket]] = {}
|
|
640
|
+
self._lock = asyncio.Lock()
|
|
641
|
+
|
|
642
|
+
async def connect(self, websocket: WebSocket, project_name: str):
|
|
643
|
+
"""Accept a WebSocket connection for a project."""
|
|
644
|
+
await websocket.accept()
|
|
645
|
+
|
|
646
|
+
async with self._lock:
|
|
647
|
+
if project_name not in self.active_connections:
|
|
648
|
+
self.active_connections[project_name] = set()
|
|
649
|
+
self.active_connections[project_name].add(websocket)
|
|
650
|
+
|
|
651
|
+
async def disconnect(self, websocket: WebSocket, project_name: str):
|
|
652
|
+
"""Remove a WebSocket connection."""
|
|
653
|
+
async with self._lock:
|
|
654
|
+
if project_name in self.active_connections:
|
|
655
|
+
self.active_connections[project_name].discard(websocket)
|
|
656
|
+
if not self.active_connections[project_name]:
|
|
657
|
+
del self.active_connections[project_name]
|
|
658
|
+
|
|
659
|
+
async def broadcast_to_project(self, project_name: str, message: dict):
|
|
660
|
+
"""Broadcast a message to all connections for a project."""
|
|
661
|
+
async with self._lock:
|
|
662
|
+
connections = list(self.active_connections.get(project_name, set()))
|
|
663
|
+
|
|
664
|
+
dead_connections = []
|
|
665
|
+
|
|
666
|
+
for connection in connections:
|
|
667
|
+
try:
|
|
668
|
+
await connection.send_json(message)
|
|
669
|
+
except Exception:
|
|
670
|
+
dead_connections.append(connection)
|
|
671
|
+
|
|
672
|
+
# Clean up dead connections
|
|
673
|
+
if dead_connections:
|
|
674
|
+
async with self._lock:
|
|
675
|
+
for connection in dead_connections:
|
|
676
|
+
if project_name in self.active_connections:
|
|
677
|
+
self.active_connections[project_name].discard(connection)
|
|
678
|
+
|
|
679
|
+
def get_connection_count(self, project_name: str) -> int:
|
|
680
|
+
"""Get number of active connections for a project."""
|
|
681
|
+
return len(self.active_connections.get(project_name, set()))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# Global connection manager
|
|
685
|
+
manager = ConnectionManager()
|
|
686
|
+
|
|
687
|
+
async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Path):
|
|
688
|
+
"""Poll database for progress changes and send updates."""
|
|
689
|
+
count_passing_tests = _get_count_passing_tests()
|
|
690
|
+
last_passing = -1
|
|
691
|
+
last_in_progress = -1
|
|
692
|
+
last_total = -1
|
|
693
|
+
|
|
694
|
+
while True:
|
|
695
|
+
try:
|
|
696
|
+
passing, in_progress, total = count_passing_tests(project_dir)
|
|
697
|
+
|
|
698
|
+
# Only send if changed
|
|
699
|
+
if passing != last_passing or in_progress != last_in_progress or total != last_total:
|
|
700
|
+
last_passing = passing
|
|
701
|
+
last_in_progress = in_progress
|
|
702
|
+
last_total = total
|
|
703
|
+
percentage = (passing / total * 100) if total > 0 else 0
|
|
704
|
+
|
|
705
|
+
await websocket.send_json({
|
|
706
|
+
"type": "progress",
|
|
707
|
+
"passing": passing,
|
|
708
|
+
"in_progress": in_progress,
|
|
709
|
+
"total": total,
|
|
710
|
+
"percentage": round(percentage, 1),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
await asyncio.sleep(2) # Poll every 2 seconds
|
|
714
|
+
except asyncio.CancelledError:
|
|
715
|
+
raise
|
|
716
|
+
except Exception as e:
|
|
717
|
+
logger.warning(f"Progress polling error: {e}")
|
|
718
|
+
break
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def project_websocket(websocket: WebSocket, project_name: str):
|
|
722
|
+
"""
|
|
723
|
+
WebSocket endpoint for project updates.
|
|
724
|
+
|
|
725
|
+
Streams:
|
|
726
|
+
- Progress updates (passing/total counts)
|
|
727
|
+
- Agent status changes
|
|
728
|
+
- Agent stdout/stderr lines
|
|
729
|
+
"""
|
|
730
|
+
if not validate_project_name(project_name):
|
|
731
|
+
await websocket.close(code=4000, reason="Invalid project name")
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
project_dir = _get_project_path(project_name)
|
|
735
|
+
if not project_dir:
|
|
736
|
+
await websocket.close(code=4004, reason="Project not found in registry")
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
if not project_dir.exists():
|
|
740
|
+
await websocket.close(code=4004, reason="Project directory not found")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
await manager.connect(websocket, project_name)
|
|
744
|
+
|
|
745
|
+
# Get agent manager and register callbacks
|
|
746
|
+
agent_manager = get_manager(project_name, project_dir, ROOT_DIR)
|
|
747
|
+
|
|
748
|
+
# Create agent tracker for multi-agent mode
|
|
749
|
+
agent_tracker = AgentTracker()
|
|
750
|
+
|
|
751
|
+
# Create orchestrator tracker for observability
|
|
752
|
+
orchestrator_tracker = OrchestratorTracker()
|
|
753
|
+
|
|
754
|
+
async def on_output(line: str):
|
|
755
|
+
"""Handle agent output - broadcast to this WebSocket."""
|
|
756
|
+
try:
|
|
757
|
+
# Extract feature ID from line if present
|
|
758
|
+
feature_id = None
|
|
759
|
+
agent_index = None
|
|
760
|
+
match = FEATURE_ID_PATTERN.match(line)
|
|
761
|
+
if match:
|
|
762
|
+
feature_id = int(match.group(1))
|
|
763
|
+
agent_index, _ = await agent_tracker.get_agent_info(feature_id)
|
|
764
|
+
|
|
765
|
+
# Send the raw log line with optional feature/agent attribution
|
|
766
|
+
log_msg: dict[str, str | int] = {
|
|
767
|
+
"type": "log",
|
|
768
|
+
"line": line,
|
|
769
|
+
"timestamp": datetime.now().isoformat(),
|
|
770
|
+
}
|
|
771
|
+
if feature_id is not None:
|
|
772
|
+
log_msg["featureId"] = feature_id
|
|
773
|
+
if agent_index is not None:
|
|
774
|
+
log_msg["agentIndex"] = agent_index
|
|
775
|
+
|
|
776
|
+
await websocket.send_json(log_msg)
|
|
777
|
+
|
|
778
|
+
# Check if this line indicates agent activity (parallel mode)
|
|
779
|
+
# and emit agent_update messages if so
|
|
780
|
+
agent_update = await agent_tracker.process_line(line)
|
|
781
|
+
if agent_update:
|
|
782
|
+
await websocket.send_json(agent_update)
|
|
783
|
+
|
|
784
|
+
# Also check for orchestrator events and emit orchestrator_update messages
|
|
785
|
+
orch_update = await orchestrator_tracker.process_line(line)
|
|
786
|
+
if orch_update:
|
|
787
|
+
await websocket.send_json(orch_update)
|
|
788
|
+
except Exception:
|
|
789
|
+
pass # Connection may be closed
|
|
790
|
+
|
|
791
|
+
async def on_status_change(status: str):
|
|
792
|
+
"""Handle status change - broadcast to this WebSocket."""
|
|
793
|
+
try:
|
|
794
|
+
await websocket.send_json({
|
|
795
|
+
"type": "agent_status",
|
|
796
|
+
"status": status,
|
|
797
|
+
})
|
|
798
|
+
# Reset trackers when agent stops OR crashes to prevent ghost agents on restart
|
|
799
|
+
if status in ("stopped", "crashed"):
|
|
800
|
+
await agent_tracker.reset()
|
|
801
|
+
await orchestrator_tracker.reset()
|
|
802
|
+
except Exception:
|
|
803
|
+
pass # Connection may be closed
|
|
804
|
+
|
|
805
|
+
# Register callbacks
|
|
806
|
+
agent_manager.add_output_callback(on_output)
|
|
807
|
+
agent_manager.add_status_callback(on_status_change)
|
|
808
|
+
|
|
809
|
+
# Get dev server manager and register callbacks
|
|
810
|
+
devserver_manager = get_devserver_manager(project_name, project_dir)
|
|
811
|
+
|
|
812
|
+
async def on_dev_output(line: str):
|
|
813
|
+
"""Handle dev server output - broadcast to this WebSocket."""
|
|
814
|
+
try:
|
|
815
|
+
await websocket.send_json({
|
|
816
|
+
"type": "dev_log",
|
|
817
|
+
"line": line,
|
|
818
|
+
"timestamp": datetime.now().isoformat(),
|
|
819
|
+
})
|
|
820
|
+
except Exception:
|
|
821
|
+
pass # Connection may be closed
|
|
822
|
+
|
|
823
|
+
async def on_dev_status_change(status: str):
|
|
824
|
+
"""Handle dev server status change - broadcast to this WebSocket."""
|
|
825
|
+
try:
|
|
826
|
+
await websocket.send_json({
|
|
827
|
+
"type": "dev_server_status",
|
|
828
|
+
"status": status,
|
|
829
|
+
"url": devserver_manager.detected_url,
|
|
830
|
+
})
|
|
831
|
+
except Exception:
|
|
832
|
+
pass # Connection may be closed
|
|
833
|
+
|
|
834
|
+
# Register dev server callbacks
|
|
835
|
+
devserver_manager.add_output_callback(on_dev_output)
|
|
836
|
+
devserver_manager.add_status_callback(on_dev_status_change)
|
|
837
|
+
|
|
838
|
+
# Start progress polling task
|
|
839
|
+
poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir))
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
# Send initial agent status
|
|
843
|
+
await websocket.send_json({
|
|
844
|
+
"type": "agent_status",
|
|
845
|
+
"status": agent_manager.status,
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
# Send initial dev server status
|
|
849
|
+
await websocket.send_json({
|
|
850
|
+
"type": "dev_server_status",
|
|
851
|
+
"status": devserver_manager.status,
|
|
852
|
+
"url": devserver_manager.detected_url,
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
# Send initial progress
|
|
856
|
+
count_passing_tests = _get_count_passing_tests()
|
|
857
|
+
passing, in_progress, total = count_passing_tests(project_dir)
|
|
858
|
+
percentage = (passing / total * 100) if total > 0 else 0
|
|
859
|
+
await websocket.send_json({
|
|
860
|
+
"type": "progress",
|
|
861
|
+
"passing": passing,
|
|
862
|
+
"in_progress": in_progress,
|
|
863
|
+
"total": total,
|
|
864
|
+
"percentage": round(percentage, 1),
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
# Keep connection alive and handle incoming messages
|
|
868
|
+
while True:
|
|
869
|
+
try:
|
|
870
|
+
# Wait for any incoming messages (ping/pong, commands, etc.)
|
|
871
|
+
data = await websocket.receive_text()
|
|
872
|
+
message = json.loads(data)
|
|
873
|
+
|
|
874
|
+
# Handle ping
|
|
875
|
+
if message.get("type") == "ping":
|
|
876
|
+
await websocket.send_json({"type": "pong"})
|
|
877
|
+
|
|
878
|
+
except WebSocketDisconnect:
|
|
879
|
+
break
|
|
880
|
+
except json.JSONDecodeError:
|
|
881
|
+
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
|
882
|
+
except Exception as e:
|
|
883
|
+
logger.warning(f"WebSocket error: {e}")
|
|
884
|
+
break
|
|
885
|
+
|
|
886
|
+
finally:
|
|
887
|
+
# Clean up
|
|
888
|
+
poll_task.cancel()
|
|
889
|
+
try:
|
|
890
|
+
await poll_task
|
|
891
|
+
except asyncio.CancelledError:
|
|
892
|
+
pass
|
|
893
|
+
|
|
894
|
+
# Unregister agent callbacks
|
|
895
|
+
agent_manager.remove_output_callback(on_output)
|
|
896
|
+
agent_manager.remove_status_callback(on_status_change)
|
|
897
|
+
|
|
898
|
+
# Unregister dev server callbacks
|
|
899
|
+
devserver_manager.remove_output_callback(on_dev_output)
|
|
900
|
+
devserver_manager.remove_status_callback(on_dev_status_change)
|
|
901
|
+
|
|
902
|
+
# Disconnect from manager
|
|
903
|
+
await manager.disconnect(websocket, project_name)
|