portacode 1.4.15.dev23__py3-none-any.whl → 1.4.16.dev5__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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.15.dev23'
32
- __version_tuple__ = version_tuple = (1, 4, 15, 'dev23')
31
+ __version__ = version = '1.4.16.dev5'
32
+ __version_tuple__ = version_tuple = (1, 4, 16, 'dev5')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -381,6 +381,7 @@ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`S
381
381
  **Payload Fields:**
382
382
 
383
383
  * `ctid` (string, required): Identifier of the container to start.
384
+ * `child_device_id` (string, required): Dashboard `Device.id` of the container that triggered the request; the handler validates the CT belongs to that device before issuing the start.
384
385
 
385
386
  **Responses:**
386
387
 
@@ -394,6 +395,7 @@ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHa
394
395
  **Payload Fields:**
395
396
 
396
397
  * `ctid` (string, required): Identifier of the container to stop.
398
+ * `child_device_id` (string, required): Dashboard `Device.id` that owns the container; the handler rejects the request if the CT is mapped to another device.
397
399
 
398
400
  **Responses:**
399
401
 
@@ -407,6 +409,7 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
407
409
  **Payload Fields:**
408
410
 
409
411
  * `ctid` (string, required): Identifier of the container to delete.
412
+ * `child_device_id` (string, required): Dashboard `Device.id` that should own the container metadata being purged.
410
413
 
411
414
  **Responses:**
412
415
 
@@ -1133,22 +1136,40 @@ Provides system information in response to a `system_info` action. Handled by [`
1133
1136
  * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1134
1137
  * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1135
1138
  * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1136
- * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1137
- * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1138
- * `count` (integer): Number of managed containers.
1139
- * `total_ram_mib` (integer): RAM footprint summed across all containers.
1140
- * `total_disk_gib` (integer): Disk footprint summed across all containers.
1141
- * `total_cpu_share` (number): CPU shares requested across all containers.
1142
- * `containers` (array[object]): Container summaries with the following fields:
1143
- * `vmid` (string|null): Numeric CT ID.
1144
- * `hostname` (string|null): Hostname configured in the CT.
1145
- * `template` (string|null): Template identifier used.
1146
- * `storage` (string|null): Storage pool backing the rootfs.
1147
- * `disk_gib` (integer): Rootfs size in GiB.
1148
- * `ram_mib` (integer): Memory size in MiB.
1149
- * `cpu_share` (number): vCPU-equivalent share requested at creation.
1150
- * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1151
- * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1139
+ * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1140
+ * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1141
+ * `count` (integer): Number of managed containers.
1142
+ * `total_ram_mib` (integer): RAM footprint summed across all containers.
1143
+ * `total_disk_gib` (integer): Disk footprint summed across all containers.
1144
+ * `total_cpu_share` (number): CPU shares requested across all containers.
1145
+ * `containers` (array[object]): Container summaries with the following fields:
1146
+ * `vmid` (string|null): Numeric CT ID.
1147
+ * `hostname` (string|null): Hostname configured in the CT.
1148
+ * `template` (string|null): Template identifier used.
1149
+ * `storage` (string|null): Storage pool backing the rootfs.
1150
+ * `disk_gib` (integer): Rootfs size in GiB.
1151
+ * `ram_mib` (integer): Memory size in MiB.
1152
+ * `cpu_share` (number): vCPU-equivalent share requested at creation.
1153
+ * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1154
+ * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1155
+ * `managed` (boolean): `true` for Portacode-managed entries.
1156
+ * `unmanaged_containers` (array[object]): Facts about containers Portacode did not provision; fields mirror the managed list but are marked `managed=false`.
1157
+ * `unmanaged_count` (integer): Number of unmanaged containers detected on the node.
1158
+ * `allocated_ram_mib` (integer): Total RAM reserved by both managed and unmanaged containers.
1159
+ * `allocated_disk_gib` (integer): Total disk reserved by both managed and unmanaged containers.
1160
+ * `allocated_cpu_share` (number): Total CPU shares requested by both managed and unmanaged containers.
1161
+ * `available_ram_mib` (integer|null): Remaining RAM after subtracting all reservations from the host total (null when unavailable).
1162
+ * `available_disk_gib` (integer|null): Remaining disk GB after subtracting allocations from the host total.
1163
+ * `available_cpu_share` (number|null): Remaining CPU shares after allocations.
1164
+ * `host_total_ram_mib` (integer|null): Host memory capacity observed via Proxmox.
1165
+ * `host_total_disk_gib` (integer|null): Host disk capacity observed via Proxmox.
1166
+ * `host_total_cpu_cores` (integer|null): Number of CPU cores reported by Proxmox.
1167
+ * `default_storage` (string|null): Storage pool name selected during infrastructure configuration.
1168
+ * `default_storage_snapshot` (object|null): Fresh stats for the default storage pool:
1169
+ * `storage` (string): Storage pool identifier.
1170
+ * `total_gib` (integer|null): Capacity of the storage pool.
1171
+ * `avail_gib` (integer|null): Available space remaining.
1172
+ * `used_gib` (integer|null): Space already consumed.
1152
1173
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1153
1174
 
