simulo 0.7.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {simulo-0.7.1/src/simulo.egg-info → simulo-0.9.0}/PKG-INFO +3 -2
  2. {simulo-0.7.1 → simulo-0.9.0}/pyproject.toml +7 -5
  3. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/app.py +40 -4
  4. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/cli.py +379 -12
  5. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/http.py +10 -2
  6. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/submit_api.py +14 -2
  7. simulo-0.9.0/src/simulo/_client/view_session_api.py +122 -0
  8. {simulo-0.7.1 → simulo-0.9.0/src/simulo.egg-info}/PKG-INFO +3 -2
  9. {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/SOURCES.txt +1 -0
  10. {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/requires.txt +2 -1
  11. {simulo-0.7.1 → simulo-0.9.0}/MANIFEST.in +0 -0
  12. {simulo-0.7.1 → simulo-0.9.0}/PYPI.md +0 -0
  13. {simulo-0.7.1 → simulo-0.9.0}/setup.cfg +0 -0
  14. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/__init__.py +0 -0
  15. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/__init__.py +0 -0
  16. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_entrypoint.py +0 -0
  17. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_mounts.py +0 -0
  18. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_runner.py +0 -0
  19. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/asset.py +0 -0
  20. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/bundle.py +0 -0
  21. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/config.py +0 -0
  22. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/credentials.py +0 -0
  23. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/discovery.py +0 -0
  24. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/jobs_api.py +0 -0
  25. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/learning.py +0 -0
  26. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/login.py +0 -0
  27. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/manifest.py +0 -0
  28. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/mode.py +0 -0
  29. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/packaging.py +0 -0
  30. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/registry.py +0 -0
  31. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/runtime.py +0 -0
  32. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/stub.py +0 -0
  33. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/volume.py +0 -0
  34. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/callbacks.py +0 -0
  35. {simulo-0.7.1 → simulo-0.9.0}/src/simulo/py.typed +0 -0
  36. {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  37. {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/entry_points.txt +0 -0
  38. {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -15,7 +15,8 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: simulo-interfaces<0.2,>=0.1
18
+ Requires-Dist: simulo-interfaces<0.3,>=0.2
19
+ Requires-Dist: mcap<2,>=1.3
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: black>=24.0; extra == "dev"
21
22
  Requires-Dist: isort>=5.13; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.7.1"
7
+ version = "0.9.0"
8
8
  description = "Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -20,10 +20,12 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.12",
21
21
  "Typing :: Typed",
22
22
  ]
23
- # Torch-free by construction: depends only on the contract layer. Heavy
24
- # imports (e.g. torch) belong inside the user's `runtime.imports()` guard,
25
- # resolved only at job-execution time — never in this distribution.
26
- dependencies = ["simulo-interfaces>=0.1,<0.2"]
23
+ # Torch-free by construction: depends only on the contract layer and mcap.
24
+ # Heavy imports (e.g. torch) belong inside the user's `runtime.imports()`
25
+ # guard, resolved only at job-execution time — never in this distribution.
26
+ # mcap: used by `simulo recordings` to read back a downloaded recording and
27
+ # verify it (message count) — MIT-licensed, deps only lz4+zstandard.
28
+ dependencies = ["simulo-interfaces>=0.2,<0.3", "mcap>=1.3,<2"]
27
29
 
28
30
  [project.optional-dependencies]
29
31
  dev = ["black>=24.0", "isort>=5.13", "ruff>=0.5", "mypy>=1.10", "pytest>=8.2", "build>=1.2", "twine>=5.1"]
@@ -70,6 +70,33 @@ def spawn_recorder() -> Iterator[list["JobHandle"]]:
70
70
  _SPAWN_RECORDER = previous
71
71
 
72
72
 
73
+ # ---------------------------------------------------------------------------
74
+ # Viewstream ambient signal — `simulo run --viewstream` scopes this for the
75
+ # duration of one submit so a cloud `spawn()` deep inside the user's
76
+ # @app.local_entrypoint (which `simulo run` does not call directly) can
77
+ # attach it to the job-create call. Deliberately NOT a `spawn()`/`submit()`
78
+ # kwarg: those kwargs feed the job's `args`, whose canonical form is the
79
+ # `package_id` sha256 — a streaming flag must never change the package hash
80
+ # (the live-visualization plan's wire-contract rule). Mirrors the
81
+ # `spawn_recorder` pattern above for the same "can't thread a return value
82
+ # through arbitrary user code" reason.
83
+ # ---------------------------------------------------------------------------
84
+
85
+ _VIEWSTREAM_REQUESTED = False
86
+
87
+
88
+ @contextlib.contextmanager
89
+ def viewstream_requested(enabled: bool) -> Iterator[None]:
90
+ """Scope ``--viewstream`` for one ``simulo run`` submit call."""
91
+ global _VIEWSTREAM_REQUESTED
92
+ previous = _VIEWSTREAM_REQUESTED
93
+ _VIEWSTREAM_REQUESTED = enabled
94
+ try:
95
+ yield
96
+ finally:
97
+ _VIEWSTREAM_REQUESTED = previous
98
+
99
+
73
100
  @dataclass
74
101
  class JobSpec:
75
102
  """Recorded metadata for one ``@app.job``-decorated function."""
@@ -125,8 +152,17 @@ def _spawn_local(job_name: str, package_path: Path) -> JobHandle:
125
152
  return JobHandle(job_id, package_path, job_name)
126
153
 
127
154
 
128
- def _spawn_cloud(job_name: str, package_path: Path, creds: Optional[dict[str, Any]]) -> JobHandle:
129
- """Tar the bundle, upload it, create the job, and return a cloud JobHandle."""
155
+ def _spawn_cloud(
156
+ job_name: str, package_path: Path, creds: Optional[dict[str, Any]], *, viewstream: bool = False
157
+ ) -> JobHandle:
158
+ """Tar the bundle, upload it, create the job, and return a cloud JobHandle.
159
+
160
+ ``viewstream`` rides ONLY the job-create call, as an optional top-level
161
+ body field (never inside ``args``, which feeds the ``package_id`` sha256
162
+ — see the live-visualization plan's wire-contract rule): a package's
163
+ content-addressed identity must never change based on whether this
164
+ particular execution asked for a live stream.
165
+ """
130
166
  from simulo._client.packaging import MANIFEST_FILENAME, ensure_tar
131
167
  from simulo._client.submit_api import SubmitApiClient
132
168
 
@@ -151,7 +187,7 @@ def _spawn_cloud(job_name: str, package_path: Path, creds: Optional[dict[str, An
151
187
  archive_size=len(archive_bytes),
152
188
  )
153
189
  client.upload_archive(package_id, archive_bytes)
154
- record = client.create_job(package_id=package_id, job_name=job_name, args=args)
190
+ record = client.create_job(package_id=package_id, job_name=job_name, args=args, viewstream=viewstream)
155
191
 
156
192
  return JobHandle(JobId(str(record["job_id"])), package_path, job_name, cloud=True, base_url=base_url, token=token)
157
193
 
@@ -210,7 +246,7 @@ class JobFunction:
210
246
  else:
211
247
  creds = credentials.try_get_valid_credentials()
212
248
  if _cloud_mode_active(creds):
213
- handle = _spawn_cloud(self._spec.name, package_path, creds)
249
+ handle = _spawn_cloud(self._spec.name, package_path, creds, viewstream=_VIEWSTREAM_REQUESTED)
214
250
  else:
215
251
  handle = _spawn_local(self._spec.name, package_path)
216
252
  _record_spawn(handle)
@@ -25,14 +25,21 @@ PKCE loopback flow (``.claude/skills/cli-auth/SKILL.md``) and manages
25
25
  ``~/.simulo/credentials``.
26
26
 
27
27
  **Observe commands** (``simulo jobs`` / ``simulo logs`` / ``simulo result`` /
28
- ``simulo recordings`` / ``simulo models``) talk HTTP ONLY — the thin client never reads the
29
- executor's run store from disk, because submit and execute behave as if on
28
+ ``simulo recordings`` / ``simulo models`` / ``simulo view``) talk HTTP ONLY — the thin client
29
+ never reads the executor's run store from disk, because submit and execute behave as if on
30
30
  two separate machines. They target the jobs API at the base URL resolved by
31
31
  ``config.resolve_base_url`` (env override -> ``SIMULO_ENV`` preset ->
32
32
  credentials -> the local ``simulo-backend serve`` stand-in), authenticated
33
- with the saved credentials' bearer token when logged in. ``simulo logs`` and
34
- ``simulo result`` take an OPTIONAL job id: omitted, they resolve the most
33
+ with the saved credentials' bearer token when logged in. ``simulo logs``,
34
+ ``simulo result``, and ``simulo view`` take an OPTIONAL job id: omitted, they resolve the most
35
35
  recent job over the API (and announce what they resolved to on stderr).
36
+
37
+ ``simulo view [JOB_ID]`` opens the default browser onto a running ``--viewstream`` job's live
38
+ 3D scene: it mints a short-lived session (``POST /v1/jobs/{id}/view-session``) and opens
39
+ ``{viewerUrl}#session={token}&gateway={gatewayUrl}&job={job_id}`` — the token rides the URL
40
+ FRAGMENT, never the query string, so it never reaches server access logs. A queued job is
41
+ waited on with a spinner; a finished job (completed/failed/cancelled) is refused with a clear
42
+ message; a job not started with ``--viewstream`` surfaces the server's own error.
36
43
  """
37
44
 
38
45
  from __future__ import annotations
@@ -42,25 +49,41 @@ import json
42
49
  import os
43
50
  import sys
44
51
  import time
52
+ import urllib.parse
53
+ import webbrowser
45
54
  from pathlib import Path, PurePath
46
55
  from typing import Any, Callable, Optional, Sequence
47
56
 
48
- from simulo._client import config, credentials
57
+ from simulo._client import config, credentials, http
49
58
  from simulo._client._entrypoint import run_entrypoint
50
59
  from simulo._client._runner import JobHandle
51
- from simulo._client.app import App, spawn_recorder
60
+ from simulo._client.app import App, spawn_recorder, viewstream_requested
52
61
  from simulo._client.discovery import import_app_module
53
62
  from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError, JobsApiUnavailable
54
63
  from simulo._client.login import LoginError, perform_login
55
64
  from simulo._client.mode import DISCOVERY
56
65
  from simulo._client.registry import find_app
57
66
  from simulo._client.submit_api import SubmitApiClient, SubmitApiError
67
+ from simulo._client.view_session_api import ViewSessionApiClient, ViewSessionApiError, ViewSessionApiHTTPError
58
68
  from simulo.interfaces.platform.enums import JobStatus
59
69
  from simulo.interfaces.platform.runs import TERMINAL_JOB_STATUSES
60
70
 
61
71
  #: ``--follow`` poll cadence. Module-level so tests can shrink it.
62
72
  _FOLLOW_POLL_INTERVAL_S = 0.7
63
73
 
74
+ #: ``simulo view`` poll cadence while a resolved job is still QUEUED.
75
+ #: Module-level (like ``_FOLLOW_POLL_INTERVAL_S`` above) so tests can shrink it.
76
+ _VIEW_POLL_INTERVAL_S = 0.7
77
+ #: Bounded window (live-visualization plan, "Lifecycle edges" hardening item)
78
+ #: tolerating the worker's claim->registration gap — the mint route can 404/409
79
+ #: briefly after a job starts running, before the worker's claim call has
80
+ #: reported its stream endpoint. ``simulo view`` retries the mint until this
81
+ #: many seconds have elapsed, then gives up with a clear message.
82
+ _MINT_GAP_WINDOW_S = 90.0
83
+ #: Retry cadence within ``_MINT_GAP_WINDOW_S``. Separate constant (not reusing
84
+ #: ``_VIEW_POLL_INTERVAL_S``) so tests can shrink the two loops independently.
85
+ _MINT_GAP_POLL_INTERVAL_S = 1.0
86
+
64
87
  # String-typed mirrors of the contract's status sets (the API sends strings).
65
88
  _TERMINAL_STATUS_STRINGS = frozenset(str(status) for status in TERMINAL_JOB_STATUSES)
66
89
  _COMPLETED_STATUS = str(JobStatus.COMPLETED)
@@ -86,8 +109,20 @@ def _resolve_client_context() -> tuple[str, Optional[str]]:
86
109
  # ---------------------------------------------------------------------------
87
110
 
88
111
 
89
- def _submit_app(app_file: Path, entrypoint: Optional[str], extras: Sequence[str], *, detach: bool) -> int:
90
- """Import the app in discovery mode and run its entrypoint (one submit)."""
112
+ def _submit_app(
113
+ app_file: Path, entrypoint: Optional[str], extras: Sequence[str], *, detach: bool, viewstream: bool = False
114
+ ) -> int:
115
+ """Import the app in discovery mode and run its entrypoint (one submit).
116
+
117
+ ``viewstream`` (``simulo run --viewstream``) is scoped over the whole
118
+ submit via :func:`~simulo._client.app.viewstream_requested` — not passed
119
+ as an entrypoint kwarg — because the actual ``spawn()``/``submit()`` call
120
+ happens deep inside the user's ``@app.local_entrypoint`` body (or, with no
121
+ entrypoint, ``_submit_sole_job`` below), never at this call site directly.
122
+ A cloud ``spawn()`` reads the ambient flag and attaches it to the job
123
+ top-level (never inside ``args`` — see the live-visualization plan's
124
+ wire-contract rule preserving ``package_id``'s hash).
125
+ """
91
126
  previous_mode = os.environ.get("SIMULO_MODE")
92
127
  os.environ["SIMULO_MODE"] = DISCOVERY
93
128
  try:
@@ -97,7 +132,7 @@ def _submit_app(app_file: Path, entrypoint: Optional[str], extras: Sequence[str]
97
132
  raise TypeError(f"Discovered object is not a simulo.App: {app!r}")
98
133
 
99
134
  names = app.local_entrypoint_names
100
- with spawn_recorder() as handles:
135
+ with spawn_recorder() as handles, viewstream_requested(viewstream):
101
136
  if names:
102
137
  chosen = _choose_entrypoint(app_file, names, entrypoint)
103
138
  run_entrypoint(app.resolve_local_entrypoint(chosen), extras)
@@ -201,7 +236,7 @@ def _follow_and_report(handle: JobHandle) -> int:
201
236
  def _cmd_run(args: argparse.Namespace, extras: Sequence[str]) -> int:
202
237
  assert args.app_file is not None, "_cmd_run: caller must guard against None app_file"
203
238
  app_file = Path(args.app_file).resolve()
204
- return _submit_app(app_file, args.entrypoint, extras, detach=args.detach)
239
+ return _submit_app(app_file, args.entrypoint, extras, detach=args.detach, viewstream=args.viewstream)
205
240
 
206
241
 
207
242
  # ---------------------------------------------------------------------------
@@ -450,8 +485,36 @@ def _recording_name(record: dict[str, Any]) -> str:
450
485
  return str(name) if name else f"{record.get('recording_id', 'recording')}.mcap"
451
486
 
452
487
 
488
+ def _verify_recording(dest: Path) -> None:
489
+ """Read *dest* back with the ``mcap`` reader and print a one-line summary.
490
+
491
+ This is the genuine use of the bundled ``mcap`` dependency (pyproject
492
+ dependencies): it replaces the manual ``python -c "from mcap.reader
493
+ import ..."`` check a user previously had to run by hand after `simulo
494
+ recordings` finished. The import is LAZY (inside this function, not at
495
+ module scope) so a plain `import simulo` — and every other CLI command
496
+ that never touches recordings — stays fast/torch-free; `mcap` is light,
497
+ but lazy is still the right default for a CLI-only code path.
498
+
499
+ Non-fatal by design: a corrupt or truncated download must not turn an
500
+ otherwise-successful fetch (the bytes are already on disk) into a crash.
501
+ Any failure — a genuinely corrupt file, or an mcap-library edge case —
502
+ prints a WARNING to stderr and returns; the file is kept and the caller's
503
+ exit code is unaffected.
504
+ """
505
+ try:
506
+ from mcap.reader import make_reader
507
+
508
+ with open(dest, "rb") as f:
509
+ reader = make_reader(f)
510
+ message_count = sum(1 for _ in reader.iter_messages())
511
+ print(f"verified {dest.name} — {message_count} messages", file=sys.stderr)
512
+ except Exception as exc: # noqa: BLE001 — best-effort verification only; must never crash a successful download
513
+ print(f"WARNING: could not verify {dest.name} as a valid MCAP file: {exc}", file=sys.stderr)
514
+
515
+
453
516
  def _download_and_report(client: SubmitApiClient, job_id: str, record: dict[str, Any], dest: Path) -> bool:
454
- """Download one recording to *dest*; print the Lichtblick hint on success.
517
+ """Download one recording to *dest*; verify + print the Lichtblick hint on success.
455
518
 
456
519
  ``dest`` must already be a caller-vetted destination — either the
457
520
  user's own ``-o PATH`` or ``out_dir / _safe_download_filename(...)``
@@ -467,12 +530,17 @@ def _download_and_report(client: SubmitApiClient, job_id: str, record: dict[str,
467
530
  — raises bare ``ValueError`` from the filesystem call, and it deserves
468
531
  the same clean one-liner (server-supplied names can no longer get here;
469
532
  ``_safe_download_filename`` rejects NULs outright).
533
+
534
+ A successful download is read back and verified (``_verify_recording``,
535
+ non-fatal) before the Lichtblick hint prints — a corrupt download is
536
+ still reported as a WARNING, never a crash.
470
537
  """
471
538
  try:
472
539
  resolved = client.download_recording(job_id, str(record["recording_id"]), dest)
473
540
  except (OSError, ValueError) as exc:
474
541
  print(f"Could not write recording to {dest}: {exc}", file=sys.stderr)
475
542
  return False
543
+ _verify_recording(resolved)
476
544
  print(f"Open in Lichtblick: {resolved.resolve()}")
477
545
  return True
478
546
 
@@ -868,6 +936,277 @@ def _cmd_models(args: argparse.Namespace) -> int:
868
936
  return 0 if _download_model_and_report(client, job_id, selected, dest) else 1
869
937
 
870
938
 
939
+ #: PR3's ONE retryable mint error code — everything else (job_not_viewstream,
940
+ #: job_not_running, job_not_found, view_sessions_disabled,
941
+ #: rate_limit_exceeded — all pinned in view_session_api.py's module
942
+ #: docstring) is a non-transient state that retrying can never fix.
943
+ _STREAM_ENDPOINT_NOT_READY = "stream_endpoint_not_ready"
944
+ #: A running job going NOT running between our own status check and the
945
+ #: mint call — almost always because it just finished (critic MINOR).
946
+ _JOB_NOT_RUNNING = "job_not_running"
947
+ #: The mint route's ONLY pinned 404 code (view_session_api.py module
948
+ #: docstring). By the time we mint, ``get_job`` has already confirmed the job
949
+ #: exists — so a 404 with any OTHER code means the ``/view-session`` route
950
+ #: itself isn't deployed in this environment (e.g. prod, which is
951
+ #: staging-only for the view-session feature today), not that the job is
952
+ #: missing. See ``_VIEW_SESSIONS_UNAVAILABLE_MESSAGE``.
953
+ _JOB_NOT_FOUND = "job_not_found"
954
+ #: Printed (and the command exits 1) when the mint 404s with any code other
955
+ #: than ``job_not_found`` — i.e. the control plane doesn't have the
956
+ #: view-session route at all, rather than not knowing this particular job.
957
+ #: Covers BOTH shapes seen in practice: a Starlette-default 404 wrapped in the
958
+ #: standard envelope (``code == "Not Found"``) and a non-envelope 404 from a
959
+ #: proxy in front of the control plane (``code == "http_error"``, ``http.py``'s
960
+ #: fallback when the body doesn't parse as the error envelope).
961
+ _VIEW_SESSIONS_UNAVAILABLE_MESSAGE = (
962
+ "Live view isn't available in this environment yet (the control plane here doesn't "
963
+ "support view sessions). The job itself is unaffected — follow it with `simulo logs`."
964
+ )
965
+ #: How long a printed "waiting..." line stays valid before ``_WaitAnnouncer``
966
+ #: re-prints it — so a multi-minute wait doesn't read as frozen on a real
967
+ #: tty (critic MINOR; no prior periodic-reannounce precedent in this file,
968
+ #: so this is a new, small, self-contained helper rather than a bigger
969
+ #: refactor of the existing single-print wait loops).
970
+ _SPINNER_REANNOUNCE_INTERVAL_S = 15.0
971
+
972
+
973
+ def _job_ended_message(job_id: str) -> str:
974
+ """The one "this job is no longer running" message (NIT: hoisted — it
975
+ used to be duplicated verbatim in three places: the terminal-status
976
+ pre-check, the terminal-status re-check mid-gap-retry, and now the
977
+ ``job_not_running`` mint response too)."""
978
+ return f"Job {job_id!r} has ended — live view is only available while a job is running."
979
+
980
+
981
+ class _WaitAnnouncer:
982
+ """Prints *message* immediately, then re-prints it every
983
+ ``_SPINNER_REANNOUNCE_INTERVAL_S`` while ``poll()`` keeps being called —
984
+ so a multi-minute queued-wait or gap-retry doesn't look frozen on a real
985
+ terminal. One small stateful helper shared by both wait loops below
986
+ rather than duplicating the timer bookkeeping twice.
987
+ """
988
+
989
+ def __init__(self, message: str) -> None:
990
+ self._message = message
991
+ self._last_announced_at: Optional[float] = None
992
+
993
+ def poll(self) -> None:
994
+ now = time.monotonic()
995
+ if self._last_announced_at is None or now - self._last_announced_at >= _SPINNER_REANNOUNCE_INTERVAL_S:
996
+ print(self._message, file=sys.stderr, flush=True)
997
+ self._last_announced_at = now
998
+
999
+
1000
+ def _wait_for_running_or_terminal(client: JobsApiClient, job_id: str, job: dict[str, Any]) -> dict[str, Any]:
1001
+ """Poll a QUEUED job until it starts RUNNING (or goes straight to a terminal status).
1002
+
1003
+ The id-less-UX twin of ``simulo logs --follow``'s wait loops (see
1004
+ ``_wait_for_job``): a spinner line prints immediately on stderr (then
1005
+ re-prints every ``_SPINNER_REANNOUNCE_INTERVAL_S`` via ``_WaitAnnouncer``
1006
+ — a long queue is not a frozen terminal), then the job record is
1007
+ re-fetched at ``_VIEW_POLL_INTERVAL_S`` while its status stays
1008
+ ``queued``. Returns as soon as the status is anything else — the caller
1009
+ (``_cmd_view``) distinguishes ``running`` from a terminal status, which it
1010
+ refuses with a friendly message rather than trying to mint a session.
1011
+ """
1012
+ announcer = _WaitAnnouncer("Waiting for the job to start streaming... (Ctrl-C to stop)")
1013
+ while str(job.get("status")) == str(JobStatus.QUEUED):
1014
+ announcer.poll()
1015
+ time.sleep(_VIEW_POLL_INTERVAL_S)
1016
+ job = client.get_job(job_id)
1017
+ return job
1018
+
1019
+
1020
+ def _mint_view_session(
1021
+ view_client: ViewSessionApiClient, jobs_client: JobsApiClient, job_id: str
1022
+ ) -> tuple[Optional[dict[str, Any]], Optional[str]]:
1023
+ """Mint a view session, tolerating the claim->registration gap (~90s bounded).
1024
+
1025
+ A running job's worker reports its stream endpoint in the claim request
1026
+ body — a step that can lag briefly behind the job's status flipping to
1027
+ ``running`` (live-visualization plan, "Lifecycle edges" hardening item).
1028
+ The mint route responds 409 ``stream_endpoint_not_ready`` during that
1029
+ gap; this function retries ONLY that exact error CODE (never classified
1030
+ by HTTP status alone — PR3 also uses 409 for ``job_not_viewstream`` and
1031
+ ``job_not_running``, which must fail fast, not spin the full window) until
1032
+ it succeeds, the job goes terminal (re-checked on EVERY retry — a race
1033
+ must not spin for the full window before noticing), or
1034
+ ``_MINT_GAP_WINDOW_S`` elapses.
1035
+
1036
+ Returns ``(payload, None)`` on success, or ``(None, message)`` — an
1037
+ already-friendly, ready-to-print message — when the job ended mid-wait
1038
+ (either noticed via our own status re-check, or reported directly by the
1039
+ server as ``job_not_running``), the mint route itself isn't deployed in
1040
+ this environment (see below), or the gap window timed out. Any OTHER
1041
+ error (e.g. ``job_not_viewstream`` for a job that was never started with
1042
+ ``--viewstream``, or the pinned ``job_not_found`` 404 for a cross-org/
1043
+ nonexistent job) is NOT retried: it propagates unchanged to the caller's
1044
+ normal error handling (``_observe_handler``), which prints the server's
1045
+ own message — "surface the server's error clearly" rather than the CLI
1046
+ inventing its own text.
1047
+
1048
+ **Route-not-deployed graceful degradation** (live-visualization plan,
1049
+ "graceful degradation" publish gate): production's control plane does not
1050
+ (yet) have the ``/view-session`` route. Because ``get_job`` already
1051
+ succeeded by the time we mint, the job is known to exist — so a 404 whose
1052
+ code is anything OTHER than the mint route's one pinned 404 code
1053
+ (``job_not_found``) can only mean the route itself is missing, not that
1054
+ the job is. This is detected and turned into
1055
+ ``_VIEW_SESSIONS_UNAVAILABLE_MESSAGE`` (never retried) rather than
1056
+ propagating the raw ``code: message`` (e.g. the cryptic
1057
+ ``Not Found: Not Found`` a bare Starlette 404 produces).
1058
+ """
1059
+ deadline = time.monotonic() + _MINT_GAP_WINDOW_S
1060
+ announcer = _WaitAnnouncer(
1061
+ "Waiting for the live view to become available (the worker is still registering "
1062
+ "its stream)... (Ctrl-C to stop)"
1063
+ )
1064
+ while True:
1065
+ try:
1066
+ return view_client.create_view_session(job_id), None
1067
+ except ViewSessionApiHTTPError as exc:
1068
+ if exc.status == 404 and exc.code != _JOB_NOT_FOUND:
1069
+ return None, _VIEW_SESSIONS_UNAVAILABLE_MESSAGE
1070
+ if exc.code == _JOB_NOT_RUNNING:
1071
+ return None, _job_ended_message(job_id)
1072
+ if exc.code != _STREAM_ENDPOINT_NOT_READY:
1073
+ raise
1074
+ job = jobs_client.get_job(job_id)
1075
+ if str(job.get("status")) in _TERMINAL_STATUS_STRINGS:
1076
+ return None, _job_ended_message(job_id)
1077
+ if time.monotonic() >= deadline:
1078
+ return None, (
1079
+ f"Timed out waiting for job {job_id!r}'s live view to become available "
1080
+ "(the worker can take a few seconds to register its stream endpoint after "
1081
+ f"a job starts running). Try `simulo view {job_id}` again in a moment."
1082
+ )
1083
+ announcer.poll()
1084
+ time.sleep(_MINT_GAP_POLL_INTERVAL_S)
1085
+
1086
+
1087
+ def _build_view_fragment_url(viewer_url: str, *, session_token: str, gateway_url: str, job_id: str) -> str:
1088
+ """Build the browser URL for a minted view session.
1089
+
1090
+ The session token rides the URL FRAGMENT (after ``#``), never the query
1091
+ string: fragments are never transmitted to the server or captured in
1092
+ access logs (live-visualization plan, "Tokens out of logs" hardening
1093
+ item), unlike a query parameter. Param names ``session``/``gateway``/
1094
+ ``job`` are an EXACT reconciliation contract with PR5's viewer host
1095
+ bootstrap page, which parses them via
1096
+ ``new URLSearchParams(location.hash.slice(1))`` — do not rename them.
1097
+ """
1098
+ fragment = urllib.parse.urlencode({"session": session_token, "gateway": gateway_url, "job": job_id})
1099
+ return f"{viewer_url}#{fragment}"
1100
+
1101
+
1102
+ def _validate_mint_response_urls(payload: dict[str, Any]) -> None:
1103
+ """Refuse to act on a mint response whose URLs use an unsafe scheme.
1104
+
1105
+ SECURITY (MINOR, critic review): ``viewerUrl``/``gatewayUrl`` are
1106
+ server-supplied strings that this command hands to ``webbrowser.open``
1107
+ (which can dispatch to arbitrary OS protocol handlers) and embeds in a
1108
+ printed/opened URL. A compromised or misbehaving control plane must not
1109
+ be able to make the CLI invoke something other than a normal http(s)/
1110
+ ws(s) endpoint. Mirrors ``http.require_https_or_loopback_for_bearer``'s
1111
+ discipline: the secure scheme is always accepted; the plaintext sibling
1112
+ is accepted ONLY to the local loopback (127.0.0.1/localhost/::1 — the
1113
+ local dev viewer-web/gateway dev servers).
1114
+ """
1115
+ for field, secure_scheme, insecure_scheme in (
1116
+ ("viewerUrl", "https", "http"),
1117
+ ("gatewayUrl", "wss", "ws"),
1118
+ ):
1119
+ url = str(payload[field])
1120
+ parsed = urllib.parse.urlsplit(url)
1121
+ if parsed.scheme == secure_scheme:
1122
+ continue
1123
+ if parsed.scheme == insecure_scheme and http.is_loopback_host(parsed.hostname):
1124
+ continue
1125
+ raise ViewSessionApiError(
1126
+ f"Refusing to use {field} {url!r} from the view-session response: expected "
1127
+ f"{secure_scheme}:// (or {insecure_scheme}:// to the local loopback for dev)."
1128
+ )
1129
+
1130
+
1131
+ def _cmd_view(args: argparse.Namespace) -> int:
1132
+ """``simulo view [JOB_ID]`` — open the browser onto a running ``--viewstream`` job's live scene.
1133
+
1134
+ JOB_ID omitted: resolve the most recent job over the API and announce it
1135
+ on stderr — the same latest-job default ``simulo logs``/``recordings``/
1136
+ ``models`` use. A queued job is waited on (spinner) until it starts
1137
+ running; a terminal job (completed/failed/cancelled) is refused with a
1138
+ friendly message; a job that was never started with ``--viewstream``
1139
+ surfaces the server's own error untouched. On success, opens the default
1140
+ browser at the viewer URL (session token carried in the fragment, never
1141
+ the query string) and also prints the URL for headless users to copy.
1142
+ """
1143
+ base_url, token = _resolve_client_context()
1144
+ jobs_client = JobsApiClient(base_url, token=token)
1145
+ view_client = ViewSessionApiClient(base_url, token=token)
1146
+
1147
+ job_id: Optional[str] = args.job_id
1148
+ if job_id is None:
1149
+ latest = jobs_client.get_latest_job()
1150
+ if latest is None:
1151
+ print(
1152
+ "No jobs found — nothing to view. Run a job first: `simulo run app.py --viewstream`.",
1153
+ file=sys.stderr,
1154
+ )
1155
+ return 1
1156
+ job = latest
1157
+ job_id = _announce_latest(job, "viewing")
1158
+ else:
1159
+ try:
1160
+ job = jobs_client.get_job(job_id)
1161
+ except JobsApiHTTPError as exc:
1162
+ if exc.status == 404:
1163
+ print(f"Job {job_id!r} not found. List runs with `simulo jobs`.", file=sys.stderr)
1164
+ return 1
1165
+ raise
1166
+
1167
+ if str(job.get("status")) == str(JobStatus.QUEUED):
1168
+ job = _wait_for_running_or_terminal(jobs_client, job_id, job)
1169
+ # A queue can run long enough for the access token to expire mid-wait
1170
+ # (critic MINOR) — re-resolve (silently refreshes via the SAME shared
1171
+ # path every other observe command uses) rather than minting with a
1172
+ # now-stale bearer and hitting a bare 401.
1173
+ base_url, token = _resolve_client_context()
1174
+ jobs_client = JobsApiClient(base_url, token=token)
1175
+ view_client = ViewSessionApiClient(base_url, token=token)
1176
+
1177
+ if str(job.get("status")) in _TERMINAL_STATUS_STRINGS:
1178
+ print(_job_ended_message(job_id), file=sys.stderr)
1179
+ return 1
1180
+
1181
+ payload, error = _mint_view_session(view_client, jobs_client, job_id)
1182
+ if error is not None:
1183
+ print(error, file=sys.stderr)
1184
+ return 1
1185
+ if payload is None:
1186
+ # Invariant: _mint_view_session always returns a payload when it
1187
+ # doesn't return an error message. A bare `assert` would be a NIT
1188
+ # (critic) — asserts vanish under `python -O`, silently turning this
1189
+ # into a `payload["viewerUrl"]` KeyError a few lines down instead of
1190
+ # a clear internal-bug report.
1191
+ raise RuntimeError("_mint_view_session returned neither a payload nor an error — this is a bug")
1192
+
1193
+ _validate_mint_response_urls(payload)
1194
+ url = _build_view_fragment_url(
1195
+ str(payload["viewerUrl"]),
1196
+ session_token=str(payload["sessionToken"]),
1197
+ gateway_url=str(payload["gatewayUrl"]),
1198
+ job_id=job_id,
1199
+ )
1200
+ # Matches the documented narrative (context/architecture/user-journey/
1201
+ # README.md Step 7: "Opening live view for job 8b52…9f in your
1202
+ # browser…"), plus the bare URL on its own line so a headless user (no
1203
+ # browser to open) can copy it directly.
1204
+ print(f"Opening live view for job {job_id} in your browser...")
1205
+ print(url)
1206
+ webbrowser.open(url)
1207
+ return 0
1208
+
1209
+
871
1210
  def _observe_handler(
872
1211
  subparser: argparse.ArgumentParser, command: Callable[[argparse.Namespace], int]
873
1212
  ) -> Callable[[argparse.Namespace, Sequence[str]], int]:
@@ -913,7 +1252,7 @@ def _build_parser() -> argparse.ArgumentParser:
913
1252
  prog="simulo",
914
1253
  description=(
915
1254
  "Simulo thin client — submit an app (local disk, or the cloud once logged in) and "
916
- "observe its runs (simulo jobs / logs / result / recordings / models)."
1255
+ "observe its runs (simulo jobs / logs / result / recordings / models / view)."
917
1256
  ),
918
1257
  )
919
1258
  sub = parser.add_subparsers(dest="command", required=True)
@@ -938,6 +1277,16 @@ def _build_parser() -> argparse.ArgumentParser:
938
1277
  action="store_true",
939
1278
  help="Submit a cloud job without waiting for its logs to finish (prints attach hints instead).",
940
1279
  )
1280
+ run.add_argument(
1281
+ "--viewstream",
1282
+ action="store_true",
1283
+ help=(
1284
+ "Enable the live 3D viewport stream for this cloud job (`simulo view` watches it once minted; "
1285
+ "no effect on a local-disk submit, which never reaches a worker). Training runs noticeably "
1286
+ "slower while streaming (roughly 4-5x, measured on a 512-environment benchmark) even with the "
1287
+ "platform's built-in low-overhead render settings applied."
1288
+ ),
1289
+ )
941
1290
 
942
1291
  def _handle_run(args: argparse.Namespace, extras: Sequence[str]) -> int:
943
1292
  """Dispatch ``simulo run``; route ``-h``/``--help`` before touching the app file.
@@ -1061,6 +1410,24 @@ def _build_parser() -> argparse.ArgumentParser:
1061
1410
  "-o", "--output", default=None, help="Output path (a file for one model; a directory for --all)."
1062
1411
  )
1063
1412
  models.set_defaults(func=_observe_handler(models, _cmd_models))
1413
+
1414
+ view = sub.add_parser(
1415
+ "view",
1416
+ help=(
1417
+ "Open the browser onto a running --viewstream job's live 3D scene "
1418
+ "(POST /v1/jobs/{id}/view-session; default: the most recent job)."
1419
+ ),
1420
+ )
1421
+ view.add_argument(
1422
+ "job_id",
1423
+ nargs="?",
1424
+ default=None,
1425
+ help=(
1426
+ "Job id to view (default: the most recent job). Only a job currently running with "
1427
+ "--viewstream can be viewed; a queued job is waited on, a finished job is refused."
1428
+ ),
1429
+ )
1430
+ view.set_defaults(func=_observe_handler(view, _cmd_view))
1064
1431
  return parser
1065
1432
 
1066
1433
 
@@ -60,7 +60,15 @@ class InsecureBearerError(HttpError):
60
60
  """Refused to attach a bearer token to a plain-http, non-loopback URL."""
61
61
 
62
62
 
63
- def _is_loopback(hostname: Optional[str]) -> bool:
63
+ def is_loopback_host(hostname: Optional[str]) -> bool:
64
+ """``True`` for 127.0.0.1 / localhost / ::1 / any 127.x.x.x — the local dev loopback.
65
+
66
+ Public (not ``_``-prefixed): shared by the bearer-attachment guard below
67
+ AND by any caller that needs the same "is this the local dev stand-in"
68
+ judgment for a DIFFERENT scheme pair (e.g. ``simulo view``'s https-or-
69
+ loopback-http / wss-or-loopback-ws checks on a server-supplied URL before
70
+ handing it to ``webbrowser.open`` — see ``cli._validate_mint_urls``).
71
+ """
64
72
  if hostname is None:
65
73
  return False
66
74
  return hostname in _LOOPBACK_HOSTS or hostname.startswith("127.")
@@ -76,7 +84,7 @@ def require_https_or_loopback_for_bearer(url: str) -> None:
76
84
  parsed = urllib.parse.urlsplit(url)
77
85
  if parsed.scheme == "https":
78
86
  return
79
- if parsed.scheme == "http" and _is_loopback(parsed.hostname):
87
+ if parsed.scheme == "http" and is_loopback_host(parsed.hostname):
80
88
  return
81
89
  raise InsecureBearerError(
82
90
  f"Refusing to send an Authorization bearer token to {url!r}: only https:// URLs "
@@ -102,13 +102,25 @@ class SubmitApiClient:
102
102
  unavailable_hint=_UNAVAILABLE_HINT,
103
103
  )
104
104
 
105
- def create_job(self, *, package_id: str, job_name: str, args: dict[str, Any]) -> dict[str, Any]:
105
+ def create_job(
106
+ self, *, package_id: str, job_name: str, args: dict[str, Any], viewstream: bool = False
107
+ ) -> dict[str, Any]:
106
108
  """``POST /v1/jobs`` — create the job that executes *package_id*.
107
109
 
110
+ ``viewstream`` is an OPTIONAL top-level body field — a sibling of
111
+ ``args``, never nested inside it (``args`` feeds the ``package_id``
112
+ sha256; a streaming flag must never change a package's content-
113
+ addressed identity). Sent only when ``True``: a control plane that
114
+ predates the live-visualization plan's ``JobSubmitRequest`` field
115
+ simply ignores an absent key, same as it ignores any unknown field.
116
+
108
117
  Returns the :class:`~simulo.interfaces.platform.runs.JobRecord` fields
109
118
  (201 on first submit; 200 on an idempotent replay of an in-flight job).
110
119
  """
111
- payload = self._post_json(JOBS_ROUTE, {"package_id": package_id, "job_name": job_name, "args": args})
120
+ body: dict[str, Any] = {"package_id": package_id, "job_name": job_name, "args": args}
121
+ if viewstream:
122
+ body["viewstream"] = True
123
+ payload = self._post_json(JOBS_ROUTE, body)
112
124
  if not isinstance(payload, dict) or not payload.get("job_id"):
113
125
  raise SubmitApiError(f"Malformed create_job response: {payload!r}")
114
126
  return payload
@@ -0,0 +1,122 @@
1
+ """HTTP client for the live-view session mint endpoint (stdlib only, torch-free).
2
+
3
+ Wire contract (live-visualization plan, Wave 2 PR3 — control-plane; see
4
+ ``context/implementation-plans/live-visualization-plan.md`` and PR3's
5
+ ``simulo_control_plane.jobs.view_sessions.mint_view_session``):
6
+ ``POST /v1/jobs/{job_id}/view-session`` mints a short-lived (TTL <= 5 min)
7
+ gateway-audience session for watching a running ``--viewstream`` job's live
8
+ 3D scene over the browser. Success (200) response body::
9
+
10
+ {"viewerUrl": "...", "gatewayUrl": "...", "sessionToken": "...", "expiresAt": "..."}
11
+
12
+ Error responses use the standard ``{"error": {"code", "message", "request_id"}}``
13
+ envelope. The PINNED codes this client's caller (``simulo._client.cli``)
14
+ depends on by name (do not rename without updating both sides) — all raised
15
+ as :class:`ViewSessionApiHTTPError` with ``exc.code``/``exc.status`` set from
16
+ the body/status:
17
+
18
+ * 409 ``stream_endpoint_not_ready`` — the ONLY transient/retryable one: the
19
+ worker claim->registration gap, or a lapsed-lease-pending-reap window.
20
+ * 409 ``job_not_running`` — the job is no longer running (a race after the
21
+ caller confirmed ``running``, or a job that finished between poll and mint).
22
+ * 409 ``job_not_viewstream`` — the job was never submitted with ``--viewstream``.
23
+ * 404 ``job_not_found`` — unknown or cross-org job id (anti-enumeration).
24
+ This is the mint route's ONLY pinned 404 code. The caller
25
+ (``simulo._client.cli._mint_view_session``) relies on that: by the time it
26
+ mints, ``get_job`` has already confirmed the job exists, so it treats any
27
+ OTHER 404 — e.g. Starlette's default unmatched-route 404 (wrapped in the
28
+ standard envelope with ``code == "Not Found"`` by the control plane's
29
+ global ``HTTPException`` handler) or a non-envelope 404 from a proxy in
30
+ front of it (``code == "http_error"``, this client's fallback code when
31
+ the body isn't the structured envelope) — as proof the ``/view-session``
32
+ route itself isn't deployed in this environment (e.g. prod, which is
33
+ staging-only for view sessions today), and degrades gracefully instead of
34
+ printing the raw ``code: message``.
35
+ * 503 ``view_sessions_disabled`` — the control plane has view sessions
36
+ fail-closed disabled (no JWT secret configured).
37
+ * 429 ``rate_limit_exceeded`` — per-user mint rate limit (30/min).
38
+
39
+ The route path is a LOCAL constant here rather than something imported from
40
+ ``simulo.interfaces.platform.runs`` — deliberately: PR3 owns the control
41
+ plane's implementation (and any shared wire-contract module it lands
42
+ alongside it), and this client codes directly against the exact path/response
43
+ shape the plan specifies so the two PRs can land independently without a
44
+ file-level merge conflict. Reconcile into a shared
45
+ ``simulo.interfaces.platform`` constant in a follow-up once PR3 has merged,
46
+ if a shared module materializes.
47
+
48
+ Shares request plumbing (structured-error parsing, the https-when-token
49
+ guard, the foreign-host bearer rule) with ``jobs_api.py``/``submit_api.py``
50
+ via ``http.py`` — one request path, not reimplemented a third time.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from typing import Any, Optional
56
+
57
+ from simulo._client import http
58
+
59
+ #: ``POST`` — mint a live-view session for a running ``--viewstream`` job.
60
+ #: ``.format(job_id=...)``.
61
+ VIEW_SESSION_ROUTE_TEMPLATE = "/v1/jobs/{job_id}/view-session"
62
+
63
+ _REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
64
+ _UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
65
+
66
+ #: Required fields of a well-formed mint response (see module docstring).
67
+ _REQUIRED_FIELDS = ("viewerUrl", "gatewayUrl", "sessionToken", "expiresAt")
68
+
69
+ # Backward-compatible-style aliases — same convention as jobs_api.py /
70
+ # submit_api.py: these are literally ``http.HttpError``/``http.HttpHTTPError``
71
+ # (not new subclasses), so callers that already catch ``JobsApiError`` (also
72
+ # ``http.HttpError``) catch these too without any extra wiring.
73
+ ViewSessionApiError = http.HttpError
74
+ ViewSessionApiHTTPError = http.HttpHTTPError
75
+
76
+
77
+ class ViewSessionApiClient:
78
+ """Client for ``POST /v1/jobs/{job_id}/view-session``."""
79
+
80
+ def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
81
+ if not base_url.startswith(("http://", "https://")):
82
+ raise ViewSessionApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
83
+ self._base_url = base_url.rstrip("/")
84
+ self._token = token
85
+
86
+ @property
87
+ def base_url(self) -> str:
88
+ return self._base_url
89
+
90
+ def create_view_session(self, job_id: str) -> dict[str, Any]:
91
+ """Mint a view session for *job_id*.
92
+
93
+ Raises :class:`ViewSessionApiHTTPError` on any non-2xx response — the
94
+ caller (``simulo._client.cli``) distinguishes the ONE retryable
95
+ claim->registration-gap error CODE (``stream_endpoint_not_ready``,
96
+ pinned with PR3) from every other code (e.g. ``job_not_viewstream``,
97
+ which is also a 409 — classify by ``exc.code``, never by
98
+ ``exc.status`` alone), letting non-retryable ones propagate unchanged
99
+ so the server's own error message reaches the user.
100
+ """
101
+ path = VIEW_SESSION_ROUTE_TEMPLATE.format(job_id=http.quote_path_segment(job_id))
102
+ payload = http.request_json(
103
+ "POST",
104
+ self._base_url + path,
105
+ token=self._token,
106
+ api_base_url=self._base_url,
107
+ json_body={},
108
+ timeout=_REQUEST_TIMEOUT_S,
109
+ unavailable_hint=_UNAVAILABLE_HINT,
110
+ )
111
+ if not isinstance(payload, dict):
112
+ raise ViewSessionApiError(f"Malformed view-session response for job {job_id!r}: expected a JSON object.")
113
+ # Security NIT: report only the MISSING field NAMES, never the raw
114
+ # payload — a malformed response could otherwise echo a partial
115
+ # sessionToken (or other sensitive value) into an exception message
116
+ # that ends up printed to the terminal or captured in a log.
117
+ missing = [field for field in _REQUIRED_FIELDS if field not in payload]
118
+ if missing:
119
+ raise ViewSessionApiError(
120
+ f"Malformed view-session response for job {job_id!r}: missing field(s) {missing}."
121
+ )
122
+ return payload
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -15,7 +15,8 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: simulo-interfaces<0.2,>=0.1
18
+ Requires-Dist: simulo-interfaces<0.3,>=0.2
19
+ Requires-Dist: mcap<2,>=1.3
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: black>=24.0; extra == "dev"
21
22
  Requires-Dist: isort>=5.13; extra == "dev"
@@ -32,4 +32,5 @@ src/simulo/_client/registry.py
32
32
  src/simulo/_client/runtime.py
33
33
  src/simulo/_client/stub.py
34
34
  src/simulo/_client/submit_api.py
35
+ src/simulo/_client/view_session_api.py
35
36
  src/simulo/_client/volume.py
@@ -1,4 +1,5 @@
1
- simulo-interfaces<0.2,>=0.1
1
+ simulo-interfaces<0.3,>=0.2
2
+ mcap<2,>=1.3
2
3
 
3
4
  [dev]
4
5
  black>=24.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes