portacode 1.4.11.dev10__py3-none-any.whl → 1.4.12__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.11.dev10'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev10')
31
+ __version__ = version = '1.4.12'
32
+ __version_tuple__ = version_tuple = (1, 4, 12)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -367,6 +367,45 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
367
367
  * On success, the device will emit a [`proxmox_container_created`](#proxmox_container_created-event) event that includes the Portacode auth key produced inside the container.
368
368
  * On failure, the device will emit an [`error`](#error) event.
369
369
 
370
+ ### `start_proxmox_container`
371
+
372
+ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`StartProxmoxContainerHandler`](./proxmox_infra.py).
373
+
374
+ **Payload Fields:**
375
+
376
+ * `ctid` (string, required): Identifier of the container to start.
377
+
378
+ **Responses:**
379
+
380
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="start"` and the refreshed infra snapshot.
381
+ * Emits an [`error`](#error) event when the request cannot be fulfilled (e.g., missing infra config, CT not tagged as managed, or API failure).
382
+
383
+ ### `stop_proxmox_container`
384
+
385
+ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHandler`](./proxmox_infra.py).
386
+
387
+ **Payload Fields:**
388
+
389
+ * `ctid` (string, required): Identifier of the container to stop.
390
+
391
+ **Responses:**
392
+
393
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="stop"` and the refreshed infra snapshot.
394
+ * Emits an [`error`](#error) event on failure.
395
+
396
+ ### `remove_proxmox_container`
397
+
398
+ Deletes a managed container from Proxmox (stopping it first if necessary) and removes the stored metadata file. Handled by [`RemoveProxmoxContainerHandler`](./proxmox_infra.py).
399
+
400
+ **Payload Fields:**
401
+
402
+ * `ctid` (string, required): Identifier of the container to delete.
403
+
404
+ **Responses:**
405
+
406
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
407
+ * Emits an [`error`](#error) event on failure.
408
+
370
409
  ### `proxmox_container_created`
371
410
 
372
411
  Emitted after a successful `create_proxmox_container` action. Contains the new container ID, the Portacode public key produced inside the container, and the bootstrap logs.
@@ -1067,21 +1106,37 @@ Provides system information in response to a `system_info` action. Handled by [`
1067
1106
  * `proxmox` (object): Detection hints for Proxmox VE nodes:
1068
1107
  * `is_proxmox_node` (boolean): True when Proxmox artifacts (e.g., `/etc/proxmox-release`) exist.
1069
1108
  * `version` (string|null): Raw contents of `/etc/proxmox-release` when readable.
1070
- * `infra` (object): Portacode infrastructure configuration snapshot:
1071
- * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1072
- * `host` (string|null): Hostname used for the API client (usually `localhost`).
1073
- * `node` (string|null): Proxmox node name that was targeted.
1074
- * `user` (string|null): API token owner (e.g., `root@pam`).
1075
- * `token_name` (string|null): API token identifier.
1076
- * `default_storage` (string|null): Storage pool chosen for future containers.
1077
- * `templates` (array[string]): Cached list of available LXC templates.
1078
- * `last_verified` (string|null): ISO timestamp when the token was last validated.
1079
- * `network` (object):
1080
- * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1081
- * `message` (string|null): Informational text about the network setup attempt.
1082
- * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1083
- * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1084
- * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1109
+ * `infra` (object): Portacode infrastructure configuration snapshot:
1110
+ * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1111
+ * `host` (string|null): Hostname used for the API client (usually `localhost`).
1112
+ * `node` (string|null): Proxmox node name that was targeted.
1113
+ * `user` (string|null): API token owner (e.g., `root@pam`).
1114
+ * `token_name` (string|null): API token identifier.
1115
+ * `default_storage` (string|null): Storage pool chosen for future containers.
1116
+ * `templates` (array[string]): Cached list of available LXC templates.
1117
+ * `last_verified` (string|null): ISO timestamp when the token was last validated.
1118
+ * `network` (object):
1119
+ * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1120
+ * `message` (string|null): Informational text about the network setup attempt.
1121
+ * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1122
+ * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1123
+ * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1124
+ * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1125
+ * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1126
+ * `count` (integer): Number of managed containers.
1127
+ * `total_ram_mib` (integer): RAM footprint summed across all containers.
1128
+ * `total_disk_gib` (integer): Disk footprint summed across all containers.
1129
+ * `total_cpu_share` (number): CPU shares requested across all containers.
1130
+ * `containers` (array[object]): Container summaries with the following fields:
1131
+ * `vmid` (string|null): Numeric CT ID.
1132
+ * `hostname` (string|null): Hostname configured in the CT.
1133
+ * `template` (string|null): Template identifier used.
1134
+ * `storage` (string|null): Storage pool backing the rootfs.
1135
+ * `disk_gib` (integer): Rootfs size in GiB.
1136
+ * `ram_mib` (integer): Memory size in MiB.
1137
+ * `cpu_share` (number): vCPU-equivalent share requested at creation.
1138
+ * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1139
+ * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1085
1140
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1086
1141
 
1087
1142
  ### `proxmox_infra_configured`
@@ -1133,6 +1188,20 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1133
1188
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1134
1189
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1135
1190
 
1191
+ ### `proxmox_container_action`
1192
+
1193
+ Emitted after `start_proxmox_container`, `stop_proxmox_container`, or `remove_proxmox_container` commands complete. Each event includes the refreshed infra snapshot so dashboards can immediately display the latest managed container totals even though the `proxmox.infra.managed_containers` cache updates only every ~30 seconds.
1194
+
1195
+ **Event Fields:**
1196
+
1197
+ * `action` (string): The action that ran (`start`, `stop`, or `remove`).
1198
+ * `success` (boolean): True when the requested action succeeded.
1199
+ * `ctid` (string): Target CT ID.
1200
+ * `message` (string): Human-friendly summary (e.g., `Stopped container 103`).
1201
+ * `status` (string): The container’s new status (e.g., `running`, `stopped`, `deleted`).
1202
+ * `details` (object, optional): Exit status information (e.g., `exitstatus`, `stop_exitstatus`, `delete_exitstatus`).
1203
+ * `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`, including the updated `managed_containers` summary.
1204
+
1136
1205
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1137
1206
 
1138
1207
  Reply sent by the gateway immediately after receiving a `clock_sync_request`. Devices use this event plus the measured round-trip time to keep their local `ntp_clock` offset accurate.
@@ -46,6 +46,9 @@ from .proxmox_infra import (
46
46
  CreateProxmoxContainerHandler,
47
47
  RevertProxmoxInfraHandler,
48
48
  StartPortacodeServiceHandler,
49
+ StartProxmoxContainerHandler,
50
+ StopProxmoxContainerHandler,
51
+ RemoveProxmoxContainerHandler,
49
52
  )
50
53
 
51
54
  __all__ = [
@@ -86,6 +89,9 @@ __all__ = [
86
89
  "ProjectStateGitRevertHandler",
87
90
  "ProjectStateGitCommitHandler",
88
91
  "StartPortacodeServiceHandler",
92
+ "StartProxmoxContainerHandler",
93
+ "StopProxmoxContainerHandler",
94
+ "RemoveProxmoxContainerHandler",
89
95
  "UpdatePortacodeHandler",
90
96
  "RevertProxmoxInfraHandler",
91
97
  ]
@@ -12,6 +12,7 @@ import stat
12
12
  import subprocess
13
13
  import sys
14
14
  import time
15
+ import threading
15
16
  from datetime import datetime
16
17
  from pathlib import Path
17
18
  from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
@@ -40,6 +41,9 @@ DNS_SERVER = "1.1.1.1"
40
41
  IFACES_PATH = Path("/etc/network/interfaces")
41
42
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
42
43
  UNIT_DIR = Path("/etc/systemd/system")
44
+ _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
45
+ _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
46
+ _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
43
47
 
44
48
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
45
49
 
@@ -55,6 +59,7 @@ def _emit_progress_event(
55
59
  message: str,
56
60
  phase: str,
57
61
  request_id: Optional[str],
62
+ client_sessions: Optional[List[str]] = None,
58
63
  details: Optional[Dict[str, Any]] = None,
59
64
  ) -> None:
60
65
  loop = handler.context.get("event_loop")
@@ -80,6 +85,8 @@ def _emit_progress_event(
80
85
  payload["request_id"] = request_id
81
86
  if details:
82
87
  payload["details"] = details
88
+ if client_sessions:
89
+ payload["client_sessions"] = client_sessions
83
90
 
84
91
  future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
85
92
  future.add_done_callback(
@@ -290,6 +297,117 @@ def _ensure_containers_dir() -> None:
290
297
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
291
298
 
292
299
 
300
+ def _invalidate_managed_containers_cache() -> None:
301
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
302
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
303
+ _MANAGED_CONTAINERS_CACHE["summary"] = None
304
+
305
+
306
+ def _load_managed_container_records() -> List[Dict[str, Any]]:
307
+ _ensure_containers_dir()
308
+ records: List[Dict[str, Any]] = []
309
+ for path in sorted(CONTAINERS_DIR.glob("ct-*.json")):
310
+ try:
311
+ payload = json.loads(path.read_text(encoding="utf-8"))
312
+ except Exception as exc: # pragma: no cover - best effort logging
313
+ logger.debug("Unable to read container record %s: %s", path, exc)
314
+ continue
315
+ records.append(payload)
316
+ return records
317
+
318
+
319
+ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
320
+ total_ram = 0
321
+ total_disk = 0
322
+ total_cpu_share = 0.0
323
+ containers: List[Dict[str, Any]] = []
324
+
325
+ def _as_int(value: Any) -> int:
326
+ try:
327
+ return int(value)
328
+ except (TypeError, ValueError):
329
+ return 0
330
+
331
+ def _as_float(value: Any) -> float:
332
+ try:
333
+ return float(value)
334
+ except (TypeError, ValueError):
335
+ return 0.0
336
+
337
+ for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
338
+ ram_mib = _as_int(record.get("ram_mib"))
339
+ disk_gib = _as_int(record.get("disk_gib"))
340
+ cpu_share = _as_float(record.get("cpus"))
341
+ total_ram += ram_mib
342
+ total_disk += disk_gib
343
+ total_cpu_share += cpu_share
344
+ status = (record.get("status") or "unknown").lower()
345
+ containers.append(
346
+ {
347
+ "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
348
+ "hostname": record.get("hostname"),
349
+ "template": record.get("template"),
350
+ "storage": record.get("storage"),
351
+ "disk_gib": disk_gib,
352
+ "ram_mib": ram_mib,
353
+ "cpu_share": cpu_share,
354
+ "created_at": record.get("created_at"),
355
+ "status": status,
356
+ }
357
+ )
358
+
359
+ return {
360
+ "updated_at": datetime.utcnow().isoformat() + "Z",
361
+ "count": len(containers),
362
+ "total_ram_mib": total_ram,
363
+ "total_disk_gib": total_disk,
364
+ "total_cpu_share": round(total_cpu_share, 2),
365
+ "containers": containers,
366
+ }
367
+
368
+
369
+ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
370
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
371
+ if not records or not config:
372
+ return
373
+ try:
374
+ proxmox = _connect_proxmox(config)
375
+ node = _get_node_from_config(config)
376
+ statuses = {
377
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
378
+ for ct in proxmox.nodes(node).lxc.get()
379
+ }
380
+ except Exception as exc: # pragma: no cover - best effort
381
+ logger.debug("Failed to refresh container statuses: %s", exc)
382
+ return
383
+ for record in records:
384
+ vmid = record.get("vmid")
385
+ if vmid is None:
386
+ continue
387
+ try:
388
+ vmid_key = str(int(vmid))
389
+ except (ValueError, TypeError):
390
+ continue
391
+ status = statuses.get(vmid_key)
392
+ if status:
393
+ record["status"] = status
394
+
395
+ now = time.monotonic()
396
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
397
+ cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
398
+ cached = _MANAGED_CONTAINERS_CACHE["summary"]
399
+ if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
400
+ return cached
401
+ config = _load_config()
402
+ records = _load_managed_container_records()
403
+ _refresh_container_statuses(records, config)
404
+ summary = _build_managed_containers_summary(records)
405
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
406
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = now
407
+ _MANAGED_CONTAINERS_CACHE["summary"] = summary
408
+ return summary
409
+
410
+
293
411
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
294
412
  if storage_type in ("lvm", "lvmthin"):
295
413
  return f"{storage}:{disk_gib}"
@@ -365,14 +483,14 @@ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) ->
365
483
  return ""
366
484
 
367
485
 
368
- def _validate_positive_int(value: Any, default: int) -> int:
486
+ def _validate_positive_number(value: Any, default: float) -> float:
369
487
  try:
370
- candidate = int(value)
488
+ candidate = float(value)
371
489
  if candidate > 0:
372
490
  return candidate
373
491
  except Exception:
374
492
  pass
375
- return default
493
+ return float(default)
376
494
 
377
495
 
378
496
  def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
@@ -424,10 +542,24 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
424
542
  return _wait_for_task(proxmox, node, upid)
425
543
 
426
544
 
545
+ def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
546
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
547
+ if status.get("status") != "running":
548
+ return status, 0.0
549
+ upid = proxmox.nodes(node).lxc(vmid).status.stop.post()
550
+ return _wait_for_task(proxmox, node, upid)
551
+
552
+
553
+ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
554
+ upid = proxmox.nodes(node).lxc(vmid).delete()
555
+ return _wait_for_task(proxmox, node, upid)
556
+
557
+
427
558
  def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
428
559
  _ensure_containers_dir()
429
560
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
430
561
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
562
+ _invalidate_managed_containers_cache()
431
563
 
432
564
 
433
565
  def _read_container_record(vmid: int) -> Dict[str, Any]:
@@ -437,6 +569,19 @@ def _read_container_record(vmid: int) -> Dict[str, Any]:
437
569
  return json.loads(path.read_text(encoding="utf-8"))
438
570
 
439
571
 
572
+ def _update_container_record(vmid: int, updates: Dict[str, Any]) -> None:
573
+ record = _read_container_record(vmid)
574
+ record.update(updates)
575
+ _write_container_record(vmid, record)
576
+
577
+
578
+ def _remove_container_record(vmid: int) -> None:
579
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
580
+ if path.exists():
581
+ path.unlink()
582
+ _invalidate_managed_containers_cache()
583
+
584
+
440
585
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
441
586
  templates = config.get("templates") or []
442
587
  default_template = templates[0] if templates else ""
@@ -446,9 +591,9 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
446
591
 
447
592
  bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
448
593
  hostname = (message.get("hostname") or "").strip()
449
- disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
450
- ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
451
- cpus = _validate_positive_int(message.get("cpus"), 1)
594
+ disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
595
+ ram_mib = int(max(round(_validate_positive_number(message.get("ram_mib") or message.get("ram"), 2048)), 1))
596
+ cpus = _validate_positive_number(message.get("cpus"), 0.2)
452
597
  storage = message.get("storage") or config.get("default_storage") or ""
453
598
  if not storage:
454
599
  raise ValueError("Storage pool could not be determined.")
@@ -473,6 +618,38 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
473
618
  return payload
474
619
 
475
620
 
621
+ def _ensure_infra_configured() -> Dict[str, Any]:
622
+ config = _load_config()
623
+ if not config or not config.get("token_value"):
624
+ raise RuntimeError("Proxmox infrastructure is not configured.")
625
+ return config
626
+
627
+
628
+ def _get_node_from_config(config: Dict[str, Any]) -> str:
629
+ return config.get("node") or DEFAULT_NODE_NAME
630
+
631
+
632
+ def _parse_ctid(message: Dict[str, Any]) -> int:
633
+ for key in ("ctid", "vmid"):
634
+ value = message.get(key)
635
+ if value is not None:
636
+ try:
637
+ return int(str(value).strip())
638
+ except ValueError:
639
+ raise ValueError(f"{key} must be an integer") from None
640
+ raise ValueError("ctid is required")
641
+
642
+
643
+ def _ensure_container_managed(
644
+ proxmox: Any, node: str, vmid: int
645
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
646
+ record = _read_container_record(vmid)
647
+ ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
648
+ if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
649
+ raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
650
+ return record, ct_cfg
651
+
652
+
476
653
  def _connect_proxmox(config: Dict[str, Any]) -> Any:
477
654
  ProxmoxAPI = _ensure_proxmoxer()
478
655
  return ProxmoxAPI(
@@ -528,15 +705,22 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
528
705
 
529
706
  last_pub = last_priv = None
530
707
  stable = 0
708
+ history: List[Dict[str, Any]] = []
709
+
710
+ process_exited = False
711
+ exit_out = exit_err = ""
531
712
  while time.time() - start < timeout_s:
532
713
  if proc.poll() is not None:
533
- out, err = proc.communicate(timeout=1)
534
- return {
535
- "ok": False,
536
- "error": "portacode connect exited before keys were created",
537
- "stdout": (out or "").strip(),
538
- "stderr": (err or "").strip(),
539
- }
714
+ process_exited = True
715
+ exit_out, exit_err = proc.communicate(timeout=1)
716
+ history.append(
717
+ {
718
+ "timestamp_s": round(time.time() - start, 2),
719
+ "status": "process_exited",
720
+ "returncode": proc.returncode,
721
+ }
722
+ )
723
+ break
540
724
  pub_size = file_size(pub_path)
541
725
  priv_size = file_size(priv_path)
542
726
  if pub_size and priv_size:
@@ -546,21 +730,60 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
546
730
  stable = 0
547
731
  last_pub, last_priv = pub_size, priv_size
548
732
  if stable >= 1:
733
+ history.append(
734
+ {
735
+ "timestamp_s": round(time.time() - start, 2),
736
+ "pub_size": pub_size,
737
+ "priv_size": priv_size,
738
+ "stable": stable,
739
+ }
740
+ )
549
741
  break
742
+ history.append(
743
+ {
744
+ "timestamp_s": round(time.time() - start, 2),
745
+ "pub_size": pub_size,
746
+ "priv_size": priv_size,
747
+ "stable": stable,
748
+ }
749
+ )
550
750
  time.sleep(1)
551
751
 
552
- if stable < 1:
752
+ final_pub = file_size(pub_path)
753
+ final_priv = file_size(priv_path)
754
+ if final_pub and final_priv:
755
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
756
+ if not process_exited:
757
+ proc.terminate()
758
+ try:
759
+ proc.wait(timeout=3)
760
+ except subprocess.TimeoutExpired:
761
+ proc.kill()
762
+ return {
763
+ "ok": True,
764
+ "public_key": key_res["stdout"].strip(),
765
+ "history": history,
766
+ }
767
+
768
+ if not process_exited:
553
769
  proc.terminate()
554
770
  try:
555
771
  proc.wait(timeout=3)
556
772
  except subprocess.TimeoutExpired:
557
773
  proc.kill()
558
- out, err = proc.communicate(timeout=1)
774
+ exit_out, exit_err = proc.communicate(timeout=1)
775
+ history.append(
776
+ {
777
+ "timestamp_s": round(time.time() - start, 2),
778
+ "status": "timeout_waiting_for_keys",
779
+ }
780
+ )
559
781
  return {
560
782
  "ok": False,
561
783
  "error": "timed out waiting for portacode key files",
562
- "stdout": (out or "").strip(),
563
- "stderr": (err or "").strip(),
784
+ "stdout": (exit_out or "").strip(),
785
+ "stderr": (exit_err or "").strip(),
786
+ "history": history,
564
787
  }
565
788
 
566
789
  proc.terminate()
@@ -573,6 +796,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
573
796
  return {
574
797
  "ok": True,
575
798
  "public_key": key_res["stdout"].strip(),
799
+ "history": history,
576
800
  }
577
801
 
578
802
 
@@ -670,6 +894,19 @@ def _bootstrap_portacode(
670
894
  total_steps=total_steps,
671
895
  )
672
896
  if not ok:
897
+ details = results[-1] if results else {}
898
+ summary = details.get("error_summary") or details.get("stderr") or details.get("stdout") or details.get("name")
899
+ history = details.get("history")
900
+ history_snippet = ""
901
+ if isinstance(history, list) and history:
902
+ history_snippet = f" history={history[-3:]}"
903
+ if summary:
904
+ logger.warning(
905
+ "Portacode bootstrap failure summary=%s%s",
906
+ summary,
907
+ f" history_len={len(history)}" if history else "",
908
+ )
909
+ raise RuntimeError(f"Portacode bootstrap steps failed: {summary}{history_snippet}")
673
910
  raise RuntimeError("Portacode bootstrap steps failed.")
674
911
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
675
912
  public_key = key_step.get("public_key") if key_step else None
@@ -748,6 +985,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
748
985
  _save_config(config)
749
986
  snapshot = build_snapshot(config)
750
987
  snapshot["node_status"] = status
988
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
751
989
  return snapshot
752
990
 
753
991
 
@@ -756,6 +994,7 @@ def get_infra_snapshot() -> Dict[str, Any]:
756
994
  snapshot = build_snapshot(config)
757
995
  if config.get("node_status"):
758
996
  snapshot["node_status"] = config["node_status"]
997
+ snapshot["managed_containers"] = _get_managed_containers_summary()
759
998
  return snapshot
760
999
 
761
1000
 
@@ -768,6 +1007,7 @@ def revert_infrastructure() -> Dict[str, Any]:
768
1007
  snapshot["network"]["applied"] = False
769
1008
  snapshot["network"]["message"] = "Reverted to previous network state"
770
1009
  snapshot["network"]["bridge"] = DEFAULT_BRIDGE
1010
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
771
1011
  return snapshot
772
1012
 
773
1013
 
@@ -791,7 +1031,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
791
1031
  rootfs=rootfs,
792
1032
  memory=int(payload["ram_mib"]),
793
1033
  swap=int(payload.get("swap_mb", 0)),
794
- cores=int(payload.get("cpus", 1)),
1034
+ cores=max(int(payload.get("cores", 1)), 1),
795
1035
  cpuunits=int(payload.get("cpuunits", 256)),
796
1036
  net0=payload["net0"],
797
1037
  unprivileged=int(payload.get("unprivileged", 1)),
@@ -815,6 +1055,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
815
1055
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
816
1056
  logger.info("create_proxmox_container command received")
817
1057
  request_id = message.get("request_id")
1058
+ source_client_session = message.get("source_client_session")
1059
+ client_sessions = [source_client_session] if source_client_session else None
818
1060
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
819
1061
  bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
820
1062
  total_steps = 3 + len(bootstrap_steps) + 2
@@ -838,6 +1080,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
838
1080
  message=start_message,
839
1081
  phase="lifecycle",
840
1082
  request_id=request_id,
1083
+ client_sessions=client_sessions,
841
1084
  )
842
1085
  try:
843
1086
  result = action()
@@ -852,6 +1095,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
852
1095
  message=f"{step_label} failed: {exc}",
853
1096
  phase="lifecycle",
854
1097
  request_id=request_id,
1098
+ client_sessions=client_sessions,
855
1099
  details={"error": str(exc)},
856
1100
  )
857
1101
  raise
@@ -865,6 +1109,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
865
1109
  message=success_message,
866
1110
  phase="lifecycle",
867
1111
  request_id=request_id,
1112
+ client_sessions=client_sessions,
868
1113
  )
869
1114
  current_step_index += 1
870
1115
  return result
@@ -905,6 +1150,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
905
1150
  vmid, _ = _instantiate_container(proxmox, node, payload)
906
1151
  payload["vmid"] = vmid
907
1152
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1153
+ payload["status"] = "creating"
908
1154
  _write_container_record(vmid, payload)
909
1155
  return proxmox, node, vmid, payload
910
1156
 
@@ -926,6 +1172,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
926
1172
  "Container startup completed.",
927
1173
  _start_container_step,
928
1174
  )
