pdd-cli 0.0.90__py3-none-any.whl → 0.0.118__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +521 -786
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +25 -8
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +185 -3
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +195 -23
- pdd/cmd_test_main.py +345 -197
- pdd/code_generator.py +4 -2
- pdd/code_generator_main.py +118 -32
- pdd/commands/__init__.py +6 -0
- pdd/commands/analysis.py +87 -29
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +136 -113
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +190 -164
- pdd/commands/sessions.py +284 -0
- pdd/construct_paths.py +334 -32
- pdd/context_generator_main.py +167 -170
- pdd/continue_generation.py +6 -3
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +27 -3
- pdd/core/cloud.py +237 -0
- pdd/core/errors.py +4 -0
- pdd/core/remote_session.py +61 -0
- pdd/crash_main.py +219 -23
- pdd/data/llm_model.csv +4 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +208 -34
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +291 -38
- pdd/fix_main.py +204 -4
- pdd/fix_verification_errors_loop.py +235 -26
- pdd/fix_verification_main.py +269 -83
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +46 -5
- pdd/generate_test.py +212 -151
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +309 -20
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +7 -5
- pdd/insert_includes.py +2 -1
- pdd/llm_invoke.py +459 -95
- pdd/load_prompt_template.py +15 -34
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +4 -1
- pdd/preprocess.py +68 -12
- pdd/preprocess_main.py +33 -1
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
- pdd/prompts/agentic_update_LLM.prompt +192 -338
- pdd/prompts/auto_include_LLM.prompt +22 -0
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +571 -14
- pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
- pdd/prompts/generate_test_LLM.prompt +20 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/remote_session.py +876 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/summarize_directory.py +236 -237
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +329 -47
- pdd/sync_main.py +272 -28
- pdd/sync_orchestration.py +136 -75
- pdd/template_expander.py +161 -0
- pdd/templates/architecture/architecture_json.prompt +41 -46
- pdd/trace.py +1 -1
- pdd/track_cost.py +0 -13
- pdd/unfinished_prompt.py +2 -1
- pdd/update_main.py +23 -5
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/remote_session.py
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote Session Management for PDD Connect.
|
|
3
|
+
|
|
4
|
+
This module handles remote session management for PDD Connect. It enables users to run
|
|
5
|
+
`pdd connect` on any machine and access it remotely via PDD Cloud. The cloud acts as a
|
|
6
|
+
message bus - it relays commands from the browser to the CLI via Firestore.
|
|
7
|
+
No external tunnel (ngrok) is required - the cloud hosts everything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import asyncio
|
|
14
|
+
import datetime
|
|
15
|
+
import platform
|
|
16
|
+
import socket
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from .core.cloud import CloudConfig
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# Global state for the active session manager
|
|
30
|
+
_active_session_manager: Optional[RemoteSessionManager] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_active_session_manager() -> Optional[RemoteSessionManager]:
|
|
34
|
+
"""Get the currently active remote session manager."""
|
|
35
|
+
return _active_session_manager
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def set_active_session_manager(manager: Optional[RemoteSessionManager]) -> None:
|
|
39
|
+
"""Set the currently active remote session manager."""
|
|
40
|
+
global _active_session_manager
|
|
41
|
+
_active_session_manager = manager
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RemoteSessionError(Exception):
|
|
45
|
+
"""Custom exception for remote session operations."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
48
|
+
self.message = message
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
super().__init__(f"{message} (Status: {status_code})" if status_code else message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SessionInfo:
|
|
55
|
+
"""
|
|
56
|
+
Represents a remote PDD session discovered from the cloud.
|
|
57
|
+
|
|
58
|
+
The cloud_url is the URL users can access in their browser to interact
|
|
59
|
+
with this session (e.g., https://pdd.dev/connect/{session_id}).
|
|
60
|
+
"""
|
|
61
|
+
session_id: str
|
|
62
|
+
cloud_url: str
|
|
63
|
+
project_name: str
|
|
64
|
+
project_path: str
|
|
65
|
+
created_at: datetime.datetime
|
|
66
|
+
last_heartbeat: datetime.datetime
|
|
67
|
+
status: str
|
|
68
|
+
metadata: Dict[str, Any]
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: Dict[str, Any]) -> SessionInfo:
|
|
72
|
+
"""Factory method to create SessionInfo from cloud API response."""
|
|
73
|
+
def parse_dt(dt_str: Optional[str]) -> datetime.datetime:
|
|
74
|
+
if not dt_str:
|
|
75
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
76
|
+
# Handle 'Z' for UTC which fromisoformat didn't handle before 3.11
|
|
77
|
+
if dt_str.endswith('Z'):
|
|
78
|
+
dt_str = dt_str[:-1] + '+00:00'
|
|
79
|
+
return datetime.datetime.fromisoformat(dt_str)
|
|
80
|
+
|
|
81
|
+
return cls(
|
|
82
|
+
session_id=data.get("sessionId", ""),
|
|
83
|
+
cloud_url=data.get("cloudUrl", ""),
|
|
84
|
+
project_name=data.get("projectName", "Unknown Project"),
|
|
85
|
+
project_path=data.get("projectPath", ""),
|
|
86
|
+
created_at=parse_dt(data.get("createdAt")),
|
|
87
|
+
last_heartbeat=parse_dt(data.get("lastHeartbeat")),
|
|
88
|
+
status=data.get("status", "unknown"),
|
|
89
|
+
metadata=data.get("metadata", {})
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CommandInfo:
|
|
95
|
+
"""
|
|
96
|
+
Represents a command from the Firestore message bus.
|
|
97
|
+
|
|
98
|
+
Commands are created by the browser and picked up by the CLI for execution.
|
|
99
|
+
"""
|
|
100
|
+
command_id: str
|
|
101
|
+
type: str # "generate" | "fix" | "sync" | "custom"
|
|
102
|
+
payload: Dict[str, Any]
|
|
103
|
+
status: str # "pending" | "processing" | "completed" | "failed"
|
|
104
|
+
created_at: datetime.datetime
|
|
105
|
+
response: Optional[Dict[str, Any]] = None
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, data: Dict[str, Any]) -> CommandInfo:
|
|
109
|
+
"""Factory method to create CommandInfo from cloud API response."""
|
|
110
|
+
def parse_dt(dt_str: Optional[str]) -> datetime.datetime:
|
|
111
|
+
if not dt_str:
|
|
112
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
113
|
+
if dt_str.endswith('Z'):
|
|
114
|
+
dt_str = dt_str[:-1] + '+00:00'
|
|
115
|
+
return datetime.datetime.fromisoformat(dt_str)
|
|
116
|
+
|
|
117
|
+
return cls(
|
|
118
|
+
command_id=data.get("commandId", ""),
|
|
119
|
+
type=data.get("type", "custom"),
|
|
120
|
+
payload=data.get("payload", {}),
|
|
121
|
+
status=data.get("status", "pending"),
|
|
122
|
+
created_at=parse_dt(data.get("createdAt")),
|
|
123
|
+
response=data.get("response"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RemoteSessionManager:
|
|
128
|
+
"""
|
|
129
|
+
Manages the lifecycle of a remote session: registration, heartbeats, and deregistration.
|
|
130
|
+
|
|
131
|
+
The cloud acts as a message bus - commands from the browser are relayed via Firestore.
|
|
132
|
+
No external tunnel is required; the cloud generates the access URL.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, jwt_token: str, project_path: Path):
|
|
136
|
+
self.jwt_token = jwt_token
|
|
137
|
+
self.project_path = project_path
|
|
138
|
+
self.session_id: Optional[str] = None
|
|
139
|
+
self.cloud_url: Optional[str] = None
|
|
140
|
+
self._heartbeat_task: Optional[asyncio.Task] = None
|
|
141
|
+
self._command_polling_task: Optional[asyncio.Task] = None
|
|
142
|
+
self._stop_event: Optional[asyncio.Event] = None
|
|
143
|
+
self._client_timeout = 30.0
|
|
144
|
+
|
|
145
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
146
|
+
return {
|
|
147
|
+
"Authorization": f"Bearer {self.jwt_token}",
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def _get_metadata(self) -> Dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"hostname": socket.gethostname(),
|
|
154
|
+
"platform": platform.system(),
|
|
155
|
+
"platformRelease": platform.release(),
|
|
156
|
+
"pythonVersion": sys.version.split()[0],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async def register(self, session_name: Optional[str] = None) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Register the session with the cloud.
|
|
162
|
+
|
|
163
|
+
No public URL is required - the cloud generates the access URL.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
session_name: Optional custom name for the session.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
str: The cloud access URL (e.g., https://pdd.dev/connect/{session_id}).
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
RemoteSessionError: If registration fails.
|
|
173
|
+
"""
|
|
174
|
+
endpoint = CloudConfig.get_endpoint_url("registerSession")
|
|
175
|
+
|
|
176
|
+
payload = {
|
|
177
|
+
"projectPath": str(self.project_path),
|
|
178
|
+
"metadata": self._get_metadata()
|
|
179
|
+
}
|
|
180
|
+
if session_name:
|
|
181
|
+
payload["sessionName"] = session_name
|
|
182
|
+
|
|
183
|
+
async with httpx.AsyncClient(timeout=self._client_timeout) as client:
|
|
184
|
+
try:
|
|
185
|
+
response = await client.post(
|
|
186
|
+
endpoint,
|
|
187
|
+
json=payload,
|
|
188
|
+
headers=self._get_headers()
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if response.status_code >= 400:
|
|
192
|
+
raise RemoteSessionError(
|
|
193
|
+
f"Failed to register session: {response.text}",
|
|
194
|
+
status_code=response.status_code
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
data = response.json()
|
|
198
|
+
self.session_id = data.get("sessionId")
|
|
199
|
+
self.cloud_url = data.get("cloudUrl")
|
|
200
|
+
|
|
201
|
+
if not self.session_id:
|
|
202
|
+
raise RemoteSessionError("Cloud response missing sessionId")
|
|
203
|
+
if not self.cloud_url:
|
|
204
|
+
raise RemoteSessionError("Cloud response missing cloudUrl")
|
|
205
|
+
|
|
206
|
+
return self.cloud_url
|
|
207
|
+
|
|
208
|
+
except httpx.RequestError as e:
|
|
209
|
+
raise RemoteSessionError(f"Network error during registration: {str(e)}")
|
|
210
|
+
|
|
211
|
+
async def _heartbeat_loop(self) -> None:
|
|
212
|
+
"""Internal loop to send heartbeats every 60 seconds."""
|
|
213
|
+
endpoint = CloudConfig.get_endpoint_url("heartbeatSession")
|
|
214
|
+
|
|
215
|
+
# Ensure stop event is initialized
|
|
216
|
+
if self._stop_event is None:
|
|
217
|
+
self._stop_event = asyncio.Event()
|
|
218
|
+
|
|
219
|
+
while not self._stop_event.is_set():
|
|
220
|
+
try:
|
|
221
|
+
# Wait for 60 seconds or until stop event is set
|
|
222
|
+
try:
|
|
223
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=60.0)
|
|
224
|
+
break # Stop event was set
|
|
225
|
+
except asyncio.TimeoutError:
|
|
226
|
+
pass # Timeout reached, send heartbeat
|
|
227
|
+
|
|
228
|
+
if not self.session_id:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
232
|
+
response = await client.post(
|
|
233
|
+
endpoint,
|
|
234
|
+
json={"sessionId": self.session_id},
|
|
235
|
+
headers=self._get_headers()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if response.status_code >= 400:
|
|
239
|
+
console.print(f"[yellow]Warning: Heartbeat failed (Status: {response.status_code})[/yellow]")
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
# We don't want to crash the server loop due to a heartbeat failure
|
|
243
|
+
console.print(f"[yellow]Warning: Heartbeat error: {str(e)}[/yellow]")
|
|
244
|
+
|
|
245
|
+
def start_heartbeat(self) -> None:
|
|
246
|
+
"""Start the background heartbeat task."""
|
|
247
|
+
if self._heartbeat_task is not None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# Initialize stop event if needed (must have event loop running)
|
|
251
|
+
if self._stop_event is None:
|
|
252
|
+
self._stop_event = asyncio.Event()
|
|
253
|
+
else:
|
|
254
|
+
self._stop_event.clear()
|
|
255
|
+
|
|
256
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
257
|
+
|
|
258
|
+
async def stop_heartbeat(self) -> None:
|
|
259
|
+
"""Stop the heartbeat task gracefully."""
|
|
260
|
+
if self._heartbeat_task:
|
|
261
|
+
if self._stop_event:
|
|
262
|
+
self._stop_event.set()
|
|
263
|
+
try:
|
|
264
|
+
await self._heartbeat_task
|
|
265
|
+
except asyncio.CancelledError:
|
|
266
|
+
pass
|
|
267
|
+
self._heartbeat_task = None
|
|
268
|
+
|
|
269
|
+
async def deregister(self) -> None:
|
|
270
|
+
"""
|
|
271
|
+
Deregister the session from the cloud.
|
|
272
|
+
Should be called on application shutdown.
|
|
273
|
+
"""
|
|
274
|
+
if not self.session_id:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
endpoint = CloudConfig.get_endpoint_url("deregisterSession")
|
|
278
|
+
|
|
279
|
+
# Stop heartbeat and command polling first to prevent race conditions
|
|
280
|
+
await self.stop_heartbeat()
|
|
281
|
+
await self.stop_command_polling()
|
|
282
|
+
|
|
283
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
284
|
+
try:
|
|
285
|
+
# Server expects POST method for deregisterSession
|
|
286
|
+
response = await client.post(
|
|
287
|
+
endpoint,
|
|
288
|
+
json={"sessionId": self.session_id},
|
|
289
|
+
headers=self._get_headers()
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if response.status_code < 400:
|
|
293
|
+
console.print("[dim]Session deregistered from cloud.[/dim]")
|
|
294
|
+
else:
|
|
295
|
+
console.print(f"[yellow]Warning: Failed to deregister session (Status: {response.status_code})[/yellow]")
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
# Idempotent: don't raise on failure during shutdown
|
|
299
|
+
console.print(f"[yellow]Warning: Error deregistering session: {str(e)}[/yellow]")
|
|
300
|
+
finally:
|
|
301
|
+
self.session_id = None
|
|
302
|
+
|
|
303
|
+
async def get_pending_commands(self) -> List[CommandInfo]:
|
|
304
|
+
"""
|
|
305
|
+
Retrieve pending commands from the cloud for this session.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
List[CommandInfo]: List of pending commands to execute.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
RemoteSessionError: If fetching commands fails.
|
|
312
|
+
"""
|
|
313
|
+
if not self.session_id:
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
endpoint = CloudConfig.get_endpoint_url("getCommands")
|
|
317
|
+
|
|
318
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
319
|
+
try:
|
|
320
|
+
response = await client.get(
|
|
321
|
+
endpoint,
|
|
322
|
+
params={"sessionId": self.session_id},
|
|
323
|
+
headers=self._get_headers()
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if response.status_code >= 400:
|
|
327
|
+
console.print(f"[yellow]Warning: Failed to get commands (Status: {response.status_code})[/yellow]")
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
data = response.json()
|
|
331
|
+
commands_data = data.get("commands", [])
|
|
332
|
+
|
|
333
|
+
return [CommandInfo.from_dict(c) for c in commands_data]
|
|
334
|
+
|
|
335
|
+
except httpx.RequestError as e:
|
|
336
|
+
console.print(f"[yellow]Warning: Network error getting commands: {str(e)}[/yellow]")
|
|
337
|
+
return []
|
|
338
|
+
except Exception as e:
|
|
339
|
+
console.print(f"[yellow]Warning: Error parsing commands: {str(e)}[/yellow]")
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
async def update_command(
|
|
343
|
+
self,
|
|
344
|
+
command_id: str,
|
|
345
|
+
status: str,
|
|
346
|
+
response: Optional[Dict[str, Any]] = None
|
|
347
|
+
) -> None:
|
|
348
|
+
"""
|
|
349
|
+
Update the status of a command in the cloud with retry logic.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
command_id: The command ID to update.
|
|
353
|
+
status: New status ("processing", "completed", "failed").
|
|
354
|
+
response: Optional response data (for completed/failed status).
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
RemoteSessionError: If update fails after all retries.
|
|
358
|
+
"""
|
|
359
|
+
if not self.session_id:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
endpoint = CloudConfig.get_endpoint_url("updateCommand")
|
|
363
|
+
|
|
364
|
+
payload = {
|
|
365
|
+
"sessionId": self.session_id,
|
|
366
|
+
"commandId": command_id,
|
|
367
|
+
"status": status
|
|
368
|
+
}
|
|
369
|
+
if response is not None:
|
|
370
|
+
payload["response"] = response
|
|
371
|
+
|
|
372
|
+
# Retry logic with exponential backoff
|
|
373
|
+
max_retries = 3
|
|
374
|
+
retry_delay = 1 # Start with 1 second
|
|
375
|
+
|
|
376
|
+
for attempt in range(max_retries):
|
|
377
|
+
try:
|
|
378
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
379
|
+
result = await client.post(
|
|
380
|
+
endpoint,
|
|
381
|
+
json=payload,
|
|
382
|
+
headers=self._get_headers()
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if result.status_code >= 400:
|
|
386
|
+
error_msg = f"Failed to update command status: {result.text}"
|
|
387
|
+
console.print(f"[red]{error_msg}[/red]")
|
|
388
|
+
raise RuntimeError(error_msg)
|
|
389
|
+
|
|
390
|
+
# Success - return immediately
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
except Exception as e:
|
|
394
|
+
if attempt == max_retries - 1:
|
|
395
|
+
# Final attempt failed - raise error
|
|
396
|
+
console.print(f"[red]Failed to update command after {max_retries} attempts: {e}[/red]")
|
|
397
|
+
raise
|
|
398
|
+
# Retry with exponential backoff
|
|
399
|
+
console.print(f"[yellow]Cloud update failed (attempt {attempt + 1}/{max_retries}), retrying in {retry_delay}s...[/yellow]")
|
|
400
|
+
await asyncio.sleep(retry_delay)
|
|
401
|
+
retry_delay *= 2 # Exponential backoff
|
|
402
|
+
|
|
403
|
+
async def _get_command_status(self, command_id: str) -> str:
|
|
404
|
+
"""
|
|
405
|
+
Get current status of a command from cloud.
|
|
406
|
+
|
|
407
|
+
Uses the getCommandStatus endpoint which returns any command regardless
|
|
408
|
+
of status (unlike getCommands which only returns pending commands).
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
command_id: The command ID to check.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
str: Current status ('pending', 'processing', 'completed', 'failed', 'cancelled', or 'unknown').
|
|
415
|
+
"""
|
|
416
|
+
if not self.session_id:
|
|
417
|
+
return "unknown"
|
|
418
|
+
|
|
419
|
+
endpoint = CloudConfig.get_endpoint_url("getCommandStatus")
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
423
|
+
result = await client.get(
|
|
424
|
+
endpoint,
|
|
425
|
+
params={
|
|
426
|
+
"sessionId": self.session_id,
|
|
427
|
+
"commandId": command_id
|
|
428
|
+
},
|
|
429
|
+
headers=self._get_headers()
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if result.status_code == 200:
|
|
433
|
+
data = result.json()
|
|
434
|
+
command = data.get("command", {})
|
|
435
|
+
return command.get("status", "unknown")
|
|
436
|
+
elif result.status_code == 404:
|
|
437
|
+
# Command not found
|
|
438
|
+
return "unknown"
|
|
439
|
+
return "unknown"
|
|
440
|
+
|
|
441
|
+
except Exception as e:
|
|
442
|
+
console.print(f"[yellow]Failed to check command status: {e}[/yellow]")
|
|
443
|
+
return "unknown"
|
|
444
|
+
|
|
445
|
+
async def _is_cancelled(self, command_id: str) -> bool:
|
|
446
|
+
"""
|
|
447
|
+
Check if command was cancelled.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
command_id: The command ID to check.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
bool: True if command status is 'cancelled', False otherwise.
|
|
454
|
+
"""
|
|
455
|
+
status = await self._get_command_status(command_id)
|
|
456
|
+
return status == "cancelled"
|
|
457
|
+
|
|
458
|
+
async def _do_execute(self, cmd: CommandInfo) -> Tuple[str, dict]:
|
|
459
|
+
"""
|
|
460
|
+
Actually execute the command via local FastAPI endpoint with log streaming.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
cmd: The command to execute.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Tuple[str, dict]: (job_id, response) from the local server.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
Exception: If execution fails.
|
|
470
|
+
asyncio.CancelledError: If the command was cancelled.
|
|
471
|
+
"""
|
|
472
|
+
local_url = "http://127.0.0.1:9876"
|
|
473
|
+
execute_endpoint = "/api/v1/commands/execute"
|
|
474
|
+
|
|
475
|
+
# Build request payload in CommandRequest format
|
|
476
|
+
cmd_args = cmd.payload.get("args", {})
|
|
477
|
+
cmd_options = cmd.payload.get("options", {})
|
|
478
|
+
|
|
479
|
+
# Defensive parsing: handle cases where arrays might arrive as stringified JSON
|
|
480
|
+
# This can happen if the cloud/Firestore serializes arrays incorrectly
|
|
481
|
+
def parse_if_stringified_list(value):
|
|
482
|
+
"""Parse value if it looks like a stringified Python list."""
|
|
483
|
+
if isinstance(value, str):
|
|
484
|
+
stripped = value.strip()
|
|
485
|
+
if stripped.startswith('[') and stripped.endswith(']'):
|
|
486
|
+
try:
|
|
487
|
+
# Try to parse as Python literal (e.g., "['a', 'b']")
|
|
488
|
+
parsed = ast.literal_eval(stripped)
|
|
489
|
+
if isinstance(parsed, list):
|
|
490
|
+
return parsed
|
|
491
|
+
except (ValueError, SyntaxError):
|
|
492
|
+
pass
|
|
493
|
+
return value
|
|
494
|
+
|
|
495
|
+
# Apply defensive parsing to args and options
|
|
496
|
+
for key in list(cmd_args.keys()):
|
|
497
|
+
cmd_args[key] = parse_if_stringified_list(cmd_args[key])
|
|
498
|
+
for key in list(cmd_options.keys()):
|
|
499
|
+
cmd_options[key] = parse_if_stringified_list(cmd_options[key])
|
|
500
|
+
|
|
501
|
+
request_payload = {
|
|
502
|
+
"command": cmd.type,
|
|
503
|
+
"args": cmd_args,
|
|
504
|
+
"options": cmd_options
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Build CLI command string for display
|
|
508
|
+
cli_parts = ["pdd", cmd.type]
|
|
509
|
+
# Handle positional args first (special 'args' key contains positional arguments)
|
|
510
|
+
if "args" in cmd_args:
|
|
511
|
+
args_value = cmd_args["args"]
|
|
512
|
+
if isinstance(args_value, (list, tuple)):
|
|
513
|
+
cli_parts.extend(str(v) for v in args_value)
|
|
514
|
+
elif args_value is not None:
|
|
515
|
+
cli_parts.append(str(args_value))
|
|
516
|
+
# Then handle other args as named options
|
|
517
|
+
for key, value in cmd_args.items():
|
|
518
|
+
if key == "args":
|
|
519
|
+
continue # Already handled above
|
|
520
|
+
if isinstance(value, bool):
|
|
521
|
+
if value:
|
|
522
|
+
cli_parts.append(f"--{key}")
|
|
523
|
+
elif isinstance(value, (list, tuple)):
|
|
524
|
+
# Handle list values (e.g., multiple --env flags)
|
|
525
|
+
for v in value:
|
|
526
|
+
cli_parts.append(f"--{key} {v}")
|
|
527
|
+
elif isinstance(value, str) and " " in value:
|
|
528
|
+
cli_parts.append(f'--{key} "{value}"')
|
|
529
|
+
else:
|
|
530
|
+
cli_parts.append(f"--{key} {value}")
|
|
531
|
+
for key, value in cmd_options.items():
|
|
532
|
+
if isinstance(value, bool):
|
|
533
|
+
if value:
|
|
534
|
+
cli_parts.append(f"--{key}")
|
|
535
|
+
elif isinstance(value, (list, tuple)):
|
|
536
|
+
# Handle list values (e.g., multiple --env flags)
|
|
537
|
+
for v in value:
|
|
538
|
+
cli_parts.append(f"--{key} {v}")
|
|
539
|
+
else:
|
|
540
|
+
cli_parts.append(f"--{key} {value}")
|
|
541
|
+
cli_command = " ".join(cli_parts)
|
|
542
|
+
|
|
543
|
+
# Log command details
|
|
544
|
+
console.print(f"\n[bold cyan]{'═' * 60}[/bold cyan]")
|
|
545
|
+
console.print(f"[bold cyan]REMOTE COMMAND RECEIVED[/bold cyan]")
|
|
546
|
+
console.print(f"[bold cyan]{'═' * 60}[/bold cyan]")
|
|
547
|
+
console.print(f"[bold]Command:[/bold] [green]{cli_command}[/green]")
|
|
548
|
+
console.print(f"[dim]{'─' * 60}[/dim]")
|
|
549
|
+
console.print(f"[bold]Output:[/bold]")
|
|
550
|
+
|
|
551
|
+
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
552
|
+
# Submit the job
|
|
553
|
+
submit_result = await client.post(
|
|
554
|
+
f"{local_url}{execute_endpoint}",
|
|
555
|
+
json=request_payload
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if submit_result.status_code >= 400:
|
|
559
|
+
raise Exception(submit_result.text)
|
|
560
|
+
|
|
561
|
+
submit_data = submit_result.json()
|
|
562
|
+
job_id = submit_data.get("job_id")
|
|
563
|
+
|
|
564
|
+
if not job_id:
|
|
565
|
+
raise Exception("No job_id in response")
|
|
566
|
+
|
|
567
|
+
# Poll for job completion with log streaming and cancellation checks
|
|
568
|
+
status_endpoint = f"/api/v1/commands/jobs/{job_id}"
|
|
569
|
+
last_update_time = asyncio.get_event_loop().time()
|
|
570
|
+
update_interval = 2.0 # Send updates every 2 seconds (was 3)
|
|
571
|
+
last_stdout_len = 0
|
|
572
|
+
last_stderr_len = 0
|
|
573
|
+
|
|
574
|
+
while True:
|
|
575
|
+
# Check for cancellation BEFORE polling - this is critical for responsiveness
|
|
576
|
+
if await self._is_cancelled(cmd.command_id):
|
|
577
|
+
console.print(f"[yellow]Cancellation detected during job execution[/yellow]")
|
|
578
|
+
# Cancel the local job
|
|
579
|
+
await self._cancel_local_job(job_id)
|
|
580
|
+
# Return cancelled status
|
|
581
|
+
return job_id, {"status": "cancelled", "result": {}}
|
|
582
|
+
|
|
583
|
+
status_result = await client.get(f"{local_url}{status_endpoint}")
|
|
584
|
+
if status_result.status_code >= 400:
|
|
585
|
+
raise Exception(status_result.text)
|
|
586
|
+
|
|
587
|
+
status_data = status_result.json()
|
|
588
|
+
job_status = status_data.get("status")
|
|
589
|
+
|
|
590
|
+
# Get current output and display new content
|
|
591
|
+
result = status_data.get("result", {})
|
|
592
|
+
if isinstance(result, dict):
|
|
593
|
+
stdout = result.get("stdout", "")
|
|
594
|
+
stderr = result.get("stderr", "")
|
|
595
|
+
|
|
596
|
+
# Print incremental stdout (raw, preserving formatting)
|
|
597
|
+
if stdout and len(stdout) > last_stdout_len:
|
|
598
|
+
new_stdout = stdout[last_stdout_len:]
|
|
599
|
+
# Print raw output directly to preserve formatting
|
|
600
|
+
print(new_stdout, end="", flush=True)
|
|
601
|
+
last_stdout_len = len(stdout)
|
|
602
|
+
|
|
603
|
+
# Send periodic output updates to cloud
|
|
604
|
+
current_time = asyncio.get_event_loop().time()
|
|
605
|
+
if current_time - last_update_time >= update_interval:
|
|
606
|
+
if stdout or stderr:
|
|
607
|
+
try:
|
|
608
|
+
await self._update_command_output(
|
|
609
|
+
cmd.command_id,
|
|
610
|
+
stdout=stdout,
|
|
611
|
+
stderr=stderr
|
|
612
|
+
)
|
|
613
|
+
last_update_time = current_time
|
|
614
|
+
except Exception:
|
|
615
|
+
pass # Don't clutter output with cloud update errors
|
|
616
|
+
|
|
617
|
+
if job_status in ("completed", "failed", "cancelled"):
|
|
618
|
+
# Get final output
|
|
619
|
+
final_result = status_data.get("result", {})
|
|
620
|
+
final_stdout = final_result.get("stdout", "") if isinstance(final_result, dict) else ""
|
|
621
|
+
final_stderr = final_result.get("stderr", "") if isinstance(final_result, dict) else ""
|
|
622
|
+
exit_code = final_result.get("exit_code", 0) if isinstance(final_result, dict) else 0
|
|
623
|
+
|
|
624
|
+
# Print final summary
|
|
625
|
+
console.print(f"\n[dim]{'─' * 60}[/dim]")
|
|
626
|
+
console.print(f"[bold]Exit code:[/bold] {exit_code}")
|
|
627
|
+
if final_stdout:
|
|
628
|
+
console.print(f"[bold]Stdout ({len(final_stdout)} chars)[/bold]")
|
|
629
|
+
if final_stderr:
|
|
630
|
+
console.print(f"[bold yellow]Stderr ({len(final_stderr)} chars):[/bold yellow]")
|
|
631
|
+
# Print stderr since we didn't stream it
|
|
632
|
+
for line in final_stderr.splitlines():
|
|
633
|
+
console.print(f"[yellow]{line}[/yellow]")
|
|
634
|
+
console.print(f"[dim]{'─' * 60}[/dim]")
|
|
635
|
+
if job_status == "completed":
|
|
636
|
+
console.print(f"[bold green]✓ COMMAND COMPLETED[/bold green]")
|
|
637
|
+
elif job_status == "failed":
|
|
638
|
+
console.print(f"[bold red]✗ COMMAND FAILED[/bold red]")
|
|
639
|
+
elif job_status == "cancelled":
|
|
640
|
+
console.print(f"[bold yellow]⊘ COMMAND CANCELLED[/bold yellow]")
|
|
641
|
+
console.print(f"[bold cyan]{'═' * 60}[/bold cyan]\n")
|
|
642
|
+
return job_id, status_data
|
|
643
|
+
|
|
644
|
+
# Short sleep to be responsive to cancellation
|
|
645
|
+
await asyncio.sleep(0.5)
|
|
646
|
+
|
|
647
|
+
async def _update_command_output(
|
|
648
|
+
self,
|
|
649
|
+
command_id: str,
|
|
650
|
+
stdout: str = "",
|
|
651
|
+
stderr: str = ""
|
|
652
|
+
) -> None:
|
|
653
|
+
"""
|
|
654
|
+
Update cloud with intermediate command output for log streaming.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
command_id: The command ID to update.
|
|
658
|
+
stdout: Current stdout output.
|
|
659
|
+
stderr: Current stderr output.
|
|
660
|
+
"""
|
|
661
|
+
if not self.session_id:
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
endpoint = CloudConfig.get_endpoint_url("updateCommand")
|
|
665
|
+
|
|
666
|
+
payload = {
|
|
667
|
+
"sessionId": self.session_id,
|
|
668
|
+
"commandId": command_id,
|
|
669
|
+
"status": "processing", # Keep status as processing
|
|
670
|
+
"response": {
|
|
671
|
+
"stdout": stdout,
|
|
672
|
+
"stderr": stderr,
|
|
673
|
+
"streaming": True, # Indicate this is a streaming update
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
679
|
+
await client.post(
|
|
680
|
+
endpoint,
|
|
681
|
+
json=payload,
|
|
682
|
+
headers=self._get_headers()
|
|
683
|
+
)
|
|
684
|
+
except Exception:
|
|
685
|
+
pass # Don't fail on streaming updates
|
|
686
|
+
|
|
687
|
+
async def _cancel_local_job(self, job_id: str) -> bool:
|
|
688
|
+
"""
|
|
689
|
+
Cancel a job running on the local server.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
job_id: The local job ID to cancel.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
bool: True if cancellation was successful.
|
|
696
|
+
"""
|
|
697
|
+
local_url = "http://127.0.0.1:9876"
|
|
698
|
+
cancel_endpoint = f"/api/v1/commands/jobs/{job_id}/cancel"
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
702
|
+
result = await client.post(f"{local_url}{cancel_endpoint}")
|
|
703
|
+
if result.status_code < 400:
|
|
704
|
+
data = result.json()
|
|
705
|
+
return data.get("cancelled", False)
|
|
706
|
+
return False
|
|
707
|
+
except Exception as e:
|
|
708
|
+
console.print(f"[yellow]Failed to cancel local job: {e}[/yellow]")
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
async def _execute_command(self, cmd: CommandInfo) -> None:
|
|
712
|
+
"""
|
|
713
|
+
Execute a command locally and report results back to the cloud.
|
|
714
|
+
|
|
715
|
+
Supports cancellation during execution - cancellation is now checked
|
|
716
|
+
inside _do_execute() for faster response times.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
cmd: The command to execute.
|
|
720
|
+
"""
|
|
721
|
+
try:
|
|
722
|
+
# 1. Update status to "processing"
|
|
723
|
+
await self.update_command(cmd.command_id, status="processing")
|
|
724
|
+
|
|
725
|
+
# 2. Check if command was cancelled before starting execution
|
|
726
|
+
if await self._is_cancelled(cmd.command_id):
|
|
727
|
+
console.print(f"[yellow]Command cancelled before execution[/yellow]")
|
|
728
|
+
await self.update_command(cmd.command_id, status="cancelled")
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
# 3. Execute the command - cancellation is checked inside _do_execute()
|
|
732
|
+
local_job_id, response_data = await self._do_execute(cmd)
|
|
733
|
+
|
|
734
|
+
# 4. Check if the local job was cancelled
|
|
735
|
+
job_status = response_data.get("status", "")
|
|
736
|
+
if job_status == "cancelled":
|
|
737
|
+
await self.update_command(cmd.command_id, status="cancelled")
|
|
738
|
+
console.print(f"[yellow]Command was cancelled[/yellow]")
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
# 5. Map to expected structure for frontend
|
|
742
|
+
result = response_data.get("result", {})
|
|
743
|
+
error_msg = ""
|
|
744
|
+
if job_status == "failed":
|
|
745
|
+
# Capture error from response or from stderr
|
|
746
|
+
error_msg = response_data.get("error", "")
|
|
747
|
+
if not error_msg and isinstance(result, dict):
|
|
748
|
+
error_msg = result.get("stderr", "") or result.get("stdout", "")
|
|
749
|
+
|
|
750
|
+
formatted_response = {
|
|
751
|
+
"success": job_status == "completed",
|
|
752
|
+
"message": error_msg,
|
|
753
|
+
"exit_code": result.get("exit_code", 0) if isinstance(result, dict) else 0,
|
|
754
|
+
"stdout": result.get("stdout", "") if isinstance(result, dict) else "",
|
|
755
|
+
"stderr": result.get("stderr", "") if isinstance(result, dict) else "",
|
|
756
|
+
"files_created": result.get("files_created", []) if isinstance(result, dict) else [],
|
|
757
|
+
"cost": response_data.get("cost", 0.0),
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
final_status = "completed" if job_status == "completed" else "failed"
|
|
761
|
+
await self.update_command(
|
|
762
|
+
cmd.command_id,
|
|
763
|
+
status=final_status,
|
|
764
|
+
response=formatted_response
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
except asyncio.CancelledError:
|
|
768
|
+
# Task was cancelled externally
|
|
769
|
+
console.print(f"[yellow]Command execution cancelled[/yellow]")
|
|
770
|
+
await self.update_command(cmd.command_id, status="cancelled")
|
|
771
|
+
|
|
772
|
+
except Exception as e:
|
|
773
|
+
# Execution error
|
|
774
|
+
console.print(f"[red]Error executing command: {str(e)}[/red]")
|
|
775
|
+
await self.update_command(
|
|
776
|
+
cmd.command_id,
|
|
777
|
+
status="failed",
|
|
778
|
+
response={"error": str(e)}
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
async def _command_polling_loop(self) -> None:
|
|
782
|
+
"""
|
|
783
|
+
Background task that polls for pending commands and executes them.
|
|
784
|
+
Runs every 5 seconds until stopped.
|
|
785
|
+
"""
|
|
786
|
+
if self._stop_event is None:
|
|
787
|
+
self._stop_event = asyncio.Event()
|
|
788
|
+
|
|
789
|
+
console.print("[dim]Command polling started[/dim]")
|
|
790
|
+
|
|
791
|
+
while not self._stop_event.is_set():
|
|
792
|
+
try:
|
|
793
|
+
# Wait for 5 seconds or until stop event is set
|
|
794
|
+
try:
|
|
795
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=5.0)
|
|
796
|
+
break # Stop event was set
|
|
797
|
+
except asyncio.TimeoutError:
|
|
798
|
+
pass # Timeout reached, poll for commands
|
|
799
|
+
|
|
800
|
+
# Get pending commands
|
|
801
|
+
commands = await self.get_pending_commands()
|
|
802
|
+
|
|
803
|
+
# Execute each command sequentially
|
|
804
|
+
for cmd in commands:
|
|
805
|
+
if self._stop_event.is_set():
|
|
806
|
+
break
|
|
807
|
+
await self._execute_command(cmd)
|
|
808
|
+
|
|
809
|
+
except Exception as e:
|
|
810
|
+
console.print(f"[yellow]Warning: Command polling error: {str(e)}[/yellow]")
|
|
811
|
+
|
|
812
|
+
console.print("[dim]Command polling stopped[/dim]")
|
|
813
|
+
|
|
814
|
+
def start_command_polling(self) -> None:
|
|
815
|
+
"""Start the background command polling task."""
|
|
816
|
+
if self._command_polling_task is not None:
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
# Initialize stop event if needed
|
|
820
|
+
if self._stop_event is None:
|
|
821
|
+
self._stop_event = asyncio.Event()
|
|
822
|
+
else:
|
|
823
|
+
self._stop_event.clear()
|
|
824
|
+
|
|
825
|
+
self._command_polling_task = asyncio.create_task(self._command_polling_loop())
|
|
826
|
+
|
|
827
|
+
async def stop_command_polling(self) -> None:
|
|
828
|
+
"""Stop the command polling task gracefully."""
|
|
829
|
+
if self._command_polling_task:
|
|
830
|
+
if self._stop_event:
|
|
831
|
+
self._stop_event.set()
|
|
832
|
+
try:
|
|
833
|
+
await self._command_polling_task
|
|
834
|
+
except asyncio.CancelledError:
|
|
835
|
+
pass
|
|
836
|
+
self._command_polling_task = None
|
|
837
|
+
|
|
838
|
+
@staticmethod
|
|
839
|
+
async def list_sessions(jwt_token: str) -> List[SessionInfo]:
|
|
840
|
+
"""
|
|
841
|
+
List all active sessions available to the user.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
jwt_token: The user's JWT authentication token.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
List[SessionInfo]: A list of active sessions.
|
|
848
|
+
|
|
849
|
+
Raises:
|
|
850
|
+
RemoteSessionError: If the listing fails.
|
|
851
|
+
"""
|
|
852
|
+
endpoint = CloudConfig.get_endpoint_url("listSessions")
|
|
853
|
+
headers = {
|
|
854
|
+
"Authorization": f"Bearer {jwt_token}",
|
|
855
|
+
"Content-Type": "application/json",
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
859
|
+
try:
|
|
860
|
+
response = await client.get(endpoint, headers=headers)
|
|
861
|
+
|
|
862
|
+
if response.status_code >= 400:
|
|
863
|
+
raise RemoteSessionError(
|
|
864
|
+
f"Failed to list sessions: {response.text}",
|
|
865
|
+
status_code=response.status_code
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
data = response.json()
|
|
869
|
+
sessions_data = data.get("sessions", [])
|
|
870
|
+
|
|
871
|
+
return [SessionInfo.from_dict(s) for s in sessions_data]
|
|
872
|
+
|
|
873
|
+
except httpx.RequestError as e:
|
|
874
|
+
raise RemoteSessionError(f"Network error listing sessions: {str(e)}")
|
|
875
|
+
except ValueError as e:
|
|
876
|
+
raise RemoteSessionError(f"Invalid response format: {str(e)}")
|