1154
1175
  ### `proxmox_infra_configured`
@@ -21,6 +21,24 @@ from .git_manager import GitManager
21
21
  from .file_system_watcher import FileSystemWatcher
22
22
  from ....logging_categories import get_categorized_logger, LogCategory
23
23
 
24
+ def _deterministic_file_tab_id(file_path: str) -> str:
25
+ try:
26
+ resolved = os.path.abspath(file_path)
27
+ mtime = int(Path(resolved).stat().st_mtime)
28
+ except OSError:
29
+ mtime = "unknown"
30
+ resolved = os.path.abspath(file_path)
31
+ return f"{resolved}:{mtime}"
32
+
33
+ def _deterministic_diff_tab_id(
34
+ file_path: str,
35
+ from_ref: str,
36
+ to_ref: str,
37
+ from_hash: Optional[str],
38
+ to_hash: Optional[str]
39
+ ) -> str:
40
+ return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
41
+
24
42
  logger = get_categorized_logger(__name__)
25
43
 
26
44
  # Global singleton instance
@@ -657,10 +675,10 @@ class ProjectStateManager:
657
675
  # Create new file tab using tab factory
658
676
  from ..tab_factory import get_tab_factory
659
677
  tab_factory = get_tab_factory()
660
-
678
+
661
679
  try:
662
680
  logger.info(f"About to create tab for file: {file_path}")
663
- new_tab = await tab_factory.create_file_tab(file_path)
681
+ new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
664
682
  logger.info(f"Tab created successfully, adding to project state")
665
683
  project_state.open_tabs[tab_key] = new_tab
666
684
  if set_active:
@@ -842,7 +860,11 @@ class ProjectStateManager:
842
860
  diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
843
861
 
844
862
  diff_tab = await tab_factory.create_diff_tab_with_title(
845
- file_path, original_content, modified_content, diff_title,
863
+ file_path,
864
+ original_content,
865
+ modified_content,
866
+ diff_title,
867
+ tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
846
868
  diff_details=diff_details
847
869
  )
848
870
 
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import math
9
9
  import os
10
+ import re
10
11
  import secrets
11
12
  import shlex
12
13
  import shutil
@@ -18,7 +19,7 @@ import time
18
19
  import threading
19
20
  from datetime import datetime, timezone
20
21
  from pathlib import Path
21
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
22
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
22
23
 
23
24
  import platformdirs
24
25
 
@@ -201,6 +202,76 @@ def _current_time_iso() -> str:
201
202
  return datetime.now(timezone.utc).isoformat()
202
203
 
203
204
 
