portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  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 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  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/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,140 @@
1
+ """
2
+ Categorized logging system for Portacode CLI.
3
+
4
+ This module provides a logging system that allows filtering by categories
5
+ to help developers focus on specific aspects of the application during debugging.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Set, Optional
12
+ from enum import Enum
13
+
14
+
15
+ class LogCategory(Enum):
16
+ """Available log categories for filtering."""
17
+ CONNECTION = "connection"
18
+ AUTHENTICATION = "auth"
19
+ WEBSOCKET = "websocket"
20
+ TERMINAL = "terminal"
21
+ PROJECT_STATE = "project_state"
22
+ FILE_SYSTEM = "filesystem"
23
+ GIT = "git"
24
+ HANDLERS = "handlers"
25
+ MULTIPLEXER = "mux"
26
+ SYSTEM = "system"
27
+ DEBUG = "debug"
28
+
29
+
30
+ class CategorizedLogger:
31
+ """
32
+ A logger that supports category-based filtering.
33
+
34
+ This wraps the standard Python logger but adds category support
35
+ for fine-grained filtering during debugging.
36
+ """
37
+
38
+ def __init__(self, name: str, enabled_categories: Optional[Set[str]] = None):
39
+ self.logger = logging.getLogger(name)
40
+ self.enabled_categories = enabled_categories or set()
41
+ self._all_enabled = len(self.enabled_categories) == 0 # If no categories specified, show all
42
+
43
+ def _should_log(self, category: LogCategory) -> bool:
44
+ """Check if a log with this category should be output."""
45
+ if self._all_enabled:
46
+ return True
47
+ return category.value in self.enabled_categories
48
+
49
+ def _format_message(self, msg: str, category: LogCategory) -> str:
50
+ """Format message with category prefix."""
51
+ return f"[{category.value.upper()}] {msg}"
52
+
53
+ def _parse_args(self, *args) -> tuple[LogCategory, tuple]:
54
+ """Parse arguments to extract category and remaining args."""
55
+ if args and isinstance(args[0], LogCategory):
56
+ return args[0], args[1:]
57
+ else:
58
+ return LogCategory.DEBUG, args
59
+
60
+ def debug(self, msg: str, *args, **kwargs):
61
+ """Log debug message with optional category."""
62
+ category, remaining_args = self._parse_args(*args)
63
+ if self._should_log(category):
64
+ formatted_msg = self._format_message(msg, category)
65
+ self.logger.debug(formatted_msg, *remaining_args, **kwargs)
66
+
67
+ def info(self, msg: str, *args, **kwargs):
68
+ """Log info message with optional category."""
69
+ category, remaining_args = self._parse_args(*args)
70
+ if self._should_log(category):
71
+ formatted_msg = self._format_message(msg, category)
72
+ self.logger.info(formatted_msg, *remaining_args, **kwargs)
73
+
74
+ def warning(self, msg: str, *args, **kwargs):
75
+ """Log warning message with optional category."""
76
+ category, remaining_args = self._parse_args(*args)
77
+ if self._should_log(category):
78
+ formatted_msg = self._format_message(msg, category)
79
+ self.logger.warning(formatted_msg, *remaining_args, **kwargs)
80
+
81
+ def error(self, msg: str, *args, **kwargs):
82
+ """Log error message with optional category."""
83
+ category, remaining_args = self._parse_args(*args)
84
+ if self._should_log(category):
85
+ formatted_msg = self._format_message(msg, category)
86
+ self.logger.error(formatted_msg, *remaining_args, **kwargs)
87
+
88
+ def exception(self, msg: str, *args, **kwargs):
89
+ """Log exception message with optional category."""
90
+ category, remaining_args = self._parse_args(*args)
91
+ if self._should_log(category):
92
+ formatted_msg = self._format_message(msg, category)
93
+ self.logger.exception(formatted_msg, *remaining_args, **kwargs)
94
+
95
+
96
+ # Global registry of enabled categories
97
+ _enabled_categories: Set[str] = set()
98
+
99
+
100
+ def configure_logging_categories(categories: Set[str]) -> None:
101
+ """Configure which log categories should be enabled globally."""
102
+ global _enabled_categories
103
+ _enabled_categories = categories
104
+
105
+
106
+ def get_categorized_logger(name: str) -> CategorizedLogger:
107
+ """Get a categorized logger instance with current category settings."""
108
+ return CategorizedLogger(name, _enabled_categories)
109
+
110
+
111
+ def list_available_categories() -> list[str]:
112
+ """Return a list of all available log categories."""
113
+ return [category.value for category in LogCategory]
114
+
115
+
116
+ def parse_category_string(category_str: str) -> Set[str]:
117
+ """
118
+ Parse a comma-separated string of categories into a set.
119
+
120
+ Args:
121
+ category_str: Comma-separated category names (e.g., "connection,auth,git")
122
+
123
+ Returns:
124
+ Set of valid category names
125
+
126
+ Raises:
127
+ ValueError: If any category is invalid
128
+ """
129
+ if not category_str.strip():
130
+ return set()
131
+
132
+ categories = {cat.strip().lower() for cat in category_str.split(',')}
133
+ valid_categories = {cat.value for cat in LogCategory}
134
+
135
+ invalid = categories - valid_categories
136
+ if invalid:
137
+ raise ValueError(f"Invalid categories: {', '.join(invalid)}. "
138
+ f"Valid categories: {', '.join(sorted(valid_categories))}")
139
+
140
+ return categories
portacode/pairing.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import websockets
11
+
12
+ PAIRING_URL_ENV = "PORTACODE_PAIRING_URL"
13
+ DEFAULT_PAIRING_URL = "wss://portacode.com/ws/pairing/device/"
14
+
15
+
16
+ class PairingError(Exception):
17
+ """Raised when pairing fails or is rejected."""
18
+
19
+
20
+ @dataclass
21
+ class PairingResult:
22
+ status: str
23
+ payload: dict
24
+
25
+
26
+ async def _pair_device(
27
+ url: str,
28
+ public_key_b64: str,
29
+ pairing_code: str,
30
+ device_name: str,
31
+ project_paths: list[str] | None = None,
32
+ timeout: float = 300.0,
33
+ ) -> PairingResult:
34
+ async with websockets.connect(url) as ws:
35
+ # Initial ready/event
36
+ try:
37
+ ready = await asyncio.wait_for(ws.recv(), timeout=10)
38
+ except asyncio.TimeoutError as exc:
39
+ raise PairingError("Gateway did not acknowledge pairing request") from exc
40
+
41
+ try:
42
+ data = json.loads(ready)
43
+ if data.get("event") != "pairing_ready":
44
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}")
45
+ except json.JSONDecodeError:
46
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}") from None
47
+
48
+ payload = {
49
+ "code": pairing_code,
50
+ "device_name": device_name,
51
+ "public_key": public_key_b64,
52
+ }
53
+ if project_paths:
54
+ payload["project_paths"] = project_paths
55
+ await ws.send(json.dumps(payload))
56
+
57
+ while True:
58
+ try:
59
+ message = await asyncio.wait_for(ws.recv(), timeout=timeout)
60
+ except asyncio.TimeoutError as exc:
61
+ raise PairingError("Pairing timed out waiting for approval") from exc
62
+
63
+ try:
64
+ data = json.loads(message)
65
+ except json.JSONDecodeError:
66
+ continue
67
+
68
+ if data.get("event") != "pairing_status":
69
+ continue
70
+
71
+ status = data.get("status")
72
+ if status == "pending":
73
+ continue
74
+ if status == "approved":
75
+ return PairingResult(status="approved", payload=data)
76
+ reason = data.get("reason") or status or "unknown_error"
77
+ raise PairingError(reason)
78
+
79
+
80
+ def pair_device_with_code(
81
+ keypair,
82
+ pairing_code: str,
83
+ device_name: Optional[str] = None,
84
+ project_paths: list[str] | None = None,
85
+ *,
86
+ timeout: float = 300.0,
87
+ ) -> PairingResult:
88
+ """Run the pairing workflow synchronously."""
89
+ pairing_url = os.getenv(PAIRING_URL_ENV, DEFAULT_PAIRING_URL)
90
+ normalized_url = pairing_url if pairing_url.startswith("ws") else f"wss://{pairing_url.lstrip('/')}"
91
+ friendly_name = device_name or socket.gethostname() or "Portacode Device"
92
+
93
+ public_key_b64 = keypair.public_key_der_b64()
94
+ return asyncio.run(
95
+ _pair_device(
96
+ normalized_url,
97
+ public_key_b64=public_key_b64,
98
+ pairing_code=pairing_code,
99
+ device_name=friendly_name,
100
+ project_paths=project_paths,
101
+ timeout=timeout,
102
+ )
103
+ )
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>NTP Clock Test</title>
5
+ <style>
6
+ body {
7
+ font-family: monospace;
8
+ padding: 20px;
9
+ background: #1e1e1e;
10
+ color: #d4d4d4;
11
+ }
12
+ h1 {
13
+ color: #4ec9b0;
14
+ }
15
+ #status {
16
+ background: #252526;
17
+ padding: 15px;
18
+ border-radius: 5px;
19
+ border: 1px solid #3e3e42;
20
+ }
21
+ .synced {
22
+ color: #4ec9b0;
23
+ }
24
+ .not-synced {
25
+ color: #f48771;
26
+ }
27
+ .label {
28
+ color: #9cdcfe;
29
+ font-weight: bold;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <h1>NTP Clock Test - time.cloudflare.com</h1>
35
+ <div id="status"></div>
36
+ <script type="module">
37
+ import ntpClock from './utils/ntp-clock.js';
38
+
39
+ function updateDisplay() {
40
+ const status = ntpClock.getStatus();
41
+ const syncClass = status.isSynced ? 'synced' : 'not-synced';
42
+
43
+ document.getElementById('status').innerHTML = `
44
+ <p><span class="label">Sync Status:</span> <span class="${syncClass}">${status.isSynced ? '✅ SYNCED' : '❌ NOT SYNCED'}</span></p>
45
+ <p><span class="label">NTP Server:</span> ${status.server}</p>
46
+ <p><span class="label">NTP Time:</span> ${ntpClock.nowISO() || 'null (not synced)'}</p>
47
+ <p><span class="label">Local Time:</span> ${new Date().toISOString()}</p>
48
+ <p><span class="label">Offset:</span> ${status.offset !== null ? status.offset.toFixed(2) + 'ms' : 'null'}</p>
49
+ <p><span class="label">Last Sync:</span> ${status.lastSync || 'Never'}</p>
50
+ <p><span class="label">Time Since Sync:</span> ${status.timeSinceSync ? (status.timeSinceSync / 1000).toFixed(0) + 's' : 'N/A'}</p>
51
+ `;
52
+ }
53
+
54
+ // Update display every 100ms
55
+ setInterval(updateDisplay, 100);
56
+
57
+ // Log status to console every 5 seconds
58
+ setInterval(() => {
59
+ console.log('NTP Clock Status:', ntpClock.getStatus());
60
+ }, 5000);
61
+ </script>
62
+ </body>
63
+ </html>
@@ -0,0 +1,232 @@
1
+ /**
2
+ * NTP Clock - Synchronized time source for distributed tracing
3
+ *
4
+ * Provides synchronization by requesting the server clock across the WebSocket
5
+ * channel defined in `WEBSOCKET_PROTOCOL`. The client measures round-trip time
6
+ * and applies an offset that compensates for latency before exposing timestamps.
7
+ */
8
+ class NTPClock {
9
+ constructor() {
10
+ this.ntpServer = 'portacode-gateway';
11
+ this.offset = null;
12
+ this.lastSync = null;
13
+ this.lastLatencyMs = null;
14
+ this.syncInterval = 60 * 1000;
15
+ this.clockSyncTimeout = 20 * 1000;
16
+ this.maxSyncFailures = 3;
17
+
18
+ this._clockSyncSender = null;
19
+ this._failureCallback = null;
20
+ this._clockSyncTimer = null;
21
+ this._clockSyncTimeoutHandle = null;
22
+ this._pendingRequest = null;
23
+ this._autoSyncStarted = false;
24
+ this._clockSyncFailures = 0;
25
+ }
26
+
27
+ /**
28
+ * Register the function responsible for piping `clock_sync_request`
29
+ * packets over the dashboard WebSocket.
30
+ */
31
+ setClockSyncSender(sender) {
32
+ this._clockSyncSender = sender;
33
+ if (this._autoSyncStarted && !this._pendingRequest) {
34
+ this._scheduleSync(0);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Callback invoked when multiple sync failures occur in a row.
40
+ * Useful for triggering a transport reconnect.
41
+ */
42
+ onClockSyncFailure(callback) {
43
+ this._failureCallback = callback;
44
+ }
45
+
46
+ /**
47
+ * Start the auto-sync loop (idempotent). Will skip sending until a sender
48
+ * has been registered.
49
+ */
50
+ startAutoSync() {
51
+ if (this._autoSyncStarted) {
52
+ return;
53
+ }
54
+ this._autoSyncStarted = true;
55
+ this._scheduleSync(0);
56
+ }
57
+
58
+ /**
59
+ * Stop the auto-sync loop (used primarily in tests).
60
+ */
61
+ stopAutoSync() {
62
+ this._autoSyncStarted = false;
63
+ if (this._clockSyncTimer) {
64
+ clearTimeout(this._clockSyncTimer);
65
+ this._clockSyncTimer = null;
66
+ }
67
+ this._clearPendingRequest();
68
+ }
69
+
70
+ sync() {
71
+ return this._performClockSync();
72
+ }
73
+
74
+ /**
75
+ * Get current NTP-synchronized timestamp in milliseconds since epoch
76
+ * Returns null if not synced
77
+ */
78
+ now() {
79
+ if (this.offset === null) {
80
+ return null;
81
+ }
82
+ return Date.now() + this.offset;
83
+ }
84
+
85
+ /**
86
+ * Get current NTP-synchronized timestamp in ISO format
87
+ * Returns null if not synced
88
+ */
89
+ nowISO() {
90
+ const ts = this.now();
91
+ if (ts === null) {
92
+ return null;
93
+ }
94
+ return new Date(ts).toISOString();
95
+ }
96
+
97
+ handleServerSync(payload) {
98
+ if (!payload) {
99
+ return;
100
+ }
101
+ const receiveTime = Date.now();
102
+ let roundTrip = 0;
103
+ if (
104
+ payload.request_id &&
105
+ this._pendingRequest &&
106
+ payload.request_id === this._pendingRequest.requestId
107
+ ) {
108
+ roundTrip = Math.max(receiveTime - this._pendingRequest.sentAt, 0);
109
+ this._clockSyncFailures = 0;
110
+ this._clearPendingRequest();
111
+ }
112
+ const serverSend = typeof payload.server_send_time === 'number' ? payload.server_send_time : payload.server_time;
113
+ const serverReceive = payload.server_receive_time;
114
+ const serverAvg = (typeof serverReceive === 'number' && typeof serverSend === 'number')
115
+ ? (serverReceive + serverSend) / 2
116
+ : typeof serverSend === 'number'
117
+ ? serverSend
118
+ : undefined;
119
+ if (typeof serverAvg === 'number') {
120
+ this.updateFromServer(serverAvg, roundTrip);
121
+ }
122
+ if (payload.server_time_iso) {
123
+ this.serverTimeIso = payload.server_time_iso;
124
+ }
125
+ this._scheduleSync(this.syncInterval);
126
+ }
127
+
128
+ applyServerSync(payload) {
129
+ this.handleServerSync(payload);
130
+ }
131
+
132
+ updateFromServer(serverTimeMs, roundTripMs = 0) {
133
+ const receiveTime = Date.now();
134
+ const halfLatency = (roundTripMs || 0) / 2;
135
+ const estimatedServerReceived = receiveTime - halfLatency;
136
+ this.offset = serverTimeMs - estimatedServerReceived;
137
+ this.lastLatencyMs = roundTripMs;
138
+ this.lastSync = receiveTime;
139
+ }
140
+
141
+ getStatus() {
142
+ return {
143
+ server: this.ntpServer,
144
+ offset: this.offset,
145
+ lastSync: this.lastSync ? new Date(this.lastSync).toISOString() : null,
146
+ lastLatencyMs: this.lastLatencyMs,
147
+ timeSinceSync: this.lastSync ? Date.now() - this.lastSync : null,
148
+ isSynced: this.offset !== null
149
+ };
150
+ }
151
+
152
+ _scheduleSync(delay) {
153
+ if (!this._autoSyncStarted) {
154
+ return;
155
+ }
156
+ if (this._clockSyncTimer) {
157
+ clearTimeout(this._clockSyncTimer);
158
+ }
159
+ this._clockSyncTimer = setTimeout(() => this._performClockSync(), delay);
160
+ }
161
+
162
+ _performClockSync() {
163
+ if (!this._autoSyncStarted) {
164
+ return false;
165
+ }
166
+ if (!this._clockSyncSender) {
167
+ this._scheduleSync(Math.min(this.syncInterval, 3000));
168
+ return false;
169
+ }
170
+ if (this._pendingRequest) {
171
+ return false;
172
+ }
173
+ const requestId = this._generateRequestId();
174
+ const payload = {
175
+ event: 'clock_sync_request',
176
+ request_id: requestId,
177
+ };
178
+ this._pendingRequest = {
179
+ requestId,
180
+ sentAt: Date.now(),
181
+ };
182
+ const sent = this._clockSyncSender(payload);
183
+ if (!sent) {
184
+ this._handleClockSyncFailure();
185
+ return false;
186
+ }
187
+ this._clockSyncTimeoutHandle = setTimeout(() => this._handleClockSyncTimeout(), this.clockSyncTimeout);
188
+ return true;
189
+ }
190
+
191
+ _handleClockSyncTimeout() {
192
+ this._clockSyncFailures += 1;
193
+ this._clearPendingRequest();
194
+ if (
195
+ this._clockSyncFailures >= this.maxSyncFailures &&
196
+ typeof this._failureCallback === 'function'
197
+ ) {
198
+ this._failureCallback();
199
+ }
200
+ this._scheduleSync(this.syncInterval);
201
+ }
202
+
203
+ _handleClockSyncFailure() {
204
+ this._clockSyncFailures += 1;
205
+ this._clearPendingRequest();
206
+ if (
207
+ this._clockSyncFailures >= this.maxSyncFailures &&
208
+ typeof this._failureCallback === 'function'
209
+ ) {
210
+ this._failureCallback();
211
+ }
212
+ this._scheduleSync(this.syncInterval);
213
+ }
214
+
215
+ _clearPendingRequest() {
216
+ if (this._clockSyncTimeoutHandle) {
217
+ clearTimeout(this._clockSyncTimeoutHandle);
218
+ this._clockSyncTimeoutHandle = null;
219
+ }
220
+ this._pendingRequest = null;
221
+ }
222
+
223
+ _generateRequestId() {
224
+ return `clock_sync:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
225
+ }
226
+ }
227
+
228
+ // Global instance - auto-starts sync
229
+ const ntpClock = new NTPClock();
230
+ ntpClock.startAutoSync();
231
+
232
+ export default ntpClock;
@@ -0,0 +1,136 @@
1
+ # NTP Clock Architecture
2
+
3
+ ## Overview
4
+
5
+ All entities (client, server, device) synchronize to **time.cloudflare.com** for distributed tracing.
6
+
7
+ ## Architecture: Single Package for Everything
8
+
9
+ All NTP clock implementations (Python and JavaScript) are in the **portacode package** to ensure DRY principles.
10
+
11
+ ## Python Implementation
12
+
13
+ **Location:** `portacode/utils/ntp_clock.py` (in portacode package)
14
+
15
+ ### Import Path
16
+ ```python
17
+ from portacode.utils.ntp_clock import ntp_clock
18
+ ```
19
+
20
+ ### Usage Locations
21
+ 1. **Django Server Consumers** (`server/portacode_django/dashboard/consumers.py`)
22
+ 2. **Device Base Handlers** (`portacode/connection/handlers/base.py`)
23
+ 3. **Device Client** (`server/portacode_django/data/services/device_client.py`)
24
+ 4. **Any Python code with portacode installed**
25
+
26
+ ### Dependencies
27
+ - `setup.py`: Added `ntplib>=0.4.0` to `install_requires`
28
+ - `server/portacode_django/requirements.txt`: Added `portacode>=1.3.26`
29
+
30
+ ### API
31
+ ```python
32
+ # Get NTP-synchronized timestamp (None if not synced)
33
+ ntp_clock.now_ms() # milliseconds
34
+ ntp_clock.now() # seconds
35
+ ntp_clock.now_iso() # ISO format
36
+
37
+ # Check sync status
38
+ status = ntp_clock.get_status()
39
+ # {
40
+ # 'server': 'time.cloudflare.com',
41
+ # 'offset_ms': 6.04,
42
+ # 'last_sync': '2025-10-05T04:37:12.768445+00:00',
43
+ # 'is_synced': True
44
+ # }
45
+ ```
46
+
47
+ ## JavaScript Implementation
48
+
49
+ **Location:** `portacode/static/js/utils/ntp-clock.js` (in portacode package)
50
+
51
+ ### Django Setup
52
+
53
+ Django will serve static files from the portacode package automatically after `collectstatic`:
54
+
55
+ ```python
56
+ # Django settings.py - no changes needed, just ensure:
57
+ INSTALLED_APPS = [
58
+ # ... other apps
59
+ 'portacode', # Add portacode as an installed app (optional, for admin integration)
60
+ ]
61
+
62
+ # Static files will be collected from portacode package
63
+ STATIC_URL = '/static/'
64
+ ```
65
+
66
+ After installing portacode (`pip install portacode` or `pip install -e .`), run:
67
+ ```bash
68
+ python manage.py collectstatic
69
+ ```
70
+
71
+ This will copy `portacode/static/js/utils/ntp-clock.js` to Django's static files directory.
72
+
73
+ ### Import Path (in Django templates/JS)
74
+ ```javascript
75
+ import ntpClock from '/static/js/utils/ntp-clock.js';
76
+ // or relative to your JS file:
77
+ import ntpClock from './utils/ntp-clock.js';
78
+ ```
79
+
80
+ ### Usage Locations
81
+ 1. **Dashboard WebSocket** (`websocket-service.js`)
82
+ 2. **Project WebSocket** (`websocket-service-project.js`)
83
+
84
+ ### API
85
+ ```javascript
86
+ // Get NTP-synchronized timestamp (null if not synced)
87
+ ntpClock.now() // milliseconds
88
+ ntpClock.nowISO() // ISO format
89
+
90
+ // Check sync status
91
+ const status = ntpClock.getStatus();
92
+ // {
93
+ // server: 'time.cloudflare.com',
94
+ // offset: 6.04,
95
+ // lastSync: '2025-10-05T04:37:12.768445+00:00',
96
+ // isSynced: true
97
+ // }
98
+ ```
99
+
100
+ ## Design Principles
101
+
102
+ 1. **DRY (Don't Repeat Yourself)**
103
+ - **Python:** Single implementation in portacode package (`portacode/utils/ntp_clock.py`)
104
+ - **JavaScript:** Single implementation in portacode package (`portacode/static/js/utils/ntp-clock.js`)
105
+ - Both served from the same package, no duplication across repos
106
+
107
+ 2. **No Fallback Servers**
108
+ - All entities MUST sync to time.cloudflare.com
109
+ - If sync fails, timestamps are None/null
110
+ - Ensures all timestamps are comparable
111
+
112
+ 3. **Auto-Sync**
113
+ - Re-syncs every 5 minutes automatically
114
+ - Initial sync on import/load
115
+ - Max 3 retry attempts before marking as failed
116
+
117
+ 4. **Thread-Safe (Python)**
118
+ - Uses threading.Lock for concurrent access
119
+ - Background daemon thread for periodic sync
120
+
121
+ ## Testing
122
+
123
+ ### Python
124
+ ```bash
125
+ python tools/test_python_ntp_clock.py
126
+ ```
127
+
128
+ ### JavaScript
129
+ The test file is included in the package at `portacode/static/js/test-ntp-clock.html`.
130
+
131
+ After Django collectstatic, open: `/static/js/test-ntp-clock.html` in browser
132
+
133
+ Or run directly from package:
134
+ ```bash
135
+ python -c "import portacode, os; print(os.path.join(os.path.dirname(portacode.__file__), 'static/js/test-ntp-clock.html'))"
136
+ ```
@@ -0,0 +1 @@
1
+ """Portacode utility modules."""