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.
Files changed (42) hide show
  1. portacode-0.3.12.dev0/.claude/settings.local.json +8 -0
  2. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/PKG-INFO +1 -1
  3. portacode-0.3.12.dev0/backup.sh +42 -0
  4. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/docker-compose.yaml +12 -4
  5. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/_version.py +2 -2
  6. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +9 -101
  7. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/base.py +6 -57
  8. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/terminal.py +95 -2
  9. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/PKG-INFO +1 -1
  10. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/SOURCES.txt +3 -0
  11. portacode-0.3.12.dev0/restore.sh +56 -0
  12. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/.gitignore +0 -0
  13. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/.gitmodules +0 -0
  14. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/LICENSE +0 -0
  15. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/MANIFEST.in +0 -0
  16. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/Makefile +0 -0
  17. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/README.md +0 -0
  18. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/README.md +0 -0
  19. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/__init__.py +0 -0
  20. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/__main__.py +0 -0
  21. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/cli.py +0 -0
  22. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/README.md +0 -0
  23. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/__init__.py +0 -0
  24. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/client.py +0 -0
  25. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/README.md +0 -0
  26. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/__init__.py +0 -0
  27. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/file_handlers.py +0 -0
  28. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/registry.py +0 -0
  29. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/session.py +0 -0
  30. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/system_handlers.py +0 -0
  31. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/handlers/terminal_handlers.py +0 -0
  32. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/connection/multiplex.py +0 -0
  33. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/data.py +0 -0
  34. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/keypair.py +0 -0
  35. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode/service.py +0 -0
  36. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/dependency_links.txt +0 -0
  37. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/entry_points.txt +0 -0
  38. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/requires.txt +0 -0
  39. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/portacode.egg-info/top_level.txt +0 -0
  40. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/pyproject.toml +0 -0
  41. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/setup.cfg +0 -0
  42. {portacode-0.3.11.dev4 → portacode-0.3.12.dev0}/setup.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:stackoverflow.com)"
5
+ ],
6
+ "deny": []
7
+ }
8
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.11.dev4
3
+ Version: 0.3.12.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -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
- - db
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
- - db
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.11.dev4'
21
- __version_tuple__ = version_tuple = (0, 3, 11, 'dev4')
20
+ __version__ = version = '0.3.12.dev'
21
+ __version_tuple__ = version_tuple = (0, 3, 12, 'dev0')
@@ -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 `device_channel` and a `payload`. This allows for multiple virtual communication channels over a single connection.
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
- "device_channel": "<channel_id>",
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
- * `device_channel` (string|integer, mandatory): Identifies the virtual channel on the device side. When sending control commands to the device, they should be sent to channel 0. When the device responds to such control commands or sends system events, they will also be sent on channel 0. When a terminal session is created in the device, it is assigned a UUID, and that UUID becomes the device_channel for communicating to that specific terminal.
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
- "client_channel": "<session_id>"
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
- * `client_channel` (string, mandatory): A session identifier that the device will echo back in its response. This identifies which client session on the server side is communicating with the device. The device uses this to track active client sessions and ensure messages are only sent to connected clients. This works like a return address - the device_channel specifies which application on the device handles the message, while the client_channel specifies which client session should receive the response.
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
- "client_channels": ["<session_id1>", "<session_id2>"]
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="client_channels"></a>`client_channels` (array, optional): List of client session identifiers that should receive this event. For direct responses to commands, this contains the single `client_channel` from the original request. For broadcast events, this contains all client sessions whose subscription filters matched the event. The server uses this to route the event to the correct client sessions.
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
- * `device_channel` (string, mandatory): The device channel name for terminal I/O (same as terminal_id).
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 (supports both old and new names)
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
- # Add both formats for backward compatibility during transition
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 (supports both old and new names)
59
+ reply_channel: Optional reply channel
103
60
  """
104
61
  payload = {"event": "error", "message": message}
105
62
  if reply_channel:
106
- # Add both formats for backward compatibility during transition
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(normalized_message)
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, normalized_message)
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, just send empty terminal list
69
- asyncio.create_task(self._send_terminal_list())
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.11.dev4
3
+ Version: 0.3.12.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -4,9 +4,12 @@ LICENSE
4
4
  MANIFEST.in
5
5
  Makefile
6
6
  README.md
7
+ backup.sh
7
8
  docker-compose.yaml
8
9
  pyproject.toml
10
+ restore.sh
9
11
  setup.py
12
+ .claude/settings.local.json
10
13
  portacode/README.md
11
14
  portacode/__init__.py
12
15
  portacode/__main__.py
@@ -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