205
+ def _to_int(value: Any, default: int = 0) -> int:
206
+ try:
207
+ return int(value)
208
+ except (TypeError, ValueError):
209
+ return default
210
+
211
+
212
+ def _to_float(value: Any, default: float = 0.0) -> float:
213
+ try:
214
+ return float(value)
215
+ except (TypeError, ValueError):
216
+ return default
217
+
218
+
219
+ def _calculate_available(total: Optional[float], used: float) -> Optional[float]:
220
+ if total is None:
221
+ return None
222
+ delta = total - used
223
+ return delta if delta >= 0 else 0.0
224
+
225
+
226
+ def _normalize_bytes(value: Any) -> float:
227
+ if isinstance(value, (int, float)):
228
+ return float(value)
229
+ text = str(value or "").strip()
230
+ if not text:
231
+ return 0.0
232
+ match = re.match(r"(?i)^\s*([0-9]*\.?[0-9]+)\s*([kmgtp]?i?b?)?\s*$", text)
233
+ if not match:
234
+ return 0.0
235
+ number = match.group(1)
236
+ unit = (match.group(2) or "").lower()
237
+ try:
238
+ value = float(number)
239
+ except ValueError:
240
+ return 0.0
241
+ if unit.startswith("k"):
242
+ return value * 1024
243
+ if unit.startswith("m"):
244
+ return value * 1024**2
245
+ if unit.startswith("g"):
246
+ return value * 1024**3
247
+ if unit.startswith("t"):
248
+ return value * 1024**4
249
+ if unit.startswith("p"):
250
+ return value * 1024**5
251
+ return value
252
+
253
+
254
+ def _bytes_to_mib(value: Any) -> int:
255
+ return int(round(_normalize_bytes(value) / (1024**2)))
256
+
257
+
258
+ def _bytes_to_gib(value: Any) -> int:
259
+ return int(round(_normalize_bytes(value) / (1024**3)))
260
+
261
+
262
+ def _normalize_storage_name(name: Any) -> str:
263
+ if not name:
264
+ return ""
265
+ return str(name).strip().lower()
266
+
267
+
268
+ def _parse_bool_flag(value: Any) -> bool:
269
+ if isinstance(value, bool):
270
+ return value
271
+ text = str(value or "").strip().lower()
272
+ return text in {"1", "true", "yes", "on"}
273
+
274
+
204
275
  def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
276
  if not value:
206
277
  return None
@@ -381,35 +452,96 @@ def _load_managed_container_records() -> List[Dict[str, Any]]:
381
452
  return records
382
453
 
383
454
 