1175
+ _update_container_record(vmid, {"status": "running"})
929
1176
 
930
1177
  def _bootstrap_progress_callback(
931
1178
  step_index: int,
@@ -963,6 +1210,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
963
1210
  message=message_text,
964
1211
  phase="bootstrap",
965
1212
  request_id=request_id,
1213
+ client_sessions=client_sessions,
966
1214
  details=details or None,
967
1215
  )
968
1216
 
@@ -978,7 +1226,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
978
1226
  )
979
1227
  current_step_index += len(bootstrap_steps)
980
1228
 
981
- return {
1229
+ response = {
982
1230
  "event": "proxmox_container_created",
983
1231
  "success": True,
984
1232
  "message": f"Container {vmid} is ready and Portacode key captured.",
@@ -995,6 +1243,9 @@ class CreateProxmoxContainerHandler(SyncHandler):
995
1243
  },
996
1244
  "setup_steps": steps,
997
1245
  }
1246
+ if client_sessions:
1247
+ response["client_sessions"] = client_sessions
1248
+ return response
998
1249
 
999
1250
 
1000
1251
  class StartPortacodeServiceHandler(SyncHandler):
@@ -1022,6 +1273,8 @@ class StartPortacodeServiceHandler(SyncHandler):
1022
1273
  start_index = int(message.get("step_index", 1))
