portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__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 +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- 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 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- 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/service.py +6 -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.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.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.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
portacode/connection/terminal.py
CHANGED
|
@@ -16,8 +16,13 @@ in the handlers directory.
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict
|
|
19
22
|
from typing import Any, Dict, Optional, List
|
|
20
23
|
|
|
24
|
+
from websockets.exceptions import ConnectionClosedError
|
|
25
|
+
|
|
21
26
|
from .multiplex import Multiplexer, Channel
|
|
22
27
|
from .handlers import (
|
|
23
28
|
CommandRegistry,
|
|
@@ -26,13 +31,329 @@ from .handlers import (
|
|
|
26
31
|
TerminalStopHandler,
|
|
27
32
|
TerminalListHandler,
|
|
28
33
|
SystemInfoHandler,
|
|
34
|
+
FileReadHandler,
|
|
35
|
+
DirectoryListHandler,
|
|
36
|
+
FileInfoHandler,
|
|
37
|
+
FileDeleteHandler,
|
|
38
|
+
FileSearchHandler,
|
|
39
|
+
FileRenameHandler,
|
|
40
|
+
ContentRequestHandler,
|
|
41
|
+
FileApplyDiffHandler,
|
|
42
|
+
FilePreviewDiffHandler,
|
|
43
|
+
ProjectStateFolderExpandHandler,
|
|
44
|
+
ProjectStateFolderCollapseHandler,
|
|
45
|
+
ProjectStateFileOpenHandler,
|
|
46
|
+
ProjectStateTabCloseHandler,
|
|
47
|
+
ProjectStateSetActiveTabHandler,
|
|
48
|
+
ProjectStateDiffOpenHandler,
|
|
49
|
+
ProjectStateDiffContentHandler,
|
|
50
|
+
ProjectStateGitStageHandler,
|
|
51
|
+
ProjectStateGitUnstageHandler,
|
|
52
|
+
ProjectStateGitRevertHandler,
|
|
53
|
+
ProjectStateGitCommitHandler,
|
|
54
|
+
UpdatePortacodeHandler,
|
|
55
|
+
ConfigureProxmoxInfraHandler,
|
|
56
|
+
)
|
|
57
|
+
from .handlers.project_aware_file_handlers import (
|
|
58
|
+
ProjectAwareFileWriteHandler,
|
|
59
|
+
ProjectAwareFileCreateHandler,
|
|
60
|
+
ProjectAwareFolderCreateHandler,
|
|
29
61
|
)
|
|
30
62
|
from .handlers.session import SessionManager
|
|
31
63
|
|
|
32
64
|
logger = logging.getLogger(__name__)
|
|
33
65
|
|
|
66
|
+
class ClientSessionManager:
|
|
67
|
+
"""Manages connected client sessions for the device."""
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self._client_sessions = {}
|
|
71
|
+
self._debug_file_path = os.path.join(os.getcwd(), "client_sessions.json")
|
|
72
|
+
logger.info("ClientSessionManager initialized")
|
|
73
|
+
|
|
74
|
+
def update_sessions(self, sessions: List[Dict]) -> List[str]:
|
|
75
|
+
"""Update the client sessions with new data from server.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of channel_names for newly added sessions
|
|
79
|
+
"""
|
|
80
|
+
old_sessions = set(self._client_sessions.keys())
|
|
81
|
+
self._client_sessions = {}
|
|
82
|
+
for session in sessions:
|
|
83
|
+
channel_name = session.get("channel_name")
|
|
84
|
+
if channel_name:
|
|
85
|
+
self._client_sessions[channel_name] = session
|
|
86
|
+
|
|
87
|
+
new_sessions = set(self._client_sessions.keys())
|
|
88
|
+
newly_added_sessions = list(new_sessions - old_sessions)
|
|
89
|
+
disconnected_sessions = list(old_sessions - new_sessions)
|
|
90
|
+
|
|
91
|
+
logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added, {len(disconnected_sessions)} disconnected")
|
|
92
|
+
if newly_added_sessions:
|
|
93
|
+
logger.info(f"Newly added sessions: {newly_added_sessions}")
|
|
94
|
+
if disconnected_sessions:
|
|
95
|
+
logger.info(f"Disconnected sessions: {disconnected_sessions}")
|
|
96
|
+
# NOTE: Not automatically cleaning up project states for disconnected sessions
|
|
97
|
+
# to handle temporary disconnections gracefully. Project states will be cleaned
|
|
98
|
+
# up only when explicitly notified by the server of permanent disconnection.
|
|
99
|
+
logger.info("Project states preserved for potential reconnection of these sessions")
|
|
100
|
+
|
|
101
|
+
# Handle project state management based on session changes
|
|
102
|
+
if newly_added_sessions or disconnected_sessions:
|
|
103
|
+
# Schedule project state management to run asynchronously
|
|
104
|
+
import asyncio
|
|
105
|
+
try:
|
|
106
|
+
loop = asyncio.get_event_loop()
|
|
107
|
+
if loop.is_running():
|
|
108
|
+
# Schedule both cleanup and initialization
|
|
109
|
+
loop.create_task(self._manage_project_states_for_session_changes(newly_added_sessions, sessions))
|
|
110
|
+
else:
|
|
111
|
+
logger.debug("No event loop running, skipping project state management")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.debug("Could not schedule project state management: %s", e)
|
|
114
|
+
|
|
115
|
+
self._write_debug_file()
|
|
116
|
+
return newly_added_sessions
|
|
117
|
+
|
|
118
|
+
async def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
119
|
+
"""Explicitly clean up resources for a client session when notified by server."""
|
|
120
|
+
logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
|
|
121
|
+
|
|
122
|
+
# Import here to avoid circular imports
|
|
123
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
124
|
+
|
|
125
|
+
# Get the project state manager from context if it exists
|
|
126
|
+
if hasattr(self, '_terminal_manager') and self._terminal_manager:
|
|
127
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
128
|
+
if context:
|
|
129
|
+
# Get or create the project state manager
|
|
130
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
131
|
+
if control_channel:
|
|
132
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
133
|
+
logger.info("Cleaning up project state for client session: %s", client_session_id)
|
|
134
|
+
await project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
135
|
+
else:
|
|
136
|
+
logger.warning("No control channel available for project state cleanup")
|
|
137
|
+
else:
|
|
138
|
+
logger.warning("No context available for project state cleanup")
|
|
139
|
+
else:
|
|
140
|
+
logger.warning("No terminal manager available for project state cleanup")
|
|
141
|
+
|
|
142
|
+
async def _manage_project_states_for_session_changes(self, newly_added_sessions: List[str], all_sessions: List[Dict]):
|
|
143
|
+
"""Comprehensive project state management for session changes."""
|
|
144
|
+
try:
|
|
145
|
+
# Import here to avoid circular imports
|
|
146
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
147
|
+
|
|
148
|
+
if not hasattr(self, '_terminal_manager') or not self._terminal_manager:
|
|
149
|
+
logger.warning("No terminal manager available for project state management")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
153
|
+
if not context:
|
|
154
|
+
logger.warning("No context available for project state management")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
158
|
+
if not control_channel:
|
|
159
|
+
logger.warning("No control channel available for project state management")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
163
|
+
|
|
164
|
+
# Convert sessions list to dict for easier lookup
|
|
165
|
+
sessions_dict = {session.get('channel_name'): session for session in all_sessions if session.get('channel_name')}
|
|
166
|
+
|
|
167
|
+
# First, clean up project states for sessions that are no longer project sessions or don't exist
|
|
168
|
+
current_project_sessions = set()
|
|
169
|
+
for session in all_sessions:
|
|
170
|
+
channel_name = session.get('channel_name')
|
|
171
|
+
project_id = session.get('project_id')
|
|
172
|
+
project_folder_path = session.get('project_folder_path')
|
|
173
|
+
|
|
174
|
+
if channel_name and project_id is not None and project_folder_path:
|
|
175
|
+
current_project_sessions.add(channel_name)
|
|
176
|
+
logger.debug(f"Active project session: {channel_name} -> {project_folder_path} (project_id: {project_id})")
|
|
177
|
+
|
|
178
|
+
# Clean up project states that don't match current project sessions
|
|
179
|
+
existing_project_states = list(project_manager.projects.keys())
|
|
180
|
+
for session_id in existing_project_states:
|
|
181
|
+
if session_id not in current_project_sessions:
|
|
182
|
+
logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
|
|
183
|
+
await project_manager.cleanup_project(session_id)
|
|
184
|
+
|
|
185
|
+
# Initialize project states for new project sessions
|
|
186
|
+
for session_name in newly_added_sessions:
|
|
187
|
+
session = sessions_dict.get(session_name)
|
|
188
|
+
if not session:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
project_id = session.get('project_id')
|
|
192
|
+
project_folder_path = session.get('project_folder_path')
|
|
193
|
+
|
|
194
|
+
if project_id is not None and project_folder_path:
|
|
195
|
+
if session_name in project_manager.projects:
|
|
196
|
+
logger.info("Project state already exists for session %s, skipping re-init", session_name)
|
|
197
|
+
continue
|
|
198
|
+
logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
# Initialize project state (this includes migration logic)
|
|
202
|
+
project_state = await project_manager.initialize_project_state(session_name, project_folder_path)
|
|
203
|
+
logger.info(f"Successfully initialized project state for {session_name}")
|
|
204
|
+
|
|
205
|
+
# Send initial project state to the client
|
|
206
|
+
# (implementation can be added here if needed)
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to initialize project state for {session_name}: {e}")
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error("Error managing project states for session changes: %s", e)
|
|
212
|
+
|
|
213
|
+
async def _cleanup_orphaned_project_states(self):
|
|
214
|
+
"""Clean up project states that don't match any current client session."""
|
|
215
|
+
try:
|
|
216
|
+
# Import here to avoid circular imports
|
|
217
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
218
|
+
|
|
219
|
+
# Get current client session IDs
|
|
220
|
+
current_sessions = list(self._client_sessions.keys())
|
|
221
|
+
|
|
222
|
+
if hasattr(self, '_terminal_manager') and self._terminal_manager:
|
|
223
|
+
context = getattr(self._terminal_manager, '_context', {})
|
|
224
|
+
if context:
|
|
225
|
+
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
226
|
+
if control_channel:
|
|
227
|
+
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
228
|
+
await project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
229
|
+
else:
|
|
230
|
+
logger.warning("No control channel available for orphaned project state cleanup")
|
|
231
|
+
else:
|
|
232
|
+
logger.warning("No context available for orphaned project state cleanup")
|
|
233
|
+
else:
|
|
234
|
+
logger.warning("No terminal manager available for orphaned project state cleanup")
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error("Error cleaning up orphaned project states: %s", e)
|
|
238
|
+
|
|
239
|
+
def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
|
|
240
|
+
"""Legacy method - now just logs disconnections without cleanup."""
|
|
241
|
+
logger.info("Sessions disconnected (but preserving project states): %s", disconnected_sessions)
|
|
242
|
+
# Project states are preserved to handle reconnections gracefully
|
|
243
|
+
|
|
244
|
+
def set_terminal_manager(self, terminal_manager):
|
|
245
|
+
"""Set reference to terminal manager for cleanup purposes."""
|
|
246
|
+
self._terminal_manager = terminal_manager
|
|
247
|
+
|
|
248
|
+
def get_sessions(self) -> Dict[str, Dict]:
|
|
249
|
+
"""Get all current client sessions."""
|
|
250
|
+
return self._client_sessions.copy()
|
|
251
|
+
|
|
252
|
+
def get_session_by_channel(self, channel_name: str) -> Optional[Dict]:
|
|
253
|
+
"""Get a specific client session by channel name."""
|
|
254
|
+
return self._client_sessions.get(channel_name)
|
|
255
|
+
|
|
256
|
+
def get_sessions_for_project(self, project_id: str) -> List[Dict]:
|
|
257
|
+
"""Get all client sessions for a specific project."""
|
|
258
|
+
return [
|
|
259
|
+
session for session in self._client_sessions.values()
|
|
260
|
+
if session.get("project_id") == project_id
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
def get_sessions_for_user(self, user_id: int) -> List[Dict]:
|
|
264
|
+
"""Get all client sessions for a specific user."""
|
|
265
|
+
return [
|
|
266
|
+
session for session in self._client_sessions.values()
|
|
267
|
+
if session.get("user_id") == user_id
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def has_interested_clients(self) -> bool:
|
|
271
|
+
"""Check if there are any connected clients interested in this device."""
|
|
272
|
+
return len(self._client_sessions) > 0
|
|
273
|
+
|
|
274
|
+
def get_target_sessions(self, project_id: str = None) -> List[str]:
|
|
275
|
+
"""Get list of channel_names for target client sessions.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
project_id: If specified, only include sessions for this project.
|
|
279
|
+
Dashboard sessions only receive events when project_id is None/empty.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of channel_names to target
|
|
283
|
+
"""
|
|
284
|
+
if not self._client_sessions:
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
target_sessions = []
|
|
288
|
+
for session in self._client_sessions.values():
|
|
289
|
+
# Dashboard sessions only receive events when project_id is None/empty
|
|
290
|
+
if session.get("connection_type") == "dashboard":
|
|
291
|
+
if project_id is None:
|
|
292
|
+
target_sessions.append(session.get("channel_name"))
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# For project sessions, filter by project_id if specified
|
|
296
|
+
if project_id and session.get("project_id") != project_id:
|
|
297
|
+
continue
|
|
298
|
+
target_sessions.append(session.get("channel_name"))
|
|
299
|
+
|
|
300
|
+
return [s for s in target_sessions if s] # Filter out None values
|
|
301
|
+
|
|
302
|
+
def get_target_sessions_for_new_clients(self, new_session_names: List[str], project_id: str = None) -> List[str]:
|
|
303
|
+
"""Get target sessions for newly added client sessions.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
new_session_names: List of newly added session channel names
|
|
307
|
+
project_id: If specified, only include sessions for this project.
|
|
308
|
+
Dashboard sessions only receive events when project_id is None/empty.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
List of channel_names to target from newly added sessions
|
|
312
|
+
"""
|
|
313
|
+
if not new_session_names or not self._client_sessions:
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
target_sessions = []
|
|
317
|
+
for channel_name in new_session_names:
|
|
318
|
+
session = self._client_sessions.get(channel_name)
|
|
319
|
+
if not session:
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
# Dashboard sessions only receive events when project_id is None/empty
|
|
323
|
+
if session.get("connection_type") == "dashboard":
|
|
324
|
+
if project_id is None:
|
|
325
|
+
target_sessions.append(channel_name)
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
# For project sessions, filter by project_id if specified
|
|
329
|
+
if project_id and session.get("project_id") != project_id:
|
|
330
|
+
continue
|
|
331
|
+
target_sessions.append(channel_name)
|
|
332
|
+
|
|
333
|
+
return target_sessions
|
|
334
|
+
|
|
335
|
+
def get_reply_channel_for_compatibility(self) -> Optional[str]:
|
|
336
|
+
"""Get the first session's channel_name for backward compatibility.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
First available channel_name or None
|
|
340
|
+
"""
|
|
341
|
+
if not self._client_sessions:
|
|
342
|
+
return None
|
|
343
|
+
return next(iter(self._client_sessions.keys()), None)
|
|
344
|
+
|
|
345
|
+
def _write_debug_file(self) -> None:
|
|
346
|
+
"""Write current client sessions to debug JSON file."""
|
|
347
|
+
try:
|
|
348
|
+
with open(self._debug_file_path, 'w') as f:
|
|
349
|
+
json.dump(list(self._client_sessions.values()), f, indent=2, default=str)
|
|
350
|
+
logger.debug(f"Updated client sessions debug file: {self._debug_file_path}")
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error(f"Failed to write client sessions debug file: {e}")
|
|
353
|
+
|
|
34
354
|
__all__ = [
|
|
35
355
|
"TerminalManager",
|
|
356
|
+
"ClientSessionManager",
|
|
36
357
|
]
|
|
37
358
|
|
|
38
359
|
class TerminalManager:
|
|
@@ -40,9 +361,12 @@ class TerminalManager:
|
|
|
40
361
|
|
|
41
362
|
CONTROL_CHANNEL_ID = 0 # messages with JSON commands/events
|
|
42
363
|
|
|
43
|
-
def __init__(self, mux: Multiplexer):
|
|
364
|
+
def __init__(self, mux: Multiplexer, debug: bool = False):
|
|
44
365
|
self.mux = mux
|
|
366
|
+
self.debug = debug
|
|
45
367
|
self._session_manager = None # Initialize as None first
|
|
368
|
+
self._client_session_manager = ClientSessionManager() # Initialize client session manager
|
|
369
|
+
self._client_session_manager.set_terminal_manager(self) # Set reference for cleanup
|
|
46
370
|
self._set_mux(mux, is_initial=True)
|
|
47
371
|
|
|
48
372
|
# ------------------------------------------------------------------
|
|
@@ -64,8 +388,8 @@ class TerminalManager:
|
|
|
64
388
|
# Start async reattachment and reconciliation
|
|
65
389
|
asyncio.create_task(self._handle_reconnection())
|
|
66
390
|
else:
|
|
67
|
-
# No existing sessions,
|
|
68
|
-
asyncio.create_task(self.
|
|
391
|
+
# No existing sessions, send empty terminal list and request client sessions
|
|
392
|
+
asyncio.create_task(self._initial_connection_setup())
|
|
69
393
|
|
|
70
394
|
def _set_mux(self, mux: Multiplexer, is_initial: bool = False) -> None:
|
|
71
395
|
self.mux = mux
|
|
@@ -73,17 +397,21 @@ class TerminalManager:
|
|
|
73
397
|
|
|
74
398
|
# Only create new session manager on initial setup, preserve existing one on reconnection
|
|
75
399
|
if is_initial or self._session_manager is None:
|
|
76
|
-
self._session_manager = SessionManager(mux)
|
|
400
|
+
self._session_manager = SessionManager(mux, terminal_manager=self)
|
|
77
401
|
logger.info("Created new SessionManager")
|
|
78
402
|
else:
|
|
79
|
-
# Update existing session manager's mux
|
|
403
|
+
# Update existing session manager's mux and terminal_manager references
|
|
80
404
|
self._session_manager.mux = mux
|
|
405
|
+
self._session_manager.terminal_manager = self
|
|
81
406
|
logger.info("Preserved existing SessionManager with %d sessions", len(self._session_manager._sessions))
|
|
82
407
|
|
|
83
408
|
# Create context for handlers
|
|
84
409
|
self._context = {
|
|
85
410
|
"session_manager": self._session_manager,
|
|
411
|
+
"client_session_manager": self._client_session_manager,
|
|
86
412
|
"mux": mux,
|
|
413
|
+
"use_content_caching": True, # Enable content caching optimization
|
|
414
|
+
"debug": self.debug,
|
|
87
415
|
}
|
|
88
416
|
|
|
89
417
|
# Initialize command registry
|
|
@@ -99,6 +427,18 @@ class TerminalManager:
|
|
|
99
427
|
except Exception:
|
|
100
428
|
pass
|
|
101
429
|
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
430
|
+
|
|
431
|
+
# Start periodic system info sender
|
|
432
|
+
if getattr(self, "_system_info_task", None):
|
|
433
|
+
try:
|
|
434
|
+
self._system_info_task.cancel()
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
self._system_info_task = asyncio.create_task(self._periodic_system_info())
|
|
438
|
+
|
|
439
|
+
# For initial connections, request client sessions after control loop starts
|
|
440
|
+
if is_initial:
|
|
441
|
+
asyncio.create_task(self._initial_connection_setup())
|
|
102
442
|
|
|
103
443
|
def _register_default_handlers(self) -> None:
|
|
104
444
|
"""Register the default command handlers."""
|
|
@@ -107,37 +447,289 @@ class TerminalManager:
|
|
|
107
447
|
self._command_registry.register(TerminalStopHandler)
|
|
108
448
|
self._command_registry.register(TerminalListHandler)
|
|
109
449
|
self._command_registry.register(SystemInfoHandler)
|
|
450
|
+
# File operation handlers
|
|
451
|
+
self._command_registry.register(FileReadHandler)
|
|
452
|
+
self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
|
|
453
|
+
self._command_registry.register(DirectoryListHandler)
|
|
454
|
+
self._command_registry.register(FileInfoHandler)
|
|
455
|
+
self._command_registry.register(FileDeleteHandler)
|
|
456
|
+
self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
|
|
457
|
+
self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
|
|
458
|
+
self._command_registry.register(FileRenameHandler)
|
|
459
|
+
self._command_registry.register(FileSearchHandler)
|
|
460
|
+
self._command_registry.register(ContentRequestHandler)
|
|
461
|
+
self._command_registry.register(FileApplyDiffHandler)
|
|
462
|
+
self._command_registry.register(FilePreviewDiffHandler)
|
|
463
|
+
# Project state handlers
|
|
464
|
+
self._command_registry.register(ProjectStateFolderExpandHandler)
|
|
465
|
+
self._command_registry.register(ProjectStateFolderCollapseHandler)
|
|
466
|
+
self._command_registry.register(ProjectStateFileOpenHandler)
|
|
467
|
+
self._command_registry.register(ProjectStateTabCloseHandler)
|
|
468
|
+
self._command_registry.register(ProjectStateSetActiveTabHandler)
|
|
469
|
+
self._command_registry.register(ProjectStateDiffOpenHandler)
|
|
470
|
+
self._command_registry.register(ProjectStateDiffContentHandler)
|
|
471
|
+
self._command_registry.register(ProjectStateGitStageHandler)
|
|
472
|
+
self._command_registry.register(ProjectStateGitUnstageHandler)
|
|
473
|
+
self._command_registry.register(ProjectStateGitRevertHandler)
|
|
474
|
+
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
475
|
+
# System management handlers
|
|
476
|
+
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
477
|
+
self._command_registry.register(UpdatePortacodeHandler)
|
|
110
478
|
|
|
111
479
|
# ---------------------------------------------------------------------
|
|
112
480
|
# Control loop – receives commands from gateway
|
|
113
481
|
# ---------------------------------------------------------------------
|
|
114
482
|
|
|
115
483
|
async def _control_loop(self) -> None:
|
|
484
|
+
logger.info("terminal_manager: Starting control loop")
|
|
116
485
|
while True:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
486
|
+
try:
|
|
487
|
+
message = await self._control_channel.recv()
|
|
488
|
+
logger.debug("terminal_manager: Received message: %s", message)
|
|
489
|
+
|
|
490
|
+
# Older parts of the system may send *raw* str. Ensure dict.
|
|
491
|
+
if isinstance(message, str):
|
|
492
|
+
try:
|
|
493
|
+
message = json.loads(message)
|
|
494
|
+
logger.debug("terminal_manager: Parsed string message to dict")
|
|
495
|
+
except Exception:
|
|
496
|
+
logger.warning("terminal_manager: Discarding non-JSON control frame: %s", message)
|
|
497
|
+
continue
|
|
498
|
+
if not isinstance(message, dict):
|
|
499
|
+
logger.warning("terminal_manager: Invalid control frame type: %r", type(message))
|
|
124
500
|
continue
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
501
|
+
cmd = message.get("cmd")
|
|
502
|
+
if not cmd:
|
|
503
|
+
# Ignore frames that are *events* coming from the remote side
|
|
504
|
+
if message.get("event"):
|
|
505
|
+
logger.debug("terminal_manager: Ignoring event message: %s", message.get("event"))
|
|
506
|
+
continue
|
|
507
|
+
logger.warning("terminal_manager: Missing 'cmd' in control frame: %s", message)
|
|
132
508
|
continue
|
|
133
|
-
|
|
509
|
+
reply_chan = message.get("reply_channel")
|
|
510
|
+
|
|
511
|
+
logger.info("terminal_manager: Processing command '%s' with reply_channel=%s", cmd, reply_chan)
|
|
512
|
+
logger.debug("terminal_manager: Full message: %s", message)
|
|
513
|
+
|
|
514
|
+
# Handle client sessions update directly (special case)
|
|
515
|
+
if cmd == "client_sessions_update":
|
|
516
|
+
sessions = message.get("sessions", [])
|
|
517
|
+
logger.info("terminal_manager: 🔔 RECEIVED client_sessions_update with %d sessions", len(sessions))
|
|
518
|
+
logger.debug("terminal_manager: Session details: %s", sessions)
|
|
519
|
+
newly_added_sessions = self._client_session_manager.update_sessions(sessions)
|
|
520
|
+
logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
|
|
521
|
+
|
|
522
|
+
# Auto-send initial data only to newly added clients
|
|
523
|
+
# Create a background task so it doesn't block the control loop
|
|
524
|
+
if newly_added_sessions:
|
|
525
|
+
logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients (non-blocking)")
|
|
526
|
+
asyncio.create_task(self._send_initial_data_to_clients(newly_added_sessions))
|
|
527
|
+
else:
|
|
528
|
+
logger.info("terminal_manager: ℹ️ No new sessions to send data to")
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
# Dispatch command through registry
|
|
532
|
+
handled = await self._command_registry.dispatch(cmd, message, reply_chan)
|
|
533
|
+
if not handled:
|
|
534
|
+
logger.warning("terminal_manager: Command '%s' was not handled by any handler", cmd)
|
|
535
|
+
await self._send_error(f"Unknown cmd: {cmd}", reply_chan)
|
|
536
|
+
|
|
537
|
+
except Exception as exc:
|
|
538
|
+
logger.exception("terminal_manager: Error in control loop: %s", exc)
|
|
539
|
+
# Continue processing other messages
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
async def _periodic_system_info(self) -> None:
|
|
543
|
+
"""Send system_info event every 10 seconds when clients are connected."""
|
|
544
|
+
while True:
|
|
545
|
+
try:
|
|
546
|
+
await asyncio.sleep(10)
|
|
547
|
+
if self._client_session_manager.has_interested_clients():
|
|
548
|
+
from .handlers.system_handlers import SystemInfoHandler
|
|
549
|
+
handler = SystemInfoHandler(self._control_channel, self._context)
|
|
550
|
+
system_info = handler.execute({})
|
|
551
|
+
await self._send_session_aware(system_info)
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
logger.exception("Error in periodic system info: %s", exc)
|
|
134
554
|
continue
|
|
135
|
-
|
|
555
|
+
|
|
556
|
+
async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
|
|
557
|
+
"""Send initial system info and terminal list to connected clients.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
newly_added_sessions: If provided, only send data to these specific sessions
|
|
561
|
+
"""
|
|
562
|
+
if newly_added_sessions:
|
|
563
|
+
logger.info("terminal_manager: 📤 Starting to send initial data to newly added clients: %s", newly_added_sessions)
|
|
564
|
+
else:
|
|
565
|
+
logger.info("terminal_manager: 📤 Starting to send initial data to all connected clients")
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
# Send system_info (always broadcasts to all clients for now)
|
|
569
|
+
logger.info("terminal_manager: 📊 Dispatching system_info command")
|
|
570
|
+
await self._command_registry.dispatch("system_info", {}, None)
|
|
571
|
+
logger.info("terminal_manager: ✅ system_info dispatch completed")
|
|
572
|
+
|
|
573
|
+
# Send terminal_list only to newly added clients or to all if not specified
|
|
574
|
+
logger.info("terminal_manager: 📋 Preparing to send terminal_list to clients")
|
|
575
|
+
|
|
576
|
+
if newly_added_sessions:
|
|
577
|
+
# Get unique project IDs from the newly added sessions
|
|
578
|
+
project_ids = set()
|
|
579
|
+
all_sessions = self._client_session_manager.get_sessions()
|
|
580
|
+
|
|
581
|
+
for session_name in newly_added_sessions:
|
|
582
|
+
session = all_sessions.get(session_name)
|
|
583
|
+
if session:
|
|
584
|
+
project_id = session.get("project_id")
|
|
585
|
+
connection_type = session.get("connection_type", "unknown")
|
|
586
|
+
logger.debug(f"terminal_manager: New session {session_name}: project_id={project_id}, type={connection_type}")
|
|
587
|
+
if project_id:
|
|
588
|
+
project_ids.add(project_id)
|
|
589
|
+
|
|
590
|
+
logger.info(f"terminal_manager: Found {len(project_ids)} unique project IDs from new sessions: {list(project_ids)}")
|
|
591
|
+
|
|
592
|
+
# Initialize project states for sessions with project_folder_path
|
|
593
|
+
await self._initialize_project_states_for_new_sessions(newly_added_sessions, all_sessions)
|
|
594
|
+
|
|
595
|
+
# Send terminal_list for each project to interested new sessions
|
|
596
|
+
for project_id in project_ids:
|
|
597
|
+
target_sessions = self._client_session_manager.get_target_sessions_for_new_clients(newly_added_sessions, project_id)
|
|
598
|
+
if target_sessions:
|
|
599
|
+
logger.info(f"terminal_manager: 📋 Sending terminal_list for project {project_id} to sessions: {target_sessions}")
|
|
600
|
+
await self._send_targeted_terminal_list({"project_id": project_id}, target_sessions)
|
|
601
|
+
logger.info(f"terminal_manager: ✅ Project {project_id} terminal_list sent to new sessions")
|
|
602
|
+
|
|
603
|
+
# Also send general terminal_list for dashboard connections (project_id=None)
|
|
604
|
+
dashboard_targets = self._client_session_manager.get_target_sessions_for_new_clients(newly_added_sessions, None)
|
|
605
|
+
if dashboard_targets:
|
|
606
|
+
logger.info("terminal_manager: 📋 Sending general terminal_list to new dashboard sessions: %s", dashboard_targets)
|
|
607
|
+
await self._send_targeted_terminal_list({}, dashboard_targets)
|
|
608
|
+
logger.info("terminal_manager: ✅ General terminal_list sent to new dashboard sessions")
|
|
609
|
+
else:
|
|
610
|
+
# Original behavior for all clients
|
|
611
|
+
# Get unique project IDs from connected clients
|
|
612
|
+
project_ids = set()
|
|
613
|
+
all_sessions = self._client_session_manager.get_sessions()
|
|
614
|
+
logger.info(f"terminal_manager: Analyzing {len(all_sessions)} client sessions for project IDs")
|
|
615
|
+
|
|
616
|
+
for session in all_sessions.values():
|
|
617
|
+
project_id = session.get("project_id")
|
|
618
|
+
connection_type = session.get("connection_type", "unknown")
|
|
619
|
+
logger.debug(f"terminal_manager: Session {session.get('channel_name')}: project_id={project_id}, type={connection_type}")
|
|
620
|
+
if project_id:
|
|
621
|
+
project_ids.add(project_id)
|
|
622
|
+
|
|
623
|
+
logger.info(f"terminal_manager: Found {len(project_ids)} unique project IDs: {list(project_ids)}")
|
|
624
|
+
|
|
625
|
+
# Send terminal_list for each project, plus one without project_id for general sessions
|
|
626
|
+
if not project_ids:
|
|
627
|
+
# No specific projects, send general terminal_list
|
|
628
|
+
logger.info("terminal_manager: 📋 Dispatching general terminal_list (no specific projects)")
|
|
629
|
+
await self._command_registry.dispatch("terminal_list", {}, None)
|
|
630
|
+
logger.info("terminal_manager: ✅ General terminal_list dispatch completed")
|
|
631
|
+
else:
|
|
632
|
+
# Send terminal_list for each project
|
|
633
|
+
for project_id in project_ids:
|
|
634
|
+
logger.info(f"terminal_manager: 📋 Dispatching terminal_list for project {project_id}")
|
|
635
|
+
await self._command_registry.dispatch("terminal_list", {"project_id": project_id}, None)
|
|
636
|
+
logger.info(f"terminal_manager: ✅ Project {project_id} terminal_list dispatch completed")
|
|
637
|
+
|
|
638
|
+
# Also send general terminal_list for dashboard connections
|
|
639
|
+
logger.info("terminal_manager: 📋 Dispatching general terminal_list for dashboard connections")
|
|
640
|
+
await self._command_registry.dispatch("terminal_list", {}, None)
|
|
641
|
+
logger.info("terminal_manager: ✅ General terminal_list for dashboard dispatch completed")
|
|
642
|
+
|
|
643
|
+
logger.info("terminal_manager: 🎉 All initial data sent successfully")
|
|
644
|
+
|
|
645
|
+
except Exception as exc:
|
|
646
|
+
logger.exception("terminal_manager: ❌ Error sending initial data to clients: %s", exc)
|
|
647
|
+
|
|
648
|
+
async def _initialize_project_states_for_new_sessions(self, newly_added_sessions: List[str], all_sessions: Dict[str, Dict]):
|
|
649
|
+
"""Initialize project states for new sessions that have project_folder_path."""
|
|
650
|
+
logger.info("terminal_manager: 🌳 Initializing project states for new sessions")
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
# Import here to avoid circular imports
|
|
654
|
+
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
655
|
+
|
|
656
|
+
# Get or create the project state manager
|
|
657
|
+
manager = _get_or_create_project_state_manager(self._context, self._control_channel)
|
|
658
|
+
|
|
659
|
+
for session_name in newly_added_sessions:
|
|
660
|
+
session = all_sessions.get(session_name)
|
|
661
|
+
if not session:
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
project_id = session.get("project_id")
|
|
665
|
+
project_folder_path = session.get("project_folder_path")
|
|
666
|
+
if project_id is None or not project_folder_path:
|
|
667
|
+
logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_id or project_folder_path, skipping")
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
# Initialize project state
|
|
674
|
+
project_state = await manager.initialize_project_state(session_name, project_folder_path)
|
|
675
|
+
|
|
676
|
+
# Send initial project state to the client
|
|
677
|
+
initial_state_payload = {
|
|
678
|
+
"event": "project_state_initialized",
|
|
679
|
+
"project_id": project_state.client_session_id, # Add missing project_id field
|
|
680
|
+
"project_folder_path": project_state.project_folder_path,
|
|
681
|
+
"is_git_repo": project_state.is_git_repo,
|
|
682
|
+
"git_branch": project_state.git_branch,
|
|
683
|
+
"git_status_summary": project_state.git_status_summary,
|
|
684
|
+
"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
|
|
685
|
+
"open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs.values()], # Fix to use .values() for dict
|
|
686
|
+
"active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
|
|
687
|
+
"items": [manager._serialize_file_item(item) for item in project_state.items],
|
|
688
|
+
"timestamp": time.time(),
|
|
689
|
+
"client_sessions": [session_name] # Target this specific session
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
await self._control_channel.send(initial_state_payload)
|
|
693
|
+
logger.info(f"terminal_manager: ✅ Project state initialized and sent for session {session_name}")
|
|
694
|
+
|
|
695
|
+
except Exception as exc:
|
|
696
|
+
logger.error(f"terminal_manager: ❌ Failed to initialize project state for session {session_name}: {exc}")
|
|
697
|
+
|
|
698
|
+
except Exception as exc:
|
|
699
|
+
logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
|
|
700
|
+
|
|
701
|
+
async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
|
|
702
|
+
"""Send terminal_list command to specific client sessions.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
message: The terminal_list command message
|
|
706
|
+
target_sessions: List of client session channel names to target
|
|
707
|
+
"""
|
|
708
|
+
try:
|
|
709
|
+
# Get the terminal list from session manager
|
|
710
|
+
session_manager = self._session_manager
|
|
711
|
+
if not session_manager:
|
|
712
|
+
logger.error("terminal_manager: Session manager not available for targeted terminal_list")
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
requested_project_id = message.get("project_id")
|
|
716
|
+
if requested_project_id == "all":
|
|
717
|
+
sessions = session_manager.list_sessions(project_id="all")
|
|
718
|
+
else:
|
|
719
|
+
sessions = session_manager.list_sessions(project_id=requested_project_id)
|
|
720
|
+
|
|
721
|
+
# Build the response payload
|
|
722
|
+
response = {
|
|
723
|
+
"event": "terminal_list",
|
|
724
|
+
"sessions": sessions,
|
|
725
|
+
"project_id": requested_project_id,
|
|
726
|
+
"client_sessions": target_sessions
|
|
727
|
+
}
|
|
136
728
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
729
|
+
logger.debug("terminal_manager: Sending targeted terminal_list: %s", response)
|
|
730
|
+
await self._control_channel.send(response)
|
|
731
|
+
except Exception as exc:
|
|
732
|
+
logger.exception("terminal_manager: Error sending targeted terminal_list: %s", exc)
|
|
141
733
|
|
|
142
734
|
# ------------------------------------------------------------------
|
|
143
735
|
# Extension API
|
|
@@ -175,7 +767,52 @@ class TerminalManager:
|
|
|
175
767
|
payload = {"event": "error", "message": message}
|
|
176
768
|
if reply_channel:
|
|
177
769
|
payload["reply_channel"] = reply_channel
|
|
178
|
-
await self.
|
|
770
|
+
await self._send_session_aware(payload)
|
|
771
|
+
|
|
772
|
+
async def _send_session_aware(self, payload: dict, project_id: str = None) -> None:
|
|
773
|
+
"""Send a message with client session awareness.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
payload: The message payload to send
|
|
777
|
+
project_id: Optional project filter for targeting specific sessions
|
|
778
|
+
"""
|
|
779
|
+
event_type = payload.get("event", "unknown")
|
|
780
|
+
|
|
781
|
+
# Check if there are any interested clients
|
|
782
|
+
if not self._client_session_manager.has_interested_clients():
|
|
783
|
+
logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
# Get target sessions
|
|
787
|
+
target_sessions = self._client_session_manager.get_target_sessions(project_id)
|
|
788
|
+
if not target_sessions:
|
|
789
|
+
logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
|
|
790
|
+
return
|
|
791
|
+
|
|
792
|
+
# Add session targeting information
|
|
793
|
+
enhanced_payload = dict(payload)
|
|
794
|
+
enhanced_payload["client_sessions"] = target_sessions
|
|
795
|
+
|
|
796
|
+
# Add backward compatibility reply_channel (first session)
|
|
797
|
+
reply_channel = self._client_session_manager.get_reply_channel_for_compatibility()
|
|
798
|
+
if reply_channel and "reply_channel" not in enhanced_payload:
|
|
799
|
+
enhanced_payload["reply_channel"] = reply_channel
|
|
800
|
+
|
|
801
|
+
# Log all event dispatches at INFO level, with data size for terminal_data
|
|
802
|
+
if event_type == "terminal_data":
|
|
803
|
+
data_size = len(payload.get("data", ""))
|
|
804
|
+
terminal_id = payload.get("channel", "unknown")
|
|
805
|
+
logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
|
|
806
|
+
event_type, terminal_id, data_size, len(target_sessions))
|
|
807
|
+
# else:
|
|
808
|
+
# logger.info("terminal_manager: Dispatching %s event to %d client sessions",
|
|
809
|
+
# event_type, len(target_sessions))
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
await self._control_channel.send(enhanced_payload)
|
|
813
|
+
except ConnectionClosedError as exc:
|
|
814
|
+
logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
|
|
815
|
+
return
|
|
179
816
|
|
|
180
817
|
async def _send_terminal_list(self) -> None:
|
|
181
818
|
"""Send terminal list for reconnection reconciliation."""
|
|
@@ -187,9 +824,35 @@ class TerminalManager:
|
|
|
187
824
|
"event": "terminal_list",
|
|
188
825
|
"sessions": sessions,
|
|
189
826
|
}
|
|
190
|
-
await self.
|
|
827
|
+
await self._send_session_aware(payload)
|
|
191
828
|
except Exception as exc:
|
|
192
829
|
logger.warning("Failed to send terminal list: %s", exc)
|
|
830
|
+
|
|
831
|
+
async def _request_client_sessions(self) -> None:
|
|
832
|
+
"""Request current client sessions from server."""
|
|
833
|
+
try:
|
|
834
|
+
payload = {
|
|
835
|
+
"event": "request_client_sessions"
|
|
836
|
+
}
|
|
837
|
+
# This is a special case - always send regardless of current client sessions
|
|
838
|
+
# because we're trying to get the client sessions list
|
|
839
|
+
await self._control_channel.send(payload)
|
|
840
|
+
logger.info("Requested client sessions from server")
|
|
841
|
+
except Exception as exc:
|
|
842
|
+
logger.warning("Failed to request client sessions: %s", exc)
|
|
843
|
+
|
|
844
|
+
async def _initial_connection_setup(self) -> None:
|
|
845
|
+
"""Handle initial connection setup sequence."""
|
|
846
|
+
try:
|
|
847
|
+
# Send empty terminal list
|
|
848
|
+
await self._send_terminal_list()
|
|
849
|
+
logger.info("Initial terminal list sent to server")
|
|
850
|
+
|
|
851
|
+
# Request current client sessions
|
|
852
|
+
await self._request_client_sessions()
|
|
853
|
+
logger.info("Initial client session request sent")
|
|
854
|
+
except Exception as exc:
|
|
855
|
+
logger.error("Failed to handle initial connection setup: %s", exc)
|
|
193
856
|
|
|
194
857
|
async def _handle_reconnection(self) -> None:
|
|
195
858
|
"""Handle the async reconnection sequence."""
|
|
@@ -201,5 +864,9 @@ class TerminalManager:
|
|
|
201
864
|
# Then send updated terminal list to server
|
|
202
865
|
await self._send_terminal_list()
|
|
203
866
|
logger.info("Terminal list sent to server after reconnection")
|
|
867
|
+
|
|
868
|
+
# Request current client sessions
|
|
869
|
+
await self._request_client_sessions()
|
|
870
|
+
logger.info("Client session request sent after reconnection")
|
|
204
871
|
except Exception as exc:
|
|
205
|
-
logger.error("Failed to handle reconnection: %s", exc)
|
|
872
|
+
logger.error("Failed to handle reconnection: %s", exc)
|