384
- def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
455
+ def _extract_host_totals(node_status: Dict[str, Any] | None) -> Tuple[Optional[int], Optional[int], Optional[int]]:
456
+ if not node_status:
457
+ return None, None, None
458
+ memory_total = node_status.get("memory", {}).get("total")
459
+ disk_total = node_status.get("disk", {}).get("total")
460
+ cpu_cores = node_status.get("cpuinfo", {}).get("cores")
461
+ host_ram = _bytes_to_mib(memory_total) if memory_total is not None else None
462
+ host_disk = _bytes_to_gib(disk_total) if disk_total is not None else None
463
+ host_cpu = _to_int(cpu_cores) if cpu_cores is not None else None
464
+ return host_ram, host_disk, host_cpu
465
+
466
+
467
+ def _build_unmanaged_container_entry(ct: Dict[str, Any], cfg: Dict[str, Any], vmid: str) -> Dict[str, Any]:
468
+ ram_mib = _to_int(cfg.get("memory")) or _bytes_to_mib(ct.get("maxmem"))
469
+ disk_gib = _bytes_to_gib(ct.get("maxdisk"))
470
+ cpu_share = _to_float(
471
+ cfg.get("cpulimit")
472
+ or cfg.get("cpus")
473
+ or cfg.get("cores")
474
+ or ct.get("cpus")
475
+ or ct.get("cpu")
476
+ )
477
+ hostname = ct.get("name") or cfg.get("hostname") or f"ct{vmid}"
478
+ storage = cfg.get("storage") or ct.get("storage")
479
+ status = (ct.get("status") or "unknown").lower()
480
+ reserved = _parse_bool_flag(cfg.get("onboot"))
481
+ return {
482
+ "vmid": vmid,
483
+ "hostname": hostname,
484
+ "template": cfg.get("ostemplate"),
485
+ "storage": storage,
486
+ "disk_gib": disk_gib,
487
+ "ram_mib": ram_mib,
488
+ "cpu_share": cpu_share,
489
+ "reserve_on_boot": reserved,
490
+ "status": status,
491
+ "managed": False,
492
+ }
493
+
494
+
495
+ def _get_storage_snapshot(proxmox: Any, node: str, storage_name: str | None) -> Dict[str, Any] | None:
496
+ if not storage_name:
497
+ return None
498
+ try:
499
+ storage = proxmox.nodes(node).storage(storage_name).status.get()
500
+ except Exception as exc:
501
+ logger.debug("Unable to read storage status %s:%s: %s", node, storage_name, exc)
502
+ return None
503
+ total_bytes = storage.get("total")
504
+ avail_bytes = storage.get("avail")
505
+ used_bytes = storage.get("used")
506
+ return {
507
+ "storage": storage_name,
508
+ "total_gib": _bytes_to_gib(total_bytes) if total_bytes is not None else None,
509
+ "avail_gib": _bytes_to_gib(avail_bytes) if avail_bytes is not None else None,
510
+ "used_gib": _bytes_to_gib(used_bytes) if used_bytes is not None else None,
511
+ }
512
+
513
+
514
+
515
+ def _storage_matches_default(storage_name: Any, default_storage: str | None) -> bool:
516
+ if not default_storage:
517
+ return True
518
+ return _normalize_storage_name(storage_name) == _normalize_storage_name(default_storage)
519
+
520
+
521
+ def _build_managed_containers_summary(
522
+ records: List[Dict[str, Any]],
523
+ unmanaged_records: List[Dict[str, Any]],
524
+ node_status: Dict[str, Any] | None,
525
+ storage_snapshot: Dict[str, Any] | None,
526
+ default_storage: str | None,
527
+ ) -> Dict[str, Any]:
528
+ managed_containers: List[Dict[str, Any]] = []
385
529
  total_ram = 0
386
530
  total_disk = 0
387
531
  total_cpu_share = 0.0
388
- containers: List[Dict[str, Any]] = []
389
-
390
- def _as_int(value: Any) -> int:
391
- try:
392
- return int(value)
393
- except (TypeError, ValueError):
394
- return 0
395
532
 
396
- def _as_float(value: Any) -> float:
397
- try:
398
- return float(value)
399
- except (TypeError, ValueError):
400
- return 0.0
401
-
402
- for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
403
- ram_mib = _as_int(record.get("ram_mib"))
404
- disk_gib = _as_int(record.get("disk_gib"))
405
- cpu_share = _as_float(record.get("cpus"))
533
+ for record in sorted(records, key=lambda entry: _to_int(entry.get("vmid"))):
534
+ ram_mib = _to_int(record.get("ram_mib"))
535
+ disk_gib = _to_int(record.get("disk_gib"))
536
+ cpu_share = _to_float(record.get("cpus"))
406
537
  total_ram += ram_mib
407
538
  total_disk += disk_gib
408
539
  total_cpu_share += cpu_share
409
540
  status = (record.get("status") or "unknown").lower()
