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.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,108 @@
1
+ """Data models for project state management.
2
+
3
+ This module contains all the dataclasses and models used throughout the project
4
+ state management system, including TabInfo, FileItem, GitFileChange,
5
+ GitDetailedStatus, ProjectState, and MonitoredFolder.
6
+ """
7
+
8
+ from dataclasses import dataclass, asdict
9
+ from typing import Any, Dict, List, Optional, Set, Union
10
+
11
+
12
+ @dataclass
13
+ class TabInfo:
14
+ """Represents an editor tab with content and metadata."""
15
+ tab_id: str # Unique identifier for the tab
16
+ tab_type: str # 'file', 'diff', 'untitled', 'image', 'audio', 'video'
17
+ title: str # Display title for the tab
18
+ file_path: Optional[str] = None # Path for file-based tabs
19
+ content: Optional[str] = None # Text content or base64 for media
20
+ original_content: Optional[str] = None # For diff view
21
+ modified_content: Optional[str] = None # For diff view
22
+
23
+ # Content hash fields for caching optimization
24
+ content_hash: Optional[str] = None # SHA-256 hash of content
25
+ original_content_hash: Optional[str] = None # SHA-256 hash of original_content for diffs
26
+ modified_content_hash: Optional[str] = None # SHA-256 hash of modified_content for diffs
27
+ html_diff_hash: Optional[str] = None # SHA-256 hash of html_diff_versions JSON
28
+
29
+ is_dirty: bool = False # Has unsaved changes
30
+ mime_type: Optional[str] = None # For media files
31
+ encoding: Optional[str] = None # Content encoding (base64, utf-8, etc.)
32
+ metadata: Optional[Dict[str, Any]] = None # Additional metadata
33
+
34
+
35
+ @dataclass
36
+ class MonitoredFolder:
37
+ """Represents a folder that is being monitored for changes."""
38
+ folder_path: str
39
+ is_expanded: bool = False
40
+
41
+
42
+ @dataclass
43
+ class FileItem:
44
+ """Represents a file or directory item with metadata."""
45
+ name: str
46
+ path: str
47
+ is_directory: bool
48
+ parent_path: str
49
+ size: Optional[int] = None
50
+ modified_time: Optional[float] = None
51
+ is_git_tracked: Optional[bool] = None
52
+ git_status: Optional[str] = None
53
+ is_staged: Optional[Union[bool, str]] = None # True, False, or "mixed"
54
+ is_hidden: bool = False
55
+ is_ignored: bool = False
56
+ children: Optional[List['FileItem']] = None
57
+ is_expanded: bool = False
58
+ is_loaded: bool = False
59
+
60
+
61
+ @dataclass
62
+ class GitFileChange:
63
+ """Represents a single file change in git."""
64
+ file_repo_path: str # Relative path from repository root
65
+ file_name: str # Just the filename (basename)
66
+ file_abs_path: str # Absolute path to the file
67
+ change_type: str # 'added', 'modified', 'deleted', 'untracked' - follows git's native types
68
+ content_hash: Optional[str] = None # SHA256 hash of current file content
69
+ is_staged: bool = False # Whether this change is staged
70
+ diff_details: Optional[Dict[str, Any]] = None # Per-character diff information using diff-match-patch
71
+
72
+
73
+ @dataclass
74
+ class GitDetailedStatus:
75
+ """Represents detailed git status with file hashes."""
76
+ head_commit_hash: Optional[str] = None # Hash of HEAD commit
77
+ staged_changes: List[GitFileChange] = None # Changes in the staging area
78
+ unstaged_changes: List[GitFileChange] = None # Changes in working directory
79
+ untracked_files: List[GitFileChange] = None # Untracked files
80
+
81
+ def __post_init__(self):
82
+ if self.staged_changes is None:
83
+ self.staged_changes = []
84
+ if self.unstaged_changes is None:
85
+ self.unstaged_changes = []
86
+ if self.untracked_files is None:
87
+ self.untracked_files = []
88
+
89
+
90
+ @dataclass
91
+ class ProjectState:
92
+ """Represents the complete state of a project."""
93
+ client_session_id: str # The client session ID - one project per client session
94
+ project_folder_path: str
95
+ items: List[FileItem]
96
+ monitored_folders: List[MonitoredFolder] = None
97
+ is_git_repo: bool = False
98
+ git_branch: Optional[str] = None
99
+ git_status_summary: Optional[Dict[str, int]] = None # Kept for backward compatibility
100
+ git_detailed_status: Optional[GitDetailedStatus] = None # New detailed git state
101
+ open_tabs: Dict[str, 'TabInfo'] = None # Changed from List to Dict with unique keys
102
+ active_tab: Optional['TabInfo'] = None
103
+
104
+ def __post_init__(self):
105
+ if self.open_tabs is None:
106
+ self.open_tabs = {}
107
+ if self.monitored_folders is None:
108
+ self.monitored_folders = []
@@ -0,0 +1,50 @@
1
+ """Utility functions for project state management.
2
+
3
+ This module contains shared utility functions used across the project state
4
+ management system, including tab key generation and other helper functions.
5
+ """
6
+
7
+ import hashlib
8
+ import uuid
9
+
10
+
11
+ def generate_tab_key(tab_type: str, file_path: str, **kwargs) -> str:
12
+ """Generate a unique key for a tab.
13
+
14
+ Args:
15
+ tab_type: Type of tab ('file', 'diff', 'untitled', etc.)
16
+ file_path: Path to the file
17
+ **kwargs: Additional parameters for diff tabs (from_ref, to_ref, from_hash, to_hash)
18
+
19
+ Returns:
20
+ Unique string key for the tab
21
+ """
22
+ if tab_type == 'file':
23
+ return file_path
24
+ elif tab_type == 'diff':
25
+ from_ref = kwargs.get('from_ref', '')
26
+ to_ref = kwargs.get('to_ref', '')
27
+ from_hash = kwargs.get('from_hash', '')
28
+ to_hash = kwargs.get('to_hash', '')
29
+ return f"diff:{file_path}:{from_ref}:{to_ref}:{from_hash}:{to_hash}"
30
+ elif tab_type == 'untitled':
31
+ # For untitled tabs, use the tab_id as the key since they don't have a file path
32
+ return kwargs.get('tab_id', str(uuid.uuid4()))
33
+ else:
34
+ # For other tab types, use file_path if available, otherwise tab_id
35
+ return file_path if file_path else kwargs.get('tab_id', str(uuid.uuid4()))
36
+
37
+
38
+ def generate_content_hash(content: str) -> str:
39
+ """Generate SHA-256 hash of content for caching.
40
+
41
+ Args:
42
+ content: The string content to hash
43
+
44
+ Returns:
45
+ SHA-256 hash prefixed with 'sha256:'
46
+ """
47
+ if content is None:
48
+ return None
49
+
50
+ return "sha256:" + hashlib.sha256(content.encode('utf-8')).hexdigest()
@@ -0,0 +1,45 @@
1
+ """Project state handlers - modular architecture.
2
+
3
+ This module serves as a compatibility layer that imports all the project state
4
+ handlers from the new modular structure. This ensures existing code continues
5
+ to work while providing access to the new architecture.
6
+
7
+ The original monolithic file has been broken down into a modular structure
8
+ located in the project_state/ subdirectory. All functionality, logging, and
9
+ documentation has been preserved while improving maintainability.
10
+
11
+ For detailed information about the new structure, see:
12
+ project_state/README.md
13
+ """
14
+
15
+ # Import everything from the modular structure for backward compatibility
16
+ from .project_state import *
17
+
18
+ # Ensure all handlers are available at module level for existing imports
19
+ from .project_state.handlers import (
20
+ ProjectStateFolderExpandHandler,
21
+ ProjectStateFolderCollapseHandler,
22
+ ProjectStateFileOpenHandler,
23
+ ProjectStateTabCloseHandler,
24
+ ProjectStateSetActiveTabHandler,
25
+ ProjectStateDiffOpenHandler,
26
+ ProjectStateDiffContentHandler,
27
+ ProjectStateGitStageHandler,
28
+ ProjectStateGitUnstageHandler,
29
+ ProjectStateGitRevertHandler,
30
+ ProjectStateGitCommitHandler,
31
+ handle_client_session_cleanup
32
+ )
33
+
34
+ from .project_state.manager import (
35
+ get_or_create_project_state_manager,
36
+ reset_global_project_state_manager,
37
+ debug_global_manager_state
38
+ )
39
+
40
+ from .project_state.utils import generate_tab_key
41
+
42
+ # Re-export with the old private function names for backward compatibility
43
+ _get_or_create_project_state_manager = get_or_create_project_state_manager
44
+ _reset_global_project_state_manager = reset_global_project_state_manager
45
+ _debug_global_manager_state = debug_global_manager_state
@@ -0,0 +1,307 @@
1
+ """Proxmox infrastructure configuration handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Iterable, List, Tuple
15
+
16
+ import platformdirs
17
+
18
+ from .base import SyncHandler
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
23
+ CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
24
+
25
+ DEFAULT_HOST = "localhost"
26
+ DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
27
+ DEFAULT_BRIDGE = "vmbr1"
28
+ SUBNET_CIDR = "10.10.0.1/24"
29
+ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
30
+ DHCP_START = "10.10.0.100"
31
+ DHCP_END = "10.10.0.200"
32
+ DNS_SERVER = "1.1.1.1"
33
+ IFACES_PATH = Path("/etc/network/interfaces")
34
+ SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
35
+ UNIT_DIR = Path("/etc/systemd/system")
36
+
37
+
38
+ def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
39
+ env = os.environ.copy()
40
+ env.setdefault("DEBIAN_FRONTEND", "noninteractive")
41
+ return subprocess.run(cmd, env=env, text=True, capture_output=True, **kwargs)
42
+
43
+
44
+ def _ensure_proxmoxer() -> Any:
45
+ try:
46
+ from proxmoxer import ProxmoxAPI # noqa: F401
47
+ except ModuleNotFoundError as exc:
48
+ python = sys.executable
49
+ logger.info("Proxmoxer missing; installing via pip")
50
+ try:
51
+ _call_subprocess([python, "-m", "pip", "install", "proxmoxer"], check=True)
52
+ except subprocess.CalledProcessError as pip_exc:
53
+ msg = pip_exc.stderr or pip_exc.stdout or str(pip_exc)
54
+ raise RuntimeError(f"Failed to install proxmoxer: {msg}") from pip_exc
55
+ from proxmoxer import ProxmoxAPI # noqa: F401
56
+ from proxmoxer import ProxmoxAPI
57
+ return ProxmoxAPI
58
+
59
+
60
+ def _parse_token(token_identifier: str) -> Tuple[str, str]:
61
+ identifier = token_identifier.strip()
62
+ if "!" not in identifier or "@" not in identifier:
63
+ raise ValueError("Expected API token in the form user@realm!tokenid")
64
+ user_part, token_name = identifier.split("!", 1)
65
+ user = user_part.strip()
66
+ token_name = token_name.strip()
67
+ if "@" not in user:
68
+ raise ValueError("API token missing user realm (user@realm)")
69
+ if not token_name:
70
+ raise ValueError("Token identifier missing token name")
71
+ return user, token_name
72
+
73
+
74
+ def _save_config(data: Dict[str, Any]) -> None:
75
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
76
+ tmp_path = CONFIG_PATH.with_suffix(".tmp")
77
+ tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
78
+ os.replace(tmp_path, CONFIG_PATH)
79
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
80
+
81
+
82
+ def _load_config() -> Dict[str, Any]:
83
+ if not CONFIG_PATH.exists():
84
+ return {}
85
+ try:
86
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
87
+ except json.JSONDecodeError as exc:
88
+ logger.warning("Failed to parse Proxmox infra config: %s", exc)
89
+ return {}
90
+
91
+
92
+ def _pick_node(client: Any) -> str:
93
+ nodes = client.nodes().get()
94
+ for node in nodes:
95
+ if node.get("node") == DEFAULT_NODE_NAME:
96
+ return DEFAULT_NODE_NAME
97
+ return nodes[0].get("node") if nodes else DEFAULT_NODE_NAME
98
+
99
+
100
+ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]]) -> List[str]:
101
+ templates: List[str] = []
102
+ for storage in storages:
103
+ storage_name = storage.get("storage")
104
+ if not storage_name:
105
+ continue
106
+ try:
107
+ items = client.nodes(node).storage(storage_name).content.get()
108
+ except Exception:
109
+ continue
110
+ for item in items:
111
+ if item.get("content") == "vztmpl" and item.get("volid"):
112
+ templates.append(item["volid"])
113
+ return templates
114
+
115
+
116
+ def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
117
+ candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
118
+ if not candidates:
119
+ candidates = [s for s in storages if "rootdir" in s.get("content", "")]
120
+ if not candidates:
121
+ return ""
122
+ candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
123
+ return candidates[0].get("storage", "")
124
+
125
+
126
+ def _write_bridge_config(bridge: str) -> None:
127
+ begin = f"# Portacode INFRA BEGIN {bridge}"
128
+ end = f"# Portacode INFRA END {bridge}"
129
+ current = IFACES_PATH.read_text(encoding="utf-8") if IFACES_PATH.exists() else ""
130
+ if begin in current:
131
+ return
132
+ block = f"""
133
+ {begin}
134
+ auto {bridge}
135
+ iface {bridge} inet static
136
+ address {SUBNET_CIDR}
137
+ bridge-ports none
138
+ bridge-stp off
139
+ bridge-fd 0
140
+ {end}
141
+
142
+ """
143
+ mode = "a" if IFACES_PATH.exists() else "w"
144
+ with open(IFACES_PATH, mode, encoding="utf-8") as fh:
145
+ if current and not current.endswith("\n"):
146
+ fh.write("\n")
147
+ fh.write(block)
148
+
149
+
150
+ def _ensure_sysctl() -> None:
151
+ SYSCTL_PATH.write_text("net.ipv4.ip_forward=1\n", encoding="utf-8")
152
+ _call_subprocess(["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"], check=True)
153
+
154
+
155
+ def _write_units(bridge: str) -> None:
156
+ nat_name = f"portacode-{bridge}-nat.service"
157
+ dns_name = f"portacode-{bridge}-dnsmasq.service"
158
+ nat = UNIT_DIR / nat_name
159
+ dns = UNIT_DIR / dns_name
160
+ nat.write_text(f"""[Unit]
161
+ Description=Portacode NAT for {bridge}
162
+ After=network-online.target
163
+ Wants=network-online.target
164
+
165
+ [Service]
166
+ Type=oneshot
167
+ RemainAfterExit=yes
168
+ ExecStart=/usr/sbin/iptables -t nat -A POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
169
+ ExecStart=/usr/sbin/iptables -A FORWARD -i {bridge} -o vmbr0 -j ACCEPT
170
+ ExecStart=/usr/sbin/iptables -A FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
171
+ ExecStop=/usr/sbin/iptables -t nat -D POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
172
+ ExecStop=/usr/sbin/iptables -D FORWARD -i {bridge} -o vmbr0 -j ACCEPT
173
+ ExecStop=/usr/sbin/iptables -D FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
174
+
175
+ [Install]
176
+ WantedBy=multi-user.target
177
+ """, encoding="utf-8")
178
+ dns.write_text(f"""[Unit]
179
+ Description=Portacode dnsmasq for {bridge}
180
+ After=network-online.target
181
+ Wants=network-online.target
182
+
183
+ [Service]
184
+ Type=simple
185
+ ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --interface={bridge} --bind-interfaces --listen-address={BRIDGE_IP} \
186
+ --port=0 --dhcp-range={DHCP_START},{DHCP_END},12h \
187
+ --dhcp-option=option:router,{BRIDGE_IP} \
188
+ --dhcp-option=option:dns-server,{DNS_SERVER} \
189
+ --conf-file=/dev/null --pid-file=/run/portacode_dnsmasq.pid --dhcp-leasefile=/var/lib/misc/portacode_dnsmasq.leases
190
+ Restart=always
191
+
192
+ [Install]
193
+ WantedBy=multi-user.target
194
+ """, encoding="utf-8")
195
+
196
+
197
+ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
198
+ if os.geteuid() != 0:
199
+ raise PermissionError("Bridge setup requires root privileges")
200
+ if not shutil.which("dnsmasq"):
201
+ apt = shutil.which("apt-get")
202
+ if not apt:
203
+ raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
204
+ _call_subprocess([apt, "update"], check=True)
205
+ _call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
206
+ _write_bridge_config(bridge)
207
+ _ensure_sysctl()
208
+ _write_units(bridge)
209
+ _call_subprocess(["/bin/systemctl", "daemon-reload"], check=True)
210
+ nat_service = f"portacode-{bridge}-nat.service"
211
+ dns_service = f"portacode-{bridge}-dnsmasq.service"
212
+ _call_subprocess(["/bin/systemctl", "enable", "--now", nat_service, dns_service], check=True)
213
+ _call_subprocess(["/sbin/ifup", bridge], check=False)
214
+ return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
215
+
216
+
217
+ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
218
+ if not config:
219
+ return {"configured": False}
220
+ network = config.get("network", {})
221
+ return {
222
+ "configured": True,
223
+ "host": config.get("host"),
224
+ "node": config.get("node"),
225
+ "user": config.get("user"),
226
+ "token_name": config.get("token_name"),
227
+ "default_storage": config.get("default_storage"),
228
+ "templates": config.get("templates") or [],
229
+ "last_verified": config.get("last_verified"),
230
+ "network": {
231
+ "applied": network.get("applied", False),
232
+ "message": network.get("message"),
233
+ "bridge": network.get("bridge", DEFAULT_BRIDGE),
234
+ },
235
+ }
236
+
237
+
238
+ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
239
+ ProxmoxAPI = _ensure_proxmoxer()
240
+ user, token_name = _parse_token(token_identifier)
241
+ client = ProxmoxAPI(
242
+ DEFAULT_HOST,
243
+ user=user,
244
+ token_name=token_name,
245
+ token_value=token_value,
246
+ verify_ssl=verify_ssl,
247
+ timeout=30,
248
+ )
249
+ node = _pick_node(client)
250
+ status = client.nodes(node).status.get()
251
+ storages = client.nodes(node).storage.get()
252
+ default_storage = _pick_storage(storages)
253
+ templates = _list_templates(client, node, storages)
254
+ network: Dict[str, Any] = {}
255
+ try:
256
+ network = _ensure_bridge()
257
+ except PermissionError as exc:
258
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
259
+ logger.warning("Bridge setup skipped: %s", exc)
260
+ except Exception as exc: # pragma: no cover - best effort
261
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
262
+ logger.warning("Bridge setup failed: %s", exc)
263
+ config = {
264
+ "host": DEFAULT_HOST,
265
+ "node": node,
266
+ "user": user,
267
+ "token_name": token_name,
268
+ "token_value": token_value,
269
+ "verify_ssl": verify_ssl,
270
+ "default_storage": default_storage,
271
+ "templates": templates,
272
+ "last_verified": datetime.utcnow().isoformat() + "Z",
273
+ "network": network,
274
+ "node_status": status,
275
+ }
276
+ _save_config(config)
277
+ snapshot = build_snapshot(config)
278
+ snapshot["node_status"] = status
279
+ return snapshot
280
+
281
+
282
+ def get_infra_snapshot() -> Dict[str, Any]:
283
+ config = _load_config()
284
+ snapshot = build_snapshot(config)
285
+ if config.get("node_status"):
286
+ snapshot["node_status"] = config["node_status"]
287
+ return snapshot
288
+
289
+
290
+ class ConfigureProxmoxInfraHandler(SyncHandler):
291
+ @property
292
+ def command_name(self) -> str:
293
+ return "setup_proxmox_infra"
294
+
295
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
296
+ token_identifier = message.get("token_identifier")
297
+ token_value = message.get("token_value")
298
+ verify_ssl = bool(message.get("verify_ssl"))
299
+ if not token_identifier or not token_value:
300
+ raise ValueError("token_identifier and token_value are required")
301
+ snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
302
+ return {
303
+ "event": "proxmox_infra_configured",
304
+ "success": True,
305
+ "message": "Proxmox infrastructure configured",
306
+ "infra": snapshot,
307
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
5
+ from portacode.utils.ntp_clock import ntp_clock
5
6
 
6
7
  if TYPE_CHECKING:
7
8
  from ..multiplex import Channel
@@ -72,32 +73,74 @@ class CommandRegistry:
72
73
 
73
74
  async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
74
75
  """Dispatch a command to its handler.
