portacode 0.3.16.dev10__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.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +928 -42
- portacode/connection/handlers/__init__.py +34 -5
- 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 -948
- 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 +389 -0
- 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 +256 -17
- 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.16.dev10.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.16.dev10.dist-info/METADATA +0 -238
- portacode-0.3.16.dev10.dist-info/RECORD +0 -29
- portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
|
@@ -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:
|
portacode/connection/terminal.py
CHANGED
|
@@ -18,8 +18,11 @@ import json
|
|
|
18
18
|
import logging
|
|
19
19
|
import os
|
|
20
20
|
import time
|
|
21
|
+
from dataclasses import asdict
|
|
21
22
|
from typing import Any, Dict, Optional, List
|
|
22
23
|
|
|
24
|
+
from websockets.exceptions import ConnectionClosedError
|
|
25
|
+
|
|
23
26
|
from .multiplex import Multiplexer, Channel
|
|
24
27
|
from .handlers import (
|
|
25
28
|
CommandRegistry,
|
|
@@ -28,12 +31,34 @@ from .handlers import (
|
|
|
28
31
|
TerminalStopHandler,
|
|
29
32
|
TerminalListHandler,
|
|
30
33
|
SystemInfoHandler,
|
|
34
|
+
FileReadHandler,
|
|
31
35
|
DirectoryListHandler,
|
|
36
|
+
FileInfoHandler,
|
|
37
|
+
FileDeleteHandler,
|
|
38
|
+
FileSearchHandler,
|
|
39
|
+
FileRenameHandler,
|
|
40
|
+
ContentRequestHandler,
|
|
41
|
+
FileApplyDiffHandler,
|
|
42
|
+
FilePreviewDiffHandler,
|
|
32
43
|
ProjectStateFolderExpandHandler,
|
|
33
44
|
ProjectStateFolderCollapseHandler,
|
|
34
45
|
ProjectStateFileOpenHandler,
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
ProjectStateTabCloseHandler,
|
|
47
|
+
ProjectStateSetActiveTabHandler,
|
|
48
|
+
ProjectStateDiffOpenHandler,
|
|
49
|
+
ProjectStateDiffContentHandler,
|
|
50
|
+
ProjectStateGitStageHandler,
|
|
51
|
+
ProjectStateGitUnstageHandler,
|
|
52
|
+
ProjectStateGitRevertHandler,
|
|
53
|
+
ProjectStateGitCommitHandler,
|
|
54
|
+
UpdatePortacodeHandler,
|
|
55
|
+
ConfigureProxmoxInfraHandler,
|
|
56
|
+
RevertProxmoxInfraHandler,
|
|
57
|
+
)
|
|
58
|
+
from .handlers.project_aware_file_handlers import (
|
|
59
|
+
ProjectAwareFileWriteHandler,
|
|
60
|
+
ProjectAwareFileCreateHandler,
|
|
61
|
+
ProjectAwareFolderCreateHandler,
|
|
37
62
|
)
|
|
38
63
|
from .handlers.session import SessionManager
|
|
39
64
|
|
|
@@ -62,14 +87,165 @@ class ClientSessionManager:
|
|
|
62
87
|
|
|
63
88
|
new_sessions = set(self._client_sessions.keys())
|
|
64
89
|
newly_added_sessions = list(new_sessions - old_sessions)
|
|
90
|
+
disconnected_sessions = list(old_sessions - new_sessions)
|
|
65
91
|
|
|
66
|
-
logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added")
|
|
92
|
+
logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added, {len(disconnected_sessions)} disconnected")
|
|
67
93
|
if newly_added_sessions:
|
|
68
94
|
logger.info(f"Newly added sessions: {newly_added_sessions}")
|
|
95
|
+
if disconnected_sessions:
|
|
96
|
+
logger.info(f"Disconnected sessions: {disconnected_sessions}")
|
|
97
|
+
# NOTE: Not automatically cleaning up project states for disconnected sessions
|
|
98
|
+
# to handle temporary disconnections gracefully. Project states will be cleaned
|
|
99
|
+
# up only when explicitly notified by the server of permanent disconnection.
|
|
100
|
+
logger.info("Project states preserved for potential reconnection of these sessions")
|
|
101
|
+
|
|
102
|
+
# Handle project state management based on session changes
|
|
103
|
+
if newly_added_sessions or disconnected_sessions:
|
|
104
|
+
# Schedule project state management to run asynchronously
|
|
105
|
+
import asyncio
|
|
106
|
+
try:
|
|
107
|
+
loop = asyncio.get_event_loop()
|
|
108
|
+
if loop.is_running():
|
|
109
|
+
# Schedule both cleanup and initialization
|
|
110
|
+
loop.create_task(self._manage_project_states_for_session_changes(newly_added_sessions, sessions))
|
|
111
|
+
else:
|
|
112
|
+
logger.debug("No event loop running, skipping project state management")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.debug("Could not schedule project state management: %s", e)
|
|
69
115
|
|
|
70
116
|
self._write_debug_file()
|
|
71
117
|
return newly_added_sessions
|
|
72
118
|
|
|
119
|
+
async def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
120
|
+
"""Explicitly clean up resources for a client session when notified by server."""
|
|
121
|
+
logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
|
|
122
|
+
|
|
123
|
+
# Import here to avoid circular imports
|
|
124
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
125
|
+
|
|
126
|
+
# Get the project state manager from context if it exists
|
|
127
|
+
if hasattr(self, '_terminal_manager') and self._terminal_manager:
|
|
128
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
129
|
+
if context:
|
|
130
|
+
# Get or create the project state manager
|
|
131
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
132
|
+
if control_channel:
|
|
133
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
134
|
+
logger.info("Cleaning up project state for client session: %s", client_session_id)
|
|
135
|
+
await project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
136
|
+
else:
|
|
137
|
+
logger.warning("No control channel available for project state cleanup")
|
|
138
|
+
else:
|
|
139
|
+
logger.warning("No context available for project state cleanup")
|
|
140
|
+
else:
|
|
141
|
+
logger.warning("No terminal manager available for project state cleanup")
|
|
142
|
+
|
|
143
|
+
async def _manage_project_states_for_session_changes(self, newly_added_sessions: List[str], all_sessions: List[Dict]):
|
|
144
|
+
"""Comprehensive project state management for session changes."""
|
|
145
|
+
try:
|
|
146
|
+
# Import here to avoid circular imports
|
|
147
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
148
|
+
|
|
149
|
+
if not hasattr(self, '_terminal_manager') or not self._terminal_manager:
|
|
150
|
+
logger.warning("No terminal manager available for project state management")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
154
|
+
if not context:
|
|
155
|
+
logger.warning("No context available for project state management")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
159
|
+
if not control_channel:
|
|
160
|
+
logger.warning("No control channel available for project state management")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
164
|
+
|
|
165
|
+
# Convert sessions list to dict for easier lookup
|
|
166
|
+
sessions_dict = {session.get('channel_name'): session for session in all_sessions if session.get('channel_name')}
|
|
167
|
+
|
|
168
|
+
# First, clean up project states for sessions that are no longer project sessions or don't exist
|
|
169
|
+
current_project_sessions = set()
|
|
170
|
+
for session in all_sessions:
|
|
171
|
+
channel_name = session.get('channel_name')
|
|
172
|
+
project_id = session.get('project_id')
|
|
173
|
+
project_folder_path = session.get('project_folder_path')
|
|
174
|
+
|
|
175
|
+
if channel_name and project_id is not None and project_folder_path:
|
|
176
|
+
current_project_sessions.add(channel_name)
|
|
177
|
+
logger.debug(f"Active project session: {channel_name} -> {project_folder_path} (project_id: {project_id})")
|
|
178
|
+
|
|
179
|
+
# Clean up project states that don't match current project sessions
|
|
180
|
+
existing_project_states = list(project_manager.projects.keys())
|
|
181
|
+
for session_id in existing_project_states:
|
|
182
|
+
if session_id not in current_project_sessions:
|
|
183
|
+
logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
|
|
184
|
+
await project_manager.cleanup_project(session_id)
|
|
185
|
+
|
|
186
|
+
# Initialize project states for new project sessions
|
|
187
|
+
for session_name in newly_added_sessions:
|
|
188
|
+
session = sessions_dict.get(session_name)
|
|
189
|
+
if not session:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
project_id = session.get('project_id')
|
|
193
|
+
project_folder_path = session.get('project_folder_path')
|
|
194
|
+
|
|
195
|
+
if project_id is not None and project_folder_path:
|
|
196
|
+
if session_name in project_manager.projects:
|
|
197
|
+
logger.info("Project state already exists for session %s, skipping re-init", session_name)
|
|
198
|
+
continue
|
|
199
|
+
logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
# Initialize project state (this includes migration logic)
|
|
203
|
+
project_state = await project_manager.initialize_project_state(session_name, project_folder_path)
|
|
204
|
+
logger.info(f"Successfully initialized project state for {session_name}")
|
|
205
|
+
|
|
206
|
+
# Send initial project state to the client
|
|
207
|
+
# (implementation can be added here if needed)
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(f"Failed to initialize project state for {session_name}: {e}")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error("Error managing project states for session changes: %s", e)
|
|
213
|
+
|
|
214
|
+
async def _cleanup_orphaned_project_states(self):
|
|
215
|
+
"""Clean up project states that don't match any current client session."""
|
|
216
|
+
try:
|
|
217
|
+
# Import here to avoid circular imports
|
|
218
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
219
|
+
|
|
220
|
+
# Get current client session IDs
|
|
221
|
+
current_sessions = list(self._client_sessions.keys())
|
|
222
|
+
|
|
223
|
+
if hasattr(self, '_terminal_manager') and self._terminal_manager:
|
|
224
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
225
|
+
if context:
|
|
226
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
227
|
+
if control_channel:
|
|
228
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
229
|
+
await project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
230
|
+
else:
|
|
231
|
+
logger.warning("No control channel available for orphaned project state cleanup")
|
|
232
|
+
else:
|
|
233
|
+
logger.warning("No context available for orphaned project state cleanup")
|
|
234
|
+
else:
|
|
235
|
+
logger.warning("No terminal manager available for orphaned project state cleanup")
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error("Error cleaning up orphaned project states: %s", e)
|
|
239
|
+
|
|
240
|
+
def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
|
|
241
|
+
"""Legacy method - now just logs disconnections without cleanup."""
|
|
242
|
+
logger.info("Sessions disconnected (but preserving project states): %s", disconnected_sessions)
|
|
243
|
+
# Project states are preserved to handle reconnections gracefully
|
|
244
|
+
|
|
245
|
+
def set_terminal_manager(self, terminal_manager):
|
|
246
|
+
"""Set reference to terminal manager for cleanup purposes."""
|
|
247
|
+
self._terminal_manager = terminal_manager
|
|
248
|
+
|
|
73
249
|
def get_sessions(self) -> Dict[str, Dict]:
|
|
74
250
|
"""Get all current client sessions."""
|
|
75
251
|
return self._client_sessions.copy()
|
|
@@ -191,6 +367,7 @@ class TerminalManager:
|
|
|
191
367
|
self.debug = debug
|
|
192
368
|
self._session_manager = None # Initialize as None first
|
|
193
369
|
self._client_session_manager = ClientSessionManager() # Initialize client session manager
|
|
370
|
+
self._client_session_manager.set_terminal_manager(self) # Set reference for cleanup
|
|
194
371
|
self._set_mux(mux, is_initial=True)
|
|
195
372
|
|
|
196
373
|
# ------------------------------------------------------------------
|
|
@@ -234,6 +411,7 @@ class TerminalManager:
|
|
|
234
411
|
"session_manager": self._session_manager,
|
|
235
412
|
"client_session_manager": self._client_session_manager,
|
|
236
413
|
"mux": mux,
|
|
414
|
+
"use_content_caching": True, # Enable content caching optimization
|
|
237
415
|
"debug": self.debug,
|
|
238
416
|
}
|
|
239
417
|
|
|
@@ -251,6 +429,14 @@ class TerminalManager:
|
|
|
251
429
|
pass
|
|
252
430
|
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
253
431
|
|
|
432
|
+
# Start periodic system info sender
|
|
433
|
+
if getattr(self, "_system_info_task", None):
|
|
434
|
+
try:
|
|
435
|
+
self._system_info_task.cancel()
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
self._system_info_task = asyncio.create_task(self._periodic_system_info())
|
|
439
|
+
|
|
254
440
|
# For initial connections, request client sessions after control loop starts
|
|
255
441
|
if is_initial:
|
|
256
442
|
asyncio.create_task(self._initial_connection_setup())
|
|
@@ -262,13 +448,35 @@ class TerminalManager:
|
|
|
262
448
|
self._command_registry.register(TerminalStopHandler)
|
|
263
449
|
self._command_registry.register(TerminalListHandler)
|
|
264
450
|
self._command_registry.register(SystemInfoHandler)
|
|
451
|
+
# File operation handlers
|
|
452
|
+
self._command_registry.register(FileReadHandler)
|
|
453
|
+
self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
|
|
265
454
|
self._command_registry.register(DirectoryListHandler)
|
|
455
|
+
self._command_registry.register(FileInfoHandler)
|
|
456
|
+
self._command_registry.register(FileDeleteHandler)
|
|
457
|
+
self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
|
|
458
|
+
self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
|
|
459
|
+
self._command_registry.register(FileRenameHandler)
|
|
460
|
+
self._command_registry.register(FileSearchHandler)
|
|
461
|
+
self._command_registry.register(ContentRequestHandler)
|
|
462
|
+
self._command_registry.register(FileApplyDiffHandler)
|
|
463
|
+
self._command_registry.register(FilePreviewDiffHandler)
|
|
266
464
|
# Project state handlers
|
|
267
465
|
self._command_registry.register(ProjectStateFolderExpandHandler)
|
|
268
466
|
self._command_registry.register(ProjectStateFolderCollapseHandler)
|
|
269
467
|
self._command_registry.register(ProjectStateFileOpenHandler)
|
|
270
|
-
self._command_registry.register(
|
|
271
|
-
self._command_registry.register(
|
|
468
|
+
self._command_registry.register(ProjectStateTabCloseHandler)
|
|
469
|
+
self._command_registry.register(ProjectStateSetActiveTabHandler)
|
|
470
|
+
self._command_registry.register(ProjectStateDiffOpenHandler)
|
|
471
|
+
self._command_registry.register(ProjectStateDiffContentHandler)
|
|
472
|
+
self._command_registry.register(ProjectStateGitStageHandler)
|
|
473
|
+
self._command_registry.register(ProjectStateGitUnstageHandler)
|
|
474
|
+
self._command_registry.register(ProjectStateGitRevertHandler)
|
|
475
|
+
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
476
|
+
# System management handlers
|
|
477
|
+
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
478
|
+
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
479
|
+
self._command_registry.register(UpdatePortacodeHandler)
|
|
272
480
|
|
|
273
481
|
# ---------------------------------------------------------------------
|
|
274
482
|
# Control loop – receives commands from gateway
|
|
@@ -314,9 +522,10 @@ class TerminalManager:
|
|
|
314
522
|
logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
|
|
315
523
|
|
|
316
524
|
# Auto-send initial data only to newly added clients
|
|
525
|
+
# Create a background task so it doesn't block the control loop
|
|
317
526
|
if newly_added_sessions:
|
|
318
|
-
logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients")
|
|
319
|
-
|
|
527
|
+
logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients (non-blocking)")
|
|
528
|
+
asyncio.create_task(self._send_initial_data_to_clients(newly_added_sessions))
|
|
320
529
|
else:
|
|
321
530
|
logger.info("terminal_manager: ℹ️ No new sessions to send data to")
|
|
322
531
|
continue
|
|
@@ -332,6 +541,20 @@ class TerminalManager:
|
|
|
332
541
|
# Continue processing other messages
|
|
333
542
|
continue
|
|
334
543
|
|
|
544
|
+
async def _periodic_system_info(self) -> None:
|
|
545
|
+
"""Send system_info event every 10 seconds when clients are connected."""
|
|
546
|
+
while True:
|
|
547
|
+
try:
|
|
548
|
+
await asyncio.sleep(10)
|
|
549
|
+
if self._client_session_manager.has_interested_clients():
|
|
550
|
+
from .handlers.system_handlers import SystemInfoHandler
|
|
551
|
+
handler = SystemInfoHandler(self._control_channel, self._context)
|
|
552
|
+
system_info = handler.execute({})
|
|
553
|
+
await self._send_session_aware(system_info)
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
logger.exception("Error in periodic system info: %s", exc)
|
|
556
|
+
continue
|
|
557
|
+
|
|
335
558
|
async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
|
|
336
559
|
"""Send initial system info and terminal list to connected clients.
|
|
337
560
|
|
|
@@ -440,9 +663,10 @@ class TerminalManager:
|
|
|
440
663
|
if not session:
|
|
441
664
|
continue
|
|
442
665
|
|
|
666
|
+
project_id = session.get("project_id")
|
|
443
667
|
project_folder_path = session.get("project_folder_path")
|
|
444
|
-
if not project_folder_path:
|
|
445
|
-
logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_folder_path, skipping")
|
|
668
|
+
if project_id is None or not project_folder_path:
|
|
669
|
+
logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_id or project_folder_path, skipping")
|
|
446
670
|
continue
|
|
447
671
|
|
|
448
672
|
logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
|
|
@@ -454,12 +678,14 @@ class TerminalManager:
|
|
|
454
678
|
# Send initial project state to the client
|
|
455
679
|
initial_state_payload = {
|
|
456
680
|
"event": "project_state_initialized",
|
|
681
|
+
"project_id": project_state.client_session_id, # Add missing project_id field
|
|
457
682
|
"project_folder_path": project_state.project_folder_path,
|
|
458
683
|
"is_git_repo": project_state.is_git_repo,
|
|
459
684
|
"git_branch": project_state.git_branch,
|
|
460
685
|
"git_status_summary": project_state.git_status_summary,
|
|
461
|
-
"
|
|
462
|
-
"
|
|
686
|
+
"git_detailed_status": asdict(project_state.git_detailed_status) if project_state.git_detailed_status and hasattr(project_state.git_detailed_status, '__dataclass_fields__') else None, # Add missing git_detailed_status field
|
|
687
|
+
"open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs.values()], # Fix to use .values() for dict
|
|
688
|
+
"active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
|
|
463
689
|
"items": [manager._serialize_file_item(item) for item in project_state.items],
|
|
464
690
|
"timestamp": time.time(),
|
|
465
691
|
"client_sessions": [session_name] # Target this specific session
|
|
@@ -552,15 +778,17 @@ class TerminalManager:
|
|
|
552
778
|
payload: The message payload to send
|
|
553
779
|
project_id: Optional project filter for targeting specific sessions
|
|
554
780
|
"""
|
|
781
|
+
event_type = payload.get("event", "unknown")
|
|
782
|
+
|
|
555
783
|
# Check if there are any interested clients
|
|
556
784
|
if not self._client_session_manager.has_interested_clients():
|
|
557
|
-
logger.
|
|
785
|
+
logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
|
|
558
786
|
return
|
|
559
787
|
|
|
560
788
|
# Get target sessions
|
|
561
789
|
target_sessions = self._client_session_manager.get_target_sessions(project_id)
|
|
562
790
|
if not target_sessions:
|
|
563
|
-
logger.
|
|
791
|
+
logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
|
|
564
792
|
return
|
|
565
793
|
|
|
566
794
|
# Add session targeting information
|
|
@@ -572,10 +800,21 @@ class TerminalManager:
|
|
|
572
800
|
if reply_channel and "reply_channel" not in enhanced_payload:
|
|
573
801
|
enhanced_payload["reply_channel"] = reply_channel
|
|
574
802
|
|
|
575
|
-
|
|
576
|
-
|
|
803
|
+
# Log all event dispatches at INFO level, with data size for terminal_data
|
|
804
|
+
if event_type == "terminal_data":
|
|
805
|
+
data_size = len(payload.get("data", ""))
|
|
806
|
+
terminal_id = payload.get("channel", "unknown")
|
|
807
|
+
logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
|
|
808
|
+
event_type, terminal_id, data_size, len(target_sessions))
|
|
809
|
+
# else:
|
|
810
|
+
# logger.info("terminal_manager: Dispatching %s event to %d client sessions",
|
|
811
|
+
# event_type, len(target_sessions))
|
|
577
812
|
|
|
578
|
-
|
|
813
|
+
try:
|
|
814
|
+
await self._control_channel.send(enhanced_payload)
|
|
815
|
+
except ConnectionClosedError as exc:
|
|
816
|
+
logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
|
|
817
|
+
return
|
|
579
818
|
|
|
580
819
|
async def _send_terminal_list(self) -> None:
|
|
581
820
|
"""Send terminal list for reconnection reconciliation."""
|
|
@@ -632,4 +871,4 @@ class TerminalManager:
|
|
|
632
871
|
await self._request_client_sessions()
|
|
633
872
|
logger.info("Client session request sent after reconnection")
|
|
634
873
|
except Exception as exc:
|
|
635
|
-
logger.error("Failed to handle reconnection: %s", exc)
|
|
874
|
+
logger.error("Failed to handle reconnection: %s", exc)
|
portacode/keypair.py
CHANGED
|
@@ -98,4 +98,66 @@ def fingerprint_public_key(pem: bytes) -> str:
|
|
|
98
98
|
"""Return a short fingerprint for display purposes (SHA-256)."""
|
|
99
99
|
digest = hashes.Hash(hashes.SHA256())
|
|
100
100
|
digest.update(pem)
|
|
101
|
-
return digest.finalize().hex()[:16]
|
|
101
|
+
return digest.finalize().hex()[:16]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InMemoryKeyPair:
|
|
105
|
+
"""Keypair kept purely in memory until explicitly persisted."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, private_pem: bytes, public_pem: bytes, key_dir: Path):
|
|
108
|
+
self._private_pem = private_pem
|
|
109
|
+
self._public_pem = public_pem
|
|
110
|
+
self._key_dir = key_dir
|
|
111
|
+
self._is_new = True
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def private_key_pem(self) -> bytes:
|
|
115
|
+
return self._private_pem
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def public_key_pem(self) -> bytes:
|
|
119
|
+
return self._public_pem
|
|
120
|
+
|
|
121
|
+
def sign_challenge(self, challenge: str) -> bytes:
|
|
122
|
+
private_key = serialization.load_pem_private_key(self._private_pem, password=None)
|
|
123
|
+
return private_key.sign(
|
|
124
|
+
challenge.encode(),
|
|
125
|
+
padding.PKCS1v15(),
|
|
126
|
+
hashes.SHA256(),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def public_key_der_b64(self) -> str:
|
|
130
|
+
pubkey = serialization.load_pem_public_key(self._public_pem)
|
|
131
|
+
der = pubkey.public_bytes(
|
|
132
|
+
encoding=serialization.Encoding.DER,
|
|
133
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
134
|
+
)
|
|
135
|
+
return base64.b64encode(der).decode()
|
|
136
|
+
|
|
137
|
+
def persist(self) -> KeyPair:
|
|
138
|
+
"""Write the keypair to disk and return a regular KeyPair."""
|
|
139
|
+
key_dir = self._key_dir
|
|
140
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
priv_path = key_dir / PRIVATE_KEY_FILE
|
|
142
|
+
pub_path = key_dir / PUBLIC_KEY_FILE
|
|
143
|
+
priv_path.write_bytes(self._private_pem)
|
|
144
|
+
pub_path.write_bytes(self._public_pem)
|
|
145
|
+
keypair = KeyPair(priv_path, pub_path)
|
|
146
|
+
keypair._is_new = True # type: ignore[attr-defined]
|
|
147
|
+
keypair._key_dir = key_dir # type: ignore[attr-defined]
|
|
148
|
+
return keypair
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def generate_in_memory_keypair() -> InMemoryKeyPair:
|
|
152
|
+
"""Generate a new keypair but keep it in memory until pairing succeeds."""
|
|
153
|
+
private_pem, public_pem = _generate_keypair()
|
|
154
|
+
key_dir = get_key_dir()
|
|
155
|
+
return InMemoryKeyPair(private_pem, public_pem, key_dir)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def keypair_files_exist() -> bool:
|
|
159
|
+
"""Return True if the persisted keypair already exists on disk."""
|
|
160
|
+
key_dir = get_key_dir()
|
|
161
|
+
priv_path = key_dir / PRIVATE_KEY_FILE
|
|
162
|
+
pub_path = key_dir / PUBLIC_KEY_FILE
|
|
163
|
+
return priv_path.exists() and pub_path.exists()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Helpers for the link capture wrapper scripts."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
# Use the stdlib helpers when they are available (Python ≥3.9).
|
|
10
|
+
from importlib.resources import as_file, files
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
# Fall back to the backport for older Python 3.x runtimes (>=3.6).
|
|
13
|
+
from importlib_resources import as_file, files
|
|
14
|
+
|
|
15
|
+
_LINK_CAPTURE_TEMP_DIR: Optional[Path] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prepare_link_capture_bin() -> Optional[Path]:
|
|
19
|
+
"""Extract the packaged link capture wrappers into a temporary dir and return it."""
|
|
20
|
+
global _LINK_CAPTURE_TEMP_DIR
|
|
21
|
+
if _LINK_CAPTURE_TEMP_DIR:
|
|
22
|
+
return _LINK_CAPTURE_TEMP_DIR
|
|
23
|
+
|
|
24
|
+
bin_source = files(__package__) / "bin"
|
|
25
|
+
if not bin_source.is_dir():
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="portacode-link-capture-"))
|
|
29
|
+
for entry in bin_source.iterdir():
|
|
30
|
+
if not entry.is_file():
|
|
31
|
+
continue
|
|
32
|
+
with as_file(entry) as file_path:
|
|
33
|
+
dest = temp_dir / entry.name
|
|
34
|
+
shutil.copyfile(file_path, dest)
|
|
35
|
+
dest.chmod(dest.stat().st_mode | 0o111)
|
|
36
|
+
|
|
37
|
+
_LINK_CAPTURE_TEMP_DIR = temp_dir
|
|
38
|
+
return _LINK_CAPTURE_TEMP_DIR
|
|
Binary file
|