410
- containers.append(
541
+ managed_containers.append(
411
542
  {
412
- "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
543
+ "vmid": str(_to_int(record.get("vmid"))) if record.get("vmid") is not None else None,
544
+ "device_id": record.get("device_id"),
413
545
  "hostname": record.get("hostname"),
414
546
  "template": record.get("template"),
415
547
  "storage": record.get("storage"),
@@ -418,44 +550,105 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
418
550
  "cpu_share": cpu_share,
419
551
  "created_at": record.get("created_at"),
420
552
  "status": status,
553
+ "managed": True,
554
+ "uses_default_storage": _storage_matches_default(record.get("storage"), default_storage),
421
555
  }
422
556
  )
423
557
 
558
+ unmanaged_total_ram = sum(
559
+ _to_int(entry.get("ram_mib"))
560
+ for entry in unmanaged_records
561
+ if entry.get("reserve_on_boot")
562
+ )
563
+ unmanaged_total_disk = sum(
564
+ _to_int(entry.get("disk_gib"))
565
+ for entry in unmanaged_records
566
+ if entry.get("reserve_on_boot")
567
+ and _storage_matches_default(entry.get("storage"), default_storage)
568
+ )
569
+ unmanaged_total_cpu = sum(
570
+ _to_float(entry.get("cpu_share"))
571
+ for entry in unmanaged_records
572
+ if entry.get("reserve_on_boot")
573
+ )
574
+
575
+ allocated_ram = total_ram + unmanaged_total_ram
576
+ allocated_disk = total_disk + unmanaged_total_disk
577
+ allocated_cpu = total_cpu_share + unmanaged_total_cpu
578
+
579
+ host_ram_node, host_disk_node, host_cpu_node = _extract_host_totals(node_status)
580
+ storage_host_disk = storage_snapshot.get("total_gib") if storage_snapshot else None
581
+ host_ram = host_ram_node
582
+ host_disk = storage_host_disk if storage_host_disk is not None else host_disk_node
583
+ host_cpu = host_cpu_node
584
+
585
+
424
586
  return {
425
587
  "updated_at": datetime.utcnow().isoformat() + "Z",
426
- "count": len(containers),
588
+ "count": len(managed_containers),
427
589
  "total_ram_mib": total_ram,
428
590
  "total_disk_gib": total_disk,
429
591
  "total_cpu_share": round(total_cpu_share, 2),
430
- "containers": containers,
592
+ "containers": managed_containers,
593
+ "unmanaged_containers": unmanaged_records,
594
+ "unmanaged_count": len(unmanaged_records),
595
+ "allocated_ram_mib": allocated_ram,
596
+ "allocated_disk_gib": allocated_disk,
597
+ "allocated_cpu_share": round(allocated_cpu, 2),
598
+ "available_ram_mib": _calculate_available(host_ram, allocated_ram) if host_ram is not None else None,
599
+ "available_disk_gib": _calculate_available(host_disk, allocated_disk) if host_disk is not None else None,
600
+ "available_cpu_share": _calculate_available(host_cpu, allocated_cpu) if host_cpu is not None else None,
601
+ "host_total_ram_mib": host_ram,
602
+ "host_total_disk_gib": host_disk,
603
+ "host_total_cpu_cores": host_cpu,
604
+ "default_storage": default_storage,
605
+ "default_storage_snapshot": storage_snapshot,
431
606
  }
432
607
 
433
608
 
434
609
  def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
435
- def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
436
- if not records or not config:
437
- return
610
+ def _refresh_container_statuses(
611
+ records: List[Dict[str, Any]],
612
+ config: Dict[str, Any] | None,
613
+ managed_vmids: Set[str],
614
+ default_storage: str | None,
615
+ ) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, Any] | None, Dict[str, Any] | None]:
616
+ statuses: Dict[str, str] = {}
617
+ unmanaged: List[Dict[str, Any]] = []
618
+ node_status: Dict[str, Any] | None = None
619
+ if not config:
620
+ return statuses, unmanaged, node_status, None
621
+ proxmox = None
622
+ node = None
438
623
  try:
439
624
  proxmox = _connect_proxmox(config)
440
625
  node = _get_node_from_config(config)
441
- statuses = {
442
- str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
443
- for ct in proxmox.nodes(node).lxc.get()
444
- }
626
+ node_status = proxmox.nodes(node).status.get()
627
+ for ct in proxmox.nodes(node).lxc.get():
628
+ vmid_val = ct.get("vmid")
629
+ if vmid_val is None:
630
+ continue
631
+ vmid_key = str(_to_int(vmid_val))
632
+ statuses[vmid_key] = (ct.get("status") or "unknown").lower()
633
+ if vmid_key in managed_vmids:
634
+ continue
635
+ cfg: Dict[str, Any] = {}
636
+ try:
637
+ cfg = proxmox.nodes(node).lxc(vmid_key).config.get() or {}
638
+ except Exception as exc: # pragma: no cover - best effort
639
+ logger.debug("Failed to read config for container %s: %s", vmid_key, exc)
640
+ description = (cfg.get("description") or "")
641
+ if MANAGED_MARKER in description:
642
+ continue
643
+ unmanaged.append(_build_unmanaged_container_entry(ct, cfg, vmid_key))
445
644
  except Exception as exc: # pragma: no cover - best effort
