pdd-cli 0.0.90__py3-none-any.whl → 0.0.118__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.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +521 -786
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +25 -8
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +185 -3
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +195 -23
- pdd/cmd_test_main.py +345 -197
- pdd/code_generator.py +4 -2
- pdd/code_generator_main.py +118 -32
- pdd/commands/__init__.py +6 -0
- pdd/commands/analysis.py +87 -29
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +136 -113
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +190 -164
- pdd/commands/sessions.py +284 -0
- pdd/construct_paths.py +334 -32
- pdd/context_generator_main.py +167 -170
- pdd/continue_generation.py +6 -3
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +27 -3
- pdd/core/cloud.py +237 -0
- pdd/core/errors.py +4 -0
- pdd/core/remote_session.py +61 -0
- pdd/crash_main.py +219 -23
- pdd/data/llm_model.csv +4 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +208 -34
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +291 -38
- pdd/fix_main.py +204 -4
- pdd/fix_verification_errors_loop.py +235 -26
- pdd/fix_verification_main.py +269 -83
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +46 -5
- pdd/generate_test.py +212 -151
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +309 -20
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +7 -5
- pdd/insert_includes.py +2 -1
- pdd/llm_invoke.py +459 -95
- pdd/load_prompt_template.py +15 -34
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +4 -1
- pdd/preprocess.py +68 -12
- pdd/preprocess_main.py +33 -1
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
- pdd/prompts/agentic_update_LLM.prompt +192 -338
- pdd/prompts/auto_include_LLM.prompt +22 -0
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +571 -14
- pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
- pdd/prompts/generate_test_LLM.prompt +20 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/remote_session.py +876 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/summarize_directory.py +236 -237
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +329 -47
- pdd/sync_main.py +272 -28
- pdd/sync_orchestration.py +136 -75
- pdd/template_expander.py +161 -0
- pdd/templates/architecture/architecture_json.prompt +41 -46
- pdd/trace.py +1 -1
- pdd/track_cost.py +0 -13
- pdd/unfinished_prompt.py +2 -1
- pdd/update_main.py +23 -5
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Set, Any, Union
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, status
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from watchdog.observers import Observer
|
|
14
|
+
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
|
15
|
+
|
|
16
|
+
# Relative imports
|
|
17
|
+
from ..models import (
|
|
18
|
+
WSMessage,
|
|
19
|
+
StdoutMessage,
|
|
20
|
+
StderrMessage,
|
|
21
|
+
ProgressMessage,
|
|
22
|
+
JobStatus,
|
|
23
|
+
)
|
|
24
|
+
from ..jobs import JobManager, Job, JobStatus as JobStatusEnum
|
|
25
|
+
|
|
26
|
+
# Initialize console for logging
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# Regex for stripping ANSI codes (CSI sequences)
|
|
30
|
+
ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*m')
|
|
31
|
+
|
|
32
|
+
# Global constants
|
|
33
|
+
DEBOUNCE_SECONDS = 0.1
|
|
34
|
+
|
|
35
|
+
router = APIRouter(tags=["websocket"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ============================================================================
|
|
39
|
+
# Utilities
|
|
40
|
+
# ============================================================================
|
|
41
|
+
|
|
42
|
+
def clean_ansi(text: str) -> str:
|
|
43
|
+
"""Remove ANSI escape sequences from text."""
|
|
44
|
+
return ANSI_ESCAPE.sub('', text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConnectionManager:
|
|
48
|
+
"""
|
|
49
|
+
Manages WebSocket connections for job streaming and file watching.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
# All active connections
|
|
54
|
+
self.active_connections: List[WebSocket] = []
|
|
55
|
+
# Map job_id -> list of WebSockets watching that job
|
|
56
|
+
self.job_subscriptions: Dict[str, List[WebSocket]] = {}
|
|
57
|
+
# List of WebSockets watching file changes
|
|
58
|
+
self.watch_subscriptions: List[WebSocket] = []
|
|
59
|
+
|
|
60
|
+
async def connect(self, websocket: WebSocket):
|
|
61
|
+
"""Accept a new connection."""
|
|
62
|
+
await websocket.accept()
|
|
63
|
+
self.active_connections.append(websocket)
|
|
64
|
+
|
|
65
|
+
def disconnect(self, websocket: WebSocket, job_id: Optional[str] = None):
|
|
66
|
+
"""Remove a connection."""
|
|
67
|
+
if websocket in self.active_connections:
|
|
68
|
+
self.active_connections.remove(websocket)
|
|
69
|
+
|
|
70
|
+
if job_id and job_id in self.job_subscriptions:
|
|
71
|
+
if websocket in self.job_subscriptions[job_id]:
|
|
72
|
+
self.job_subscriptions[job_id].remove(websocket)
|
|
73
|
+
if not self.job_subscriptions[job_id]:
|
|
74
|
+
del self.job_subscriptions[job_id]
|
|
75
|
+
|
|
76
|
+
if websocket in self.watch_subscriptions:
|
|
77
|
+
self.watch_subscriptions.remove(websocket)
|
|
78
|
+
|
|
79
|
+
async def subscribe_to_job(self, websocket: WebSocket, job_id: str):
|
|
80
|
+
"""Subscribe a websocket to a specific job's events."""
|
|
81
|
+
if job_id not in self.job_subscriptions:
|
|
82
|
+
self.job_subscriptions[job_id] = []
|
|
83
|
+
self.job_subscriptions[job_id].append(websocket)
|
|
84
|
+
|
|
85
|
+
async def subscribe_to_watch(self, websocket: WebSocket):
|
|
86
|
+
"""Subscribe a websocket to file watcher events."""
|
|
87
|
+
if websocket not in self.watch_subscriptions:
|
|
88
|
+
self.watch_subscriptions.append(websocket)
|
|
89
|
+
|
|
90
|
+
async def broadcast_job_message(self, job_id: str, message: WSMessage):
|
|
91
|
+
"""Send a message to all clients watching a specific job."""
|
|
92
|
+
if job_id not in self.job_subscriptions:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Serialize once
|
|
96
|
+
data = message.model_dump_json()
|
|
97
|
+
|
|
98
|
+
# Broadcast
|
|
99
|
+
to_remove = []
|
|
100
|
+
for connection in self.job_subscriptions[job_id]:
|
|
101
|
+
try:
|
|
102
|
+
await connection.send_text(data)
|
|
103
|
+
except Exception:
|
|
104
|
+
to_remove.append(connection)
|
|
105
|
+
|
|
106
|
+
# Cleanup dead connections
|
|
107
|
+
for connection in to_remove:
|
|
108
|
+
self.disconnect(connection, job_id)
|
|
109
|
+
|
|
110
|
+
async def broadcast_file_change(self, message: WSMessage):
|
|
111
|
+
"""Send a file change event to all watchers."""
|
|
112
|
+
data = message.model_dump_json()
|
|
113
|
+
to_remove = []
|
|
114
|
+
for connection in self.watch_subscriptions:
|
|
115
|
+
try:
|
|
116
|
+
await connection.send_text(data)
|
|
117
|
+
except Exception:
|
|
118
|
+
to_remove.append(connection)
|
|
119
|
+
|
|
120
|
+
for connection in to_remove:
|
|
121
|
+
self.disconnect(connection)
|
|
122
|
+
|
|
123
|
+
async def broadcast_to_all(self, message: WSMessage):
|
|
124
|
+
"""Send a message to ALL connected clients."""
|
|
125
|
+
data = message.model_dump_json()
|
|
126
|
+
to_remove = []
|
|
127
|
+
for connection in self.active_connections:
|
|
128
|
+
try:
|
|
129
|
+
await connection.send_text(data)
|
|
130
|
+
except Exception:
|
|
131
|
+
to_remove.append(connection)
|
|
132
|
+
|
|
133
|
+
for connection in to_remove:
|
|
134
|
+
self.disconnect(connection)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Global manager instance
|
|
138
|
+
manager = ConnectionManager()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ============================================================================
|
|
142
|
+
# File Watcher Logic
|
|
143
|
+
# ============================================================================
|
|
144
|
+
|
|
145
|
+
class AsyncFileEventHandler(FileSystemEventHandler):
|
|
146
|
+
"""
|
|
147
|
+
Watchdog handler that bridges file system events to an asyncio queue.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue):
|
|
151
|
+
self.loop = loop
|
|
152
|
+
self.queue = queue
|
|
153
|
+
self.last_events: Dict[str, float] = {}
|
|
154
|
+
|
|
155
|
+
def _process_event(self, event: FileSystemEvent, event_type: str):
|
|
156
|
+
if event.is_directory:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Debounce
|
|
160
|
+
now = time.time()
|
|
161
|
+
path = str(event.src_path)
|
|
162
|
+
last_time = self.last_events.get(path, 0)
|
|
163
|
+
|
|
164
|
+
if now - last_time < DEBOUNCE_SECONDS:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
self.last_events[path] = now
|
|
168
|
+
|
|
169
|
+
# Create message
|
|
170
|
+
msg = WSMessage(
|
|
171
|
+
type="file_change",
|
|
172
|
+
data={
|
|
173
|
+
"path": path,
|
|
174
|
+
"event": event_type,
|
|
175
|
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Schedule putting into queue on the main loop
|
|
180
|
+
self.loop.call_soon_threadsafe(self.queue.put_nowait, msg)
|
|
181
|
+
|
|
182
|
+
def on_modified(self, event: FileSystemEvent):
|
|
183
|
+
self._process_event(event, "modified")
|
|
184
|
+
|
|
185
|
+
def on_created(self, event: FileSystemEvent):
|
|
186
|
+
self._process_event(event, "created")
|
|
187
|
+
|
|
188
|
+
def on_deleted(self, event: FileSystemEvent):
|
|
189
|
+
self._process_event(event, "deleted")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ============================================================================
|
|
193
|
+
# Dependencies
|
|
194
|
+
# ============================================================================
|
|
195
|
+
|
|
196
|
+
# Placeholder for dependency injection.
|
|
197
|
+
# In a real app, this would be imported from ..dependencies or ..main
|
|
198
|
+
async def get_job_manager():
|
|
199
|
+
"""
|
|
200
|
+
Dependency to retrieve the JobManager instance.
|
|
201
|
+
This should be overridden by the app's dependency_overrides.
|
|
202
|
+
"""
|
|
203
|
+
raise NotImplementedError("JobManager dependency not configured")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def get_project_root():
|
|
207
|
+
"""
|
|
208
|
+
Dependency to retrieve the project root path.
|
|
209
|
+
"""
|
|
210
|
+
raise NotImplementedError("Project root dependency not configured")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ============================================================================
|
|
214
|
+
# Endpoints
|
|
215
|
+
# ============================================================================
|
|
216
|
+
|
|
217
|
+
@router.websocket("/ws/jobs/{job_id}/stream")
|
|
218
|
+
async def websocket_job_stream(
|
|
219
|
+
websocket: WebSocket,
|
|
220
|
+
job_id: str,
|
|
221
|
+
job_manager: JobManager = Depends(get_job_manager)
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
WebSocket endpoint for streaming job output and interaction.
|
|
225
|
+
"""
|
|
226
|
+
await manager.connect(websocket)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
job = job_manager.get_job(job_id)
|
|
230
|
+
if not job:
|
|
231
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Job not found")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
await manager.subscribe_to_job(websocket, job_id)
|
|
235
|
+
console.print(f"[cyan]WS:[/cyan] Client connected to stream for job {job_id}")
|
|
236
|
+
|
|
237
|
+
# If job is already completed, send the result immediately
|
|
238
|
+
if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
|
|
239
|
+
result_msg = WSMessage(
|
|
240
|
+
type="complete",
|
|
241
|
+
data={
|
|
242
|
+
"success": job.status == JobStatus.COMPLETED,
|
|
243
|
+
"result": job.result,
|
|
244
|
+
"cost": job.cost,
|
|
245
|
+
"status": job.status.value
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
await websocket.send_text(result_msg.model_dump_json())
|
|
249
|
+
await websocket.close()
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Listen for client messages (input/cancel)
|
|
253
|
+
while True:
|
|
254
|
+
data = await websocket.receive_text()
|
|
255
|
+
try:
|
|
256
|
+
message = json.loads(data)
|
|
257
|
+
msg_type = message.get("type")
|
|
258
|
+
|
|
259
|
+
if msg_type == "cancel":
|
|
260
|
+
console.print(f"[cyan]WS:[/cyan] Cancel request for job {job_id}")
|
|
261
|
+
await job_manager.cancel(job_id)
|
|
262
|
+
|
|
263
|
+
elif msg_type == "input":
|
|
264
|
+
# In a real implementation, this would pipe data to the job's stdin
|
|
265
|
+
user_input = message.get("data", "")
|
|
266
|
+
console.print(f"[cyan]WS:[/cyan] Input received for job {job_id}: {len(user_input)} chars")
|
|
267
|
+
# TODO: Implement stdin piping in JobManager
|
|
268
|
+
|
|
269
|
+
except json.JSONDecodeError:
|
|
270
|
+
error_msg = WSMessage(
|
|
271
|
+
type="error",
|
|
272
|
+
data={"message": "Invalid JSON format"}
|
|
273
|
+
)
|
|
274
|
+
await websocket.send_text(error_msg.model_dump_json())
|
|
275
|
+
|
|
276
|
+
except WebSocketDisconnect:
|
|
277
|
+
console.print(f"[cyan]WS:[/cyan] Client disconnected from job {job_id}")
|
|
278
|
+
except Exception as e:
|
|
279
|
+
console.print(f"[bold red]WS Error:[/bold red] {e}")
|
|
280
|
+
finally:
|
|
281
|
+
manager.disconnect(websocket, job_id)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.websocket("/ws/watch")
|
|
285
|
+
async def websocket_watch(
|
|
286
|
+
websocket: WebSocket,
|
|
287
|
+
project_root: Path = Depends(get_project_root)
|
|
288
|
+
):
|
|
289
|
+
"""
|
|
290
|
+
WebSocket endpoint for watching file system changes.
|
|
291
|
+
"""
|
|
292
|
+
await manager.connect(websocket)
|
|
293
|
+
|
|
294
|
+
observer = Observer()
|
|
295
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
296
|
+
loop = asyncio.get_running_loop()
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# 1. Wait for subscription message
|
|
300
|
+
# Client must send: {"paths": ["src", "public"]}
|
|
301
|
+
data = await websocket.receive_text()
|
|
302
|
+
subscription = json.loads(data)
|
|
303
|
+
paths_to_watch = subscription.get("paths", [])
|
|
304
|
+
|
|
305
|
+
if not paths_to_watch:
|
|
306
|
+
# Default to watching root if nothing specified
|
|
307
|
+
paths_to_watch = ["."]
|
|
308
|
+
|
|
309
|
+
# 2. Setup Watchdog
|
|
310
|
+
handler = AsyncFileEventHandler(loop, queue)
|
|
311
|
+
|
|
312
|
+
for path_str in paths_to_watch:
|
|
313
|
+
watch_path = (project_root / path_str).resolve()
|
|
314
|
+
if watch_path.exists() and watch_path.is_dir():
|
|
315
|
+
observer.schedule(handler, str(watch_path), recursive=True)
|
|
316
|
+
console.print(f"[cyan]WS:[/cyan] Watching path: {watch_path}")
|
|
317
|
+
else:
|
|
318
|
+
console.print(f"[yellow]WS Warning:[/yellow] Path not found or not a directory: {watch_path}")
|
|
319
|
+
|
|
320
|
+
observer.start()
|
|
321
|
+
await manager.subscribe_to_watch(websocket)
|
|
322
|
+
|
|
323
|
+
# 3. Event Loop
|
|
324
|
+
# We need to handle both incoming messages (ping/close) and outgoing events
|
|
325
|
+
while True:
|
|
326
|
+
# Create tasks for receiving from WS and reading from Queue
|
|
327
|
+
receive_task = asyncio.create_task(websocket.receive_text())
|
|
328
|
+
queue_task = asyncio.create_task(queue.get())
|
|
329
|
+
|
|
330
|
+
done, pending = await asyncio.wait(
|
|
331
|
+
[receive_task, queue_task],
|
|
332
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Handle File Events
|
|
336
|
+
if queue_task in done:
|
|
337
|
+
msg = queue_task.result()
|
|
338
|
+
await websocket.send_text(msg.model_dump_json())
|
|
339
|
+
# Cancel receive task to restart loop cleanly
|
|
340
|
+
receive_task.cancel()
|
|
341
|
+
else:
|
|
342
|
+
queue_task.cancel()
|
|
343
|
+
|
|
344
|
+
# Handle Client Disconnect/Messages
|
|
345
|
+
if receive_task in done:
|
|
346
|
+
try:
|
|
347
|
+
_ = receive_task.result()
|
|
348
|
+
# We ignore client messages after subscription, but keep connection alive
|
|
349
|
+
except WebSocketDisconnect:
|
|
350
|
+
raise
|
|
351
|
+
except Exception:
|
|
352
|
+
# If receive failed, connection is likely dead
|
|
353
|
+
raise WebSocketDisconnect()
|
|
354
|
+
|
|
355
|
+
except WebSocketDisconnect:
|
|
356
|
+
console.print("[cyan]WS:[/cyan] Watcher disconnected")
|
|
357
|
+
except Exception as e:
|
|
358
|
+
console.print(f"[bold red]WS Watch Error:[/bold red] {e}")
|
|
359
|
+
finally:
|
|
360
|
+
if observer.is_alive():
|
|
361
|
+
observer.stop()
|
|
362
|
+
observer.join()
|
|
363
|
+
manager.disconnect(websocket)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ============================================================================
|
|
367
|
+
# Job Event Integration
|
|
368
|
+
# ============================================================================
|
|
369
|
+
|
|
370
|
+
async def emit_job_output(job_id: str, stream: str, text: str):
|
|
371
|
+
"""
|
|
372
|
+
Helper to emit stdout/stderr messages to subscribers.
|
|
373
|
+
To be called by the Job Executor.
|
|
374
|
+
"""
|
|
375
|
+
msg_type = "stdout" if stream == "stdout" else "stderr"
|
|
376
|
+
|
|
377
|
+
# Create specific message model
|
|
378
|
+
if stream == "stdout":
|
|
379
|
+
msg = StdoutMessage(
|
|
380
|
+
data=clean_ansi(text),
|
|
381
|
+
raw=text,
|
|
382
|
+
timestamp=datetime.now(timezone.utc)
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
msg = StderrMessage(
|
|
386
|
+
data=clean_ansi(text),
|
|
387
|
+
raw=text,
|
|
388
|
+
timestamp=datetime.now(timezone.utc)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
await manager.broadcast_job_message(job_id, msg)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def emit_job_progress(job_id: str, current: int, total: int, message: str):
|
|
395
|
+
"""
|
|
396
|
+
Helper to emit progress messages.
|
|
397
|
+
"""
|
|
398
|
+
msg = ProgressMessage(
|
|
399
|
+
current=current,
|
|
400
|
+
total=total,
|
|
401
|
+
message=message,
|
|
402
|
+
timestamp=datetime.now(timezone.utc)
|
|
403
|
+
)
|
|
404
|
+
await manager.broadcast_job_message(job_id, msg)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def emit_job_complete(job_id: str, result: Any, success: bool, cost: float = 0.0):
|
|
408
|
+
"""
|
|
409
|
+
Helper to emit completion messages.
|
|
410
|
+
"""
|
|
411
|
+
msg = WSMessage(
|
|
412
|
+
type="complete",
|
|
413
|
+
data={
|
|
414
|
+
"success": success,
|
|
415
|
+
"result": result,
|
|
416
|
+
"cost": cost,
|
|
417
|
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
await manager.broadcast_job_message(job_id, msg)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def emit_spawned_job_complete(job_id: str, command: str, success: bool, exit_code: int):
|
|
424
|
+
"""
|
|
425
|
+
Helper to emit spawned job completion to ALL connected clients.
|
|
426
|
+
|
|
427
|
+
Spawned terminal jobs don't have WebSocket subscriptions, so we broadcast
|
|
428
|
+
to all connected clients. The frontend dashboard can filter by job_id.
|
|
429
|
+
"""
|
|
430
|
+
msg = WSMessage(
|
|
431
|
+
type="spawned_job_complete",
|
|
432
|
+
data={
|
|
433
|
+
"job_id": job_id,
|
|
434
|
+
"command": command,
|
|
435
|
+
"success": success,
|
|
436
|
+
"exit_code": exit_code,
|
|
437
|
+
"status": "completed" if success else "failed",
|
|
438
|
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
await manager.broadcast_to_all(msg)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ============================================================================
|
|
445
|
+
# App Integration
|
|
446
|
+
# ============================================================================
|
|
447
|
+
|
|
448
|
+
def create_websocket_routes(app, connection_manager: ConnectionManager, job_manager: JobManager = None):
|
|
449
|
+
"""
|
|
450
|
+
Register WebSocket routes with the FastAPI application.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
app: FastAPI application instance.
|
|
454
|
+
connection_manager: ConnectionManager instance for handling WebSocket connections.
|
|
455
|
+
job_manager: JobManager instance for registering output callbacks.
|
|
456
|
+
"""
|
|
457
|
+
global manager
|
|
458
|
+
manager = connection_manager
|
|
459
|
+
app.include_router(router)
|
|
460
|
+
|
|
461
|
+
# Register callbacks to stream job output to WebSocket clients
|
|
462
|
+
if job_manager:
|
|
463
|
+
async def on_job_output(job: Job, stream_type: str, text: str):
|
|
464
|
+
"""Callback to broadcast job output to WebSocket subscribers."""
|
|
465
|
+
await emit_job_output(job.id, stream_type, text)
|
|
466
|
+
|
|
467
|
+
async def on_job_complete(job: Job):
|
|
468
|
+
"""Callback to broadcast job completion to WebSocket subscribers."""
|
|
469
|
+
success = job.status == JobStatusEnum.COMPLETED
|
|
470
|
+
await emit_job_complete(job.id, job.result, success, job.cost)
|
|
471
|
+
|
|
472
|
+
job_manager.callbacks.on_output(on_job_output)
|
|
473
|
+
job_manager.callbacks.on_complete(on_job_complete)
|