portacode 0.3.11.dev4__tar.gz → 0.3.12.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- portacode-0.3.12.dev0/.claude/settings.local.json +8 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/PKG-INFO +1 -1
- portacode-0.3.12.dev0/backup.sh +42 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/docker-compose.yaml +12 -4
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/_version.py +2 -2
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +9 -101
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/base.py +6 -57
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/terminal.py +95 -2
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/PKG-INFO +1 -1
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/SOURCES.txt +3 -0
- portacode-0.3.12.dev0/restore.sh +56 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/.gitignore +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/.gitmodules +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/LICENSE +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/MANIFEST.in +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/Makefile +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/README.md +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/README.md +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/__init__.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/__main__.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/cli.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/README.md +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/__init__.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/client.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/README.md +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/__init__.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/file_handlers.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/registry.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/session.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/system_handlers.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/terminal_handlers.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/multiplex.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/data.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/keypair.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/service.py +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/dependency_links.txt +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/entry_points.txt +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/requires.txt +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/top_level.txt +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/pyproject.toml +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/setup.cfg +0 -0
- {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/setup.py +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# backup.sh
|
|
2
|
+
#!/usr/bin/env bash
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
# ensure we’re in project root
|
|
6
|
+
cd "$(dirname "$0")"
|
|
7
|
+
|
|
8
|
+
# config
|
|
9
|
+
BACKUP_DIR="${1:-$PWD/../backups}"
|
|
10
|
+
VOLUME_NAME="portacode_pgdata"
|
|
11
|
+
SERVICE_NAME="db"
|
|
12
|
+
|
|
13
|
+
mkdir -p "$BACKUP_DIR"
|
|
14
|
+
|
|
15
|
+
# check & stop DB for a consistent dump
|
|
16
|
+
if [ "$(docker inspect -f '{{.State.Running}}' portacode-db 2>/dev/null || echo false)" = "true" ]; then
|
|
17
|
+
echo "🛑 Stopping $SERVICE_NAME..."
|
|
18
|
+
docker-compose stop "$SERVICE_NAME"
|
|
19
|
+
RESTART_DB=true
|
|
20
|
+
else
|
|
21
|
+
RESTART_DB=false
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# create backup
|
|
25
|
+
TS=$(date +%F_%H-%M-%S)
|
|
26
|
+
FILE="pgdata-$TS.tar.gz"
|
|
27
|
+
echo "📦 Backing up volume to $BACKUP_DIR/$FILE..."
|
|
28
|
+
docker run --rm \
|
|
29
|
+
-v "${VOLUME_NAME}":/data:ro \
|
|
30
|
+
-v "${BACKUP_DIR}":/backup \
|
|
31
|
+
alpine \
|
|
32
|
+
sh -c "cd /data && tar czf /backup/${FILE} ."
|
|
33
|
+
|
|
34
|
+
echo "✅ Backup complete."
|
|
35
|
+
|
|
36
|
+
# restart DB if we stopped it
|
|
37
|
+
if [ "$RESTART_DB" = true ]; then
|
|
38
|
+
echo "🔄 Starting $SERVICE_NAME..."
|
|
39
|
+
docker-compose start "$SERVICE_NAME"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
echo "🎉 Done."
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
version: "3.9"
|
|
2
1
|
services:
|
|
3
2
|
db:
|
|
4
3
|
image: postgres:15
|
|
@@ -10,6 +9,11 @@ services:
|
|
|
10
9
|
- pgdata:/var/lib/postgresql/data
|
|
11
10
|
ports:
|
|
12
11
|
- "5432:5432"
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD-SHELL", "pg_isready -U portacode -d portacode"]
|
|
14
|
+
interval: 5s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 5
|
|
13
17
|
|
|
14
18
|
django:
|
|
15
19
|
build:
|
|
@@ -17,11 +21,12 @@ services:
|
|
|
17
21
|
dockerfile: Dockerfile
|
|
18
22
|
container_name: portacode-django
|
|
19
23
|
restart: unless-stopped
|
|
20
|
-
command: ["bash", "-c", "python manage.py collectstatic --noinput && daphne portacode_django.asgi:application -b 0.0.0.0 -p 8001"]
|
|
24
|
+
command: ["bash", "-c", "python manage.py migrate && python manage.py collectstatic --noinput && daphne portacode_django.asgi:application -b 0.0.0.0 -p 8001"]
|
|
21
25
|
env_file:
|
|
22
26
|
- main.env
|
|
23
27
|
depends_on:
|
|
24
|
-
|
|
28
|
+
db:
|
|
29
|
+
condition: service_healthy
|
|
25
30
|
ports:
|
|
26
31
|
- "8001:8001"
|
|
27
32
|
volumes:
|
|
@@ -36,7 +41,10 @@ services:
|
|
|
36
41
|
env_file:
|
|
37
42
|
- main.env
|
|
38
43
|
depends_on:
|
|
39
|
-
|
|
44
|
+
db:
|
|
45
|
+
condition: service_healthy
|
|
46
|
+
redis:
|
|
47
|
+
condition: service_started
|
|
40
48
|
ports:
|
|
41
49
|
- "8000:8000"
|
|
42
50
|
volumes:
|
|
@@ -17,5 +17,5 @@ __version__: str
|
|
|
17
17
|
__version_tuple__: VERSION_TUPLE
|
|
18
18
|
version_tuple: VERSION_TUPLE
|
|
19
19
|
|
|
20
|
-
__version__ = version = '0.3.
|
|
21
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
20
|
+
__version__ = version = '0.3.12.dev'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 3, 12, 'dev0')
|
{portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md
RENAMED
|
@@ -2,26 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
This document outlines the WebSocket communication protocol between the Portacode server and the connected client devices.
|
|
4
4
|
|
|
5
|
-
## Protocol Architecture
|
|
6
|
-
|
|
7
|
-
This protocol uses a **unified message structure** throughout the entire system. No message transformations occur between layers, ensuring consistency and eliminating complexity.
|
|
8
|
-
|
|
9
5
|
## Raw Message Format
|
|
10
6
|
|
|
11
|
-
All communication over the WebSocket is managed by a [multiplexer](./multiplex.py) that wraps every message in a JSON object with a `
|
|
7
|
+
All communication over the WebSocket is managed by a [multiplexer](./multiplex.py) that wraps every message in a JSON object with a `channel` and a `payload`. This allows for multiple virtual communication channels over a single connection.
|
|
12
8
|
|
|
13
9
|
**Raw Message Structure:**
|
|
14
10
|
|
|
15
11
|
```json
|
|
16
12
|
{
|
|
17
|
-
"
|
|
13
|
+
"channel": "<channel_id>",
|
|
18
14
|
"payload": {
|
|
19
15
|
// This is where the Action or Event object goes
|
|
20
16
|
}
|
|
21
17
|
}
|
|
22
18
|
```
|
|
23
19
|
|
|
24
|
-
* `
|
|
20
|
+
* `channel` (string|integer, mandatory): Identifies the virtual channel the message is for. When sending control commands to the device, they should be sent to channel 0 and when the device responsed to such control commands or sends system events, they will also be send on the zero channel. When a terminal session is created in the device, it is assigned a uuid, the uuid becomes the channel for communicating to that specific terminal.
|
|
25
21
|
* `payload` (object, mandatory): The content of the message, which will be either an [Action](#actions) or an [Event](#events) object.
|
|
26
22
|
|
|
27
23
|
---
|
|
@@ -39,23 +35,13 @@ Actions are messages sent from the server to the device, placed within the `payl
|
|
|
39
35
|
"arg1": "value1",
|
|
40
36
|
"...": "..."
|
|
41
37
|
},
|
|
42
|
-
"
|
|
38
|
+
"reply_channel": "<channel_name>"
|
|
43
39
|
}
|
|
44
40
|
```
|
|
45
41
|
|
|
46
42
|
* `command` (string, mandatory): The name of the action to be executed (e.g., `terminal_start`).
|
|
47
43
|
* `payload` (object, mandatory): An object containing the specific arguments for the action.
|
|
48
|
-
* `
|
|
49
|
-
|
|
50
|
-
### Session Management and Event Subscriptions
|
|
51
|
-
|
|
52
|
-
**Client Session Tracking**: Devices maintain awareness of active client sessions through explicit session management commands. The server notifies devices when client sessions connect, disconnect, or change their event subscriptions.
|
|
53
|
-
|
|
54
|
-
**Mandatory Client Channel**: All commands sent to devices MUST include a `client_channel` to enable proper session tracking and response routing.
|
|
55
|
-
|
|
56
|
-
**Event Subscription System**: Client sessions can specify which events they want to receive through subscription management. This allows devices to filter events and reduce bandwidth usage by only sending relevant updates to interested clients.
|
|
57
|
-
|
|
58
|
-
---
|
|
44
|
+
* `reply_channel` (string, optional): A channel name that the device will echo back in its response. The difference between this field and the `channel` field handled in the device connection [multiplexer](./multiplex.py), is simply that `channel` defines which application in the device is handeling this communication, while `reply_channel` defines which client session on the server side is communicating with the device. That's why the [multiplexer](./multiplex.py) for `channel` is here, while the multiplexer for the `reply_channel` is on the other side of the conversation. A lot like the source port number and destination port number in TCP/IP.
|
|
59
45
|
|
|
60
46
|
### `terminal_start`
|
|
61
47
|
|
|
@@ -191,41 +177,6 @@ Deletes a file or directory. Handled by [`file_delete`](./file_handlers.py).
|
|
|
191
177
|
* On success, the device will respond with a [`file_delete_response`](#file_delete_response) event.
|
|
192
178
|
* On error, a generic [`error`](#error) event is sent.
|
|
193
179
|
|
|
194
|
-
### `session_subscribe`
|
|
195
|
-
|
|
196
|
-
Manages client session subscriptions and notifies the device about active sessions and their event interests. The device stores subscription filters for each active session and uses them to determine which sessions should receive each event.
|
|
197
|
-
|
|
198
|
-
**Payload Fields:**
|
|
199
|
-
|
|
200
|
-
* `client_channel` (string, mandatory): The session identifier being managed.
|
|
201
|
-
* `action` (string, mandatory): The subscription action - "connect", "disconnect", or "update".
|
|
202
|
-
* `filters` (array, optional): Array of filter objects the client is interested in. Required for "connect" and "update" actions.
|
|
203
|
-
|
|
204
|
-
**Filter Object Format:**
|
|
205
|
-
Each filter is a flexible object that can match against any field in event payloads:
|
|
206
|
-
* **Field Matching**: Any field name with string value or "*" for wildcard
|
|
207
|
-
* **Pattern Matching**: String values support wildcards (e.g., "terminal_*")
|
|
208
|
-
* **Multiple Conditions**: All specified fields must match for filter to match
|
|
209
|
-
|
|
210
|
-
**Example Filter Objects:**
|
|
211
|
-
```json
|
|
212
|
-
[
|
|
213
|
-
{"event": "terminal_data", "terminal_id": "uuid-123"},
|
|
214
|
-
{"event": "terminal_*", "project_id": "project-456"},
|
|
215
|
-
{"event": "system_info"},
|
|
216
|
-
{"event": "*", "project_id": "project-789"},
|
|
217
|
-
{"event": "file_*", "path": "/home/user/*"}
|
|
218
|
-
]
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
**Processing Architecture:**
|
|
222
|
-
The device maintains a subscription registry mapping each `client_channel` to its filter objects. When broadcasting events, the device evaluates each active session's filters against the event payload to determine which sessions should receive the event.
|
|
223
|
-
|
|
224
|
-
**Responses:**
|
|
225
|
-
|
|
226
|
-
* On success, the device will respond with a [`session_subscribe_ack`](#session_subscribe_ack) event.
|
|
227
|
-
* On error, a generic [`error`](#error) event is sent.
|
|
228
|
-
|
|
229
180
|
---
|
|
230
181
|
|
|
231
182
|
## Events
|
|
@@ -238,35 +189,12 @@ Events are messages sent from the device to the server, placed within the `paylo
|
|
|
238
189
|
{
|
|
239
190
|
"event": "<event_name>",
|
|
240
191
|
// Event-specific fields...
|
|
241
|
-
"
|
|
192
|
+
"reply_channel": "<channel_name>"
|
|
242
193
|
}
|
|
243
194
|
```
|
|
244
195
|
|
|
245
196
|
* `event` (string, mandatory): The name of the event being sent (e.g., `terminal_started`).
|
|
246
|
-
* <a name="
|
|
247
|
-
|
|
248
|
-
### Event Broadcasting and Session Awareness
|
|
249
|
-
|
|
250
|
-
**Targeted Events**: Events that are direct responses to commands include the original `client_channel` in the `client_channels` array, ensuring they are routed only to the requesting session.
|
|
251
|
-
|
|
252
|
-
**Broadcast Events**: Events that are not direct responses (such as spontaneous terminal exits or system notifications) are processed through subscription filtering. The device evaluates the event payload against each active session's filter objects to determine which sessions should receive the event.
|
|
253
|
-
|
|
254
|
-
**Subscription Filtering Process**:
|
|
255
|
-
1. Device generates an event with its payload fields
|
|
256
|
-
2. For each active client session, device evaluates session's filter objects against event payload
|
|
257
|
-
3. If any filter matches, the session's `client_channel` is added to the `client_channels` array
|
|
258
|
-
4. Event is sent once with all matching `client_channels`, minimizing network traffic
|
|
259
|
-
5. Server routes the single event to all specified client sessions
|
|
260
|
-
|
|
261
|
-
**Filter Matching**: A filter matches an event if all specified fields in the filter have corresponding fields in the event payload with matching values (supporting wildcards like `terminal_*` or `*`).
|
|
262
|
-
|
|
263
|
-
**Efficiency Benefits**:
|
|
264
|
-
- **Single Event Transmission**: Each event is sent once from device to server, regardless of how many client sessions need it
|
|
265
|
-
- **Flexible Filtering**: Clients can filter on any event field without protocol changes
|
|
266
|
-
- **Minimal Bandwidth**: Only events with at least one interested client session are transmitted
|
|
267
|
-
- **Simple Routing**: Server receives pre-filtered events with target client sessions already identified
|
|
268
|
-
|
|
269
|
-
---
|
|
197
|
+
* <a name="reply_channel"></a>`reply_channel` (string, optional): If the event is a direct response to an action that included a `reply_channel`, this field will contain the same value. This allows the server to correlate responses with their original requests.
|
|
270
198
|
|
|
271
199
|
### <a name="error"></a>`error`
|
|
272
200
|
|
|
@@ -276,16 +204,6 @@ A generic event sent when an error occurs during the execution of an action.
|
|
|
276
204
|
|
|
277
205
|
* `message` (string, mandatory): A description of the error that occurred.
|
|
278
206
|
|
|
279
|
-
### <a name="session_subscribe_ack"></a>`session_subscribe_ack`
|
|
280
|
-
|
|
281
|
-
Acknowledges the receipt and processing of a `session_subscribe` action. This confirms that the device has updated its session registry and subscription filters.
|
|
282
|
-
|
|
283
|
-
**Event Fields:**
|
|
284
|
-
|
|
285
|
-
* `action` (string, mandatory): The subscription action that was processed ("connect", "disconnect", or "update").
|
|
286
|
-
* `active_sessions` (integer, mandatory): The total number of currently active client sessions.
|
|
287
|
-
* `filter_count` (integer, mandatory): The number of filter objects registered for the managed session (0 for disconnect action).
|
|
288
|
-
|
|
289
207
|
### <a name="terminal_started"></a>`terminal_started`
|
|
290
208
|
|
|
291
209
|
Confirms that a new terminal session has been successfully started. Triggered by a `terminal_start` action. Handled by [`terminal_start`](./terminal_handlers.py).
|
|
@@ -293,7 +211,7 @@ Confirms that a new terminal session has been successfully started. Triggered by
|
|
|
293
211
|
**Event Fields:**
|
|
294
212
|
|
|
295
213
|
* `terminal_id` (string, mandatory): The unique ID of the newly created terminal session.
|
|
296
|
-
* `
|
|
214
|
+
* `channel` (string, mandatory): The channel name for terminal I/O.
|
|
297
215
|
* `project_id` (string, optional): The project ID associated with the terminal.
|
|
298
216
|
|
|
299
217
|
### <a name="terminal_exit"></a>`terminal_exit`
|
|
@@ -410,14 +328,4 @@ Confirms that a file or directory has been deleted in response to a `file_delete
|
|
|
410
328
|
|
|
411
329
|
* `path` (string, mandatory): The path of the deleted file or directory.
|
|
412
330
|
* `deleted_type` (string, mandatory): The type of the deleted item ("file" or "directory").
|
|
413
|
-
* `success` (boolean, mandatory): Indicates whether the deletion was successful.
|
|
414
|
-
|
|
415
|
-
---
|
|
416
|
-
|
|
417
|
-
## Future Enhancements
|
|
418
|
-
|
|
419
|
-
### Bandwidth Management
|
|
420
|
-
The protocol will support configurable rate limiting and bandwidth monitoring to prevent resource exhaustion and ensure fair usage across multiple client sessions. This will include per-session bandwidth quotas, throttling mechanisms, and automatic rate limiting enforcement.
|
|
421
|
-
|
|
422
|
-
### Channel Traffic Isolation
|
|
423
|
-
Different device channels (control, terminal sessions, file operations) will have separate traffic shaping to ensure high-priority control commands are not delayed by high-volume terminal output. This includes priority queuing and per-channel bandwidth allocation.
|
|
331
|
+
* `success` (boolean, mandatory): Indicates whether the deletion was successful.
|
|
@@ -40,58 +40,15 @@ class BaseHandler(ABC):
|
|
|
40
40
|
"""
|
|
41
41
|
pass
|
|
42
42
|
|
|
43
|
-
def _normalize_field_names(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
-
"""Normalize field names for backward compatibility.
|
|
45
|
-
|
|
46
|
-
Supports both old and new field names:
|
|
47
|
-
- 'cmd' -> 'command'
|
|
48
|
-
- 'reply_channel' -> 'client_channel'
|
|
49
|
-
|
|
50
|
-
Args:
|
|
51
|
-
message: Original message dict
|
|
52
|
-
|
|
53
|
-
Returns:
|
|
54
|
-
Message dict with normalized field names
|
|
55
|
-
"""
|
|
56
|
-
normalized = message.copy()
|
|
57
|
-
|
|
58
|
-
# Support both 'cmd' and 'command' fields
|
|
59
|
-
if 'cmd' in normalized and 'command' not in normalized:
|
|
60
|
-
normalized['command'] = normalized['cmd']
|
|
61
|
-
logger.debug("Converted 'cmd' to 'command' for backward compatibility")
|
|
62
|
-
|
|
63
|
-
# Support both 'reply_channel' and 'client_channel' fields
|
|
64
|
-
if 'reply_channel' in normalized and 'client_channel' not in normalized:
|
|
65
|
-
normalized['client_channel'] = normalized['reply_channel']
|
|
66
|
-
logger.debug("Converted 'reply_channel' to 'client_channel' for backward compatibility")
|
|
67
|
-
|
|
68
|
-
return normalized
|
|
69
|
-
|
|
70
|
-
def _get_client_channels(self, client_channel: Optional[str] = None) -> Optional[list]:
|
|
71
|
-
"""Convert single client_channel to client_channels array for responses.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
client_channel: Single client channel identifier
|
|
75
|
-
|
|
76
|
-
Returns:
|
|
77
|
-
List with single client channel, or None if no channel provided
|
|
78
|
-
"""
|
|
79
|
-
if client_channel:
|
|
80
|
-
return [client_channel]
|
|
81
|
-
return None
|
|
82
|
-
|
|
83
43
|
async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
84
44
|
"""Send a response back to the gateway.
|
|
85
45
|
|
|
86
46
|
Args:
|
|
87
47
|
payload: Response payload
|
|
88
|
-
reply_channel: Optional reply channel
|
|
48
|
+
reply_channel: Optional reply channel
|
|
89
49
|
"""
|
|
90
|
-
# Support both reply_channel (old) and client_channel (new) parameter names
|
|
91
50
|
if reply_channel:
|
|
92
|
-
|
|
93
|
-
payload["reply_channel"] = reply_channel # Old format
|
|
94
|
-
payload["client_channels"] = [reply_channel] # New format
|
|
51
|
+
payload["reply_channel"] = reply_channel
|
|
95
52
|
await self.control_channel.send(payload)
|
|
96
53
|
|
|
97
54
|
async def send_error(self, message: str, reply_channel: Optional[str] = None) -> None:
|
|
@@ -99,13 +56,11 @@ class BaseHandler(ABC):
|
|
|
99
56
|
|
|
100
57
|
Args:
|
|
101
58
|
message: Error message
|
|
102
|
-
reply_channel: Optional reply channel
|
|
59
|
+
reply_channel: Optional reply channel
|
|
103
60
|
"""
|
|
104
61
|
payload = {"event": "error", "message": message}
|
|
105
62
|
if reply_channel:
|
|
106
|
-
|
|
107
|
-
payload["reply_channel"] = reply_channel # Old format
|
|
108
|
-
payload["client_channels"] = [reply_channel] # New format
|
|
63
|
+
payload["reply_channel"] = reply_channel
|
|
109
64
|
await self.control_channel.send(payload)
|
|
110
65
|
|
|
111
66
|
|
|
@@ -126,14 +81,11 @@ class AsyncHandler(BaseHandler):
|
|
|
126
81
|
|
|
127
82
|
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
128
83
|
"""Handle the command by executing it and sending the response."""
|
|
129
|
-
# Normalize field names for backward compatibility
|
|
130
|
-
normalized_message = self._normalize_field_names(message)
|
|
131
|
-
|
|
132
84
|
logger.info("handler: Processing command %s with reply_channel=%s",
|
|
133
85
|
self.command_name, reply_channel)
|
|
134
86
|
|
|
135
87
|
try:
|
|
136
|
-
response = await self.execute(
|
|
88
|
+
response = await self.execute(message)
|
|
137
89
|
logger.info("handler: Command %s executed successfully", self.command_name)
|
|
138
90
|
await self.send_response(response, reply_channel)
|
|
139
91
|
except Exception as exc:
|
|
@@ -158,12 +110,9 @@ class SyncHandler(BaseHandler):
|
|
|
158
110
|
|
|
159
111
|
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
160
112
|
"""Handle the command by executing it in an executor and sending the response."""
|
|
161
|
-
# Normalize field names for backward compatibility
|
|
162
|
-
normalized_message = self._normalize_field_names(message)
|
|
163
|
-
|
|
164
113
|
try:
|
|
165
114
|
loop = asyncio.get_running_loop()
|
|
166
|
-
response = await loop.run_in_executor(None, self.execute,
|
|
115
|
+
response = await loop.run_in_executor(None, self.execute, message)
|
|
167
116
|
await self.send_response(response, reply_channel)
|
|
168
117
|
except Exception as exc:
|
|
169
118
|
logger.exception("Error in sync handler %s: %s", self.command_name, exc)
|
|
@@ -16,6 +16,7 @@ in the handlers directory.
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
|
+
import os
|
|
19
20
|
from typing import Any, Dict, Optional, List
|
|
20
21
|
|
|
21
22
|
from .multiplex import Multiplexer, Channel
|
|
@@ -32,8 +33,63 @@ from .handlers.session import SessionManager
|
|
|
32
33
|
|
|
33
34
|
logger = logging.getLogger(__name__)
|
|
34
35
|
|
|
36
|
+
class ClientSessionManager:
|
|
37
|
+
"""Manages connected client sessions for the device."""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self._client_sessions = {}
|
|
41
|
+
self._debug_file_path = os.path.join(os.getcwd(), "client_sessions.json")
|
|
42
|
+
logger.info("ClientSessionManager initialized")
|
|
43
|
+
|
|
44
|
+
def update_sessions(self, sessions: List[Dict]) -> None:
|
|
45
|
+
"""Update the client sessions with new data from server."""
|
|
46
|
+
self._client_sessions = {}
|
|
47
|
+
for session in sessions:
|
|
48
|
+
channel_name = session.get("channel_name")
|
|
49
|
+
if channel_name:
|
|
50
|
+
self._client_sessions[channel_name] = session
|
|
51
|
+
|
|
52
|
+
logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions")
|
|
53
|
+
self._write_debug_file()
|
|
54
|
+
|
|
55
|
+
def get_sessions(self) -> Dict[str, Dict]:
|
|
56
|
+
"""Get all current client sessions."""
|
|
57
|
+
return self._client_sessions.copy()
|
|
58
|
+
|
|
59
|
+
def get_session_by_channel(self, channel_name: str) -> Optional[Dict]:
|
|
60
|
+
"""Get a specific client session by channel name."""
|
|
61
|
+
return self._client_sessions.get(channel_name)
|
|
62
|
+
|
|
63
|
+
def get_sessions_for_project(self, project_id: str) -> List[Dict]:
|
|
64
|
+
"""Get all client sessions for a specific project."""
|
|
65
|
+
return [
|
|
66
|
+
session for session in self._client_sessions.values()
|
|
67
|
+
if session.get("project_id") == project_id
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def get_sessions_for_user(self, user_id: int) -> List[Dict]:
|
|
71
|
+
"""Get all client sessions for a specific user."""
|
|
72
|
+
return [
|
|
73
|
+
session for session in self._client_sessions.values()
|
|
74
|
+
if session.get("user_id") == user_id
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def has_interested_clients(self) -> bool:
|
|
78
|
+
"""Check if there are any connected clients interested in this device."""
|
|
79
|
+
return len(self._client_sessions) > 0
|
|
80
|
+
|
|
81
|
+
def _write_debug_file(self) -> None:
|
|
82
|
+
"""Write current client sessions to debug JSON file."""
|
|
83
|
+
try:
|
|
84
|
+
with open(self._debug_file_path, 'w') as f:
|
|
85
|
+
json.dump(list(self._client_sessions.values()), f, indent=2, default=str)
|
|
86
|
+
logger.debug(f"Updated client sessions debug file: {self._debug_file_path}")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Failed to write client sessions debug file: {e}")
|
|
89
|
+
|
|
35
90
|
__all__ = [
|
|
36
91
|
"TerminalManager",
|
|
92
|
+
"ClientSessionManager",
|
|
37
93
|
]
|
|
38
94
|
|
|
39
95
|
class TerminalManager:
|
|
@@ -44,6 +100,7 @@ class TerminalManager:
|
|
|
44
100
|
def __init__(self, mux: Multiplexer):
|
|
45
101
|
self.mux = mux
|
|
46
102
|
self._session_manager = None # Initialize as None first
|
|
103
|
+
self._client_session_manager = ClientSessionManager() # Initialize client session manager
|
|
47
104
|
self._set_mux(mux, is_initial=True)
|
|
48
105
|
|
|
49
106
|
# ------------------------------------------------------------------
|
|
@@ -65,8 +122,8 @@ class TerminalManager:
|
|
|
65
122
|
# Start async reattachment and reconciliation
|
|
66
123
|
asyncio.create_task(self._handle_reconnection())
|
|
67
124
|
else:
|
|
68
|
-
# No existing sessions,
|
|
69
|
-
asyncio.create_task(self.
|
|
125
|
+
# No existing sessions, send empty terminal list and request client sessions
|
|
126
|
+
asyncio.create_task(self._initial_connection_setup())
|
|
70
127
|
|
|
71
128
|
def _set_mux(self, mux: Multiplexer, is_initial: bool = False) -> None:
|
|
72
129
|
self.mux = mux
|
|
@@ -84,6 +141,7 @@ class TerminalManager:
|
|
|
84
141
|
# Create context for handlers
|
|
85
142
|
self._context = {
|
|
86
143
|
"session_manager": self._session_manager,
|
|
144
|
+
"client_session_manager": self._client_session_manager,
|
|
87
145
|
"mux": mux,
|
|
88
146
|
}
|
|
89
147
|
|
|
@@ -144,6 +202,13 @@ class TerminalManager:
|
|
|
144
202
|
|
|
145
203
|
logger.info("terminal_manager: Processing command '%s' with reply_channel=%s", cmd, reply_chan)
|
|
146
204
|
|
|
205
|
+
# Handle client sessions update directly (special case)
|
|
206
|
+
if cmd == "client_sessions_update":
|
|
207
|
+
sessions = message.get("sessions", [])
|
|
208
|
+
self._client_session_manager.update_sessions(sessions)
|
|
209
|
+
logger.info("terminal_manager: Updated client sessions (%d sessions)", len(sessions))
|
|
210
|
+
continue
|
|
211
|
+
|
|
147
212
|
# Dispatch command through registry
|
|
148
213
|
handled = await self._command_registry.dispatch(cmd, message, reply_chan)
|
|
149
214
|
if not handled:
|
|
@@ -206,6 +271,30 @@ class TerminalManager:
|
|
|
206
271
|
await self._control_channel.send(payload)
|
|
207
272
|
except Exception as exc:
|
|
208
273
|
logger.warning("Failed to send terminal list: %s", exc)
|
|
274
|
+
|
|
275
|
+
async def _request_client_sessions(self) -> None:
|
|
276
|
+
"""Request current client sessions from server."""
|
|
277
|
+
try:
|
|
278
|
+
payload = {
|
|
279
|
+
"event": "request_client_sessions"
|
|
280
|
+
}
|
|
281
|
+
await self._control_channel.send(payload)
|
|
282
|
+
logger.info("Requested client sessions from server")
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logger.warning("Failed to request client sessions: %s", exc)
|
|
285
|
+
|
|
286
|
+
async def _initial_connection_setup(self) -> None:
|
|
287
|
+
"""Handle initial connection setup sequence."""
|
|
288
|
+
try:
|
|
289
|
+
# Send empty terminal list
|
|
290
|
+
await self._send_terminal_list()
|
|
291
|
+
logger.info("Initial terminal list sent to server")
|
|
292
|
+
|
|
293
|
+
# Request current client sessions
|
|
294
|
+
await self._request_client_sessions()
|
|
295
|
+
logger.info("Initial client session request sent")
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
logger.error("Failed to handle initial connection setup: %s", exc)
|
|
209
298
|
|
|
210
299
|
async def _handle_reconnection(self) -> None:
|
|
211
300
|
"""Handle the async reconnection sequence."""
|
|
@@ -217,5 +306,9 @@ class TerminalManager:
|
|
|
217
306
|
# Then send updated terminal list to server
|
|
218
307
|
await self._send_terminal_list()
|
|
219
308
|
logger.info("Terminal list sent to server after reconnection")
|
|
309
|
+
|
|
310
|
+
# Request current client sessions
|
|
311
|
+
await self._request_client_sessions()
|
|
312
|
+
logger.info("Client session request sent after reconnection")
|
|
220
313
|
except Exception as exc:
|
|
221
314
|
logger.error("Failed to handle reconnection: %s", exc)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
cd "$(dirname "$0")"
|
|
5
|
+
|
|
6
|
+
# ─── CONFIG ────────────────────────────────────────────────────────────────
|
|
7
|
+
BACKUP_DIR="${1:-$PWD/../backups}"
|
|
8
|
+
VOLUME_NAME="portacode_pgdata"
|
|
9
|
+
SERVICE="db"
|
|
10
|
+
|
|
11
|
+
# ─── PICK A BACKUP ────────────────────────────────────────────────────────
|
|
12
|
+
mapfile -t BACKUPS < <(
|
|
13
|
+
find "$BACKUP_DIR" -maxdepth 1 -type f -name "pgdata-*.tar.gz" \
|
|
14
|
+
-printf "%f\n" | sort -r
|
|
15
|
+
)
|
|
16
|
+
[ ${#BACKUPS[@]} -gt 0 ] || { echo "❌ No backups in $BACKUP_DIR"; exit 1; }
|
|
17
|
+
|
|
18
|
+
echo "Available backups:"
|
|
19
|
+
for i in "${!BACKUPS[@]}"; do
|
|
20
|
+
printf " %2d) %s\n" $((i+1)) "${BACKUPS[i]}"
|
|
21
|
+
done
|
|
22
|
+
read -rp "Select backup [1-${#BACKUPS[@]}]: " SEL
|
|
23
|
+
(( SEL>=1 && SEL<=${#BACKUPS[@]} )) || { echo "❌ Invalid choice."; exit 1; }
|
|
24
|
+
FILE="${BACKUPS[$((SEL-1))]}"
|
|
25
|
+
|
|
26
|
+
# ─── STOP & REMOVE OLD DB ─────────────────────────────────────────────────
|
|
27
|
+
if docker-compose ps --status=running | grep -q "$SERVICE"; then
|
|
28
|
+
echo "🛑 Stopping & removing existing '$SERVICE' container..."
|
|
29
|
+
docker-compose stop "$SERVICE"
|
|
30
|
+
docker-compose rm -f "$SERVICE"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# ─── DROP THE OLD VOLUME ───────────────────────────────────────────────────
|
|
34
|
+
if docker volume inspect "$VOLUME_NAME" &>/dev/null; then
|
|
35
|
+
echo "🗑️ Removing old volume $VOLUME_NAME..."
|
|
36
|
+
docker volume rm "$VOLUME_NAME"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# ─── HAVE COMPOSE CREATE THE BLANK VOLUME ─────────────────────────────────
|
|
40
|
+
echo "➕ Letting Docker Compose create a fresh volume for '$SERVICE'..."
|
|
41
|
+
# 'create' makes the container (and volume) without starting it
|
|
42
|
+
docker-compose create "$SERVICE"
|
|
43
|
+
|
|
44
|
+
# ─── RESTORE INTO THAT VOLUME ─────────────────────────────────────────────
|
|
45
|
+
echo "📥 Restoring '$FILE' into volume '$VOLUME_NAME'..."
|
|
46
|
+
docker run --rm \
|
|
47
|
+
-v "${VOLUME_NAME}":/data \
|
|
48
|
+
-v "$BACKUP_DIR":/backup \
|
|
49
|
+
alpine \
|
|
50
|
+
sh -c "cd /data && tar xzf /backup/${FILE}"
|
|
51
|
+
|
|
52
|
+
# ─── START THE DB AGAIN ────────────────────────────────────────────────────
|
|
53
|
+
echo "🚀 Starting '$SERVICE'..."
|
|
54
|
+
docker-compose up -d "$SERVICE"
|
|
55
|
+
|
|
56
|
+
echo "✅ Restore complete."
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/file_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/system_handlers.py
RENAMED
|
File without changes
|
{portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/terminal_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|