pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__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 +506 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +537 -0
- pdd/agentic_common.py +533 -770
- pdd/agentic_crash.py +2 -1
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +582 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +27 -9
- pdd/agentic_verify.py +3 -2
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +236 -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 +113 -48
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +358 -0
- pdd/commands/fix.py +155 -114
- pdd/commands/generate.py +5 -0
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +225 -163
- pdd/commands/sessions.py +284 -0
- pdd/commands/utility.py +12 -7
- 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 +44 -7
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +68 -20
- 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 +208 -6
- 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-CUWd8al1.js +450 -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 +531 -97
- pdd/load_prompt_template.py +15 -34
- pdd/operation_log.py +342 -0
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +122 -97
- 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 +140 -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 +19 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +123 -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 +1347 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +217 -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 +289 -211
- pdd/sync_order.py +304 -0
- 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 +68 -26
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
- pdd_cli-0.0.121.dist-info/RECORD +229 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API endpoints for PDD command execution.
|
|
3
|
+
|
|
4
|
+
Provides endpoints for submitting, monitoring, and canceling CLI commands
|
|
5
|
+
through asynchronous job management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
console = Console()
|
|
27
|
+
except ImportError:
|
|
28
|
+
class Console:
|
|
29
|
+
def print(self, *args, **kwargs):
|
|
30
|
+
import builtins
|
|
31
|
+
builtins.print(*args)
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
|
|
36
|
+
from ..models import CommandRequest, JobHandle, JobResult, JobStatus
|
|
37
|
+
from ..jobs import JobManager
|
|
38
|
+
from ..click_executor import ClickCommandExecutor, get_pdd_command
|
|
39
|
+
|
|
40
|
+
# Import construct_paths functions for smart output path detection
|
|
41
|
+
from ...construct_paths import (
|
|
42
|
+
detect_context_for_file,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RunResult(BaseModel):
|
|
47
|
+
"""Result of running a command in terminal mode."""
|
|
48
|
+
success: bool
|
|
49
|
+
message: str
|
|
50
|
+
exit_code: int = 0
|
|
51
|
+
stdout: Optional[str] = None
|
|
52
|
+
stderr: Optional[str] = None
|
|
53
|
+
error_details: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CancelResult(BaseModel):
|
|
57
|
+
"""Result of cancelling a command."""
|
|
58
|
+
cancelled: bool
|
|
59
|
+
message: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Track current running process for cancellation
|
|
63
|
+
class ProcessTracker:
|
|
64
|
+
"""Thread-safe tracker for the currently running command process."""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
self._process: Optional[subprocess.Popen] = None
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
self._command_info: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
def set_process(self, process: subprocess.Popen, command_info: str):
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._process = process
|
|
74
|
+
self._command_info = command_info
|
|
75
|
+
|
|
76
|
+
def clear_process(self):
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._process = None
|
|
79
|
+
self._command_info = None
|
|
80
|
+
|
|
81
|
+
def cancel(self) -> Tuple[bool, str]:
|
|
82
|
+
"""Cancel the current process if running. Returns (success, message)."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
if self._process is None:
|
|
85
|
+
return False, "No command is currently running"
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Try graceful termination first
|
|
89
|
+
self._process.terminate()
|
|
90
|
+
|
|
91
|
+
# Give it a moment to terminate gracefully
|
|
92
|
+
try:
|
|
93
|
+
self._process.wait(timeout=2)
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
# Force kill if it doesn't respond
|
|
96
|
+
self._process.kill()
|
|
97
|
+
|
|
98
|
+
cmd_info = self._command_info or "unknown command"
|
|
99
|
+
self._process = None
|
|
100
|
+
self._command_info = None
|
|
101
|
+
return True, f"Cancelled: {cmd_info}"
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return False, f"Failed to cancel: {str(e)}"
|
|
105
|
+
|
|
106
|
+
def is_running(self) -> bool:
|
|
107
|
+
with self._lock:
|
|
108
|
+
return self._process is not None and self._process.poll() is None
|
|
109
|
+
|
|
110
|
+
def get_command_info(self) -> Optional[str]:
|
|
111
|
+
with self._lock:
|
|
112
|
+
return self._command_info
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Global process tracker instance
|
|
116
|
+
_process_tracker = ProcessTracker()
|
|
117
|
+
|
|
118
|
+
# Allowed commands whitelist
|
|
119
|
+
ALLOWED_COMMANDS = {
|
|
120
|
+
"sync": "Synchronize prompts with code and tests",
|
|
121
|
+
"update": "Update prompts based on code changes",
|
|
122
|
+
"bug": "Generate unit test from bug (agentic mode)",
|
|
123
|
+
"generate": "Generate code from prompt",
|
|
124
|
+
"test": "Generate unit tests",
|
|
125
|
+
"example": "Generate example code",
|
|
126
|
+
"fix": "Fix code based on test errors",
|
|
127
|
+
"crash": "Fix code based on crash errors",
|
|
128
|
+
"verify": "Verify code against prompt requirements",
|
|
129
|
+
# Advanced operations
|
|
130
|
+
"split": "Split large prompt files into smaller ones",
|
|
131
|
+
"change": "Modify prompts based on change instructions",
|
|
132
|
+
"detect": "Detect which prompts need changes",
|
|
133
|
+
"auto-deps": "Analyze project dependencies and update prompt",
|
|
134
|
+
"conflicts": "Check for conflicts between prompt files",
|
|
135
|
+
"preprocess": "Preprocess prompt file for LLM use",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _compute_smart_output_path(
|
|
140
|
+
command: str,
|
|
141
|
+
prompt_file: str,
|
|
142
|
+
project_root: Path,
|
|
143
|
+
) -> Optional[str]:
|
|
144
|
+
"""
|
|
145
|
+
Compute the correct output path based on .pddrc context detection.
|
|
146
|
+
|
|
147
|
+
For commands like 'example' and 'test', this:
|
|
148
|
+
1. Detects which context the prompt file belongs to
|
|
149
|
+
2. Gets the appropriate output path from context config (e.g., example_output_path)
|
|
150
|
+
3. Preserves subdirectory structure from the prompt file
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
prompt_file: "prompts/server/terminal_spawner_python.prompt"
|
|
154
|
+
context: pdd_cli (example_output_path: "context")
|
|
155
|
+
-> output: "context/server/terminal_spawner_example.py"
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
command: The pdd command (e.g., "example", "test")
|
|
159
|
+
prompt_file: Path to the prompt file
|
|
160
|
+
project_root: Project root directory
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Computed output path, or None if detection fails
|
|
164
|
+
"""
|
|
165
|
+
if command not in ("example", "test"):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# Detect context for the prompt file
|
|
170
|
+
context_name, context_defaults = detect_context_for_file(
|
|
171
|
+
prompt_file,
|
|
172
|
+
repo_root=str(project_root)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if not context_name or not context_defaults:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Get the appropriate output path based on command
|
|
179
|
+
if command == "example":
|
|
180
|
+
base_output = context_defaults.get("example_output_path")
|
|
181
|
+
elif command == "test":
|
|
182
|
+
base_output = context_defaults.get("test_output_path")
|
|
183
|
+
else:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
if not base_output:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
# Compute subdirectory structure from prompt file path
|
|
190
|
+
# e.g., prompts/server/foo.prompt -> server/
|
|
191
|
+
prompt_path = Path(prompt_file)
|
|
192
|
+
|
|
193
|
+
# Try to find "prompts" directory in the path and get relative subdirectory
|
|
194
|
+
path_parts = prompt_path.parts
|
|
195
|
+
subdirs = []
|
|
196
|
+
|
|
197
|
+
for i, part in enumerate(path_parts):
|
|
198
|
+
if part == "prompts":
|
|
199
|
+
# Get subdirectories between "prompts" and the filename
|
|
200
|
+
subdirs = list(path_parts[i+1:-1])
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
# If we found subdirectories, append them to the output path
|
|
204
|
+
if subdirs:
|
|
205
|
+
output_path = os.path.join(base_output, *subdirs)
|
|
206
|
+
# Ensure it ends with / to indicate directory
|
|
207
|
+
if not output_path.endswith('/'):
|
|
208
|
+
output_path += '/'
|
|
209
|
+
return output_path
|
|
210
|
+
|
|
211
|
+
# No subdirectories, just return base output
|
|
212
|
+
return base_output
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
console.print(f"[yellow]Warning: Could not compute smart output path: {e}[/yellow]")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
router = APIRouter(prefix="/api/v1/commands", tags=["commands"])
|
|
220
|
+
|
|
221
|
+
# Dependency injection placeholder - will be overridden by app
|
|
222
|
+
_job_manager: Optional[JobManager] = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_job_manager() -> JobManager:
|
|
226
|
+
"""Dependency to get the JobManager instance."""
|
|
227
|
+
if _job_manager is None:
|
|
228
|
+
raise RuntimeError("JobManager not configured")
|
|
229
|
+
return _job_manager
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def set_job_manager(manager: JobManager) -> None:
|
|
233
|
+
"""Configure the JobManager instance."""
|
|
234
|
+
global _job_manager
|
|
235
|
+
_job_manager = manager
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Project root dependency - will be overridden by app
|
|
239
|
+
_project_root: Optional[Path] = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_project_root() -> Path:
|
|
243
|
+
"""Dependency to get the project root path."""
|
|
244
|
+
if _project_root is None:
|
|
245
|
+
# Fallback to cwd if not configured (shouldn't happen in production)
|
|
246
|
+
return Path(os.getcwd())
|
|
247
|
+
return _project_root
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_project_root(project_root: Path) -> None:
|
|
251
|
+
"""Configure the project root path."""
|
|
252
|
+
global _project_root
|
|
253
|
+
_project_root = project_root
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Server port dependency - for terminal spawner callbacks
|
|
257
|
+
_server_port: int = 9876 # Default port
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_server_port() -> int:
|
|
261
|
+
"""Dependency to get the server port for terminal callbacks."""
|
|
262
|
+
return _server_port
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def set_server_port(port: int) -> None:
|
|
266
|
+
"""Configure the server port."""
|
|
267
|
+
global _server_port
|
|
268
|
+
_server_port = port
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.post("/execute", response_model=JobHandle)
|
|
272
|
+
async def execute_command(
|
|
273
|
+
request: CommandRequest,
|
|
274
|
+
manager: JobManager = Depends(get_job_manager),
|
|
275
|
+
):
|
|
276
|
+
"""
|
|
277
|
+
Submit a command for execution.
|
|
278
|
+
|
|
279
|
+
Returns immediately with a job handle. Use /jobs/{job_id} to check status.
|
|
280
|
+
"""
|
|
281
|
+
# Validate command is allowed
|
|
282
|
+
if request.command not in ALLOWED_COMMANDS:
|
|
283
|
+
raise HTTPException(
|
|
284
|
+
status_code=400,
|
|
285
|
+
detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Submit job
|
|
289
|
+
job = await manager.submit(
|
|
290
|
+
command=request.command,
|
|
291
|
+
args=request.args,
|
|
292
|
+
options=request.options,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return JobHandle(
|
|
296
|
+
job_id=job.id,
|
|
297
|
+
status=job.status,
|
|
298
|
+
created_at=job.created_at,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get("/jobs/{job_id}", response_model=JobResult)
|
|
303
|
+
async def get_job_status(
|
|
304
|
+
job_id: str,
|
|
305
|
+
manager: JobManager = Depends(get_job_manager),
|
|
306
|
+
):
|
|
307
|
+
"""
|
|
308
|
+
Get the status and result of a job.
|
|
309
|
+
"""
|
|
310
|
+
job = manager.get_job(job_id)
|
|
311
|
+
if not job:
|
|
312
|
+
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
|
|
313
|
+
|
|
314
|
+
duration = 0.0
|
|
315
|
+
if job.started_at and job.completed_at:
|
|
316
|
+
duration = (job.completed_at - job.started_at).total_seconds()
|
|
317
|
+
elif job.started_at:
|
|
318
|
+
# Use timezone-aware UTC now
|
|
319
|
+
now = datetime.now(timezone.utc)
|
|
320
|
+
# Handle case where job.started_at might be naive (from legacy code or DB)
|
|
321
|
+
if job.started_at.tzinfo is None:
|
|
322
|
+
now = now.replace(tzinfo=None)
|
|
323
|
+
duration = (now - job.started_at).total_seconds()
|
|
324
|
+
|
|
325
|
+
# For running/queued jobs, provide live output in the result field
|
|
326
|
+
result = job.result
|
|
327
|
+
if job.status in (JobStatus.RUNNING, JobStatus.QUEUED) and result is None:
|
|
328
|
+
# Provide live output while job is running
|
|
329
|
+
result = {
|
|
330
|
+
"stdout": job.live_stdout,
|
|
331
|
+
"stderr": job.live_stderr,
|
|
332
|
+
"exit_code": None, # Not yet available
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return JobResult(
|
|
336
|
+
job_id=job.id,
|
|
337
|
+
status=job.status,
|
|
338
|
+
result=result,
|
|
339
|
+
error=job.error,
|
|
340
|
+
cost=job.cost,
|
|
341
|
+
duration_seconds=duration,
|
|
342
|
+
completed_at=job.completed_at,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@router.post("/jobs/{job_id}/cancel")
|
|
347
|
+
async def cancel_job(
|
|
348
|
+
job_id: str,
|
|
349
|
+
manager: JobManager = Depends(get_job_manager),
|
|
350
|
+
):
|
|
351
|
+
"""
|
|
352
|
+
Attempt to cancel a running job.
|
|
353
|
+
"""
|
|
354
|
+
job = manager.get_job(job_id)
|
|
355
|
+
if not job:
|
|
356
|
+
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
|
|
357
|
+
|
|
358
|
+
if job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
|
|
359
|
+
raise HTTPException(
|
|
360
|
+
status_code=409,
|
|
361
|
+
detail=f"Job already finished with status: {job.status.value}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
cancelled = await manager.cancel(job_id)
|
|
365
|
+
return {
|
|
366
|
+
"cancelled": cancelled,
|
|
367
|
+
"message": "Cancellation requested" if cancelled else "Could not cancel job"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@router.get("/history", response_model=List[JobResult])
|
|
372
|
+
async def get_job_history(
|
|
373
|
+
limit: int = Query(50, description="Maximum number of jobs to return", ge=1, le=200),
|
|
374
|
+
offset: int = Query(0, description="Offset for pagination", ge=0),
|
|
375
|
+
status: Optional[JobStatus] = Query(None, description="Filter by status"),
|
|
376
|
+
manager: JobManager = Depends(get_job_manager),
|
|
377
|
+
):
|
|
378
|
+
"""
|
|
379
|
+
Get job history.
|
|
380
|
+
|
|
381
|
+
Returns jobs ordered by creation time (newest first).
|
|
382
|
+
"""
|
|
383
|
+
all_jobs = manager.get_all_jobs()
|
|
384
|
+
|
|
385
|
+
# Filter by status if specified
|
|
386
|
+
if status:
|
|
387
|
+
jobs = [j for j in all_jobs.values() if j.status == status]
|
|
388
|
+
else:
|
|
389
|
+
jobs = list(all_jobs.values())
|
|
390
|
+
|
|
391
|
+
# Sort by created_at descending
|
|
392
|
+
jobs.sort(key=lambda j: j.created_at, reverse=True)
|
|
393
|
+
|
|
394
|
+
# Apply pagination
|
|
395
|
+
jobs = jobs[offset:offset + limit]
|
|
396
|
+
|
|
397
|
+
results = []
|
|
398
|
+
for job in jobs:
|
|
399
|
+
duration = 0.0
|
|
400
|
+
if job.started_at and job.completed_at:
|
|
401
|
+
duration = (job.completed_at - job.started_at).total_seconds()
|
|
402
|
+
elif job.started_at:
|
|
403
|
+
# Use timezone-aware UTC now
|
|
404
|
+
now = datetime.now(timezone.utc)
|
|
405
|
+
# Handle case where job.started_at might be naive
|
|
406
|
+
if job.started_at.tzinfo is None:
|
|
407
|
+
now = now.replace(tzinfo=None)
|
|
408
|
+
duration = (now - job.started_at).total_seconds()
|
|
409
|
+
|
|
410
|
+
results.append(JobResult(
|
|
411
|
+
job_id=job.id,
|
|
412
|
+
status=job.status,
|
|
413
|
+
result=job.result,
|
|
414
|
+
error=job.error,
|
|
415
|
+
cost=job.cost,
|
|
416
|
+
duration_seconds=duration,
|
|
417
|
+
completed_at=job.completed_at,
|
|
418
|
+
))
|
|
419
|
+
|
|
420
|
+
return results
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@router.get("/available")
|
|
424
|
+
async def get_available_commands():
|
|
425
|
+
"""
|
|
426
|
+
Get list of available commands with descriptions.
|
|
427
|
+
"""
|
|
428
|
+
return [
|
|
429
|
+
{"name": name, "description": desc}
|
|
430
|
+
for name, desc in ALLOWED_COMMANDS.items()
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _find_pdd_executable() -> Optional[str]:
|
|
435
|
+
"""Find the pdd executable path."""
|
|
436
|
+
import shutil
|
|
437
|
+
from pathlib import Path
|
|
438
|
+
|
|
439
|
+
# First try to find 'pdd' in PATH
|
|
440
|
+
pdd_path = shutil.which("pdd")
|
|
441
|
+
if pdd_path:
|
|
442
|
+
return pdd_path
|
|
443
|
+
|
|
444
|
+
# Try to find 'pdd' in the same directory as the Python interpreter
|
|
445
|
+
# This handles virtual environments and conda environments
|
|
446
|
+
python_dir = Path(sys.executable).parent
|
|
447
|
+
pdd_in_python_dir = python_dir / "pdd"
|
|
448
|
+
if pdd_in_python_dir.exists():
|
|
449
|
+
return str(pdd_in_python_dir)
|
|
450
|
+
|
|
451
|
+
# Fallback: None means we need to use the module approach
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# Commands where specific args should be positional (not --options)
|
|
456
|
+
# Order matters! Arguments are added in this order.
|
|
457
|
+
POSITIONAL_ARGS = {
|
|
458
|
+
"sync": ["basename"],
|
|
459
|
+
"generate": ["prompt_file"],
|
|
460
|
+
"test": ["prompt_file", "code_file"],
|
|
461
|
+
"example": ["prompt_file", "code_file"],
|
|
462
|
+
"fix": ["prompt_file", "code_file", "unit_test_files", "error_file"], # pdd fix PROMPT CODE TEST... ERROR
|
|
463
|
+
"bug": ["args"], # Special: 'args' contains the positional arguments
|
|
464
|
+
"update": ["args"], # Special: 'args' contains the positional arguments
|
|
465
|
+
"crash": ["prompt_file", "code_file", "program_file", "error_file"], # pdd crash PROMPT CODE PROGRAM ERROR
|
|
466
|
+
"verify": ["prompt_file", "code_file", "verification_program"], # pdd verify PROMPT CODE VERIFICATION_PROGRAM
|
|
467
|
+
# Advanced operations
|
|
468
|
+
"split": ["input_prompt", "input_code", "example_code"], # pdd split INPUT_PROMPT INPUT_CODE EXAMPLE_CODE
|
|
469
|
+
"change": ["change_prompt_file", "input_code", "input_prompt_file"], # pdd change CHANGE_PROMPT INPUT_CODE [INPUT_PROMPT]
|
|
470
|
+
"detect": ["args"], # Special: 'args' contains [PROMPT_FILES..., CHANGE_FILE]
|
|
471
|
+
"auto-deps": ["prompt_file", "directory_path"], # pdd auto-deps PROMPT DIRECTORY
|
|
472
|
+
"conflicts": ["prompt_file", "prompt2"], # pdd conflicts PROMPT1 PROMPT2
|
|
473
|
+
"preprocess": ["prompt_file"], # pdd preprocess PROMPT
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# Global options that must be placed BEFORE the subcommand (defined on cli group)
|
|
478
|
+
GLOBAL_OPTIONS = {
|
|
479
|
+
"force", "strength", "temperature", "time", "verbose", "quiet",
|
|
480
|
+
"output_cost", "review_examples", "local", "context", "list_contexts", "core_dump"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_pdd_command_args(command: str, args: Optional[Dict], options: Optional[Dict]) -> List[str]:
|
|
485
|
+
"""Build command line arguments for pdd subprocess.
|
|
486
|
+
|
|
487
|
+
Global options (--force, --strength, etc.) are placed BEFORE the subcommand.
|
|
488
|
+
Command-specific options are placed AFTER the subcommand and positional args.
|
|
489
|
+
"""
|
|
490
|
+
pdd_exe = _find_pdd_executable()
|
|
491
|
+
|
|
492
|
+
# Start with just the executable - we'll add global options, then command
|
|
493
|
+
if pdd_exe:
|
|
494
|
+
cmd_args = [pdd_exe]
|
|
495
|
+
else:
|
|
496
|
+
# Fallback: use runpy to run the CLI module with proper argument handling
|
|
497
|
+
cmd_args = [
|
|
498
|
+
sys.executable, "-c",
|
|
499
|
+
"import sys; from pdd.cli import cli; sys.exit(cli())"
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
# Separate global options from command-specific options
|
|
503
|
+
global_opts: Dict[str, Any] = {}
|
|
504
|
+
cmd_opts: Dict[str, Any] = {}
|
|
505
|
+
|
|
506
|
+
if options:
|
|
507
|
+
for key, value in options.items():
|
|
508
|
+
# Normalize key for comparison (replace hyphens with underscores)
|
|
509
|
+
normalized_key = key.replace("-", "_")
|
|
510
|
+
if normalized_key in GLOBAL_OPTIONS:
|
|
511
|
+
global_opts[key] = value
|
|
512
|
+
else:
|
|
513
|
+
cmd_opts[key] = value
|
|
514
|
+
|
|
515
|
+
# Add global options BEFORE the command
|
|
516
|
+
for key, value in global_opts.items():
|
|
517
|
+
if isinstance(value, bool):
|
|
518
|
+
if value:
|
|
519
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
520
|
+
elif isinstance(value, (list, tuple)):
|
|
521
|
+
for v in value:
|
|
522
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
523
|
+
elif value is not None:
|
|
524
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
525
|
+
|
|
526
|
+
# Now add the command
|
|
527
|
+
cmd_args.append(command)
|
|
528
|
+
|
|
529
|
+
# Get positional arg names for this command
|
|
530
|
+
positional_names = POSITIONAL_ARGS.get(command, [])
|
|
531
|
+
|
|
532
|
+
if args:
|
|
533
|
+
# First, add positional arguments in order
|
|
534
|
+
for pos_name in positional_names:
|
|
535
|
+
if pos_name in args:
|
|
536
|
+
value = args[pos_name]
|
|
537
|
+
if pos_name == "args" and isinstance(value, (list, tuple)):
|
|
538
|
+
# Special case: 'args' is a list of positional args
|
|
539
|
+
cmd_args.extend(str(v) for v in value)
|
|
540
|
+
elif value is not None:
|
|
541
|
+
cmd_args.append(str(value))
|
|
542
|
+
|
|
543
|
+
# Then, add remaining args as options (--key value)
|
|
544
|
+
for key, value in args.items():
|
|
545
|
+
if key in positional_names:
|
|
546
|
+
continue # Already handled as positional
|
|
547
|
+
if isinstance(value, bool):
|
|
548
|
+
if value:
|
|
549
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
550
|
+
elif isinstance(value, (list, tuple)):
|
|
551
|
+
for v in value:
|
|
552
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
553
|
+
elif value is not None:
|
|
554
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
555
|
+
|
|
556
|
+
# Add command-specific options (global options were already added before the command)
|
|
557
|
+
if cmd_opts:
|
|
558
|
+
for key, value in cmd_opts.items():
|
|
559
|
+
if isinstance(value, bool):
|
|
560
|
+
if value:
|
|
561
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
562
|
+
elif isinstance(value, (list, tuple)):
|
|
563
|
+
# Handle array options (e.g., multiple -e KEY=VALUE flags)
|
|
564
|
+
for v in value:
|
|
565
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
566
|
+
elif value is not None:
|
|
567
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
568
|
+
|
|
569
|
+
return cmd_args
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _parse_error_details(exit_code: int) -> str:
|
|
573
|
+
"""Parse exit code and return user-friendly error details."""
|
|
574
|
+
error_messages = {
|
|
575
|
+
1: "Command failed - check the terminal output above for details",
|
|
576
|
+
2: "Command line usage error - invalid arguments or options",
|
|
577
|
+
126: "Command not executable - permission denied",
|
|
578
|
+
127: "Command not found - pdd may not be properly installed",
|
|
579
|
+
128: "Invalid exit argument",
|
|
580
|
+
130: "Command terminated by Ctrl+C",
|
|
581
|
+
137: "Command killed (SIGKILL) - possibly out of memory",
|
|
582
|
+
139: "Segmentation fault",
|
|
583
|
+
143: "Command terminated (SIGTERM)",
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
# Check for specific PDD exit codes
|
|
587
|
+
if exit_code == 1:
|
|
588
|
+
return "Command failed - this may indicate missing files, authentication issues, or other errors. Check the terminal for details."
|
|
589
|
+
|
|
590
|
+
return error_messages.get(exit_code, f"Command exited with code {exit_code}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@router.post("/run", response_model=RunResult)
|
|
594
|
+
async def run_command_in_terminal(
|
|
595
|
+
request: CommandRequest,
|
|
596
|
+
project_root: Path = Depends(get_project_root),
|
|
597
|
+
):
|
|
598
|
+
"""
|
|
599
|
+
Execute a command in terminal mode as a subprocess.
|
|
600
|
+
|
|
601
|
+
Output goes directly to the terminal where the server is running.
|
|
602
|
+
This endpoint blocks until the command completes or is cancelled.
|
|
603
|
+
|
|
604
|
+
Use POST /commands/cancel to stop a running command.
|
|
605
|
+
"""
|
|
606
|
+
# Validate command is allowed
|
|
607
|
+
if request.command not in ALLOWED_COMMANDS:
|
|
608
|
+
raise HTTPException(
|
|
609
|
+
status_code=400,
|
|
610
|
+
detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Check if a command is already running
|
|
614
|
+
if _process_tracker.is_running():
|
|
615
|
+
raise HTTPException(
|
|
616
|
+
status_code=409,
|
|
617
|
+
detail=f"A command is already running: {_process_tracker.get_command_info()}"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Build command arguments
|
|
621
|
+
cmd_args = _build_pdd_command_args(request.command, request.args, request.options)
|
|
622
|
+
command_str = f"pdd {request.command}"
|
|
623
|
+
|
|
624
|
+
# Build display command (just 'pdd <cmd> <args>' without full path)
|
|
625
|
+
display_args = cmd_args[1:] if cmd_args[0].endswith('pdd') else cmd_args[3:] # Skip python -c wrapper
|
|
626
|
+
display_cmd = f"pdd {' '.join(display_args)}"
|
|
627
|
+
|
|
628
|
+
# Print separator for visibility in terminal
|
|
629
|
+
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
|
|
630
|
+
console.print(f"[bold cyan]Running: {display_cmd}[/bold cyan]")
|
|
631
|
+
console.print(f"[cyan]{'='*60}[/cyan]\n")
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
# Set environment to disable TUI and enable headless mode
|
|
635
|
+
env = os.environ.copy()
|
|
636
|
+
env['CI'] = '1' # Triggers headless mode in sync
|
|
637
|
+
env['PDD_FORCE'] = '1' # Skip confirmation prompts
|
|
638
|
+
env['TERM'] = 'dumb' # Disable fancy terminal features
|
|
639
|
+
|
|
640
|
+
# Start subprocess - output goes directly to terminal (inherit stdio)
|
|
641
|
+
# Use project_root as cwd to ensure correct .pddrc and context detection
|
|
642
|
+
process = subprocess.Popen(
|
|
643
|
+
cmd_args,
|
|
644
|
+
stdout=None, # Inherit from parent (terminal)
|
|
645
|
+
stderr=None, # Inherit from parent (terminal)
|
|
646
|
+
stdin=None,
|
|
647
|
+
cwd=str(project_root),
|
|
648
|
+
env=env,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Track the process for cancellation
|
|
652
|
+
_process_tracker.set_process(process, command_str)
|
|
653
|
+
|
|
654
|
+
# Wait for completion in a way that allows async cancellation
|
|
655
|
+
def wait_for_process():
|
|
656
|
+
return process.wait()
|
|
657
|
+
|
|
658
|
+
loop = asyncio.get_event_loop()
|
|
659
|
+
exit_code = await loop.run_in_executor(None, wait_for_process)
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
console.print(f"\n[bold red]{'='*60}[/bold red]")
|
|
663
|
+
console.print(f"[bold red]Failed to start command: {str(e)}[/bold red]")
|
|
664
|
+
console.print(f"[red]{'='*60}[/red]\n")
|
|
665
|
+
return RunResult(
|
|
666
|
+
success=False,
|
|
667
|
+
message=f"Failed to start command: {str(e)}",
|
|
668
|
+
exit_code=1,
|
|
669
|
+
)
|
|
670
|
+
finally:
|
|
671
|
+
_process_tracker.clear_process()
|
|
672
|
+
|
|
673
|
+
# Print completion message
|
|
674
|
+
if exit_code == 0:
|
|
675
|
+
console.print(f"\n[bold green]{'='*60}[/bold green]")
|
|
676
|
+
console.print(f"[bold green]Command completed successfully[/bold green]")
|
|
677
|
+
console.print(f"[green]{'='*60}[/green]\n")
|
|
678
|
+
elif exit_code == -signal.SIGTERM or exit_code == -signal.SIGKILL:
|
|
679
|
+
console.print(f"\n[bold yellow]{'='*60}[/bold yellow]")
|
|
680
|
+
console.print(f"[bold yellow]Command was cancelled[/bold yellow]")
|
|
681
|
+
console.print(f"[yellow]{'='*60}[/yellow]\n")
|
|
682
|
+
return RunResult(
|
|
683
|
+
success=False,
|
|
684
|
+
message="Command was cancelled",
|
|
685
|
+
exit_code=exit_code,
|
|
686
|
+
)
|
|
687
|
+
else:
|
|
688
|
+
console.print(f"\n[bold red]{'='*60}[/bold red]")
|
|
689
|
+
console.print(f"[bold red]Command failed (exit code: {exit_code})[/bold red]")
|
|
690
|
+
console.print(f"[red]{'='*60}[/red]\n")
|
|
691
|
+
|
|
692
|
+
# Parse common error patterns based on exit code
|
|
693
|
+
error_details = _parse_error_details(exit_code)
|
|
694
|
+
|
|
695
|
+
return RunResult(
|
|
696
|
+
success=False,
|
|
697
|
+
message=f"Command failed with exit code {exit_code}",
|
|
698
|
+
exit_code=exit_code,
|
|
699
|
+
error_details=error_details,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return RunResult(
|
|
703
|
+
success=True,
|
|
704
|
+
message="Command completed successfully",
|
|
705
|
+
exit_code=0,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@router.post("/cancel", response_model=CancelResult)
|
|
710
|
+
async def cancel_current_command():
|
|
711
|
+
"""
|
|
712
|
+
Cancel the currently running command.
|
|
713
|
+
|
|
714
|
+
Returns success if a command was cancelled, failure if no command was running.
|
|
715
|
+
"""
|
|
716
|
+
cancelled, message = _process_tracker.cancel()
|
|
717
|
+
|
|
718
|
+
if cancelled:
|
|
719
|
+
console.print(f"\n[bold yellow]{'='*60}[/bold yellow]")
|
|
720
|
+
console.print(f"[bold yellow]{message}[/bold yellow]")
|
|
721
|
+
console.print(f"[yellow]{'='*60}[/yellow]\n")
|
|
722
|
+
|
|
723
|
+
return CancelResult(cancelled=cancelled, message=message)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@router.get("/status")
|
|
727
|
+
async def get_command_status():
|
|
728
|
+
"""
|
|
729
|
+
Get the status of the current running command.
|
|
730
|
+
|
|
731
|
+
Returns whether a command is running and its info.
|
|
732
|
+
"""
|
|
733
|
+
is_running = _process_tracker.is_running()
|
|
734
|
+
command_info = _process_tracker.get_command_info()
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
"running": is_running,
|
|
738
|
+
"command": command_info,
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ============================================================================
|
|
743
|
+
# Terminal Spawning - Run commands in separate terminal windows
|
|
744
|
+
# ============================================================================
|
|
745
|
+
|
|
746
|
+
from ..terminal_spawner import TerminalSpawner
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class SpawnTerminalResponse(BaseModel):
|
|
750
|
+
"""Response from spawning a terminal."""
|
|
751
|
+
success: bool
|
|
752
|
+
message: str
|
|
753
|
+
command: str
|
|
754
|
+
platform: str
|
|
755
|
+
job_id: Optional[str] = None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
class SpawnedJobCompleteRequest(BaseModel):
|
|
759
|
+
"""Request body for completing a spawned job."""
|
|
760
|
+
success: bool
|
|
761
|
+
exit_code: int = 0
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class SpawnedJobCompleteResponse(BaseModel):
|
|
765
|
+
"""Response from completing a spawned job."""
|
|
766
|
+
updated: bool
|
|
767
|
+
job_id: str
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class SpawnedJobStatus(BaseModel):
|
|
771
|
+
"""Status of a spawned job."""
|
|
772
|
+
job_id: str
|
|
773
|
+
command: str
|
|
774
|
+
status: str
|
|
775
|
+
started_at: str
|
|
776
|
+
completed_at: Optional[str] = None
|
|
777
|
+
exit_code: Optional[int] = None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# Track spawned jobs in memory
|
|
781
|
+
# Key: job_id, Value: dict with job info
|
|
782
|
+
_spawned_jobs: Dict[str, dict] = {}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@router.post("/spawned-jobs/{job_id}/complete", response_model=SpawnedJobCompleteResponse)
|
|
786
|
+
async def complete_spawned_job(job_id: str, request: SpawnedJobCompleteRequest):
|
|
787
|
+
"""
|
|
788
|
+
Called by spawned terminal script when command completes.
|
|
789
|
+
|
|
790
|
+
The shell script in the terminal calls this endpoint via curl
|
|
791
|
+
when the command finishes to report success/failure.
|
|
792
|
+
"""
|
|
793
|
+
if job_id in _spawned_jobs:
|
|
794
|
+
_spawned_jobs[job_id]["status"] = "completed" if request.success else "failed"
|
|
795
|
+
_spawned_jobs[job_id]["exit_code"] = request.exit_code
|
|
796
|
+
_spawned_jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
797
|
+
|
|
798
|
+
status_str = "completed" if request.success else "failed"
|
|
799
|
+
command = _spawned_jobs[job_id].get("command", "unknown")
|
|
800
|
+
console.print(f"[cyan]Spawned job {job_id[:16]}... {status_str} (exit code: {request.exit_code})[/cyan]")
|
|
801
|
+
|
|
802
|
+
# Broadcast to ALL connected WebSocket clients
|
|
803
|
+
# (spawned jobs don't have specific subscribers, so we broadcast to all)
|
|
804
|
+
try:
|
|
805
|
+
from .websocket import emit_spawned_job_complete
|
|
806
|
+
await emit_spawned_job_complete(
|
|
807
|
+
job_id=job_id,
|
|
808
|
+
command=command,
|
|
809
|
+
success=request.success,
|
|
810
|
+
exit_code=request.exit_code
|
|
811
|
+
)
|
|
812
|
+
except ImportError:
|
|
813
|
+
# WebSocket module may not be available in test environment
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
return SpawnedJobCompleteResponse(updated=True, job_id=job_id)
|
|
817
|
+
|
|
818
|
+
return SpawnedJobCompleteResponse(updated=False, job_id=job_id)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@router.get("/spawned-jobs/{job_id}/status", response_model=SpawnedJobStatus)
|
|
822
|
+
async def get_spawned_job_status(job_id: str):
|
|
823
|
+
"""
|
|
824
|
+
Get the status of a spawned job.
|
|
825
|
+
|
|
826
|
+
Frontend polls this endpoint to check if spawned jobs have completed.
|
|
827
|
+
"""
|
|
828
|
+
if job_id in _spawned_jobs:
|
|
829
|
+
job = _spawned_jobs[job_id]
|
|
830
|
+
return SpawnedJobStatus(
|
|
831
|
+
job_id=job_id,
|
|
832
|
+
command=job.get("command", "unknown"),
|
|
833
|
+
status=job.get("status", "unknown"),
|
|
834
|
+
started_at=job.get("started_at", ""),
|
|
835
|
+
completed_at=job.get("completed_at"),
|
|
836
|
+
exit_code=job.get("exit_code"),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
return SpawnedJobStatus(
|
|
840
|
+
job_id=job_id,
|
|
841
|
+
command="unknown",
|
|
842
|
+
status="unknown",
|
|
843
|
+
started_at="",
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@router.post("/spawn-terminal", response_model=SpawnTerminalResponse)
|
|
848
|
+
async def spawn_terminal_command(
|
|
849
|
+
request: CommandRequest,
|
|
850
|
+
project_root: Path = Depends(get_project_root),
|
|
851
|
+
server_port: int = Depends(get_server_port),
|
|
852
|
+
):
|
|
853
|
+
"""
|
|
854
|
+
Spawn a PDD command in a new terminal window.
|
|
855
|
+
|
|
856
|
+
The command runs in complete isolation from the server.
|
|
857
|
+
User sees output directly in the new terminal window.
|
|
858
|
+
|
|
859
|
+
This is safer than running commands in the server process because:
|
|
860
|
+
- Each command runs in its own terminal process
|
|
861
|
+
- No risk of WebSocket/subprocess conflicts
|
|
862
|
+
- User can manage terminal windows directly
|
|
863
|
+
|
|
864
|
+
The spawned terminal script will call back to report completion status.
|
|
865
|
+
"""
|
|
866
|
+
# Validate command is allowed
|
|
867
|
+
if request.command not in ALLOWED_COMMANDS:
|
|
868
|
+
raise HTTPException(
|
|
869
|
+
status_code=400,
|
|
870
|
+
detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Generate unique job ID
|
|
874
|
+
job_id = f"spawned-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
|
|
875
|
+
|
|
876
|
+
# Smart output path detection for example/test commands
|
|
877
|
+
# If --output is not already provided, compute it from .pddrc context
|
|
878
|
+
options = dict(request.options) if request.options else {}
|
|
879
|
+
if request.command in ("example", "test") and not options.get("output"):
|
|
880
|
+
prompt_file = request.args.get("prompt_file") if request.args else None
|
|
881
|
+
if prompt_file:
|
|
882
|
+
smart_output = _compute_smart_output_path(
|
|
883
|
+
request.command,
|
|
884
|
+
prompt_file,
|
|
885
|
+
project_root,
|
|
886
|
+
)
|
|
887
|
+
if smart_output:
|
|
888
|
+
options["output"] = smart_output
|
|
889
|
+
console.print(f"[green]Auto-detected output path: {smart_output}[/green]")
|
|
890
|
+
|
|
891
|
+
# Build full command with potentially updated options
|
|
892
|
+
cmd_args = _build_pdd_command_args(request.command, request.args, options)
|
|
893
|
+
cmd_str = " ".join(cmd_args)
|
|
894
|
+
|
|
895
|
+
# Use project root as working directory (not os.getcwd())
|
|
896
|
+
cwd = str(project_root)
|
|
897
|
+
|
|
898
|
+
# Track the job before spawning
|
|
899
|
+
_spawned_jobs[job_id] = {
|
|
900
|
+
"job_id": job_id,
|
|
901
|
+
"command": request.command,
|
|
902
|
+
"status": "running",
|
|
903
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
904
|
+
"completed_at": None,
|
|
905
|
+
"exit_code": None,
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
# Spawn terminal with job_id and server_port for callback
|
|
909
|
+
success = TerminalSpawner.spawn(
|
|
910
|
+
cmd_str, working_dir=cwd, job_id=job_id, server_port=server_port
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
if success:
|
|
914
|
+
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
|
|
915
|
+
console.print(f"[bold cyan]Spawned terminal: pdd {request.command}[/bold cyan]")
|
|
916
|
+
console.print(f"[cyan]Job ID: {job_id}[/cyan]")
|
|
917
|
+
console.print(f"[cyan]{'='*60}[/cyan]\n")
|
|
918
|
+
else:
|
|
919
|
+
console.print(f"\n[bold red]Failed to spawn terminal for: pdd {request.command}[/bold red]")
|
|
920
|
+
# Remove from tracking if spawn failed
|
|
921
|
+
del _spawned_jobs[job_id]
|
|
922
|
+
|
|
923
|
+
return SpawnTerminalResponse(
|
|
924
|
+
success=success,
|
|
925
|
+
message="Terminal window opened" if success else "Failed to open terminal",
|
|
926
|
+
command=request.command,
|
|
927
|
+
platform=sys.platform,
|
|
928
|
+
job_id=job_id if success else None,
|
|
929
|
+
)
|