pdd-cli 0.0.45__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 +40 -8
- 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 +598 -0
- pdd/agentic_crash.py +534 -0
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +1294 -0
- pdd/agentic_langtest.py +162 -0
- pdd/agentic_update.py +387 -0
- pdd/agentic_verify.py +183 -0
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +71 -51
- pdd/auto_include.py +245 -5
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +196 -23
- pdd/bug_to_unit_test.py +2 -0
- pdd/change_main.py +11 -4
- pdd/cli.py +22 -1181
- pdd/cmd_test_main.py +350 -150
- pdd/code_generator.py +60 -18
- pdd/code_generator_main.py +790 -57
- pdd/commands/__init__.py +48 -0
- pdd/commands/analysis.py +306 -0
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +163 -0
- pdd/commands/generate.py +257 -0
- pdd/commands/maintenance.py +175 -0
- pdd/commands/misc.py +87 -0
- pdd/commands/modify.py +256 -0
- pdd/commands/report.py +144 -0
- pdd/commands/sessions.py +284 -0
- pdd/commands/templates.py +215 -0
- pdd/commands/utility.py +110 -0
- pdd/config_resolution.py +58 -0
- pdd/conflicts_main.py +8 -3
- pdd/construct_paths.py +589 -111
- pdd/context_generator.py +10 -2
- pdd/context_generator_main.py +175 -76
- pdd/continue_generation.py +53 -10
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +527 -0
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +554 -0
- pdd/core/errors.py +67 -0
- pdd/core/remote_session.py +61 -0
- pdd/core/utils.py +90 -0
- pdd/crash_main.py +262 -33
- pdd/data/language_format.csv +71 -63
- pdd/data/llm_model.csv +20 -18
- pdd/detect_change_main.py +5 -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 +523 -95
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +491 -92
- pdd/fix_errors_from_unit_tests.py +4 -3
- pdd/fix_main.py +278 -21
- pdd/fix_verification_errors.py +12 -100
- pdd/fix_verification_errors_loop.py +529 -286
- pdd/fix_verification_main.py +294 -89
- 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 +139 -15
- pdd/generate_test.py +218 -146
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +318 -22
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +75 -0
- pdd/get_test_command.py +68 -0
- pdd/git_update.py +70 -19
- pdd/incremental_code_generator.py +2 -2
- pdd/insert_includes.py +13 -4
- pdd/llm_invoke.py +1711 -181
- pdd/load_prompt_template.py +19 -12
- pdd/path_resolution.py +140 -0
- pdd/pdd_completion.fish +25 -2
- pdd/pdd_completion.sh +30 -4
- pdd/pdd_completion.zsh +79 -4
- pdd/postprocess.py +14 -4
- pdd/preprocess.py +293 -24
- pdd/preprocess_main.py +41 -6
- 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_crash_explore_LLM.prompt +49 -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_explore_LLM.prompt +45 -0
- pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
- pdd/prompts/agentic_update_LLM.prompt +925 -0
- pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
- pdd/prompts/auto_include_LLM.prompt +122 -905
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +686 -27
- pdd/prompts/example_generator_LLM.prompt +22 -1
- pdd/prompts/extract_code_LLM.prompt +5 -1
- pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
- pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
- pdd/prompts/extract_promptline_LLM.prompt +17 -11
- pdd/prompts/find_verification_errors_LLM.prompt +6 -0
- pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
- pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
- pdd/prompts/generate_test_LLM.prompt +41 -7
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/increase_tests_LLM.prompt +1 -5
- pdd/prompts/insert_includes_LLM.prompt +316 -186
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/prompts/trace_LLM.prompt +25 -22
- pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
- pdd/prompts/update_prompt_LLM.prompt +22 -1
- pdd/pytest_output.py +127 -12
- pdd/remote_session.py +876 -0
- pdd/render_mermaid.py +236 -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/setup_tool.py +648 -0
- pdd/simple_math.py +2 -0
- pdd/split_main.py +3 -2
- pdd/summarize_directory.py +237 -195
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +839 -112
- pdd/sync_main.py +351 -57
- pdd/sync_orchestration.py +1400 -756
- pdd/sync_tui.py +848 -0
- pdd/template_expander.py +161 -0
- pdd/template_registry.py +264 -0
- pdd/templates/architecture/architecture_json.prompt +237 -0
- pdd/templates/generic/generate_prompt.prompt +174 -0
- pdd/trace.py +168 -12
- pdd/trace_main.py +4 -3
- pdd/track_cost.py +140 -63
- pdd/unfinished_prompt.py +51 -4
- pdd/update_main.py +567 -67
- pdd/update_model_costs.py +2 -2
- pdd/update_prompt.py +19 -4
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
- pdd_cli-0.0.45.dist-info/RECORD +0 -116
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/server/jobs.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
# Robust import for rich console
|
|
18
|
+
try:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
console = Console()
|
|
21
|
+
except ImportError:
|
|
22
|
+
class Console:
|
|
23
|
+
def print(self, *args, **kwargs):
|
|
24
|
+
import builtins
|
|
25
|
+
builtins.print(*args)
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
# Robust import for internal dependencies
|
|
29
|
+
try:
|
|
30
|
+
from .click_executor import ClickCommandExecutor, get_pdd_command
|
|
31
|
+
except ImportError:
|
|
32
|
+
class ClickCommandExecutor:
|
|
33
|
+
def __init__(self, base_context_obj=None, output_callback=None):
|
|
34
|
+
pass
|
|
35
|
+
def execute(self, *args, **kwargs):
|
|
36
|
+
raise NotImplementedError("ClickCommandExecutor not available")
|
|
37
|
+
|
|
38
|
+
def get_pdd_command(name):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
from .models import JobStatus
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Global options that must be placed BEFORE the subcommand (defined on cli group)
|
|
45
|
+
GLOBAL_OPTIONS = {
|
|
46
|
+
"force", "strength", "temperature", "time", "verbose", "quiet",
|
|
47
|
+
"output_cost", "review_examples", "local", "context", "list_contexts", "core_dump"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Commands where specific args should be positional (not --options)
|
|
51
|
+
POSITIONAL_ARGS = {
|
|
52
|
+
"sync": ["basename"],
|
|
53
|
+
"generate": ["prompt_file"],
|
|
54
|
+
"test": ["prompt_file", "code_file"],
|
|
55
|
+
"example": ["prompt_file", "code_file"],
|
|
56
|
+
"fix": ["args"], # Always uses variadic "args" (both agentic and manual modes)
|
|
57
|
+
"bug": ["args"],
|
|
58
|
+
"update": ["args"],
|
|
59
|
+
"crash": ["prompt_file", "code_file", "program_file", "error_file"],
|
|
60
|
+
"verify": ["prompt_file", "code_file", "verification_program"],
|
|
61
|
+
"split": ["input_prompt", "input_code", "example_code"],
|
|
62
|
+
"change": ["args"], # Always uses variadic "args" (both agentic and manual modes)
|
|
63
|
+
"detect": ["args"],
|
|
64
|
+
"auto-deps": ["prompt_file", "directory_path"],
|
|
65
|
+
"conflicts": ["prompt_file", "prompt2"],
|
|
66
|
+
"preprocess": ["prompt_file"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Manual mode file key mappings for fix/change commands
|
|
70
|
+
# These commands use variadic "args" for BOTH modes, but the frontend sends semantic keys
|
|
71
|
+
# for manual mode which we need to convert to ordered positional arguments
|
|
72
|
+
MANUAL_MODE_FILE_KEYS = {
|
|
73
|
+
"fix": ["prompt_file", "code_file", "unit_test_files", "error_file"],
|
|
74
|
+
"change": ["change_prompt_file", "input_code", "input_prompt_file"],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_pdd_executable() -> Optional[str]:
|
|
81
|
+
"""Find the pdd executable path."""
|
|
82
|
+
import shutil
|
|
83
|
+
|
|
84
|
+
# First try to find 'pdd' in PATH
|
|
85
|
+
pdd_path = shutil.which("pdd")
|
|
86
|
+
if pdd_path:
|
|
87
|
+
return pdd_path
|
|
88
|
+
|
|
89
|
+
# Try to find 'pdd' in the same directory as the Python interpreter
|
|
90
|
+
python_dir = Path(sys.executable).parent
|
|
91
|
+
pdd_in_python_dir = python_dir / "pdd"
|
|
92
|
+
if pdd_in_python_dir.exists():
|
|
93
|
+
return str(pdd_in_python_dir)
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_subprocess_command_args(
|
|
99
|
+
command: str,
|
|
100
|
+
args: Optional[Dict[str, Any]],
|
|
101
|
+
options: Optional[Dict[str, Any]]
|
|
102
|
+
) -> List[str]:
|
|
103
|
+
"""
|
|
104
|
+
Build command line arguments for pdd subprocess.
|
|
105
|
+
|
|
106
|
+
Global options (--force, --strength, etc.) are placed BEFORE the subcommand.
|
|
107
|
+
Command-specific options are placed AFTER the subcommand and positional args.
|
|
108
|
+
"""
|
|
109
|
+
pdd_exe = _find_pdd_executable()
|
|
110
|
+
|
|
111
|
+
if pdd_exe:
|
|
112
|
+
cmd_args = [pdd_exe]
|
|
113
|
+
else:
|
|
114
|
+
# Fallback: use runpy to run the CLI module
|
|
115
|
+
cmd_args = [
|
|
116
|
+
sys.executable, "-c",
|
|
117
|
+
"import sys; from pdd.cli import cli; sys.exit(cli())"
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Separate global options from command-specific options
|
|
121
|
+
global_opts: Dict[str, Any] = {}
|
|
122
|
+
cmd_opts: Dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
if options:
|
|
125
|
+
for key, value in options.items():
|
|
126
|
+
normalized_key = key.replace("-", "_")
|
|
127
|
+
if normalized_key in GLOBAL_OPTIONS:
|
|
128
|
+
global_opts[key] = value
|
|
129
|
+
else:
|
|
130
|
+
cmd_opts[key] = value
|
|
131
|
+
|
|
132
|
+
# Add global options BEFORE the command
|
|
133
|
+
for key, value in global_opts.items():
|
|
134
|
+
if isinstance(value, bool):
|
|
135
|
+
if value:
|
|
136
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
137
|
+
elif isinstance(value, (list, tuple)):
|
|
138
|
+
for v in value:
|
|
139
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
140
|
+
elif value is not None:
|
|
141
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
142
|
+
|
|
143
|
+
# Add the command
|
|
144
|
+
cmd_args.append(command)
|
|
145
|
+
|
|
146
|
+
# Handle fix/change manual mode: convert semantic file keys to positional args
|
|
147
|
+
# and add --manual flag. Both modes use variadic "args" parameter.
|
|
148
|
+
if command in MANUAL_MODE_FILE_KEYS and args and "args" not in args:
|
|
149
|
+
# Manual mode detected: has file keys but no "args" key
|
|
150
|
+
file_keys = MANUAL_MODE_FILE_KEYS[command]
|
|
151
|
+
# Check if any file keys are present
|
|
152
|
+
if any(k in args for k in file_keys):
|
|
153
|
+
# Convert file keys to ordered positional args list (order matters!)
|
|
154
|
+
positional_values = []
|
|
155
|
+
for key in file_keys:
|
|
156
|
+
if key in args and args[key] is not None:
|
|
157
|
+
positional_values.append(str(args[key]))
|
|
158
|
+
# Collect remaining args that aren't file keys (e.g., verification_program)
|
|
159
|
+
remaining_args = {k: v for k, v in args.items() if k not in file_keys}
|
|
160
|
+
# Build new args with positional values
|
|
161
|
+
args = {"args": positional_values}
|
|
162
|
+
# Move remaining args to cmd_opts (they should be options like --verification-program)
|
|
163
|
+
for key, value in remaining_args.items():
|
|
164
|
+
cmd_opts[key] = value
|
|
165
|
+
# Add --manual flag to command-specific options
|
|
166
|
+
cmd_opts["manual"] = True
|
|
167
|
+
|
|
168
|
+
# Get positional arg names for this command
|
|
169
|
+
positional_names = POSITIONAL_ARGS.get(command, [])
|
|
170
|
+
|
|
171
|
+
if args:
|
|
172
|
+
# First, add positional arguments in order
|
|
173
|
+
for pos_name in positional_names:
|
|
174
|
+
if pos_name in args:
|
|
175
|
+
value = args[pos_name]
|
|
176
|
+
if pos_name == "args" and isinstance(value, (list, tuple)):
|
|
177
|
+
cmd_args.extend(str(v) for v in value)
|
|
178
|
+
elif value is not None:
|
|
179
|
+
cmd_args.append(str(value))
|
|
180
|
+
|
|
181
|
+
# Then, add remaining args as options
|
|
182
|
+
for key, value in args.items():
|
|
183
|
+
if key in positional_names:
|
|
184
|
+
continue
|
|
185
|
+
if isinstance(value, bool):
|
|
186
|
+
if value:
|
|
187
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
188
|
+
elif isinstance(value, (list, tuple)):
|
|
189
|
+
for v in value:
|
|
190
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
191
|
+
elif value is not None:
|
|
192
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
193
|
+
|
|
194
|
+
# Add command-specific options
|
|
195
|
+
if cmd_opts:
|
|
196
|
+
for key, value in cmd_opts.items():
|
|
197
|
+
if isinstance(value, bool):
|
|
198
|
+
if value:
|
|
199
|
+
cmd_args.append(f"--{key.replace('_', '-')}")
|
|
200
|
+
elif isinstance(value, (list, tuple)):
|
|
201
|
+
for v in value:
|
|
202
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
|
|
203
|
+
elif value is not None:
|
|
204
|
+
cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
|
|
205
|
+
|
|
206
|
+
return cmd_args
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class Job:
|
|
211
|
+
"""
|
|
212
|
+
Internal representation of a queued or executing job.
|
|
213
|
+
"""
|
|
214
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
215
|
+
command: str = ""
|
|
216
|
+
args: Dict[str, Any] = field(default_factory=dict)
|
|
217
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
218
|
+
status: JobStatus = JobStatus.QUEUED
|
|
219
|
+
result: Optional[Any] = None
|
|
220
|
+
error: Optional[str] = None
|
|
221
|
+
cost: float = 0.0
|
|
222
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
223
|
+
started_at: Optional[datetime] = None
|
|
224
|
+
completed_at: Optional[datetime] = None
|
|
225
|
+
# Live output during execution (updated in real-time)
|
|
226
|
+
live_stdout: str = ""
|
|
227
|
+
live_stderr: str = ""
|
|
228
|
+
|
|
229
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
230
|
+
return {
|
|
231
|
+
"id": self.id,
|
|
232
|
+
"command": self.command,
|
|
233
|
+
"args": self.args,
|
|
234
|
+
"options": self.options,
|
|
235
|
+
"status": self.status.value,
|
|
236
|
+
"result": self.result,
|
|
237
|
+
"error": self.error,
|
|
238
|
+
"cost": self.cost,
|
|
239
|
+
"created_at": self.created_at.isoformat(),
|
|
240
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
241
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
242
|
+
"live_stdout": self.live_stdout,
|
|
243
|
+
"live_stderr": self.live_stderr,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class JobCallbacks:
|
|
248
|
+
"""Async callback handlers for job lifecycle events."""
|
|
249
|
+
|
|
250
|
+
def __init__(self):
|
|
251
|
+
self._on_start: List[Callable[[Job], Awaitable[None]]] = []
|
|
252
|
+
self._on_output: List[Callable[[Job, str, str], Awaitable[None]]] = []
|
|
253
|
+
self._on_progress: List[Callable[[Job, int, int, str], Awaitable[None]]] = []
|
|
254
|
+
self._on_complete: List[Callable[[Job], Awaitable[None]]] = []
|
|
255
|
+
|
|
256
|
+
def on_start(self, callback: Callable[[Job], Awaitable[None]]) -> None:
|
|
257
|
+
self._on_start.append(callback)
|
|
258
|
+
|
|
259
|
+
def on_output(self, callback: Callable[[Job, str, str], Awaitable[None]]) -> None:
|
|
260
|
+
self._on_output.append(callback)
|
|
261
|
+
|
|
262
|
+
def on_progress(self, callback: Callable[[Job, int, int, str], Awaitable[None]]) -> None:
|
|
263
|
+
self._on_progress.append(callback)
|
|
264
|
+
|
|
265
|
+
def on_complete(self, callback: Callable[[Job], Awaitable[None]]) -> None:
|
|
266
|
+
self._on_complete.append(callback)
|
|
267
|
+
|
|
268
|
+
async def emit_start(self, job: Job) -> None:
|
|
269
|
+
for callback in self._on_start:
|
|
270
|
+
try:
|
|
271
|
+
await callback(job)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
console.print(f"[red]Error in on_start callback: {e}[/red]")
|
|
274
|
+
|
|
275
|
+
async def emit_output(self, job: Job, stream_type: str, text: str) -> None:
|
|
276
|
+
for callback in self._on_output:
|
|
277
|
+
try:
|
|
278
|
+
await callback(job, stream_type, text)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
console.print(f"[red]Error in on_output callback: {e}[/red]")
|
|
281
|
+
|
|
282
|
+
async def emit_progress(self, job: Job, current: int, total: int, message: str = "") -> None:
|
|
283
|
+
for callback in self._on_progress:
|
|
284
|
+
try:
|
|
285
|
+
await callback(job, current, total, message)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
console.print(f"[red]Error in on_progress callback: {e}[/red]")
|
|
288
|
+
|
|
289
|
+
async def emit_complete(self, job: Job) -> None:
|
|
290
|
+
for callback in self._on_complete:
|
|
291
|
+
try:
|
|
292
|
+
await callback(job)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
console.print(f"[red]Error in on_complete callback: {e}[/red]")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class JobManager:
|
|
298
|
+
"""
|
|
299
|
+
Manages async job execution, queuing, and lifecycle tracking.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
max_concurrent: int = 1,
|
|
305
|
+
executor: Optional[Callable[[Job], Awaitable[Dict[str, Any]]]] = None,
|
|
306
|
+
project_root: Optional[Path] = None,
|
|
307
|
+
):
|
|
308
|
+
self.max_concurrent = max_concurrent
|
|
309
|
+
self.callbacks = JobCallbacks()
|
|
310
|
+
self.project_root = project_root or Path.cwd()
|
|
311
|
+
|
|
312
|
+
self._jobs: Dict[str, Job] = {}
|
|
313
|
+
self._tasks: Dict[str, asyncio.Task] = {}
|
|
314
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
315
|
+
self._cancel_events: Dict[str, asyncio.Event] = {}
|
|
316
|
+
|
|
317
|
+
# Track running subprocesses for cancellation
|
|
318
|
+
self._processes: Dict[str, subprocess.Popen] = {}
|
|
319
|
+
self._process_lock = threading.Lock()
|
|
320
|
+
|
|
321
|
+
self._thread_pool = ThreadPoolExecutor(
|
|
322
|
+
max_workers=max_concurrent,
|
|
323
|
+
thread_name_prefix="pdd_job_worker"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
self._custom_executor = executor
|
|
327
|
+
|
|
328
|
+
async def submit(
|
|
329
|
+
self,
|
|
330
|
+
command: str,
|
|
331
|
+
args: Dict[str, Any] = None,
|
|
332
|
+
options: Dict[str, Any] = None,
|
|
333
|
+
) -> Job:
|
|
334
|
+
job = Job(
|
|
335
|
+
command=command,
|
|
336
|
+
args=args or {},
|
|
337
|
+
options=options or {},
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
self._jobs[job.id] = job
|
|
341
|
+
self._cancel_events[job.id] = asyncio.Event()
|
|
342
|
+
|
|
343
|
+
console.print(f"[blue]Job submitted:[/blue] {job.id} ({command})")
|
|
344
|
+
|
|
345
|
+
task = asyncio.create_task(self._execute_wrapper(job))
|
|
346
|
+
self._tasks[job.id] = task
|
|
347
|
+
|
|
348
|
+
# Callback to handle cleanup and edge-case cancellation (cancelled before start)
|
|
349
|
+
def _on_task_done(t: asyncio.Task):
|
|
350
|
+
if job.id in self._tasks:
|
|
351
|
+
del self._tasks[job.id]
|
|
352
|
+
|
|
353
|
+
# If task was cancelled but job status wasn't updated (e.g. never started running)
|
|
354
|
+
if t.cancelled() and job.status == JobStatus.QUEUED:
|
|
355
|
+
job.status = JobStatus.CANCELLED
|
|
356
|
+
if not job.completed_at:
|
|
357
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
358
|
+
console.print(f"[yellow]Job cancelled (Task Done):[/yellow] {job.id}")
|
|
359
|
+
|
|
360
|
+
task.add_done_callback(_on_task_done)
|
|
361
|
+
|
|
362
|
+
return job
|
|
363
|
+
|
|
364
|
+
async def _execute_wrapper(self, job: Job) -> None:
|
|
365
|
+
try:
|
|
366
|
+
async with self._semaphore:
|
|
367
|
+
await self._execute_job(job)
|
|
368
|
+
except asyncio.CancelledError:
|
|
369
|
+
# Handle cancellation while waiting for semaphore
|
|
370
|
+
if job.status == JobStatus.QUEUED:
|
|
371
|
+
job.status = JobStatus.CANCELLED
|
|
372
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
373
|
+
console.print(f"[yellow]Job cancelled (Queue):[/yellow] {job.id}")
|
|
374
|
+
raise # Re-raise to ensure task is marked as cancelled for the callback
|
|
375
|
+
|
|
376
|
+
async def _execute_job(self, job: Job) -> None:
|
|
377
|
+
try:
|
|
378
|
+
# 1. Check cancellation before starting
|
|
379
|
+
if self._cancel_events[job.id].is_set():
|
|
380
|
+
job.status = JobStatus.CANCELLED
|
|
381
|
+
console.print(f"[yellow]Job cancelled (Queued):[/yellow] {job.id}")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# 2. Update status and notify
|
|
385
|
+
job.status = JobStatus.RUNNING
|
|
386
|
+
job.started_at = datetime.now(timezone.utc)
|
|
387
|
+
await self.callbacks.emit_start(job)
|
|
388
|
+
|
|
389
|
+
# 3. Execute
|
|
390
|
+
result = None
|
|
391
|
+
|
|
392
|
+
if self._custom_executor:
|
|
393
|
+
result = await self._custom_executor(job)
|
|
394
|
+
else:
|
|
395
|
+
result = await self._run_click_command(job)
|
|
396
|
+
|
|
397
|
+
# 4. Handle Result
|
|
398
|
+
if self._cancel_events[job.id].is_set():
|
|
399
|
+
job.status = JobStatus.CANCELLED
|
|
400
|
+
console.print(f"[yellow]Job cancelled:[/yellow] {job.id}")
|
|
401
|
+
else:
|
|
402
|
+
job.result = result
|
|
403
|
+
job.cost = float(result.get("cost", 0.0)) if isinstance(result, dict) else 0.0
|
|
404
|
+
job.status = JobStatus.COMPLETED
|
|
405
|
+
console.print(f"[green]Job completed:[/green] {job.id}")
|
|
406
|
+
|
|
407
|
+
except asyncio.CancelledError:
|
|
408
|
+
job.status = JobStatus.CANCELLED
|
|
409
|
+
console.print(f"[yellow]Job cancelled (Task):[/yellow] {job.id}")
|
|
410
|
+
raise # Re-raise to propagate cancellation
|
|
411
|
+
|
|
412
|
+
except Exception as e:
|
|
413
|
+
job.error = str(e)
|
|
414
|
+
job.status = JobStatus.FAILED
|
|
415
|
+
console.print(f"[red]Job failed:[/red] {job.id} - {e}")
|
|
416
|
+
|
|
417
|
+
finally:
|
|
418
|
+
# 5. Cleanup and Notify
|
|
419
|
+
if not job.completed_at:
|
|
420
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
421
|
+
await self.callbacks.emit_complete(job)
|
|
422
|
+
|
|
423
|
+
if job.id in self._cancel_events:
|
|
424
|
+
del self._cancel_events[job.id]
|
|
425
|
+
|
|
426
|
+
async def _run_click_command(self, job: Job) -> Dict[str, Any]:
|
|
427
|
+
"""
|
|
428
|
+
Run a PDD command as a subprocess with output streaming and cancellation support.
|
|
429
|
+
|
|
430
|
+
This uses subprocess execution instead of direct Click invocation to enable:
|
|
431
|
+
- Proper cancellation via SIGTERM/SIGKILL
|
|
432
|
+
- Process isolation
|
|
433
|
+
- Output streaming
|
|
434
|
+
"""
|
|
435
|
+
loop = asyncio.get_running_loop()
|
|
436
|
+
|
|
437
|
+
# Build command args - add --force to skip confirmation prompts
|
|
438
|
+
options_with_force = dict(job.options) if job.options else {}
|
|
439
|
+
options_with_force['force'] = True # Skip all confirmation prompts
|
|
440
|
+
cmd_args = _build_subprocess_command_args(job.command, job.args, options_with_force)
|
|
441
|
+
|
|
442
|
+
# Set up environment for headless execution
|
|
443
|
+
env = os.environ.copy()
|
|
444
|
+
env['CI'] = '1'
|
|
445
|
+
env['PDD_FORCE'] = '1'
|
|
446
|
+
env['TERM'] = 'dumb'
|
|
447
|
+
env['PDD_SKIP_UPDATE_CHECK'] = '1' # Skip update prompts
|
|
448
|
+
|
|
449
|
+
stdout_lines = []
|
|
450
|
+
stderr_lines = []
|
|
451
|
+
|
|
452
|
+
def run_subprocess():
|
|
453
|
+
"""Run subprocess in thread with output streaming."""
|
|
454
|
+
try:
|
|
455
|
+
process = subprocess.Popen(
|
|
456
|
+
cmd_args,
|
|
457
|
+
stdout=subprocess.PIPE,
|
|
458
|
+
stderr=subprocess.PIPE,
|
|
459
|
+
stdin=subprocess.DEVNULL,
|
|
460
|
+
cwd=str(self.project_root),
|
|
461
|
+
env=env,
|
|
462
|
+
text=True,
|
|
463
|
+
bufsize=1, # Line buffered
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Track process for cancellation
|
|
467
|
+
with self._process_lock:
|
|
468
|
+
self._processes[job.id] = process
|
|
469
|
+
|
|
470
|
+
# Read output in real-time
|
|
471
|
+
def read_stream(stream, stream_type, lines_list):
|
|
472
|
+
try:
|
|
473
|
+
for line in iter(stream.readline, ''):
|
|
474
|
+
if line:
|
|
475
|
+
lines_list.append(line)
|
|
476
|
+
# Update live output on the job for polling
|
|
477
|
+
if stream_type == "stdout":
|
|
478
|
+
job.live_stdout += line
|
|
479
|
+
else:
|
|
480
|
+
job.live_stderr += line
|
|
481
|
+
# Emit output callback
|
|
482
|
+
if job.status == JobStatus.RUNNING:
|
|
483
|
+
asyncio.run_coroutine_threadsafe(
|
|
484
|
+
self.callbacks.emit_output(job, stream_type, line),
|
|
485
|
+
loop
|
|
486
|
+
)
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
finally:
|
|
490
|
+
stream.close()
|
|
491
|
+
|
|
492
|
+
# Start threads to read stdout and stderr
|
|
493
|
+
stdout_thread = threading.Thread(
|
|
494
|
+
target=read_stream,
|
|
495
|
+
args=(process.stdout, "stdout", stdout_lines)
|
|
496
|
+
)
|
|
497
|
+
stderr_thread = threading.Thread(
|
|
498
|
+
target=read_stream,
|
|
499
|
+
args=(process.stderr, "stderr", stderr_lines)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
stdout_thread.start()
|
|
503
|
+
stderr_thread.start()
|
|
504
|
+
|
|
505
|
+
# Wait for process to complete
|
|
506
|
+
exit_code = process.wait()
|
|
507
|
+
|
|
508
|
+
# Wait for output threads to finish
|
|
509
|
+
stdout_thread.join(timeout=5)
|
|
510
|
+
stderr_thread.join(timeout=5)
|
|
511
|
+
|
|
512
|
+
return exit_code
|
|
513
|
+
|
|
514
|
+
finally:
|
|
515
|
+
# Clean up process tracking
|
|
516
|
+
with self._process_lock:
|
|
517
|
+
self._processes.pop(job.id, None)
|
|
518
|
+
|
|
519
|
+
# Run in thread pool
|
|
520
|
+
exit_code = await loop.run_in_executor(self._thread_pool, run_subprocess)
|
|
521
|
+
|
|
522
|
+
# Check if cancelled
|
|
523
|
+
if self._cancel_events.get(job.id) and self._cancel_events[job.id].is_set():
|
|
524
|
+
raise asyncio.CancelledError("Job was cancelled")
|
|
525
|
+
|
|
526
|
+
stdout_text = ''.join(stdout_lines)
|
|
527
|
+
stderr_text = ''.join(stderr_lines)
|
|
528
|
+
|
|
529
|
+
if exit_code != 0:
|
|
530
|
+
# Combine stdout and stderr for complete error context
|
|
531
|
+
# Filter out INFO/DEBUG logs to find the actual error message
|
|
532
|
+
all_output = stdout_text + "\n" + stderr_text if stderr_text else stdout_text
|
|
533
|
+
|
|
534
|
+
# Try to find actual error lines (not INFO/DEBUG logs)
|
|
535
|
+
error_lines = []
|
|
536
|
+
for line in all_output.split('\n'):
|
|
537
|
+
line_stripped = line.strip()
|
|
538
|
+
if not line_stripped:
|
|
539
|
+
continue
|
|
540
|
+
# Skip common log prefixes
|
|
541
|
+
if ' - INFO - ' in line or ' - DEBUG - ' in line:
|
|
542
|
+
continue
|
|
543
|
+
# Skip lines that are just timestamps with INFO
|
|
544
|
+
if line_stripped.startswith('202') and ' INFO ' in line:
|
|
545
|
+
continue
|
|
546
|
+
error_lines.append(line)
|
|
547
|
+
|
|
548
|
+
if error_lines:
|
|
549
|
+
error_msg = '\n'.join(error_lines[-50:]) # Last 50 non-INFO lines
|
|
550
|
+
else:
|
|
551
|
+
# No non-INFO lines found, use all output
|
|
552
|
+
error_msg = all_output or f"Command failed with exit code {exit_code}"
|
|
553
|
+
|
|
554
|
+
raise RuntimeError(error_msg)
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"stdout": stdout_text,
|
|
558
|
+
"stderr": stderr_text,
|
|
559
|
+
"exit_code": exit_code,
|
|
560
|
+
"cost": 0.0
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
def get_job(self, job_id: str) -> Optional[Job]:
|
|
564
|
+
return self._jobs.get(job_id)
|
|
565
|
+
|
|
566
|
+
def get_all_jobs(self) -> Dict[str, Job]:
|
|
567
|
+
return self._jobs.copy()
|
|
568
|
+
|
|
569
|
+
def get_active_jobs(self) -> Dict[str, Job]:
|
|
570
|
+
return {
|
|
571
|
+
job_id: job
|
|
572
|
+
for job_id, job in self._jobs.items()
|
|
573
|
+
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async def cancel(self, job_id: str) -> bool:
|
|
577
|
+
"""
|
|
578
|
+
Cancel a running job by terminating its subprocess.
|
|
579
|
+
|
|
580
|
+
This method:
|
|
581
|
+
1. Sets the cancel event to signal cancellation
|
|
582
|
+
2. Terminates the subprocess (SIGTERM, then SIGKILL if needed)
|
|
583
|
+
3. Cancels the async task
|
|
584
|
+
|
|
585
|
+
Returns True if cancellation was initiated, False if job already finished.
|
|
586
|
+
"""
|
|
587
|
+
job = self._jobs.get(job_id)
|
|
588
|
+
if not job:
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
if job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
# Set cancel event first
|
|
595
|
+
if job_id in self._cancel_events:
|
|
596
|
+
self._cancel_events[job_id].set()
|
|
597
|
+
|
|
598
|
+
# Terminate the subprocess if running
|
|
599
|
+
with self._process_lock:
|
|
600
|
+
process = self._processes.get(job_id)
|
|
601
|
+
if process and process.poll() is None:
|
|
602
|
+
console.print(f"[yellow]Terminating subprocess for job:[/yellow] {job_id}")
|
|
603
|
+
try:
|
|
604
|
+
# Try graceful termination first
|
|
605
|
+
process.terminate()
|
|
606
|
+
|
|
607
|
+
# Give it a moment to terminate
|
|
608
|
+
try:
|
|
609
|
+
process.wait(timeout=2)
|
|
610
|
+
except subprocess.TimeoutExpired:
|
|
611
|
+
# Force kill if it doesn't respond
|
|
612
|
+
console.print(f"[yellow]Force killing subprocess for job:[/yellow] {job_id}")
|
|
613
|
+
process.kill()
|
|
614
|
+
process.wait(timeout=2)
|
|
615
|
+
except Exception as e:
|
|
616
|
+
console.print(f"[red]Error terminating subprocess: {e}[/red]")
|
|
617
|
+
|
|
618
|
+
# Cancel the async task
|
|
619
|
+
if job_id in self._tasks:
|
|
620
|
+
self._tasks[job_id].cancel()
|
|
621
|
+
|
|
622
|
+
# Update job status
|
|
623
|
+
job.status = JobStatus.CANCELLED
|
|
624
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
625
|
+
|
|
626
|
+
console.print(f"[yellow]Cancellation completed for job:[/yellow] {job_id}")
|
|
627
|
+
return True
|
|
628
|
+
|
|
629
|
+
def cleanup_old_jobs(self, max_age_seconds: float = 3600) -> int:
|
|
630
|
+
now = datetime.now(timezone.utc)
|
|
631
|
+
to_remove = []
|
|
632
|
+
|
|
633
|
+
for job_id, job in self._jobs.items():
|
|
634
|
+
if job.completed_at:
|
|
635
|
+
age = (now - job.completed_at).total_seconds()
|
|
636
|
+
if age > max_age_seconds:
|
|
637
|
+
to_remove.append(job_id)
|
|
638
|
+
|
|
639
|
+
for job_id in to_remove:
|
|
640
|
+
del self._jobs[job_id]
|
|
641
|
+
if job_id in self._cancel_events:
|
|
642
|
+
del self._cancel_events[job_id]
|
|
643
|
+
if job_id in self._tasks:
|
|
644
|
+
del self._tasks[job_id]
|
|
645
|
+
|
|
646
|
+
if to_remove:
|
|
647
|
+
console.print(f"[dim]Cleaned up {len(to_remove)} old jobs[/dim]")
|
|
648
|
+
|
|
649
|
+
return len(to_remove)
|
|
650
|
+
|
|
651
|
+
async def shutdown(self) -> None:
|
|
652
|
+
console.print("[bold red]Shutting down JobManager...[/bold red]")
|
|
653
|
+
|
|
654
|
+
active_jobs = list(self.get_active_jobs().keys())
|
|
655
|
+
for job_id in active_jobs:
|
|
656
|
+
await self.cancel(job_id)
|
|
657
|
+
|
|
658
|
+
if active_jobs:
|
|
659
|
+
await asyncio.sleep(0.1)
|
|
660
|
+
|
|
661
|
+
self._thread_pool.shutdown(wait=False)
|