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.
Files changed (144) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +521 -786
  7. pdd/agentic_e2e_fix.py +319 -0
  8. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  9. pdd/agentic_fix.py +118 -3
  10. pdd/agentic_update.py +25 -8
  11. pdd/architecture_sync.py +565 -0
  12. pdd/auth_service.py +210 -0
  13. pdd/auto_deps_main.py +63 -53
  14. pdd/auto_include.py +185 -3
  15. pdd/auto_update.py +125 -47
  16. pdd/bug_main.py +195 -23
  17. pdd/cmd_test_main.py +345 -197
  18. pdd/code_generator.py +4 -2
  19. pdd/code_generator_main.py +118 -32
  20. pdd/commands/__init__.py +6 -0
  21. pdd/commands/analysis.py +87 -29
  22. pdd/commands/auth.py +309 -0
  23. pdd/commands/connect.py +290 -0
  24. pdd/commands/fix.py +136 -113
  25. pdd/commands/maintenance.py +3 -2
  26. pdd/commands/misc.py +8 -0
  27. pdd/commands/modify.py +190 -164
  28. pdd/commands/sessions.py +284 -0
  29. pdd/construct_paths.py +334 -32
  30. pdd/context_generator_main.py +167 -170
  31. pdd/continue_generation.py +6 -3
  32. pdd/core/__init__.py +33 -0
  33. pdd/core/cli.py +27 -3
  34. pdd/core/cloud.py +237 -0
  35. pdd/core/errors.py +4 -0
  36. pdd/core/remote_session.py +61 -0
  37. pdd/crash_main.py +219 -23
  38. pdd/data/llm_model.csv +4 -4
  39. pdd/docs/prompting_guide.md +864 -0
  40. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  41. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  42. pdd/fix_code_loop.py +208 -34
  43. pdd/fix_code_module_errors.py +6 -2
  44. pdd/fix_error_loop.py +291 -38
  45. pdd/fix_main.py +204 -4
  46. pdd/fix_verification_errors_loop.py +235 -26
  47. pdd/fix_verification_main.py +269 -83
  48. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  49. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  50. pdd/frontend/dist/index.html +376 -0
  51. pdd/frontend/dist/logo.svg +33 -0
  52. pdd/generate_output_paths.py +46 -5
  53. pdd/generate_test.py +212 -151
  54. pdd/get_comment.py +19 -44
  55. pdd/get_extension.py +8 -9
  56. pdd/get_jwt_token.py +309 -20
  57. pdd/get_language.py +8 -7
  58. pdd/get_run_command.py +7 -5
  59. pdd/insert_includes.py +2 -1
  60. pdd/llm_invoke.py +459 -95
  61. pdd/load_prompt_template.py +15 -34
  62. pdd/path_resolution.py +140 -0
  63. pdd/postprocess.py +4 -1
  64. pdd/preprocess.py +68 -12
  65. pdd/preprocess_main.py +33 -1
  66. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  67. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  68. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  69. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  70. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  71. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  72. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  73. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  74. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  75. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  76. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  77. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  78. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  79. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  80. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  81. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  82. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  83. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  84. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  85. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  86. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  87. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  88. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  89. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  90. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  91. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  92. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  93. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  94. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  95. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  96. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  97. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  98. pdd/prompts/agentic_update_LLM.prompt +192 -338
  99. pdd/prompts/auto_include_LLM.prompt +22 -0
  100. pdd/prompts/change_LLM.prompt +3093 -1
  101. pdd/prompts/detect_change_LLM.prompt +571 -14
  102. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  103. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  104. pdd/prompts/generate_test_LLM.prompt +20 -1
  105. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  106. pdd/prompts/insert_includes_LLM.prompt +262 -252
  107. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  108. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  109. pdd/remote_session.py +876 -0
  110. pdd/server/__init__.py +52 -0
  111. pdd/server/app.py +335 -0
  112. pdd/server/click_executor.py +587 -0
  113. pdd/server/executor.py +338 -0
  114. pdd/server/jobs.py +661 -0
  115. pdd/server/models.py +241 -0
  116. pdd/server/routes/__init__.py +31 -0
  117. pdd/server/routes/architecture.py +451 -0
  118. pdd/server/routes/auth.py +364 -0
  119. pdd/server/routes/commands.py +929 -0
  120. pdd/server/routes/config.py +42 -0
  121. pdd/server/routes/files.py +603 -0
  122. pdd/server/routes/prompts.py +1322 -0
  123. pdd/server/routes/websocket.py +473 -0
  124. pdd/server/security.py +243 -0
  125. pdd/server/terminal_spawner.py +209 -0
  126. pdd/server/token_counter.py +222 -0
  127. pdd/summarize_directory.py +236 -237
  128. pdd/sync_animation.py +8 -4
  129. pdd/sync_determine_operation.py +329 -47
  130. pdd/sync_main.py +272 -28
  131. pdd/sync_orchestration.py +136 -75
  132. pdd/template_expander.py +161 -0
  133. pdd/templates/architecture/architecture_json.prompt +41 -46
  134. pdd/trace.py +1 -1
  135. pdd/track_cost.py +0 -13
  136. pdd/unfinished_prompt.py +2 -1
  137. pdd/update_main.py +23 -5
  138. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
  139. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  140. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  141. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  142. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  143. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
  144. {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)