446
645
  logger.debug("Failed to refresh container statuses: %s", exc)
447
- return
448
- for record in records:
449
- vmid = record.get("vmid")
450
- if vmid is None:
451
- continue
452
- try:
453
- vmid_key = str(int(vmid))
454
- except (ValueError, TypeError):
455
- continue
456
- status = statuses.get(vmid_key)
457
- if status:
458
- record["status"] = status
646
+ storage_snapshot = (
647
+ _get_storage_snapshot(proxmox, node, default_storage)
648
+ if proxmox and node
649
+ else None
650
+ )
651
+ return statuses, unmanaged, node_status, storage_snapshot
459
652
 
460
653
  now = time.monotonic()
461
654
  with _MANAGED_CONTAINERS_CACHE_LOCK:
@@ -465,8 +658,22 @@ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
465
658
  return cached
466
659
  config = _load_config()
467
660
  records = _load_managed_container_records()
468
- _refresh_container_statuses(records, config)
469
- summary = _build_managed_containers_summary(records)
661
+ managed_vmids: Set[str] = {
662
+ str(_to_int(record.get("vmid"))) for record in records if record.get("vmid") is not None
663
+ }
664
+ default_storage = config.get("default_storage") if config else None
665
+ statuses, unmanaged, node_status, storage_snapshot = _refresh_container_statuses(
666
+ records, config, managed_vmids, default_storage
667
+ )
668
+ for record in records:
669
+ vmid = record.get("vmid")
670
+ if vmid is None:
671
+ continue
672
+ vmid_key = str(_to_int(vmid))
673
+ status = statuses.get(vmid_key)
674
+ if status:
675
+ record["status"] = status
676
+ summary = _build_managed_containers_summary(records, unmanaged, node_status, storage_snapshot, default_storage)
470
677
  with _MANAGED_CONTAINERS_CACHE_LOCK:
471
678
  _MANAGED_CONTAINERS_CACHE["timestamp"] = now
472
679
  _MANAGED_CONTAINERS_CACHE["summary"] = summary
@@ -607,7 +814,18 @@ def _build_bootstrap_steps(
607
814
  )
