portacode 1.4.11.dev9__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.dev9'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev9')
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,11 +1080,13 @@ 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()
844
1087
  except Exception as exc:
845
1088
  _emit_progress_event(
1089
+ self,
846
1090
  step_index=step_index,
847
1091
  total_steps=total_steps,
848
1092
  step_name=step_name,
@@ -851,10 +1095,12 @@ class CreateProxmoxContainerHandler(SyncHandler):
851
1095
  message=f"{step_label} failed: {exc}",
852
1096
  phase="lifecycle",
853
1097
  request_id=request_id,
1098
+ client_sessions=client_sessions,
854
1099
  details={"error": str(exc)},
855
1100
  )
856
1101
  raise
857
- self._emit_progress_event(
1102
+ _emit_progress_event(
1103
+ self,
858
1104
  step_index=step_index,
859
1105
  total_steps=total_steps,
860
1106
  step_name=step_name,
@@ -863,6 +1109,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
863
1109
  message=success_message,
864
1110
  phase="lifecycle",
865
1111
  request_id=request_id,
1112
+ client_sessions=client_sessions,
866
1113
  )
867
1114
  current_step_index += 1
868
1115
  return result
@@ -903,6 +1150,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
903
1150
  vmid, _ = _instantiate_container(proxmox, node, payload)
904
1151
  payload["vmid"] = vmid
905
1152
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1153
+ payload["status"] = "creating"
906
1154
  _write_container_record(vmid, payload)
907
1155
  return proxmox, node, vmid, payload
908
1156
 
@@ -924,6 +1172,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
924
1172
  "Container startup completed.",
925
1173
  _start_container_step,
926
1174
  )
1175
+ _update_container_record(vmid, {"status": "running"})
927
1176
 
928
1177
  def _bootstrap_progress_callback(
929
1178
  step_index: int,
@@ -952,6 +1201,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
952
1201
  if error_summary:
953
1202
  details["error_summary"] = error_summary
954
1203
  _emit_progress_event(
1204
+ self,
955
1205
  step_index=step_index,
956
1206
  total_steps=total,
957
1207
  step_name=step.get("name", "bootstrap"),
@@ -960,6 +1210,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
960
1210
  message=message_text,
961
1211
  phase="bootstrap",
962
1212
  request_id=request_id,
1213
+ client_sessions=client_sessions,
963
1214
  details=details or None,
964
1215
  )
965
1216
 
@@ -975,7 +1226,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
975
1226
  )
976
1227
  current_step_index += len(bootstrap_steps)
977
1228
 
978
- return {
1229
+ response = {
979
1230
  "event": "proxmox_container_created",
980
1231
  "success": True,
981
1232
  "message": f"Container {vmid} is ready and Portacode key captured.",
@@ -992,6 +1243,9 @@ class CreateProxmoxContainerHandler(SyncHandler):
992
1243
  },
993
1244
  "setup_steps": steps,
994
1245
  }
1246
+ if client_sessions:
1247
+ response["client_sessions"] = client_sessions
1248
+ return response
995
1249
 
996
1250
 
997
1251
  class StartPortacodeServiceHandler(SyncHandler):
@@ -1019,6 +1273,8 @@ class StartPortacodeServiceHandler(SyncHandler):
1019
1273
  start_index = int(message.get("step_index", 1))
1020
1274
  total_steps = int(message.get("total_steps", start_index + 2))
1021
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
1022
1278
 
1023
1279
  auth_step_name = "setup_device_authentication"
1024
1280
  auth_label = "Setting up device authentication"
@@ -1032,6 +1288,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1032
1288
  message="Notifying the server of the new device…",
1033
1289
  phase="service",
1034
1290
  request_id=request_id,
1291
+ client_sessions=client_sessions,
1035
1292
  )
1036
1293
  _emit_progress_event(
1037
1294
  self,
@@ -1043,6 +1300,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1043
1300
  message="Authentication metadata recorded.",
1044
1301
  phase="service",
1045
1302
  request_id=request_id,
1303
+ client_sessions=client_sessions,
1046
1304
  )
1047
1305
 
1048
1306
  install_step = start_index + 1
@@ -1057,6 +1315,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1057
1315
  message="Running sudo portacode service install…",
1058
1316
  phase="service",
1059
1317
  request_id=request_id,
1318
+ client_sessions=client_sessions,
1060
1319
  )
1061
1320
 
1062
1321
  cmd = f"su - {user} -c 'sudo -S portacode service install'"
@@ -1073,6 +1332,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1073
1332
  message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1074
1333
  phase="service",
1075
1334
  request_id=request_id,
1335
+ client_sessions=client_sessions,
1076
1336
  details={
1077
1337
  "stderr": res.get("stderr"),
1078
1338
  "stdout": res.get("stdout"),
@@ -1090,14 +1350,133 @@ class StartPortacodeServiceHandler(SyncHandler):
1090
1350
  message="Portacode service install finished.",
1091
1351
  phase="service",
1092
1352
  request_id=request_id,
1353
+ client_sessions=client_sessions,
1093
1354
  )
1094
1355
 
1095
- return {
1356
+ response = {
1096
1357
  "event": "proxmox_service_started",
1097
1358
  "success": True,
1098
1359
  "message": "Portacode service install completed",
1099
1360
  "ctid": str(vmid),
1100
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
1101
1480
 
1102
1481
 
1103
1482
  class ConfigureProxmoxInfraHandler(SyncHandler):
@@ -1111,13 +1490,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
1111
1490
  verify_ssl = bool(message.get("verify_ssl"))
1112
1491
  if not token_identifier or not token_value:
1113
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
1114
1495
  snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
1115
- return {
1496
+ response = {
1116
1497
  "event": "proxmox_infra_configured",
1117
1498
  "success": True,
1118
1499
  "message": "Proxmox infrastructure configured",
1119
1500
  "infra": snapshot,
1120
1501
  }
1502
+ if client_sessions:
1503
+ response["client_sessions"] = client_sessions
1504
+ return response
1121
1505
 
1122
1506
 
1123
1507
  class RevertProxmoxInfraHandler(SyncHandler):
@@ -1126,10 +1510,15 @@ class RevertProxmoxInfraHandler(SyncHandler):
1126
1510
  return "revert_proxmox_infra"
1127
1511
 
1128
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
1129
1515
  snapshot = revert_infrastructure()
1130
- return {
1516
+ response = {
1131
1517
  "event": "proxmox_infra_reverted",
1132
1518
  "success": True,
1133
1519
  "message": "Proxmox infrastructure configuration reverted",
1134
1520
  "infra": snapshot,
1135
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.dev9
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=mJn8gWrCYLjin-hMutE8GPItb6qRWQTUv7n2KlE0vk4,719
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=LMdncCXGmsPX2rnup6PRDC4R6z2gBawL3QBH-N04DV4,41127
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.dev9.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.dev9.dist-info/METADATA,sha256=Aw-px0_HwDUE0ProOjwQtAJnFc2X63LOZvrF5Z3sLtc,13051
94
- portacode-1.4.11.dev9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev9.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev9.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev9.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