1023
1274
  total_steps = int(message.get("total_steps", start_index + 2))
1024
1275
  request_id = message.get("request_id")
1276
+ source_client_session = message.get("source_client_session")
1277
+ client_sessions = [source_client_session] if source_client_session else None
1025
1278
 
1026
1279
  auth_step_name = "setup_device_authentication"
1027
1280
  auth_label = "Setting up device authentication"
@@ -1035,6 +1288,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1035
1288
  message="Notifying the server of the new device…",
1036
1289
  phase="service",
1037
1290
  request_id=request_id,
1291
+ client_sessions=client_sessions,
1038
1292
  )
1039
1293
  _emit_progress_event(
1040
1294
  self,
@@ -1046,6 +1300,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1046
1300
  message="Authentication metadata recorded.",
1047
1301
  phase="service",
1048
1302
  request_id=request_id,
1303
+ client_sessions=client_sessions,
1049
1304
  )
1050
1305
 
1051
1306
  install_step = start_index + 1
@@ -1060,6 +1315,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1060
1315
  message="Running sudo portacode service install…",
1061
1316
  phase="service",
1062
1317
  request_id=request_id,
1318
+ client_sessions=client_sessions,
1063
1319
  )
1064
1320
 
1065
1321
  cmd = f"su - {user} -c 'sudo -S portacode service install'"