608
815
  steps.extend(
609
816
  [
610
- {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
817
+ {
818
+ "name": "user_exists",
819
+ "cmd": (
820
+ f"id -u {user} >/dev/null 2>&1 || "
821
+ f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
822
+ f" adduser --disabled-password --gecos '' {user}; "
823
+ "else "
824
+ f" useradd -m -s /bin/sh {user}; "
825
+ "fi)"
826
+ ),
827
+ "retries": 0,
828
+ },
611
829
  {
612
830
  "name": "add_sudo",
613
831
  "cmd": (
@@ -633,7 +851,7 @@ def _build_bootstrap_steps(
633
851
  {
634
852
  "name": "add_sudoers",
635
853
  "cmd": (
636
- f"printf '%s ALL=(ALL) NOPASSWD:ALL\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
854
+ f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
637
855
  "chmod 0440 /etc/sudoers.d/portacode"
638
856
  ),
639
857
  "retries": 0,
@@ -865,12 +1083,17 @@ def _parse_ctid(message: Dict[str, Any]) -> int:
865
1083
 
866
1084
 
867
1085
  def _ensure_container_managed(
868
- proxmox: Any, node: str, vmid: int
1086
+ proxmox: Any, node: str, vmid: int, *, device_id: Optional[str] = None
869
1087
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
870
1088
  record = _read_container_record(vmid)
871
1089
  ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
872
1090
  if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
873
1091
  raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
1092
+ record_device_id = record.get("device_id")
1093
+ if device_id and str(record_device_id or "") != str(device_id):
1094
+ raise RuntimeError(
1095
+ f"Container {vmid} is managed for device {record_device_id!r}, not {device_id!r}."
1096
+ )
874
1097
  return record, ct_cfg
875
1098
 
876
1099
 
@@ -1371,6 +1594,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1371
1594
  ssh_public_keys=payload.get("ssh_public_key") or None,
1372
1595
  )
1373
1596
  status, elapsed = _wait_for_task(proxmox, node, upid)
1597
+ exitstatus = (status or {}).get("exitstatus")
1598
+ if exitstatus and exitstatus.upper() != "OK":
1599
+ msg = status.get("status") or "unknown error"
1600
+ details = status.get("error") or status.get("errmsg") or status.get("description") or status
1601
+ raise RuntimeError(
1602
+ f"Container creation task failed ({exitstatus}): {msg} details={details}"
1603
+ )
1374
1604
  return vmid, elapsed
1375
1605
  except ResourceException as exc:
1376
1606
  raise RuntimeError(f"Failed to create container: {exc}") from exc
@@ -1817,10 +2047,13 @@ class StartProxmoxContainerHandler(SyncHandler):
1817
2047
 
1818
2048
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1819
2049
  vmid = _parse_ctid(message)
2050
+ child_device_id = (message.get("child_device_id") or "").strip()
2051
+ if not child_device_id:
2052
+ raise ValueError("child_device_id is required for start_proxmox_container")
1820
2053
  config = _ensure_infra_configured()
1821
2054
  proxmox = _connect_proxmox(config)
1822
2055
  node = _get_node_from_config(config)
1823
- _ensure_container_managed(proxmox, node, vmid)
2056
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1824
2057
 
1825
2058
  status, elapsed = _start_container(proxmox, node, vmid)
1826
2059
  _update_container_record(vmid, {"status": "running"})
@@ -1847,10 +2080,13 @@ class StopProxmoxContainerHandler(SyncHandler):
1847
2080
 
1848
2081
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1849
2082
  vmid = _parse_ctid(message)
2083
+ child_device_id = (message.get("child_device_id") or "").strip()
2084
+ if not child_device_id:
2085
+ raise ValueError("child_device_id is required for stop_proxmox_container")
1850
2086
  config = _ensure_infra_configured()
1851
2087
  proxmox = _connect_proxmox(config)
1852
2088
  node = _get_node_from_config(config)
1853
- _ensure_container_managed(proxmox, node, vmid)
2089
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1854
2090
 
1855
2091
  status, elapsed = _stop_container(proxmox, node, vmid)
1856
2092
  final_status = status.get("status") or "stopped"
@@ -1883,10 +2119,13 @@ class RemoveProxmoxContainerHandler(SyncHandler):
1883
2119
 
1884
2120
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1885
2121
  vmid = _parse_ctid(message)
2122
+ child_device_id = (message.get("child_device_id") or "").strip()
2123
+ if not child_device_id:
2124
+ raise ValueError("child_device_id is required for remove_proxmox_container")
1886
2125
  config = _ensure_infra_configured()
1887
2126
  proxmox = _connect_proxmox(config)
1888
2127
  node = _get_node_from_config(config)
1889
- _ensure_container_managed(proxmox, node, vmid)
2128
+ _ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
1890
2129
 
1891
2130
  stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1892
2131
  delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
@@ -685,6 +685,7 @@ class TerminalManager:
685
685
  try:
686
686
  # Initialize project state
687
687
  project_state = await manager.initialize_project_state(session_name, project_folder_path)
688
+ await self._restore_tabs_from_session_metadata(manager, project_state, session)
688
689
 
689
690
  # Send initial project state to the client
690
691
  initial_state_payload = {
@@ -710,6 +711,85 @@ class TerminalManager:
710
711
 
711
712
  except Exception as exc:
712
713
  logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
714
+
715
+ async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
716
+ """Restore open tabs/active tab from client session metadata if available."""
717
+ if not session or not project_state:
718
+ return
719
+
720
+ descriptors = session.get("open_tabs") or []
721
+ if not descriptors:
722
+ return
723
+
724
+ session_id = project_state.client_session_id
725
+ logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
726
+
727
+ for descriptor in descriptors:
728
+ parsed = self._parse_tab_descriptor(descriptor)
729
+ if not parsed:
730
+ continue
731
+
732
+ tab_type = parsed.get("tab_type")
733
+ file_path = parsed.get("file_path")
734
+ metadata = parsed.get("metadata", {})
735
+
736
+ if tab_type == "file" and file_path:
737
+ try:
738
+ await manager.open_file(session_id, file_path, set_active=False)
739
+ except Exception as exc:
740
+ logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
741
+ continue
742
+
743
+ if tab_type == "diff" and file_path:
744
+ from_ref = metadata.get("from") or metadata.get("from_ref")
745
+ to_ref = metadata.get("to") or metadata.get("to_ref")
746
+ if not from_ref or not to_ref:
747
+ logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
748
+ continue
749
+ from_hash = metadata.get("from_hash") or metadata.get("fromHash")
750
+ to_hash = metadata.get("to_hash") or metadata.get("toHash")
751
+ try:
752
+ await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
753
+ except Exception as exc:
754
+ logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
755
+ continue
756
+
757
+ logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
758
+
759
+ active_index = session.get("active_tab")
760
+ try:
761
+ active_index_int = int(active_index) if active_index is not None else None
762
+ except (TypeError, ValueError):
763
+ active_index_int = None
764
+
765
+ if active_index_int is not None and active_index_int >= 0:
766
+ current_tabs = list(project_state.open_tabs.values())
767
+ if 0 <= active_index_int < len(current_tabs):
768
+ try:
769
+ await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
770
+ except Exception as exc:
771
+ logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
772
+ else:
773
+ logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
774
+
775
+ def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
776
+ """Parse a URL-friendly tab descriptor string."""
777
+ if not descriptor:
778
+ return None
779
+
780
+ try:
781
+ parts = descriptor.split("|")
782
+ tab_type = parts[0] if parts else None
783
+ file_path = parts[1] if len(parts) > 1 else None
784
+ metadata = {}
785
+ for part in parts[2:]:
786
+ if "=" in part:
787
+ key, value = part.split("=", 1)
788
+ metadata[key] = value
789
+ return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
790
+ except Exception as exc:
791
+ logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
792
+ return None
713
793
 
714
794
  async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
715
795
  """Send terminal_list command to specific client sessions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.15.dev23
3
+ Version: 1.4.16.dev5
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=8YGMPC5_rqCpJnjJF3sLM5flS3H0d8do1siDivvmwVs,721
4
+ portacode/_version.py,sha256=7ZUO3SboTD37oV4BJWi-Sx9y4MGlZpUHiLsekZex9ds,719
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
15
+ portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=K7ZjHIWwscX0nBLinpEclO2TGSAiZPBr_JMJUGrn0To,103095
18
18
  portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=cAueQb_D3COmPI84LfI1bqHOcgt02BOgho_pCRGeLWE,71132
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=Z7C0ekRkTB7g4oeye2pE6K5EYdIg6QF6OAnmq1dBLBQ,80780
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
35
35
  portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
36
36
  portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
37
37
  portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
38
- portacode/connection/handlers/project_state/manager.py,sha256=pRMZqPOTK9YE3abNxiAbnERIJmRys673HFOEIBiKnm4,67184
38
+ portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
39
39
  portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
40
40
  portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
41
41
  portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.15.dev23.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.16.dev5.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.15.dev23.dist-info/METADATA,sha256=1pp2pJiX9YjuOBghWO4mYN7fkpK0dqJSv6CdFMgbwOQ,13052
95
- portacode-1.4.15.dev23.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.15.dev23.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.15.dev23.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.15.dev23.dist-info/RECORD,,
94
+ portacode-1.4.16.dev5.dist-info/METADATA,sha256=qKD5PeIEODD6P9-ODtKSE5q5Tqg1UCk0e7N3xt_Ws-4,13051
95
+ portacode-1.4.16.dev5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.16.dev5.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.16.dev5.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.16.dev5.dist-info/RECORD,,