portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- 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/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
|
@@ -1,17 +1,155 @@
|
|
|
1
1
|
"""System command handlers."""
|
|
2
2
|
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import getpass
|
|
5
|
+
import importlib.util
|
|
3
6
|
import logging
|
|
4
7
|
import os
|
|
5
8
|
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import threading
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
from typing import Any, Dict
|
|
13
|
+
|
|
8
14
|
from portacode import __version__
|
|
9
15
|
import psutil
|
|
10
16
|
|
|
17
|
+
try:
|
|
18
|
+
from importlib import metadata as importlib_metadata
|
|
19
|
+
except ImportError: # pragma: no cover - py<3.8
|
|
20
|
+
import importlib_metadata
|
|
21
|
+
|
|
11
22
|
from .base import SyncHandler
|
|
23
|
+
from .proxmox_infra import get_infra_snapshot
|
|
12
24
|
|
|
13
25
|
logger = logging.getLogger(__name__)
|
|
14
26
|
|
|
27
|
+
# Global CPU monitoring
|
|
28
|
+
_cpu_percent = 0.0
|
|
29
|
+
_cpu_thread = None
|
|
30
|
+
_cpu_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def _cpu_monitor():
|
|
33
|
+
"""Background thread to update CPU usage every 5 seconds."""
|
|
34
|
+
global _cpu_percent
|
|
35
|
+
while True:
|
|
36
|
+
_cpu_percent = psutil.cpu_percent(interval=5.0)
|
|
37
|
+
|
|
38
|
+
def _ensure_cpu_thread():
|
|
39
|
+
"""Ensure CPU monitoring thread is running (singleton)."""
|
|
40
|
+
global _cpu_thread
|
|
41
|
+
with _cpu_lock:
|
|
42
|
+
if _cpu_thread is None or not _cpu_thread.is_alive():
|
|
43
|
+
_cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
|
|
44
|
+
_cpu_thread.start()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_user_context() -> Dict[str, Any]:
|
|
48
|
+
"""Gather current CLI user plus permission hints."""
|
|
49
|
+
context = {}
|
|
50
|
+
login_source = "os.getlogin"
|
|
51
|
+
try:
|
|
52
|
+
username = os.getlogin()
|
|
53
|
+
except Exception:
|
|
54
|
+
login_source = "getpass"
|
|
55
|
+
username = getpass.getuser()
|
|
56
|
+
|
|
57
|
+
context["username"] = username
|
|
58
|
+
context["username_source"] = login_source
|
|
59
|
+
context["home"] = str(Path.home())
|
|
60
|
+
|
|
61
|
+
uid = getattr(os, "getuid", None)
|
|
62
|
+
euid = getattr(os, "geteuid", None)
|
|
63
|
+
context["uid"] = uid() if uid else None
|
|
64
|
+
context["euid"] = euid() if euid else context["uid"]
|
|
65
|
+
if os.name == "nt":
|
|
66
|
+
try:
|
|
67
|
+
import ctypes
|
|
68
|
+
|
|
69
|
+
context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
70
|
+
except Exception:
|
|
71
|
+
context["is_root"] = None
|
|
72
|
+
else:
|
|
73
|
+
context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
|
|
74
|
+
|
|
75
|
+
context["has_sudo"] = shutil.which("sudo") is not None
|
|
76
|
+
context["sudo_user"] = os.environ.get("SUDO_USER")
|
|
77
|
+
context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
|
|
78
|
+
return context
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_playwright_info() -> Dict[str, Any]:
|
|
82
|
+
"""Return Playwright presence, version, and browser binaries if available."""
|
|
83
|
+
result: Dict[str, Any] = {
|
|
84
|
+
"installed": False,
|
|
85
|
+
"version": None,
|
|
86
|
+
"browsers": {},
|
|
87
|
+
"error": None,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if importlib.util.find_spec("playwright") is None:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
result["installed"] = True
|
|
94
|
+
try:
|
|
95
|
+
result["version"] = importlib_metadata.version("playwright")
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.debug("Unable to read Playwright version metadata: %s", exc)
|
|
98
|
+
|
|
99
|
+
def _inspect_browsers() -> Dict[str, Any]:
|
|
100
|
+
from playwright.sync_api import sync_playwright
|
|
101
|
+
|
|
102
|
+
browsers_data: Dict[str, Any] = {}
|
|
103
|
+
with sync_playwright() as p:
|
|
104
|
+
for name in ("chromium", "firefox", "webkit"):
|
|
105
|
+
browser_type = getattr(p, name, None)
|
|
106
|
+
if browser_type is None:
|
|
107
|
+
continue
|
|
108
|
+
exec_path = getattr(browser_type, "executable_path", None)
|
|
109
|
+
browsers_data[name] = {
|
|
110
|
+
"available": bool(exec_path),
|
|
111
|
+
"executable_path": exec_path,
|
|
112
|
+
}
|
|
113
|
+
return browsers_data
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
117
|
+
future = executor.submit(_inspect_browsers)
|
|
118
|
+
browsers = future.result(timeout=5)
|
|
119
|
+
result["browsers"] = browsers
|
|
120
|
+
except concurrent.futures.TimeoutError:
|
|
121
|
+
msg = "Playwright inspection timed out"
|
|
122
|
+
logger.warning(msg)
|
|
123
|
+
result["error"] = msg
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.warning("Playwright browser inspection failed: %s", exc)
|
|
126
|
+
result["error"] = str(exc)
|
|
127
|
+
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_proxmox_info() -> Dict[str, Any]:
|
|
132
|
+
"""Detect if the current host is a Proxmox node."""
|
|
133
|
+
info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
|
|
134
|
+
release_file = Path("/etc/proxmox-release")
|
|
135
|
+
if release_file.exists():
|
|
136
|
+
info["is_proxmox_node"] = True
|
|
137
|
+
try:
|
|
138
|
+
info["version"] = release_file.read_text().strip()
|
|
139
|
+
except Exception:
|
|
140
|
+
info["version"] = None
|
|
141
|
+
elif Path("/etc/pve").exists():
|
|
142
|
+
info["is_proxmox_node"] = True
|
|
143
|
+
if not info["version"]:
|
|
144
|
+
version_hint = Path("/etc/pve/.version")
|
|
145
|
+
if version_hint.exists():
|
|
146
|
+
try:
|
|
147
|
+
info["version"] = version_hint.read_text().strip()
|
|
148
|
+
except Exception:
|
|
149
|
+
info["version"] = None
|
|
150
|
+
info["infra"] = get_infra_snapshot()
|
|
151
|
+
return info
|
|
152
|
+
|
|
15
153
|
|
|
16
154
|
def _get_os_info() -> Dict[str, Any]:
|
|
17
155
|
"""Get operating system information with robust error handling."""
|
|
@@ -98,15 +236,13 @@ class SystemInfoHandler(SyncHandler):
|
|
|
98
236
|
"""Get system information including OS details."""
|
|
99
237
|
logger.debug("Collecting system information...")
|
|
100
238
|
|
|
239
|
+
# Ensure CPU monitoring thread is running
|
|
240
|
+
_ensure_cpu_thread()
|
|
241
|
+
|
|
101
242
|
# Collect basic system metrics
|
|
102
243
|
info = {}
|
|
103
244
|
|
|
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
|
|
245
|
+
info["cpu_percent"] = _cpu_percent
|
|
110
246
|
|
|
111
247
|
try:
|
|
112
248
|
info["memory"] = psutil.virtual_memory()._asdict()
|
|
@@ -124,11 +260,14 @@ class SystemInfoHandler(SyncHandler):
|
|
|
124
260
|
|
|
125
261
|
# Add OS information - this is critical for proper shell detection
|
|
126
262
|
info["os_info"] = _get_os_info()
|
|
127
|
-
|
|
263
|
+
info["user_context"] = _get_user_context()
|
|
264
|
+
info["playwright"] = _get_playwright_info()
|
|
265
|
+
info["proxmox"] = _get_proxmox_info()
|
|
266
|
+
# logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
|
|
128
267
|
|
|
129
268
|
info["portacode_version"] = __version__
|
|
130
269
|
|
|
131
270
|
return {
|
|
132
271
|
"event": "system_info",
|
|
133
272
|
"info": info,
|
|
134
|
-
}
|
|
273
|
+
}
|
|
@@ -14,6 +14,8 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Optional, Dict, Any
|
|
15
15
|
|
|
16
16
|
from .project_state_handlers import TabInfo
|
|
17
|
+
from .project_state.utils import generate_content_hash
|
|
18
|
+
from .file_handlers import cache_content
|
|
17
19
|
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
19
21
|
|
|
@@ -57,7 +59,7 @@ MEDIA_EXTENSIONS = {
|
|
|
57
59
|
'.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
|
|
58
60
|
|
|
59
61
|
# Audio
|
|
60
|
-
'.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus', '.webm',
|
|
62
|
+
'.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
|
|
61
63
|
|
|
62
64
|
# Video
|
|
63
65
|
'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
|
|
@@ -140,7 +142,11 @@ class TabFactory:
|
|
|
140
142
|
# Determine how to handle the file
|
|
141
143
|
if extension in IGNORED_EXTENSIONS:
|
|
142
144
|
tab_info['metadata']['ignored'] = True
|
|
143
|
-
|
|
145
|
+
content = f"# Binary file not displayed\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
|
|
146
|
+
tab_info['content'] = content
|
|
147
|
+
content_hash = generate_content_hash(content)
|
|
148
|
+
tab_info['content_hash'] = content_hash
|
|
149
|
+
cache_content(content_hash, content)
|
|
144
150
|
return TabInfo(**tab_info)
|
|
145
151
|
|
|
146
152
|
# Handle different file types
|
|
@@ -156,45 +162,7 @@ class TabFactory:
|
|
|
156
162
|
await self._load_binary_content(file_path, tab_info, file_size)
|
|
157
163
|
|
|
158
164
|
return TabInfo(**tab_info)
|
|
159
|
-
|
|
160
|
-
async def create_diff_tab(self, file_path: str, original_content: str,
|
|
161
|
-
modified_content: str, tab_id: Optional[str] = None,
|
|
162
|
-
diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
|
|
163
|
-
"""Create a diff tab for comparing file versions.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
file_path: Path to the file being compared
|
|
167
|
-
original_content: Original version of the file
|
|
168
|
-
modified_content: Modified version of the file
|
|
169
|
-
tab_id: Optional tab ID, will generate UUID if not provided
|
|
170
|
-
diff_details: Optional detailed diff information from diff-match-patch
|
|
171
|
-
|
|
172
|
-
Returns:
|
|
173
|
-
TabInfo object configured for diff viewing
|
|
174
|
-
"""
|
|
175
|
-
if tab_id is None:
|
|
176
|
-
tab_id = str(uuid.uuid4())
|
|
177
|
-
|
|
178
|
-
file_path = Path(file_path)
|
|
179
|
-
|
|
180
|
-
metadata = {'diff_mode': True}
|
|
181
|
-
if diff_details:
|
|
182
|
-
metadata['diff_details'] = diff_details
|
|
183
|
-
|
|
184
|
-
return TabInfo(
|
|
185
|
-
tab_id=tab_id,
|
|
186
|
-
tab_type='diff',
|
|
187
|
-
title=f"{file_path.name} (diff)",
|
|
188
|
-
file_path=str(file_path),
|
|
189
|
-
content=None, # Diff tabs don't use regular content
|
|
190
|
-
original_content=original_content,
|
|
191
|
-
modified_content=modified_content,
|
|
192
|
-
is_dirty=False,
|
|
193
|
-
mime_type=None,
|
|
194
|
-
encoding='utf-8',
|
|
195
|
-
metadata=metadata
|
|
196
|
-
)
|
|
197
|
-
|
|
165
|
+
|
|
198
166
|
async def create_diff_tab_with_title(self, file_path: str, original_content: str,
|
|
199
167
|
modified_content: str, title: str,
|
|
200
168
|
tab_id: Optional[str] = None,
|
|
@@ -219,6 +187,12 @@ class TabFactory:
|
|
|
219
187
|
if diff_details:
|
|
220
188
|
metadata['diff_details'] = diff_details
|
|
221
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
|
+
|
|
222
196
|
return TabInfo(
|
|
223
197
|
tab_id=tab_id,
|
|
224
198
|
tab_type='diff',
|
|
@@ -227,6 +201,8 @@ class TabFactory:
|
|
|
227
201
|
content=None, # Diff tabs don't use regular content
|
|
228
202
|
original_content=original_content,
|
|
229
203
|
modified_content=modified_content,
|
|
204
|
+
original_content_hash=original_hash,
|
|
205
|
+
modified_content_hash=modified_hash,
|
|
230
206
|
is_dirty=False,
|
|
231
207
|
mime_type=None,
|
|
232
208
|
encoding='utf-8',
|
|
@@ -248,12 +224,17 @@ class TabFactory:
|
|
|
248
224
|
if tab_id is None:
|
|
249
225
|
tab_id = str(uuid.uuid4())
|
|
250
226
|
|
|
227
|
+
# Cache untitled content
|
|
228
|
+
content_hash = generate_content_hash(content)
|
|
229
|
+
cache_content(content_hash, content)
|
|
230
|
+
|
|
251
231
|
return TabInfo(
|
|
252
232
|
tab_id=tab_id,
|
|
253
233
|
tab_type='untitled',
|
|
254
234
|
title="Untitled",
|
|
255
235
|
file_path=None,
|
|
256
236
|
content=content,
|
|
237
|
+
content_hash=content_hash,
|
|
257
238
|
original_content=None,
|
|
258
239
|
modified_content=None,
|
|
259
240
|
is_dirty=bool(content), # Dirty if has initial content
|
|
@@ -265,7 +246,11 @@ class TabFactory:
|
|
|
265
246
|
async def _load_text_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
|
|
266
247
|
"""Load text content from file."""
|
|
267
248
|
if file_size > MAX_TEXT_FILE_SIZE:
|
|
268
|
-
|
|
249
|
+
content = f"# File too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Maximum size for text files: {self._format_file_size(MAX_TEXT_FILE_SIZE)}"
|
|
250
|
+
tab_info['content'] = content
|
|
251
|
+
content_hash = generate_content_hash(content)
|
|
252
|
+
tab_info['content_hash'] = content_hash
|
|
253
|
+
cache_content(content_hash, content)
|
|
269
254
|
tab_info['metadata']['truncated'] = True
|
|
270
255
|
return
|
|
271
256
|
|
|
@@ -275,6 +260,9 @@ class TabFactory:
|
|
|
275
260
|
try:
|
|
276
261
|
content = file_path.read_text(encoding=encoding)
|
|
277
262
|
tab_info['content'] = content
|
|
263
|
+
content_hash = generate_content_hash(content)
|
|
264
|
+
tab_info['content_hash'] = content_hash
|
|
265
|
+
cache_content(content_hash, content)
|
|
278
266
|
tab_info['encoding'] = encoding
|
|
279
267
|
self.logger.debug(f"Successfully loaded {file_path} with {encoding} encoding")
|
|
280
268
|
return
|
|
@@ -287,14 +275,22 @@ class TabFactory:
|
|
|
287
275
|
|
|
288
276
|
except OSError as e:
|
|
289
277
|
self.logger.error(f"Error reading file {file_path}: {e}")
|
|
290
|
-
|
|
278
|
+
content = f"# Error reading file\n# {e}"
|
|
279
|
+
tab_info['content'] = content
|
|
280
|
+
content_hash = generate_content_hash(content)
|
|
281
|
+
tab_info['content_hash'] = content_hash
|
|
282
|
+
cache_content(content_hash, content)
|
|
291
283
|
tab_info['metadata']['error'] = str(e)
|
|
292
284
|
|
|
293
285
|
async def _load_media_content(self, file_path: Path, tab_info: Dict[str, Any],
|
|
294
286
|
file_size: int, mime_type: Optional[str]):
|
|
295
287
|
"""Load media content as base64."""
|
|
296
288
|
if file_size > MAX_BINARY_FILE_SIZE:
|
|
297
|
-
|
|
289
|
+
content = f"# Media file too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
|
|
290
|
+
tab_info['content'] = content
|
|
291
|
+
content_hash = generate_content_hash(content)
|
|
292
|
+
tab_info['content_hash'] = content_hash
|
|
293
|
+
cache_content(content_hash, content)
|
|
298
294
|
tab_info['metadata']['too_large'] = True
|
|
299
295
|
return
|
|
300
296
|
|
|
@@ -313,6 +309,9 @@ class TabFactory:
|
|
|
313
309
|
base64_content = base64.b64encode(binary_content).decode('ascii')
|
|
314
310
|
|
|
315
311
|
tab_info['content'] = base64_content
|
|
312
|
+
content_hash = generate_content_hash(base64_content)
|
|
313
|
+
tab_info['content_hash'] = content_hash
|
|
314
|
+
cache_content(content_hash, base64_content)
|
|
316
315
|
tab_info['encoding'] = 'base64'
|
|
317
316
|
tab_info['metadata']['original_size'] = file_size
|
|
318
317
|
|
|
@@ -320,12 +319,20 @@ class TabFactory:
|
|
|
320
319
|
|
|
321
320
|
except OSError as e:
|
|
322
321
|
self.logger.error(f"Error reading media file {file_path}: {e}")
|
|
323
|
-
|
|
322
|
+
content = f"# Error loading media file\n# {e}"
|
|
323
|
+
tab_info['content'] = content
|
|
324
|
+
content_hash = generate_content_hash(content)
|
|
325
|
+
tab_info['content_hash'] = content_hash
|
|
326
|
+
cache_content(content_hash, content)
|
|
324
327
|
tab_info['metadata']['error'] = str(e)
|
|
325
328
|
|
|
326
329
|
async def _load_binary_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
|
|
327
330
|
"""Handle binary files that can't be displayed."""
|
|
328
|
-
|
|
331
|
+
content = f"# Binary file\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Type: {tab_info.get('mime_type', 'Unknown')}\n\n# This file contains binary data and cannot be displayed as text."
|
|
332
|
+
tab_info['content'] = content
|
|
333
|
+
content_hash = generate_content_hash(content)
|
|
334
|
+
tab_info['content_hash'] = content_hash
|
|
335
|
+
cache_content(content_hash, content)
|
|
329
336
|
tab_info['metadata']['binary'] = True
|
|
330
337
|
self.logger.debug(f"Marked {file_path} as binary file")
|
|
331
338
|
|
|
@@ -218,24 +218,33 @@ class TerminalListHandler(AsyncHandler):
|
|
|
218
218
|
|
|
219
219
|
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
220
220
|
"""Handle the command by executing it and sending the response to the requesting client session."""
|
|
221
|
-
logger.info("handler: Processing command %s with reply_channel=%s",
|
|
221
|
+
logger.info("handler: Processing command %s with reply_channel=%s",
|
|
222
222
|
self.command_name, reply_channel)
|
|
223
|
-
|
|
223
|
+
|
|
224
224
|
try:
|
|
225
225
|
response = await self.execute(message)
|
|
226
226
|
logger.info("handler: Command %s executed successfully", self.command_name)
|
|
227
|
-
|
|
227
|
+
|
|
228
|
+
# Automatically copy request_id if present in the incoming message
|
|
229
|
+
if "request_id" in message and "request_id" not in response:
|
|
230
|
+
response["request_id"] = message["request_id"]
|
|
231
|
+
|
|
228
232
|
# Get the source client session from the message
|
|
229
233
|
source_client_session = message.get("source_client_session")
|
|
230
234
|
project_id = response.get("project_id")
|
|
231
|
-
|
|
232
|
-
logger.info("handler: %s response project_id=%s, source_client_session=%s",
|
|
235
|
+
|
|
236
|
+
logger.info("handler: %s response project_id=%s, source_client_session=%s",
|
|
233
237
|
self.command_name, project_id, source_client_session)
|
|
234
|
-
|
|
238
|
+
|
|
235
239
|
# Send response only to the requesting client session
|
|
236
240
|
if source_client_session:
|
|
237
241
|
# Add client_sessions field to target only the requesting session
|
|
238
242
|
response["client_sessions"] = [source_client_session]
|
|
243
|
+
|
|
244
|
+
import json
|
|
245
|
+
logger.info("handler: 📤 SENDING EVENT '%s' (via direct control_channel.send)", response.get("event", "unknown"))
|
|
246
|
+
logger.info("handler: 📤 FULL EVENT PAYLOAD: %s", json.dumps(response, indent=2, default=str))
|
|
247
|
+
|
|
239
248
|
await self.control_channel.send(response)
|
|
240
249
|
else:
|
|
241
250
|
# Fallback to original behavior if no source_client_session
|
|
@@ -249,15 +258,19 @@ class TerminalListHandler(AsyncHandler):
|
|
|
249
258
|
session_manager = self.context.get("session_manager")
|
|
250
259
|
if not session_manager:
|
|
251
260
|
raise RuntimeError("Session manager not available")
|
|
252
|
-
|
|
261
|
+
|
|
253
262
|
# Accept project_id argument: None (default) = only no project, 'all' = all, else = filter by project_id
|
|
254
263
|
requested_project_id = message.get("project_id")
|
|
264
|
+
logger.info("terminal_list: requested_project_id=%r (type: %s)", requested_project_id, type(requested_project_id))
|
|
255
265
|
|
|
256
266
|
if requested_project_id == "all":
|
|
267
|
+
logger.info("terminal_list: Using 'all' mode to list all terminals")
|
|
257
268
|
sessions = session_manager.list_sessions(project_id="all")
|
|
258
269
|
else:
|
|
270
|
+
logger.info("terminal_list: Filtering by project_id=%r", requested_project_id)
|
|
259
271
|
sessions = session_manager.list_sessions(project_id=requested_project_id)
|
|
260
|
-
|
|
272
|
+
|
|
273
|
+
logger.info("terminal_list: Found %d sessions, returning with project_id=%r", len(sessions), requested_project_id)
|
|
261
274
|
return {
|
|
262
275
|
"event": "terminal_list",
|
|
263
276
|
"sessions": sessions,
|
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
+
import time
|
|
6
7
|
from asyncio import Queue
|
|
7
8
|
from typing import Any, Dict, Union
|
|
8
9
|
|
|
@@ -49,8 +50,65 @@ class Multiplexer:
|
|
|
49
50
|
return self._channels[channel_id]
|
|
50
51
|
|
|
51
52
|
async def _send_on_channel(self, channel_id: Union[int, str], payload: Any) -> None:
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Start timing the serialization and sending
|
|
54
|
+
start_time = time.time()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Serialize the frame
|
|
58
|
+
serialization_start = time.time()
|
|
59
|
+
frame = json.dumps({"channel": channel_id, "payload": payload})
|
|
60
|
+
serialization_time = time.time() - serialization_start
|
|
61
|
+
|
|
62
|
+
# Calculate message size
|
|
63
|
+
frame_size_bytes = len(frame.encode('utf-8'))
|
|
64
|
+
frame_size_kb = frame_size_bytes / 1024
|
|
65
|
+
|
|
66
|
+
# Log warnings for large messages
|
|
67
|
+
if frame_size_kb > 500: # Warn for messages > 500KB
|
|
68
|
+
logger.warning("🚨 LARGE WEBSOCKET MESSAGE: %.1f KB on channel %s (event: %s)",
|
|
69
|
+
frame_size_kb, channel_id, payload.get('event', 'unknown'))
|
|
70
|
+
|
|
71
|
+
# Log additional details for very large messages
|
|
72
|
+
if frame_size_kb > 1000: # > 1MB
|
|
73
|
+
logger.warning("🚨 VERY LARGE MESSAGE: %.1f KB - This may cause connection drops!", frame_size_kb)
|
|
74
|
+
|
|
75
|
+
# Try to identify what's making the message large
|
|
76
|
+
if isinstance(payload, dict):
|
|
77
|
+
large_fields = []
|
|
78
|
+
for key, value in payload.items():
|
|
79
|
+
if isinstance(value, (str, list, dict)):
|
|
80
|
+
field_size = len(json.dumps(value).encode('utf-8')) / 1024
|
|
81
|
+
if field_size > 100: # Fields > 100KB
|
|
82
|
+
large_fields.append(f"{key}: {field_size:.1f}KB")
|
|
83
|
+
if large_fields:
|
|
84
|
+
logger.warning("🚨 Large fields detected: %s", ", ".join(large_fields))
|
|
85
|
+
|
|
86
|
+
elif frame_size_kb > 100: # Info for messages > 100KB
|
|
87
|
+
logger.info("📦 Large websocket message: %.1f KB on channel %s (event: %s)",
|
|
88
|
+
frame_size_kb, channel_id, payload.get('event', 'unknown'))
|
|
89
|
+
|
|
90
|
+
# Send the frame
|
|
91
|
+
send_start = time.time()
|
|
92
|
+
await self._send_func(frame)
|
|
93
|
+
send_time = time.time() - send_start
|
|
94
|
+
|
|
95
|
+
total_time = time.time() - start_time
|
|
96
|
+
|
|
97
|
+
# Log performance metrics for large messages or slow operations
|
|
98
|
+
if frame_size_kb > 50 or total_time > 0.1: # Log for messages > 50KB or operations > 100ms
|
|
99
|
+
logger.info("📊 WebSocket send performance: %.1f KB in %.3fs (serialize: %.3fs, send: %.3fs) - channel %s",
|
|
100
|
+
frame_size_kb, total_time, serialization_time, send_time, channel_id)
|
|
101
|
+
|
|
102
|
+
# Log detailed timing for very large messages
|
|
103
|
+
if frame_size_kb > 200:
|
|
104
|
+
logger.info("🔍 Detailed timing - Channel: %s, Event: %s, Size: %.1f KB, Total: %.3fs",
|
|
105
|
+
channel_id, payload.get('event', 'unknown'), frame_size_kb, total_time)
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
total_time = time.time() - start_time
|
|
109
|
+
logger.error("❌ Failed to send websocket message on channel %s after %.3fs: %s",
|
|
110
|
+
channel_id, total_time, e)
|
|
111
|
+
raise
|
|
54
112
|
|
|
55
113
|
async def on_raw_message(self, raw: str) -> None:
|
|
56
114
|
try:
|