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.
- {simulo-0.7.1/src/simulo.egg-info → simulo-0.9.0}/PKG-INFO +3 -2
- {simulo-0.7.1 → simulo-0.9.0}/pyproject.toml +7 -5
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/app.py +40 -4
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/cli.py +379 -12
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/http.py +10 -2
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/submit_api.py +14 -2
- simulo-0.9.0/src/simulo/_client/view_session_api.py +122 -0
- {simulo-0.7.1 → simulo-0.9.0/src/simulo.egg-info}/PKG-INFO +3 -2
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/SOURCES.txt +1 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/requires.txt +2 -1
- {simulo-0.7.1 → simulo-0.9.0}/MANIFEST.in +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/PYPI.md +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/setup.cfg +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/__init__.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_entrypoint.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/_runner.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/bundle.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/credentials.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/jobs_api.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/learning.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/manifest.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/packaging.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo/py.typed +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.7.1 → simulo-0.9.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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
|
+
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.
|
|
24
|
-
# imports (e.g. torch) belong inside the user's `runtime.imports()`
|
|
25
|
-
# resolved only at job-execution time — never in this distribution.
|
|
26
|
-
|
|
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(
|
|
129
|
-
|
|
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
|
|
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
|
|
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(
|
|
90
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|