75
-
76
+
76
77
  Args:
77
78
  command_name: The command name
78
79
  message: The command message
79
80
  reply_channel: Optional reply channel
80
-
81
+
81
82
  Returns:
82
83
  True if handler was found and executed, False otherwise
83
84
  """
85
+ logger.info("registry: Dispatching command '%s' with reply_channel=%s", command_name, reply_channel)
86
+
87
+ # Add handler_receive timestamp if trace present
88
+ if "trace" in message and "request_id" in message:
89
+ handler_receive_time = ntp_clock.now_ms()
90
+ if handler_receive_time is not None:
91
+ message["trace"]["handler_receive"] = handler_receive_time
92
+ # Update ping to show total time from client_send
93
+ if "client_send" in message["trace"]:
94
+ message["trace"]["ping"] = handler_receive_time - message["trace"]["client_send"]
95
+ logger.info(f"🎯 Handler received traced message: {message['request_id']}")
96
+
84
97
  handler = self.get_handler(command_name)
85
98
  if handler is None:
86
- logger.warning("No handler found for command: %s", command_name)
99
+ logger.warning("registry: No handler found for command: %s", command_name)
87
100
  return False
88
-
101
+
89
102
  try:
90
103
  await handler.handle(message, reply_channel)
104
+ logger.info("registry: Successfully dispatched command '%s'", command_name)
91
105
  return True
92
106
  except Exception as exc:
93
- logger.exception("Error dispatching command %s: %s", command_name, exc)
94
- # Send error response
95
- error_payload = {"event": "error", "message": str(exc)}
96
- if reply_channel:
97
- error_payload["reply_channel"] = reply_channel
98
- await self.control_channel.send(error_payload)
107
+ logger.exception("registry: Error dispatching command %s: %s", command_name, exc)
108
+ # Send session-aware error response
109
+ await self._send_session_aware_error(str(exc), reply_channel, message.get("project_id"))
99
110
  return False
100
111
 
112
+ async def _send_session_aware_error(self, message: str, reply_channel: Optional[str] = None, project_id: str = None) -> None:
113
+ """Send an error response with client session awareness."""
114
+ error_payload = {"event": "error", "message": message}
115
+
116
+ # Get client session manager from context
117
+ client_session_manager = self.context.get("client_session_manager")
118
+
119
+ if client_session_manager and client_session_manager.has_interested_clients():
120
+ # Get target sessions
121
+ target_sessions = client_session_manager.get_target_sessions(project_id)
122
+ if not target_sessions:
123
+ logger.debug("registry: No target sessions found, skipping error send")
124
+ return
125
+
126
+ # Add session targeting information
127
+ error_payload["client_sessions"] = target_sessions
128
+
129
+ # Add backward compatibility reply_channel (first session if not provided)
130
+ if not reply_channel:
131
+ reply_channel = client_session_manager.get_reply_channel_for_compatibility()
132
+ if reply_channel:
133
+ error_payload["reply_channel"] = reply_channel
134
+
135
+ logger.debug("registry: Sending error to %d client sessions: %s",
136
+ len(target_sessions), target_sessions)
137
+ else:
138
+ # Fallback to original behavior
139
+ if reply_channel:
140
+ error_payload["reply_channel"] = reply_channel
141
+
142
+ await self.control_channel.send(error_payload)
143
+
101
144
  def update_context(self, context: Dict[str, Any]) -> None:
102
145
  """Update the shared context for all handlers.
103
146