portacode 1.3.32__py3-none-any.whl → 1.4.15.dev3__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.
- portacode/_version.py +2 -2
- portacode/cli.py +158 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +600 -4
- portacode/connection/handlers/__init__.py +30 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +2082 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +311 -9
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/test_proxmox_infra.py +13 -0
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +64 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/METADATA +71 -3
- portacode-1.4.15.dev3.dist-info/RECORD +98 -0
- {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/WHEEL +1 -1
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/top_level.txt +0 -0
|
@@ -1,17 +1,318 @@
|
|
|
1
1
|
"""System command handlers."""
|
|
2
|
+
from __future__ import annotations
|
|
2
3
|
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import getpass
|
|
6
|
+
import importlib.util
|
|
3
7
|
import logging
|
|
4
8
|
import os
|
|
5
9
|
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
6
14
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
8
17
|
from portacode import __version__
|
|
9
18
|
import psutil
|
|
10
19
|
|
|
20
|
+
try:
|
|
21
|
+
from importlib import metadata as importlib_metadata
|
|
22
|
+
except ImportError: # pragma: no cover - py<3.8
|
|
23
|
+
import importlib_metadata
|
|
24
|
+
|
|
11
25
|
from .base import SyncHandler
|
|
26
|
+
from .proxmox_infra import get_infra_snapshot
|
|
12
27
|
|
|
13
28
|
logger = logging.getLogger(__name__)
|
|
14
29
|
|
|
30
|
+
# Global CPU monitoring
|
|
31
|
+
_cpu_percent = 0.0
|
|
32
|
+
_cpu_thread = None
|
|
33
|
+
_cpu_lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
# Cgroup v2 tracking
|
|
36
|
+
_CGROUP_ROOT = Path("/sys/fs/cgroup")
|
|
37
|
+
_cgroup_path: Optional[Path] = None
|
|
38
|
+
_cgroup_v2_supported: Optional[bool] = None
|
|
39
|
+
_CGROUP_CPU_STAT = "cpu.stat"
|
|
40
|
+
_CGROUP_CPU_MAX = "cpu.max"
|
|
41
|
+
_last_cgroup_usage: Optional[int] = None
|
|
42
|
+
_last_cgroup_time: Optional[float] = None
|
|
43
|
+
|
|
44
|
+
def _cpu_monitor():
|
|
45
|
+
"""Background thread to update CPU usage every 5 seconds."""
|
|
46
|
+
global _cpu_percent
|
|
47
|
+
while True:
|
|
48
|
+
percent = _get_cgroup_cpu_percent()
|
|
49
|
+
if percent is None:
|
|
50
|
+
# Fall back to psutil when cgroup stats are not available yet.
|
|
51
|
+
percent = psutil.cpu_percent(interval=5.0)
|
|
52
|
+
else:
|
|
53
|
+
time.sleep(5.0)
|
|
54
|
+
_cpu_percent = percent
|
|
55
|
+
|
|
56
|
+
def _ensure_cpu_thread():
|
|
57
|
+
"""Ensure CPU monitoring thread is running (singleton)."""
|
|
58
|
+
global _cpu_thread
|
|
59
|
+
with _cpu_lock:
|
|
60
|
+
if _cpu_thread is None or not _cpu_thread.is_alive():
|
|
61
|
+
_cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
|
|
62
|
+
_cpu_thread.start()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_user_context() -> Dict[str, Any]:
|
|
66
|
+
"""Gather current CLI user plus permission hints."""
|
|
67
|
+
context = {}
|
|
68
|
+
login_source = "os.getlogin"
|
|
69
|
+
try:
|
|
70
|
+
username = os.getlogin()
|
|
71
|
+
except Exception:
|
|
72
|
+
login_source = "getpass"
|
|
73
|
+
username = getpass.getuser()
|
|
74
|
+
|
|
75
|
+
context["username"] = username
|
|
76
|
+
context["username_source"] = login_source
|
|
77
|
+
context["home"] = str(Path.home())
|
|
78
|
+
|
|
79
|
+
uid = getattr(os, "getuid", None)
|
|
80
|
+
euid = getattr(os, "geteuid", None)
|
|
81
|
+
context["uid"] = uid() if uid else None
|
|
82
|
+
context["euid"] = euid() if euid else context["uid"]
|
|
83
|
+
if os.name == "nt":
|
|
84
|
+
try:
|
|
85
|
+
import ctypes
|
|
86
|
+
|
|
87
|
+
context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
88
|
+
except Exception:
|
|
89
|
+
context["is_root"] = None
|
|
90
|
+
else:
|
|
91
|
+
context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
|
|
92
|
+
|
|
93
|
+
context["has_sudo"] = shutil.which("sudo") is not None
|
|
94
|
+
context["sudo_user"] = os.environ.get("SUDO_USER")
|
|
95
|
+
context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
|
|
96
|
+
return context
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_playwright_info() -> Dict[str, Any]:
|
|
100
|
+
"""Return Playwright presence, version, and browser binaries if available."""
|
|
101
|
+
result: Dict[str, Any] = {
|
|
102
|
+
"installed": False,
|
|
103
|
+
"version": None,
|
|
104
|
+
"browsers": {},
|
|
105
|
+
"error": None,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if importlib.util.find_spec("playwright") is None:
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
result["installed"] = True
|
|
112
|
+
try:
|
|
113
|
+
result["version"] = importlib_metadata.version("playwright")
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.debug("Unable to read Playwright version metadata: %s", exc)
|
|
116
|
+
|
|
117
|
+
def _inspect_browsers() -> Dict[str, Any]:
|
|
118
|
+
from playwright.sync_api import sync_playwright
|
|
119
|
+
|
|
120
|
+
browsers_data: Dict[str, Any] = {}
|
|
121
|
+
with sync_playwright() as p:
|
|
122
|
+
for name in ("chromium", "firefox", "webkit"):
|
|
123
|
+
browser_type = getattr(p, name, None)
|
|
124
|
+
if browser_type is None:
|
|
125
|
+
continue
|
|
126
|
+
exec_path = getattr(browser_type, "executable_path", None)
|
|
127
|
+
browsers_data[name] = {
|
|
128
|
+
"available": bool(exec_path),
|
|
129
|
+
"executable_path": exec_path,
|
|
130
|
+
}
|
|
131
|
+
return browsers_data
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
135
|
+
future = executor.submit(_inspect_browsers)
|
|
136
|
+
browsers = future.result(timeout=5)
|
|
137
|
+
result["browsers"] = browsers
|
|
138
|
+
except concurrent.futures.TimeoutError:
|
|
139
|
+
msg = "Playwright inspection timed out"
|
|
140
|
+
logger.warning(msg)
|
|
141
|
+
result["error"] = msg
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.warning("Playwright browser inspection failed: %s", exc)
|
|
144
|
+
result["error"] = str(exc)
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_cgroup_path() -> Path:
|
|
150
|
+
global _cgroup_path
|
|
151
|
+
if _cgroup_path is not None and _cgroup_path.exists():
|
|
152
|
+
return _cgroup_path
|
|
153
|
+
path = _CGROUP_ROOT
|
|
154
|
+
cgroup_file = Path("/proc/self/cgroup")
|
|
155
|
+
if cgroup_file.exists():
|
|
156
|
+
try:
|
|
157
|
+
contents = cgroup_file.read_text()
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
else:
|
|
161
|
+
for line in contents.splitlines():
|
|
162
|
+
line = line.strip()
|
|
163
|
+
if not line:
|
|
164
|
+
continue
|
|
165
|
+
parts = line.split(":", 2)
|
|
166
|
+
if len(parts) < 3:
|
|
167
|
+
continue
|
|
168
|
+
rel_path = parts[-1].lstrip("/")
|
|
169
|
+
candidate = _CGROUP_ROOT / rel_path
|
|
170
|
+
# Fallback to root path if the relative path is empty
|
|
171
|
+
candidate = candidate if rel_path else _CGROUP_ROOT
|
|
172
|
+
if candidate.exists():
|
|
173
|
+
path = candidate
|
|
174
|
+
break
|
|
175
|
+
_cgroup_path = path
|
|
176
|
+
return _cgroup_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cgroup_file(name: str) -> Path:
|
|
180
|
+
return _resolve_cgroup_path() / name
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _detect_cgroup_v2() -> bool:
|
|
184
|
+
global _cgroup_v2_supported
|
|
185
|
+
if _cgroup_v2_supported is not None:
|
|
186
|
+
return _cgroup_v2_supported
|
|
187
|
+
controllers = _cgroup_file("cgroup.controllers")
|
|
188
|
+
cpu_stat = _cgroup_file(_CGROUP_CPU_STAT)
|
|
189
|
+
_cgroup_v2_supported = controllers.exists() and cpu_stat.exists()
|
|
190
|
+
return _cgroup_v2_supported
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_cgroup_cpu_usage() -> Optional[int]:
|
|
194
|
+
path = _cgroup_file(_CGROUP_CPU_STAT)
|
|
195
|
+
try:
|
|
196
|
+
data = path.read_text()
|
|
197
|
+
except (OSError, UnicodeDecodeError):
|
|
198
|
+
return None
|
|
199
|
+
for line in data.splitlines():
|
|
200
|
+
parts = line.strip().split()
|
|
201
|
+
if len(parts) >= 2 and parts[0] == "usage_usec":
|
|
202
|
+
try:
|
|
203
|
+
return int(parts[1])
|
|
204
|
+
except ValueError:
|
|
205
|
+
return None
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_cgroup_cpu_limit() -> Optional[float]:
|
|
210
|
+
"""Return the allowed CPU (cores) for this cgroup, if limited."""
|
|
211
|
+
path = _cgroup_file(_CGROUP_CPU_MAX)
|
|
212
|
+
if not path.exists():
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
data = path.read_text().strip()
|
|
216
|
+
except (OSError, UnicodeDecodeError):
|
|
217
|
+
return None
|
|
218
|
+
parts = data.split()
|
|
219
|
+
if len(parts) < 2:
|
|
220
|
+
return None
|
|
221
|
+
quota, period = parts[0], parts[1]
|
|
222
|
+
if quota == "max":
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
quota_value = int(quota)
|
|
226
|
+
period_value = int(period)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
if period_value <= 0:
|
|
230
|
+
return None
|
|
231
|
+
return quota_value / period_value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _get_cgroup_cpu_percent() -> Optional[float]:
|
|
235
|
+
if not _detect_cgroup_v2():
|
|
236
|
+
return None
|
|
237
|
+
usage = _read_cgroup_cpu_usage()
|
|
238
|
+
if usage is None:
|
|
239
|
+
return None
|
|
240
|
+
now = time.monotonic()
|
|
241
|
+
global _last_cgroup_usage, _last_cgroup_time
|
|
242
|
+
prev_usage = _last_cgroup_usage
|
|
243
|
+
prev_time = _last_cgroup_time
|
|
244
|
+
_last_cgroup_usage = usage
|
|
245
|
+
_last_cgroup_time = now
|
|
246
|
+
if prev_usage is None or prev_time is None:
|
|
247
|
+
return None
|
|
248
|
+
delta_usage = usage - prev_usage
|
|
249
|
+
delta_time = now - prev_time
|
|
250
|
+
if delta_time <= 0 or delta_usage < 0:
|
|
251
|
+
return None
|
|
252
|
+
cpu_ratio = (delta_usage / 1_000_000) / delta_time
|
|
253
|
+
limit_cpus = _read_cgroup_cpu_limit()
|
|
254
|
+
if limit_cpus and limit_cpus > 0:
|
|
255
|
+
percent = (cpu_ratio / limit_cpus) * 100.0
|
|
256
|
+
else:
|
|
257
|
+
cpu_count = psutil.cpu_count(logical=True) or 1
|
|
258
|
+
percent = (cpu_ratio / cpu_count) * 100.0
|
|
259
|
+
return max(0.0, min(percent, 100.0))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _run_probe_command(cmd: list[str]) -> str | None:
|
|
263
|
+
try:
|
|
264
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
|
|
265
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
266
|
+
return None
|
|
267
|
+
return result.stdout.strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_pveversion(output: str) -> str | None:
|
|
271
|
+
first_token = output.split(None, 1)[0] if output else ""
|
|
272
|
+
if not first_token:
|
|
273
|
+
return None
|
|
274
|
+
if "/" in first_token:
|
|
275
|
+
return first_token.split("/", 1)[1]
|
|
276
|
+
return first_token
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_dpkg_version(output: str) -> str | None:
|
|
280
|
+
for line in output.splitlines():
|
|
281
|
+
if line.lower().startswith("version:"):
|
|
282
|
+
return line.split(":", 1)[1].strip()
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _get_proxmox_version() -> str | None:
|
|
287
|
+
release_file = Path("/etc/proxmox-release")
|
|
288
|
+
if release_file.exists():
|
|
289
|
+
try:
|
|
290
|
+
return release_file.read_text().strip()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
value = _run_probe_command(["pveversion"])
|
|
294
|
+
parsed = _parse_pveversion(value or "")
|
|
295
|
+
if parsed:
|
|
296
|
+
return parsed
|
|
297
|
+
for pkg in ("pve-manager", "proxmox-ve"):
|
|
298
|
+
pkg_output = _run_probe_command(["dpkg", "-s", pkg])
|
|
299
|
+
parsed = _parse_dpkg_version(pkg_output or "")
|
|
300
|
+
if parsed:
|
|
301
|
+
return parsed
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _get_proxmox_info() -> Dict[str, Any]:
|
|
306
|
+
"""Detect if the current host is a Proxmox node."""
|
|
307
|
+
info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
|
|
308
|
+
if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
|
|
309
|
+
info["is_proxmox_node"] = True
|
|
310
|
+
version = _get_proxmox_version()
|
|
311
|
+
if version:
|
|
312
|
+
info["version"] = version
|
|
313
|
+
info["infra"] = get_infra_snapshot()
|
|
314
|
+
return info
|
|
315
|
+
|
|
15
316
|
|
|
16
317
|
def _get_os_info() -> Dict[str, Any]:
|
|
17
318
|
"""Get operating system information with robust error handling."""
|
|
@@ -98,15 +399,13 @@ class SystemInfoHandler(SyncHandler):
|
|
|
98
399
|
"""Get system information including OS details."""
|
|
99
400
|
logger.debug("Collecting system information...")
|
|
100
401
|
|
|
402
|
+
# Ensure CPU monitoring thread is running
|
|
403
|
+
_ensure_cpu_thread()
|
|
404
|
+
|
|
101
405
|
# Collect basic system metrics
|
|
102
406
|
info = {}
|
|
103
407
|
|
|
104
|
-
|
|
105
|
-
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
|
106
|
-
logger.debug("CPU usage: %s%%", info["cpu_percent"])
|
|
107
|
-
except Exception as e:
|
|
108
|
-
logger.warning("Failed to get CPU info: %s", e)
|
|
109
|
-
info["cpu_percent"] = 0.0
|
|
408
|
+
info["cpu_percent"] = _cpu_percent
|
|
110
409
|
|
|
111
410
|
try:
|
|
112
411
|
info["memory"] = psutil.virtual_memory()._asdict()
|
|
@@ -124,11 +423,14 @@ class SystemInfoHandler(SyncHandler):
|
|
|
124
423
|
|
|
125
424
|
# Add OS information - this is critical for proper shell detection
|
|
126
425
|
info["os_info"] = _get_os_info()
|
|
127
|
-
|
|
426
|
+
info["user_context"] = _get_user_context()
|
|
427
|
+
info["playwright"] = _get_playwright_info()
|
|
428
|
+
info["proxmox"] = _get_proxmox_info()
|
|
429
|
+
# logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
|
|
128
430
|
|
|
129
431
|
info["portacode_version"] = __version__
|
|
130
432
|
|
|
131
433
|
return {
|
|
132
434
|
"event": "system_info",
|
|
133
435
|
"info": info,
|
|
134
|
-
}
|
|
436
|
+
}
|
|
@@ -162,53 +162,7 @@ class TabFactory:
|
|
|
162
162
|
await self._load_binary_content(file_path, tab_info, file_size)
|
|
163
163
|
|
|
164
164
|
return TabInfo(**tab_info)
|
|
165
|
-
|
|
166
|
-
async def create_diff_tab(self, file_path: str, original_content: str,
|
|
167
|
-
modified_content: str, tab_id: Optional[str] = None,
|
|
168
|
-
diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
|
|
169
|
-
"""Create a diff tab for comparing file versions.
|
|
170
|
-
|
|
171
|
-
Args:
|
|
172
|
-
file_path: Path to the file being compared
|
|
173
|
-
original_content: Original version of the file
|
|
174
|
-
modified_content: Modified version of the file
|
|
175
|
-
tab_id: Optional tab ID, will generate UUID if not provided
|
|
176
|
-
diff_details: Optional detailed diff information from diff-match-patch
|
|
177
|
-
|
|
178
|
-
Returns:
|
|
179
|
-
TabInfo object configured for diff viewing
|
|
180
|
-
"""
|
|
181
|
-
if tab_id is None:
|
|
182
|
-
tab_id = str(uuid.uuid4())
|
|
183
|
-
|
|
184
|
-
file_path = Path(file_path)
|
|
185
|
-
|
|
186
|
-
metadata = {'diff_mode': True}
|
|
187
|
-
if diff_details:
|
|
188
|
-
metadata['diff_details'] = diff_details
|
|
189
|
-
|
|
190
|
-
# Cache diff content
|
|
191
|
-
original_hash = generate_content_hash(original_content)
|
|
192
|
-
modified_hash = generate_content_hash(modified_content)
|
|
193
|
-
cache_content(original_hash, original_content)
|
|
194
|
-
cache_content(modified_hash, modified_content)
|
|
195
|
-
|
|
196
|
-
return TabInfo(
|
|
197
|
-
tab_id=tab_id,
|
|
198
|
-
tab_type='diff',
|
|
199
|
-
title=f"{file_path.name} (diff)",
|
|
200
|
-
file_path=str(file_path),
|
|
201
|
-
content=None, # Diff tabs don't use regular content
|
|
202
|
-
original_content=original_content,
|
|
203
|
-
modified_content=modified_content,
|
|
204
|
-
original_content_hash=original_hash,
|
|
205
|
-
modified_content_hash=modified_hash,
|
|
206
|
-
is_dirty=False,
|
|
207
|
-
mime_type=None,
|
|
208
|
-
encoding='utf-8',
|
|
209
|
-
metadata=metadata
|
|
210
|
-
)
|
|
211
|
-
|
|
165
|
+
|
|
212
166
|
async def create_diff_tab_with_title(self, file_path: str, original_content: str,
|
|
213
167
|
modified_content: str, title: str,
|
|
214
168
|
tab_id: Optional[str] = None,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from portacode.connection.handlers.proxmox_infra import _build_bootstrap_steps
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProxmoxInfraHandlerTests(TestCase):
|
|
7
|
+
def test_build_bootstrap_steps_includes_portacode_connect_by_default(self):
|
|
8
|
+
steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=True)
|
|
9
|
+
self.assertTrue(any(step.get("name") == "portacode_connect" for step in steps))
|
|
10
|
+
|
|
11
|
+
def test_build_bootstrap_steps_skips_portacode_connect_when_requested(self):
|
|
12
|
+
steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=False)
|
|
13
|
+
self.assertFalse(any(step.get("name") == "portacode_connect" for step in steps))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Update handler for Portacode CLI."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from .base import AsyncHandler
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UpdatePortacodeHandler(AsyncHandler):
|
|
13
|
+
"""Handler for updating Portacode CLI."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def command_name(self) -> str:
|
|
17
|
+
return "update_portacode_cli"
|
|
18
|
+
|
|
19
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
"""Update Portacode package and restart process."""
|
|
21
|
+
try:
|
|
22
|
+
logger.info("Starting Portacode CLI update...")
|
|
23
|
+
|
|
24
|
+
# Update the package
|
|
25
|
+
result = subprocess.run([
|
|
26
|
+
sys.executable, "-m", "pip", "install", "--upgrade", "portacode"
|
|
27
|
+
], capture_output=True, text=True, timeout=120)
|
|
28
|
+
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
logger.error("Update failed: %s", result.stderr)
|
|
31
|
+
return {
|
|
32
|
+
"event": "update_portacode_response",
|
|
33
|
+
"success": False,
|
|
34
|
+
"error": f"Update failed: {result.stderr}"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info("Update successful, restarting process...")
|
|
38
|
+
|
|
39
|
+
# Send success response before exit
|
|
40
|
+
await self.send_response({
|
|
41
|
+
"event": "update_portacode_response",
|
|
42
|
+
"success": True,
|
|
43
|
+
"message": "Update completed. Process restarting..."
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
# Exit with special code to trigger restart
|
|
47
|
+
sys.exit(42)
|
|
48
|
+
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
return {
|
|
51
|
+
"event": "update_portacode_response",
|
|
52
|
+
"success": False,
|
|
53
|
+
"error": "Update timed out after 120 seconds"
|
|
54
|
+
}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.exception("Update failed with exception")
|
|
57
|
+
return {
|
|
58
|
+
"event": "update_portacode_response",
|
|
59
|
+
"success": False,
|
|
60
|
+
"error": str(e)
|
|
61
|
+
}
|
portacode/connection/terminal.py
CHANGED
|
@@ -21,6 +21,8 @@ import time
|
|
|
21
21
|
from dataclasses import asdict
|
|
22
22
|
from typing import Any, Dict, Optional, List
|
|
23
23
|
|
|
24
|
+
from websockets.exceptions import ConnectionClosedError
|
|
25
|
+
|
|
24
26
|
from .multiplex import Multiplexer, Channel
|
|
25
27
|
from .handlers import (
|
|
26
28
|
CommandRegistry,
|
|
@@ -33,8 +35,11 @@ from .handlers import (
|
|
|
33
35
|
DirectoryListHandler,
|
|
34
36
|
FileInfoHandler,
|
|
35
37
|
FileDeleteHandler,
|
|
38
|
+
FileSearchHandler,
|
|
36
39
|
FileRenameHandler,
|
|
37
40
|
ContentRequestHandler,
|
|
41
|
+
FileApplyDiffHandler,
|
|
42
|
+
FilePreviewDiffHandler,
|
|
38
43
|
ProjectStateFolderExpandHandler,
|
|
39
44
|
ProjectStateFolderCollapseHandler,
|
|
40
45
|
ProjectStateFileOpenHandler,
|
|
@@ -46,6 +51,14 @@ from .handlers import (
|
|
|
46
51
|
ProjectStateGitUnstageHandler,
|
|
47
52
|
ProjectStateGitRevertHandler,
|
|
48
53
|
ProjectStateGitCommitHandler,
|
|
54
|
+
UpdatePortacodeHandler,
|
|
55
|
+
ConfigureProxmoxInfraHandler,
|
|
56
|
+
CreateProxmoxContainerHandler,
|
|
57
|
+
RevertProxmoxInfraHandler,
|
|
58
|
+
StartPortacodeServiceHandler,
|
|
59
|
+
StartProxmoxContainerHandler,
|
|
60
|
+
StopProxmoxContainerHandler,
|
|
61
|
+
RemoveProxmoxContainerHandler,
|
|
49
62
|
)
|
|
50
63
|
from .handlers.project_aware_file_handlers import (
|
|
51
64
|
ProjectAwareFileWriteHandler,
|
|
@@ -108,7 +121,7 @@ class ClientSessionManager:
|
|
|
108
121
|
self._write_debug_file()
|
|
109
122
|
return newly_added_sessions
|
|
110
123
|
|
|
111
|
-
def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
124
|
+
async def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
112
125
|
"""Explicitly clean up resources for a client session when notified by server."""
|
|
113
126
|
logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
|
|
114
127
|
|
|
@@ -124,7 +137,7 @@ class ClientSessionManager:
|
|
|
124
137
|
if control_channel:
|
|
125
138
|
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
126
139
|
logger.info("Cleaning up project state for client session: %s", client_session_id)
|
|
127
|
-
project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
140
|
+
await project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
128
141
|
else:
|
|
129
142
|
logger.warning("No control channel available for project state cleanup")
|
|
130
143
|
else:
|
|
@@ -173,7 +186,7 @@ class ClientSessionManager:
|
|
|
173
186
|
for session_id in existing_project_states:
|
|
174
187
|
if session_id not in current_project_sessions:
|
|
175
188
|
logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
|
|
176
|
-
project_manager.cleanup_project(session_id)
|
|
189
|
+
await project_manager.cleanup_project(session_id)
|
|
177
190
|
|
|
178
191
|
# Initialize project states for new project sessions
|
|
179
192
|
for session_name in newly_added_sessions:
|
|
@@ -185,6 +198,9 @@ class ClientSessionManager:
|
|
|
185
198
|
project_folder_path = session.get('project_folder_path')
|
|
186
199
|
|
|
187
200
|
if project_id is not None and project_folder_path:
|
|
201
|
+
if session_name in project_manager.projects:
|
|
202
|
+
logger.info("Project state already exists for session %s, skipping re-init", session_name)
|
|
203
|
+
continue
|
|
188
204
|
logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
|
|
189
205
|
|
|
190
206
|
try:
|
|
@@ -197,7 +213,6 @@ class ClientSessionManager:
|
|
|
197
213
|
|
|
198
214
|
except Exception as e:
|
|
199
215
|
logger.error(f"Failed to initialize project state for {session_name}: {e}")
|
|
200
|
-
|
|
201
216
|
except Exception as e:
|
|
202
217
|
logger.error("Error managing project states for session changes: %s", e)
|
|
203
218
|
|
|
@@ -216,7 +231,7 @@ class ClientSessionManager:
|
|
|
216
231
|
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
217
232
|
if control_channel:
|
|
218
233
|
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
219
|
-
project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
234
|
+
await project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
220
235
|
else:
|
|
221
236
|
logger.warning("No control channel available for orphaned project state cleanup")
|
|
222
237
|
else:
|
|
@@ -403,6 +418,7 @@ class TerminalManager:
|
|
|
403
418
|
"mux": mux,
|
|
404
419
|
"use_content_caching": True, # Enable content caching optimization
|
|
405
420
|
"debug": self.debug,
|
|
421
|
+
"event_loop": asyncio.get_running_loop(),
|
|
406
422
|
}
|
|
407
423
|
|
|
408
424
|
# Initialize command registry
|
|
@@ -419,6 +435,14 @@ class TerminalManager:
|
|
|
419
435
|
pass
|
|
420
436
|
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
421
437
|
|
|
438
|
+
# Start periodic system info sender
|
|
439
|
+
if getattr(self, "_system_info_task", None):
|
|
440
|
+
try:
|
|
441
|
+
self._system_info_task.cancel()
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
self._system_info_task = asyncio.create_task(self._periodic_system_info())
|
|
445
|
+
|
|
422
446
|
# For initial connections, request client sessions after control loop starts
|
|
423
447
|
if is_initial:
|
|
424
448
|
asyncio.create_task(self._initial_connection_setup())
|
|
@@ -439,7 +463,10 @@ class TerminalManager:
|
|
|
439
463
|
self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
|
|
440
464
|
self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
|
|
441
465
|
self._command_registry.register(FileRenameHandler)
|
|
466
|
+
self._command_registry.register(FileSearchHandler)
|
|
442
467
|
self._command_registry.register(ContentRequestHandler)
|
|
468
|
+
self._command_registry.register(FileApplyDiffHandler)
|
|
469
|
+
self._command_registry.register(FilePreviewDiffHandler)
|
|
443
470
|
# Project state handlers
|
|
444
471
|
self._command_registry.register(ProjectStateFolderExpandHandler)
|
|
445
472
|
self._command_registry.register(ProjectStateFolderCollapseHandler)
|
|
@@ -452,6 +479,15 @@ class TerminalManager:
|
|
|
452
479
|
self._command_registry.register(ProjectStateGitUnstageHandler)
|
|
453
480
|
self._command_registry.register(ProjectStateGitRevertHandler)
|
|
454
481
|
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
482
|
+
# System management handlers
|
|
483
|
+
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
484
|
+
self._command_registry.register(CreateProxmoxContainerHandler)
|
|
485
|
+
self._command_registry.register(StartPortacodeServiceHandler)
|
|
486
|
+
self._command_registry.register(StartProxmoxContainerHandler)
|
|
487
|
+
self._command_registry.register(StopProxmoxContainerHandler)
|
|
488
|
+
self._command_registry.register(RemoveProxmoxContainerHandler)
|
|
489
|
+
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
490
|
+
self._command_registry.register(UpdatePortacodeHandler)
|
|
455
491
|
|
|
456
492
|
# ---------------------------------------------------------------------
|
|
457
493
|
# Control loop – receives commands from gateway
|
|
@@ -516,6 +552,20 @@ class TerminalManager:
|
|
|
516
552
|
# Continue processing other messages
|
|
517
553
|
continue
|
|
518
554
|
|
|
555
|
+
async def _periodic_system_info(self) -> None:
|
|
556
|
+
"""Send system_info event every 10 seconds when clients are connected."""
|
|
557
|
+
while True:
|
|
558
|
+
try:
|
|
559
|
+
await asyncio.sleep(10)
|
|
560
|
+
if self._client_session_manager.has_interested_clients():
|
|
561
|
+
from .handlers.system_handlers import SystemInfoHandler
|
|
562
|
+
handler = SystemInfoHandler(self._control_channel, self._context)
|
|
563
|
+
system_info = handler.execute({})
|
|
564
|
+
await self._send_session_aware(system_info)
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
logger.exception("Error in periodic system info: %s", exc)
|
|
567
|
+
continue
|
|
568
|
+
|
|
519
569
|
async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
|
|
520
570
|
"""Send initial system info and terminal list to connected clients.
|
|
521
571
|
|
|
@@ -767,11 +817,15 @@ class TerminalManager:
|
|
|
767
817
|
terminal_id = payload.get("channel", "unknown")
|
|
768
818
|
logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
|
|
769
819
|
event_type, terminal_id, data_size, len(target_sessions))
|
|
770
|
-
else:
|
|
771
|
-
|
|
772
|
-
|
|
820
|
+
# else:
|
|
821
|
+
# logger.info("terminal_manager: Dispatching %s event to %d client sessions",
|
|
822
|
+
# event_type, len(target_sessions))
|
|
773
823
|
|
|
774
|
-
|
|
824
|
+
try:
|
|
825
|
+
await self._control_channel.send(enhanced_payload)
|
|
826
|
+
except ConnectionClosedError as exc:
|
|
827
|
+
logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
|
|
828
|
+
return
|
|
775
829
|
|
|
776
830
|
async def _send_terminal_list(self) -> None:
|
|
777
831
|
"""Send terminal list for reconnection reconciliation."""
|
|
@@ -828,4 +882,4 @@ class TerminalManager:
|
|
|
828
882
|
await self._request_client_sessions()
|
|
829
883
|
logger.info("Client session request sent after reconnection")
|
|
830
884
|
except Exception as exc:
|
|
831
|
-
logger.error("Failed to handle reconnection: %s", exc)
|
|
885
|
+
logger.error("Failed to handle reconnection: %s", exc)
|