@@ -1076,6 +1332,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1076
1332
  message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1077
1333
  phase="service",
1078
1334
  request_id=request_id,
1335
+ client_sessions=client_sessions,
1079
1336
  details={
1080
1337
  "stderr": res.get("stderr"),
1081
1338
  "stdout": res.get("stdout"),
@@ -1093,14 +1350,133 @@ class StartPortacodeServiceHandler(SyncHandler):
1093
1350
  message="Portacode service install finished.",
1094
1351
  phase="service",
1095
1352
  request_id=request_id,
1353
+ client_sessions=client_sessions,
1096
1354
  )
1097
1355
 
1098
- return {
1356
+ response = {
1099
1357
  "event": "proxmox_service_started",
1100
1358
  "success": True,
1101
1359
  "message": "Portacode service install completed",
1102
1360
  "ctid": str(vmid),
1103
1361
  }
1362
+ if client_sessions:
1363
+ response["client_sessions"] = client_sessions
1364
+ return response
1365
+
1366
+
1367
+ class StartProxmoxContainerHandler(SyncHandler):
1368
+ """Start a managed container via the Proxmox API."""
1369
+
1370
+ @property
1371
+ def command_name(self) -> str:
1372
+ return "start_proxmox_container"
1373
+
1374
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1375
+ vmid = _parse_ctid(message)
1376
+ config = _ensure_infra_configured()
1377
+ proxmox = _connect_proxmox(config)
1378
+ node = _get_node_from_config(config)
1379
+ _ensure_container_managed(proxmox, node, vmid)
1380
+ source_client_session = message.get("source_client_session")
1381
+ client_sessions = [source_client_session] if source_client_session else None
1382
+
1383
+ status, elapsed = _start_container(proxmox, node, vmid)
1384
+ _update_container_record(vmid, {"status": "running"})
1385
+
1386
+ infra = get_infra_snapshot()
1387
+ response = {
1388
+ "event": "proxmox_container_action",
1389
+ "action": "start",
1390
+ "success": True,
1391
+ "ctid": str(vmid),
1392
+ "message": f"Started container {vmid} in {elapsed:.1f}s.",
1393
+ "details": {"exitstatus": status.get("exitstatus")},
1394
+ "status": status.get("status"),
1395
+ "infra": infra,
1396
+ }
1397
+ if client_sessions:
1398
+ response["client_sessions"] = client_sessions
1399
+ return response
1400
+
1401
+
1402
+ class StopProxmoxContainerHandler(SyncHandler):
1403
+ """Stop a managed container via the Proxmox API."""
1404
+
1405
+ @property
1406
+ def command_name(self) -> str:
1407
+ return "stop_proxmox_container"
1408
+
1409
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1410
+ vmid = _parse_ctid(message)
1411
+ config = _ensure_infra_configured()
1412
+ proxmox = _connect_proxmox(config)
1413
+ node = _get_node_from_config(config)
1414
+ _ensure_container_managed(proxmox, node, vmid)
1415
+ source_client_session = message.get("source_client_session")
1416
+ client_sessions = [source_client_session] if source_client_session else None
1417
+
1418
+ status, elapsed = _stop_container(proxmox, node, vmid)
1419
+ final_status = status.get("status") or "stopped"
1420
+ _update_container_record(vmid, {"status": final_status})
1421
+
1422
+ infra = get_infra_snapshot()
1423
+ message_text = (
1424
+ f"Container {vmid} is already stopped."
1425
+ if final_status != "running" and elapsed == 0.0
1426
+ else f"Stopped container {vmid} in {elapsed:.1f}s."
1427
+ )
1428
+ response = {
1429
+ "event": "proxmox_container_action",
1430
+ "action": "stop",
1431
+ "success": True,
1432
+ "ctid": str(vmid),
1433
+ "message": message_text,
1434
+ "details": {"exitstatus": status.get("exitstatus")},
1435
+ "status": final_status,
1436
+ "infra": infra,
1437
+ }
1438
+ if client_sessions:
1439
+ response["client_sessions"] = client_sessions
1440
+ return response
1441
+
1442
+
1443
+ class RemoveProxmoxContainerHandler(SyncHandler):
1444
+ """Delete a managed container via the Proxmox API."""
1445
+
1446
+ @property
1447
+ def command_name(self) -> str:
1448
+ return "remove_proxmox_container"
1449
+
1450
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1451
+ vmid = _parse_ctid(message)
1452
+ config = _ensure_infra_configured()
1453
+ proxmox = _connect_proxmox(config)
1454
+ node = _get_node_from_config(config)
1455
+ _ensure_container_managed(proxmox, node, vmid)
1456
+ source_client_session = message.get("source_client_session")
1457
+ client_sessions = [source_client_session] if source_client_session else None
1458
+
1459
+ stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1460
+ delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1461
+ _remove_container_record(vmid)
1462
+
1463
+ infra = get_infra_snapshot()
1464
+ response = {
1465
+ "event": "proxmox_container_action",
1466
+ "action": "remove",
1467
+ "success": True,
1468
+ "ctid": str(vmid),
1469
+ "message": f"Deleted container {vmid} in {delete_elapsed:.1f}s.",
1470
+ "details": {
1471
+ "stop_exitstatus": stop_status.get("exitstatus"),
1472
+ "delete_exitstatus": delete_status.get("exitstatus"),
1473
+ },
1474
+ "status": "deleted",
1475
+ "infra": infra,
1476
+ }
1477
+ if client_sessions:
1478
+ response["client_sessions"] = client_sessions
1479
+ return response
1104
1480
 
1105
1481
 
1106
1482
  class ConfigureProxmoxInfraHandler(SyncHandler):
@@ -1114,13 +1490,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
1114
1490
  verify_ssl = bool(message.get("verify_ssl"))
1115
1491
  if not token_identifier or not token_value:
1116
1492
  raise ValueError("token_identifier and token_value are required")
1493
+ source_client_session = message.get("source_client_session")
1494
+ client_sessions = [source_client_session] if source_client_session else None
1117
1495
  snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
1118
- return {
1496
+ response = {
1119
1497
  "event": "proxmox_infra_configured",
1120
1498
  "success": True,
1121
1499
  "message": "Proxmox infrastructure configured",
1122
1500
  "infra": snapshot,
1123
1501
  }
1502
+ if client_sessions:
1503
+ response["client_sessions"] = client_sessions
1504
+ return response
1124
1505
 
1125
1506
 
1126
1507
  class RevertProxmoxInfraHandler(SyncHandler):
@@ -1129,10 +1510,15 @@ class RevertProxmoxInfraHandler(SyncHandler):
1129
1510
  return "revert_proxmox_infra"
1130
1511
 
1131
1512
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1513
+ source_client_session = message.get("source_client_session")
1514
+ client_sessions = [source_client_session] if source_client_session else None
1132
1515
  snapshot = revert_infrastructure()
1133
- return {
1516
+ response = {
1134
1517
  "event": "proxmox_infra_reverted",
1135
1518
  "success": True,
1136
1519
  "message": "Proxmox infrastructure configuration reverted",
1137
1520
  "infra": snapshot,
1138
1521
  }
1522
+ if client_sessions:
1523
+ response["client_sessions"] = client_sessions
1524
+ return response
@@ -56,6 +56,9 @@ from .handlers import (
56
56
  CreateProxmoxContainerHandler,
57
57
  RevertProxmoxInfraHandler,
58
58
  StartPortacodeServiceHandler,
59
+ StartProxmoxContainerHandler,
60
+ StopProxmoxContainerHandler,
61
+ RemoveProxmoxContainerHandler,
59
62
  )
60
63
  from .handlers.project_aware_file_handlers import (
61
64
  ProjectAwareFileWriteHandler,
@@ -480,6 +483,9 @@ class TerminalManager:
480
483
  self._command_registry.register(ConfigureProxmoxInfraHandler)
481
484
  self._command_registry.register(CreateProxmoxContainerHandler)
482
485
  self._command_registry.register(StartPortacodeServiceHandler)
486
+ self._command_registry.register(StartProxmoxContainerHandler)
487
+ self._command_registry.register(StopProxmoxContainerHandler)
488
+ self._command_registry.register(RemoveProxmoxContainerHandler)
483
489
  self._command_registry.register(RevertProxmoxInfraHandler)
484
490
  self._command_registry.register(UpdatePortacodeHandler)
485
491
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.11.dev10
3
+ Version: 1.4.12
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=N01xrWD9pWCsVQSV1BEau1bnnemce8pHIwKp_9Gicbo,721
4
+ portacode/_version.py,sha256=fngNnJDsfflZ81w9BH0O75dXs5-skjVThUqmwrIaOd0,706
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,17 +12,17 @@ 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=07wxG_55JMy3yQ9TXCBldW9h43qCW3U8rv2yzGMx4FM,44757
15
+ portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=Z5L0gJWoHxV7UonHqxHko_PXZd7Z1mbI6yWtmjxna-s,93951
18
- portacode/connection/handlers/__init__.py,sha256=y-Aj5SXqc_QJt7i1xkl7kv381Fd2CIcUiG5gR1F8qkI,2711
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=7tBYNEY8EBGAPIMT606BqeHnyMOQIZVlQYpH7me26LY,97962
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
21
21
  portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
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=dO1LjKOgIqubib_JF6AfILl3cMY3djP7Xq4iPrGPWMY,41192
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=qjzMq51xig7p4Y5EVZ1Oo9GA9mAxq4RG0anszL6leKw,55909
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=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
@@ -64,7 +64,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
64
64
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
65
65
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
66
66
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
67
- portacode-1.4.11.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
67
+ portacode-1.4.12.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
68
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
69
69
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
70
70
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -90,8 +90,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
90
90
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
91
91
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
92
92
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
93
- portacode-1.4.11.dev10.dist-info/METADATA,sha256=MOIgg0chGfuiFrYIhUhiPegZ-06JrXL20Y5vsFs2wvo,13052
94
- portacode-1.4.11.dev10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev10.dist-info/RECORD,,
93
+ portacode-1.4.12.dist-info/METADATA,sha256=lAGJrKnZnwWgPbSOe99hICWCxFjU_UMdIRfYOVmqxLI,13046
94
+ portacode-1.4.12.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
95
+ portacode-1.4.12.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
+ portacode-1.4.12.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
+ portacode-1.4.12.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5