simulo 0.33.0__tar.gz → 0.34.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 (85) hide show
  1. {simulo-0.33.0/src/simulo.egg-info → simulo-0.34.0}/PKG-INFO +4 -2
  2. {simulo-0.33.0 → simulo-0.34.0}/pyproject.toml +3 -2
  3. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/__init__.py +6 -0
  4. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/app.py +52 -6
  5. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/bundle.py +9 -7
  6. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_context.py +25 -4
  7. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_job_reporting.py +62 -22
  8. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_waiting.py +37 -25
  9. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/assets.py +6 -3
  10. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/jobs.py +79 -24
  11. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/recordings.py +49 -41
  12. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/run.py +125 -48
  13. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/credentials.py +11 -1
  14. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/evaluation_report.py +45 -6
  15. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/facades.py +2 -0
  16. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/learning.py +12 -1
  17. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/packaging.py +27 -17
  18. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preview_report.py +129 -101
  19. simulo-0.34.0/src/simulo/_client/recording_export.py +54 -0
  20. simulo-0.34.0/src/simulo/_client/repeatable_episodes.py +22 -0
  21. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/submit_api.py +36 -21
  22. simulo-0.34.0/src/simulo/_client/training_algorithm.py +11 -0
  23. simulo-0.34.0/src/simulo/_client/viewstream_policy.py +86 -0
  24. {simulo-0.33.0 → simulo-0.34.0/src/simulo.egg-info}/PKG-INFO +4 -2
  25. {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/SOURCES.txt +4 -0
  26. {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/requires.txt +6 -1
  27. {simulo-0.33.0 → simulo-0.34.0}/MANIFEST.in +0 -0
  28. {simulo-0.33.0 → simulo-0.34.0}/PYPI.md +0 -0
  29. {simulo-0.33.0 → simulo-0.34.0}/setup.cfg +0 -0
  30. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/__init__.py +0 -0
  31. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_arguments.py +0 -0
  32. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_mounts.py +0 -0
  33. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_runner.py +0 -0
  34. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_secure_downloads.py +0 -0
  35. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset.py +0 -0
  36. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_api.py +0 -0
  37. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_package.py +0 -0
  38. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_pins.py +0 -0
  39. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/builtin_aliases.py +0 -0
  40. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cancel_api.py +0 -0
  41. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli.py +0 -0
  42. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_arguments.py +0 -0
  43. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_downloads.py +0 -0
  44. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_errors.py +0 -0
  45. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_listing.py +0 -0
  46. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_rendering.py +0 -0
  47. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_text.py +0 -0
  48. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/__init__.py +0 -0
  49. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/auth.py +0 -0
  50. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/create.py +0 -0
  51. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/policies.py +0 -0
  52. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/systems.py +0 -0
  53. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/config.py +0 -0
  54. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/discovery.py +0 -0
  55. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/export_api.py +0 -0
  56. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/http.py +0 -0
  57. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/identity_api.py +0 -0
  58. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/install_samples.py +0 -0
  59. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/jobs_api.py +0 -0
  60. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/login.py +0 -0
  61. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/mode.py +0 -0
  62. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/onnx_bundle.py +0 -0
  63. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/outputs.py +0 -0
  64. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/policies_api.py +0 -0
  65. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preflight_api.py +0 -0
  66. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preflight_render.py +0 -0
  67. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/registry.py +0 -0
  68. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/runtime.py +0 -0
  69. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/runtime_display.py +0 -0
  70. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/stub.py +0 -0
  71. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/__init__.py +0 -0
  72. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/eval.py.tmpl +0 -0
  73. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/play.py.tmpl +0 -0
  74. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/preview.py.tmpl +0 -0
  75. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  76. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/task.py.tmpl +0 -0
  77. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/train.py.tmpl +0 -0
  78. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/view_fragment.py +0 -0
  79. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/view_session_api.py +0 -0
  80. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/volume.py +0 -0
  81. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/callbacks.py +0 -0
  82. {simulo-0.33.0 → simulo-0.34.0}/src/simulo/py.typed +0 -0
  83. {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  84. {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/entry_points.txt +0 -0
  85. {simulo-0.33.0 → simulo-0.34.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.33.0
3
+ Version: 0.34.0
4
4
  Summary: Python SDK and CLI for cloud-native robotics simulation, reinforcement learning, and robot policy training on managed GPUs.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -31,10 +31,12 @@ Classifier: Topic :: System :: Distributed Computing
31
31
  Classifier: Typing :: Typed
32
32
  Requires-Python: >=3.11
33
33
  Description-Content-Type: text/markdown
34
- Requires-Dist: simulo-interfaces<0.25,>=0.24.0
34
+ Requires-Dist: simulo-interfaces<0.26,>=0.25.0
35
35
  Requires-Dist: mcap<2,>=1.3
36
36
  Requires-Dist: defusedxml>=0.7.1
37
37
  Requires-Dist: usd-core<27,>=26.5
38
+ Provides-Extra: viz
39
+ Requires-Dist: simulo-viz<0.2,>=0.1.0; python_version >= "3.12" and extra == "viz"
38
40
  Provides-Extra: assets
39
41
  Requires-Dist: usd-core<27,>=26.5; extra == "assets"
40
42
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.33.0"
7
+ version = "0.34.0"
8
8
  description = "Python SDK and CLI for cloud-native robotics simulation, reinforcement learning, and robot policy training on managed GPUs."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -59,7 +59,7 @@ classifiers = [
59
59
  # mcap: used by `simulo recordings` to read back a downloaded recording and
60
60
  # verify it (message count) — MIT-licensed, deps only lz4+zstandard.
61
61
  dependencies = [
62
- "simulo-interfaces>=0.24.0,<0.25",
62
+ "simulo-interfaces>=0.25.0,<0.26",
63
63
  "mcap>=1.3,<2",
64
64
  # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
65
65
  # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
@@ -81,6 +81,7 @@ dependencies = [
81
81
  ]
82
82
 
83
83
  [project.optional-dependencies]
84
+ viz = ["simulo-viz>=0.1.0,<0.2; python_version >= '3.12'"]
84
85
  # `assets` — kept for backward compatibility with `pip install
85
86
  # "simulo[assets]"` (the 0.13.0-era spelling): usd-core moved into the base
86
87
  # install above in 0.13.1, so this extra is now redundant (its dep is
@@ -129,6 +129,7 @@ from simulo.interfaces import (
129
129
  # re-exported here like every other concrete authoring type — no exclusion
130
130
  # needed any more.
131
131
  from simulo.interfaces.authoring import (
132
+ ActionChannel,
132
133
  ArrowMarker,
133
134
  AssetEntity,
134
135
  AssetSource,
@@ -143,6 +144,8 @@ from simulo.interfaces.authoring import (
143
144
  DomeLight,
144
145
  GroundPlane,
145
146
  LightConfig,
147
+ LineMarker,
148
+ ObjectVariants,
146
149
  PointMarker,
147
150
  PolicyCheckpoint,
148
151
  PolicyInput,
@@ -210,6 +213,7 @@ __all__ = [
210
213
  "Digest",
211
214
  # authoring value types (eager, real) — the rest of the vocabulary the
212
215
  # lazy resolver below does not carry
216
+ "ActionChannel",
213
217
  "Collision",
214
218
  "RigidPhysics",
215
219
  "DeformableMaterial",
@@ -219,6 +223,7 @@ __all__ = [
219
223
  "USDAsset",
220
224
  "URDFAsset",
221
225
  "LightConfig",
226
+ "ObjectVariants",
222
227
  "DistantLight",
223
228
  "DomeLight",
224
229
  "TerrainConfig",
@@ -238,6 +243,7 @@ __all__ = [
238
243
  "SphereMarker",
239
244
  "ArrowMarker",
240
245
  "PointMarker",
246
+ "LineMarker",
241
247
  # lazy, mode-aware (the learning names + save_output)
242
248
  "ActuatorGainsConfig",
243
249
  "ArticulationConfig",
@@ -48,7 +48,9 @@ from typing import (
48
48
 
49
49
  from simulo._client import config, credentials, registry
50
50
  from simulo._client._runner import JobFailedError, JobHandle, JobResultUnavailable
51
+ from simulo._client.repeatable_episodes import requested_repeatable_episodes
51
52
  from simulo._client.runtime import DEFAULT_RUNTIME
53
+ from simulo._client.training_algorithm import validate_training_algorithm
52
54
  from simulo.interfaces.ids import JobId, parse_job_public_id
53
55
  from simulo.interfaces.platform import JobCallbackProtocol, JobType, normalize_job_tags
54
56
  from simulo.interfaces.platform.app import Mount
@@ -131,19 +133,43 @@ def spawn_recorder() -> Iterator[list["JobHandle"]]:
131
133
  # through arbitrary user code" reason.
132
134
  # ---------------------------------------------------------------------------
133
135
 
134
- _VIEWSTREAM_REQUESTED = False
136
+ _VIEWSTREAM_REQUESTED: Optional[bool] = None
137
+ _VIEWSTREAM_NOTICE: Optional[str] = None
135
138
 
136
139
 
137
140
  @contextlib.contextmanager
138
- def viewstream_requested(enabled: bool) -> Iterator[None]:
139
- """Scope ``--viewstream`` for one ``simulo run`` submit call."""
140
- global _VIEWSTREAM_REQUESTED
141
+ def viewstream_requested(enabled: Optional[bool], *, notice: Optional[str] = None) -> Iterator[None]:
142
+ """Scope one resolved live-view request for one ``simulo run`` submit call."""
143
+ global _VIEWSTREAM_REQUESTED, _VIEWSTREAM_NOTICE
141
144
  previous = _VIEWSTREAM_REQUESTED
145
+ previous_notice = _VIEWSTREAM_NOTICE
142
146
  _VIEWSTREAM_REQUESTED = enabled
147
+ _VIEWSTREAM_NOTICE = notice
143
148
  try:
144
149
  yield
145
150
  finally:
146
151
  _VIEWSTREAM_REQUESTED = previous
152
+ _VIEWSTREAM_NOTICE = previous_notice
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # 3D-recording ambient signal — `simulo run --record-3d COPIES` scopes an
157
+ # execution option without placing it in package arguments or the manifest.
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _RECORDING_COPIES_REQUESTED: Optional[int] = None
161
+
162
+
163
+ @contextlib.contextmanager
164
+ def recording_copies_requested(copies: Optional[int]) -> Iterator[None]:
165
+ """Scope an optional 3D recording copy count for one submit."""
166
+ global _RECORDING_COPIES_REQUESTED
167
+ previous = _RECORDING_COPIES_REQUESTED
168
+ _RECORDING_COPIES_REQUESTED = copies
169
+ try:
170
+ yield
171
+ finally:
172
+ _RECORDING_COPIES_REQUESTED = previous
147
173
 
148
174
 
149
175
  # ---------------------------------------------------------------------------
@@ -526,7 +552,9 @@ def _spawn_cloud(
526
552
  package_path: Path,
527
553
  creds: Optional[dict[str, Any]],
528
554
  *,
529
- viewstream: bool = False,
555
+ viewstream: Optional[bool] = None,
556
+ recording_copies: Optional[int] = None,
557
+ repeatable_episodes: Optional[bool] = None,
530
558
  input_checkpoint: Optional[str] = None,
531
559
  compare_checkpoint: Optional[str] = None,
532
560
  require_success: Optional[float] = None,
@@ -689,7 +717,7 @@ def _spawn_cloud(
689
717
  archive_size=len(archive_bytes),
690
718
  job_type=job_type,
691
719
  input_checkpoint=input_checkpoint,
692
- viewstream=viewstream,
720
+ viewstream=viewstream is True,
693
721
  assets=[] if not asset_refs else None,
694
722
  )
695
723
  deferred_stdout, deferred_stderr = _handle_preflight_outcome(outcome, job_name=job_name)
@@ -721,6 +749,8 @@ def _spawn_cloud(
721
749
  job_name=job_name,
722
750
  args=args,
723
751
  viewstream=viewstream,
752
+ recording_copies=recording_copies,
753
+ repeatable_episodes=repeatable_episodes,
724
754
  input_checkpoint=input_checkpoint,
725
755
  compare_checkpoint=compare_checkpoint,
726
756
  require_success=require_success,
@@ -732,6 +762,14 @@ def _spawn_cloud(
732
762
  tags=tags,
733
763
  manifest_version=manifest_version,
734
764
  )
765
+ # A submitted entrypoint can call ``spawn()`` more than once. The
766
+ # resolved notice describes the run-level opt-in, so print it once only
767
+ # after the first successful job-create response. Do not consume it when
768
+ # create_job raises: then the run did not successfully submit anything.
769
+ global _VIEWSTREAM_NOTICE
770
+ if _VIEWSTREAM_NOTICE is not None:
771
+ print(_VIEWSTREAM_NOTICE, file=sys.stderr)
772
+ _VIEWSTREAM_NOTICE = None
735
773
 
736
774
  raw_public_id = record.get("public_id")
737
775
  try:
@@ -813,6 +851,12 @@ class JobFunction:
813
851
  # job/success contract as ``simulo run``. This happens before the
814
852
  # intent marker, bundle construction, asset resolution, or network I/O.
815
853
  self._app.validate_job_submission(self._spec.name)
854
+ if self._spec.job_type is JobType.TRAIN:
855
+ parameter = inspect.signature(self._spec.fn).parameters.get("algorithm")
856
+ if "algorithm" in kwargs:
857
+ validate_training_algorithm(kwargs["algorithm"])
858
+ elif parameter is not None and parameter.default is not inspect.Parameter.empty:
859
+ validate_training_algorithm(parameter.default)
816
860
 
817
861
  # Intent-to-submit is recorded FIRST — before any packaging or network
818
862
  # I/O — so a submit that fails partway (or times out AFTER the server
@@ -861,6 +905,8 @@ class JobFunction:
861
905
  package_path,
862
906
  creds,
863
907
  viewstream=_VIEWSTREAM_REQUESTED,
908
+ recording_copies=_RECORDING_COPIES_REQUESTED,
909
+ repeatable_episodes=requested_repeatable_episodes(),
864
910
  input_checkpoint=_INPUT_CHECKPOINT_REF,
865
911
  compare_checkpoint=_COMPARE_CHECKPOINT_REF,
866
912
  require_success=_REQUIRE_SUCCESS,
@@ -4,9 +4,10 @@ Shared by ``simulo run`` (the CLI) and ``JobFunction.spawn`` — the *submit* pa
4
4
  discover the app's metadata and write the package (manifest + source bundle) to
5
5
  disk. The manifest builder reads only the public ``App`` surface, so it works in
6
6
  discovery mode (where submit runs). ``ensure_bundle`` memoises the written package
7
- per *submission* — ``(app, entry_file, job_name, args)`` — so re-submitting the
8
- same file and job with the same args reuses the package, while a second spawn with
9
- a different entry file, job, or args writes a distinct package (no clobber).
7
+ per *submission* — ``(app, entry_file, job_name, args, captured assets)`` — so
8
+ re-submitting the same file and job with the same args and captured assets reuses
9
+ the package. Package identity hashes source, entry file, job, canonical args,
10
+ and the canonical asset list; changing any of those writes a distinct package.
10
11
 
11
12
  Submit NEVER executes a job and NEVER invokes the backend — it only writes the
12
13
  package. This module imports no heavy library; it stays on the torch-free side of
@@ -59,9 +60,9 @@ DEFAULT_OUT_SUBPATH = (".simulo", "packages")
59
60
  #: capture — see ``asset.py``) — included so a LATER spawn() in the same process,
60
61
  #: after user code has constructed MORE assets since an earlier spawn() with
61
62
  #: the identical ``(app, entry_file, job_name, args)``, is never served a stale cached bundle
62
- #: whose ``resources`` predates those constructions (``package_id`` itself never
63
- #: changes with the asset set — see the package-hash-invariance rule — so the
64
- #: `package_id` re-check below cannot by itself catch this staleness).
63
+ #: whose ``resources`` predates those constructions. The package id also hashes
64
+ #: resources, but a cache hit does not rebuild the manifest or recompute the id,
65
+ #: so this key must still distinguish the captured asset set.
65
66
  _BUNDLE_CACHE: dict[tuple[int, str, str, str, tuple[str, ...]], tuple[Path, str]] = {}
66
67
 
67
68
 
@@ -197,7 +198,8 @@ def ensure_bundle(
197
198
  This is the *submit* primitive: the manifest is built from the live app object
198
199
  (no second discovery import) with the submitted job's ``args`` recorded, and
199
200
  the source tree is written to ``<out>/<bundle_dir_name(package_id)>/`` — the
200
- per-submission directory keyed on source + entry file + ``job_name`` + ``args`` (named by
201
+ per-submission directory keyed on source + entry file + ``job_name`` + canonical
202
+ ``args`` + canonical asset list (named by
201
203
  the id's short, filesystem-safe form; see ``packaging.bundle_dir_name``).
202
204
  Memoised per submission (``(id(app), entry_file, job_name, canonical_json(args), sorted
203
205
  extra_assets uris)``) so re-submitting the same job with the same args AND the
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import os
7
7
  import sys
8
- from typing import Callable, Optional, Sequence
8
+ from typing import Any, Callable, Optional, Sequence
9
9
 
10
10
  from simulo._client import config, credentials, preview_report
11
11
  from simulo._client.app import (
@@ -43,11 +43,32 @@ def _resolve_client_context() -> tuple[str, Optional[str]]:
43
43
  return base_url, token
44
44
 
45
45
 
46
- def _client_with_fresh_token(client: JobsApiClient) -> JobsApiClient:
46
+ def _client_with_fresh_token(client: JobsApiClient, initial_credentials: dict[str, Any]) -> JobsApiClient:
47
47
  """Return a client with current stored credentials, preserving its target."""
48
- creds = credentials.try_get_valid_credentials(base_url=client.base_url)
49
- if creds is None:
48
+ if not credentials.credentials_file_exists():
49
+ # Preserve the selected-file error and the manual-token fallback.
50
+ if os.environ.get(credentials.CREDENTIALS_FILE_ENV) is not None:
51
+ credentials.load_credentials()
50
52
  return client
53
+ creds = credentials.load_credentials()
54
+ if (
55
+ not initial_credentials
56
+ or config.resolve_base_url(creds).rstrip("/") != client.base_url
57
+ or any(
58
+ creds.get(field) != initial_credentials.get(field)
59
+ for field in ("api_base_url", "user_email", "active_organization_id")
60
+ )
61
+ ):
62
+ raise credentials.CredentialsError(
63
+ "Your saved sign-in changed while this command was running "
64
+ "(different user, organization or server). Re-run the command."
65
+ )
66
+ # Refresh the checked snapshot directly. A second file read could adopt a
67
+ # different login between this guard and the refresh request.
68
+ if credentials.is_expired(creds):
69
+ creds = credentials.refresh_access_token(
70
+ creds, base_url=client.base_url, min_validity_s=credentials._EXPIRY_SKEW_S
71
+ )
51
72
  return JobsApiClient(client.base_url, token=str(creds["access_token"]))
52
73
 
53
74
 
@@ -7,9 +7,8 @@ import sys
7
7
  import time
8
8
  from typing import Any, Callable, Optional, TypeVar
9
9
 
10
- from simulo._client import credentials, http, preview_report
10
+ from simulo._client import credentials, preview_report
11
11
  from simulo._client.cli_context import _client_with_fresh_token
12
- from simulo._client.cli_errors import caused_by
13
12
  from simulo._client.cli_rendering import _safe_table_cell
14
13
  from simulo._client.cli_text import (
15
14
  _MAX_OUTPUTS_LINE_CHARS,
@@ -18,11 +17,10 @@ from simulo._client.cli_text import (
18
17
  _safe_cli_error_text,
19
18
  _safe_single_line_text,
20
19
  )
21
- from simulo._client.cli_waiting import GaveUpUnavailable, _retry_transient, _sleep_for_rate_limit
20
+ from simulo._client.cli_waiting import _retry_transient, _sleep_for_rate_limit
22
21
  from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError
23
22
  from simulo.interfaces.platform.enums import JobStatus
24
23
  from simulo.interfaces.platform.outputs import OUTPUT_FLAG_ANOMALY, OUTPUT_FLAG_OUTPUT_DROPPED
25
- from simulo.interfaces.platform.preview import PREVIEW_REPORT_SCHEMA_VERSION
26
24
  from simulo.interfaces.platform.runs import (
27
25
  JOB_SCOPE_MINE,
28
26
  LOG_ARCHIVE_EXPIRED_CODE,
@@ -31,9 +29,12 @@ from simulo.interfaces.platform.runs import (
31
29
  LOG_TAIL_MAX_BYTES,
32
30
  TERMINAL_JOB_STATUSES,
33
31
  )
32
+ from simulo.interfaces.platform.submit import LIVE_VIEW_UNAVAILABLE_WARNING_CODE
34
33
 
35
34
  #: ``--follow`` poll cadence. Module-level so tests can shrink it.
36
35
  _FOLLOW_POLL_INTERVAL_S = 0.7
36
+ #: Keep warning refreshes aligned with the worker's default heartbeat cadence.
37
+ _LIVE_VIEW_WARNING_POLL_INTERVAL_S = 15.0
37
38
  _T = TypeVar("_T")
38
39
  #: ``simulo logs --follow`` starts from the last this-many LINES of existing
39
40
  #: output by default (``tail -f`` style) — override with ``--tail N`` /
@@ -56,6 +57,8 @@ _COMPLETED_STATUS = str(JobStatus.COMPLETED)
56
57
  _QUEUED_STATUS = str(JobStatus.QUEUED)
57
58
  _RUNNING_STATUS = str(JobStatus.RUNNING)
58
59
  _CANCELLED_STATUS = str(JobStatus.CANCELLED)
60
+ _ORG_RUNNING_LIMIT_REASON = "org_running_limit_reached"
61
+ _ORG_RUNNING_LIMIT_MESSAGE = "waiting for a running job in your organization to finish"
59
62
  #: The terminal statuses that carry a self-diagnosis reason (JobRecord's
60
63
  #: ``status_reason``/``status_detail``): a failed, cancelled, or timed-out
61
64
  #: job. A cleanly ``completed`` job has none.
@@ -144,7 +147,7 @@ def _echo_report_stages(echo: Callable[[str], None], report: dict[str, Any]) ->
144
147
  check and its status, prints every row's detail, the owner of a warning or
145
148
  failure, and where to look in the recording — see
146
149
  :func:`preview_report.echo_preview_stages`."""
147
- if report.get("report_schema_version") == PREVIEW_REPORT_SCHEMA_VERSION:
150
+ if preview_report.is_preview_report(report):
148
151
  preview_report.echo_preview_stages(echo, report)
149
152
  return
150
153
  for stage in report.get("stages") or []:
@@ -216,22 +219,17 @@ def _outcome_unknown_text(reason: str, job_id: str) -> str:
216
219
  )
217
220
 
218
221
 
219
- def _fresh_client(client: JobsApiClient, job_id: str, follow: bool) -> JobsApiClient:
222
+ def _fresh_client(
223
+ client: JobsApiClient, job_id: str, follow: bool, initial_credentials: dict[str, Any]
224
+ ) -> JobsApiClient:
220
225
  """Refresh the access token; while *follow*ing, a refresh that cannot reach
221
226
  the server is the same unknown-outcome give-up as a failed log read, not a
222
227
  credentials error that callers report as the job failing."""
223
- try:
224
- return _client_with_fresh_token(client)
225
- except credentials.TokenRefreshError as exc:
226
- if follow and caused_by(exc, http.HttpUnavailable):
227
- raise GaveUpUnavailable(
228
- f"{_outcome_unknown_text('the session could not be refreshed', job_id)}; {exc}"
229
- ) from exc
230
- raise
228
+ return _followed(lambda: _client_with_fresh_token(client, initial_credentials), job_id, follow)
231
229
 
232
230
 
233
231
  def _followed(call: Callable[[], _T], job_id: str, follow: bool) -> _T:
234
- """Run a log read, retrying a transient failure only while *follow*ing a job.
232
+ """Run a log read or token refresh, retrying only while *follow*ing a job.
235
233
 
236
234
  A one-shot read reports the error as before; a follow that keeps failing
237
235
  says the job's outcome is unknown and names how to check it.
@@ -333,6 +331,7 @@ def _drain_logs(
333
331
  follow: bool,
334
332
  tail_lines: Optional[int] = None,
335
333
  scope: str = JOB_SCOPE_MINE,
334
+ initial_credentials: Optional[dict[str, Any]] = None,
336
335
  ) -> int:
337
336
  """Print log chunks; ``follow`` polls to a terminal status.
338
337
 
@@ -345,8 +344,12 @@ def _drain_logs(
345
344
  post-``simulo run`` default follow — one follow loop, one
346
345
  exit-code-mirrors-outcome rule.
347
346
  """
347
+ if initial_credentials is None:
348
+ initial_credentials = credentials.load_credentials() if credentials.credentials_file_exists() else {}
348
349
  if follow:
349
- client = _fresh_client(client, job_id, follow)
350
+ client = _fresh_client(client, job_id, follow, initial_credentials)
351
+ warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
352
+ next_warning_check_at = time.monotonic() + _LIVE_VIEW_WARNING_POLL_INTERVAL_S
350
353
  offset = 0
351
354
  if tail_lines is not None:
352
355
  try:
@@ -373,7 +376,7 @@ def _drain_logs(
373
376
  # history from where the server's offset-0 answer left off.
374
377
  while True:
375
378
  if follow:
376
- client = _fresh_client(client, job_id, follow)
379
+ client = _fresh_client(client, job_id, follow, initial_credentials)
377
380
  try:
378
381
  chunk, offset, status, _ = _followed(
379
382
  lambda: client.get_log_chunk(job_id, offset, scope=scope), job_id, follow
@@ -410,11 +413,16 @@ def _drain_logs(
410
413
  # and a completed job's typed outputs summary (e.g. `completed ⚠ 1
411
414
  # anomaly (non_finite_reward)`) gets the identical best-effort
412
415
  # treatment — on stderr, never touching stdout or the exit code.
416
+ if not warning_reported:
417
+ warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
413
418
  if status in _FAILURE_STATUS_STRINGS:
414
419
  _emit_terminal_reason(client, job_id, scope=scope)
415
420
  elif status == _COMPLETED_STATUS:
416
421
  _emit_outputs_summary(client, job_id, scope=scope)
417
422
  return 0 if status == _COMPLETED_STATUS else 1
423
+ if not warning_reported and time.monotonic() >= next_warning_check_at:
424
+ warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
425
+ next_warning_check_at = time.monotonic() + _LIVE_VIEW_WARNING_POLL_INTERVAL_S
418
426
  time.sleep(_FOLLOW_POLL_INTERVAL_S)
419
427
 
420
428
 
@@ -500,12 +508,15 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
500
508
 
501
509
 
502
510
  def _failure_reason_code(record: dict[str, Any]) -> Optional[str]:
503
- """The short machine-readable ``reason_code`` of a failed/cancelled job, or
504
- ``None`` — the wire ``JobRecord.status_reason`` (additive; absent on a
505
- control plane that predates the field, which reads as "no reason")."""
506
- if str(record.get("status", "")) not in _FAILURE_STATUS_STRINGS:
507
- return None
511
+ """The displayed reason for a failed/cancelled job, or the known queued
512
+ organization-running-limit wait. Other queued reasons remain hidden until
513
+ they have a deliberate CLI rendering."""
514
+ status = str(record.get("status", ""))
508
515
  reason = record.get("status_reason")
516
+ if status == _QUEUED_STATUS and reason == _ORG_RUNNING_LIMIT_REASON:
517
+ return _ORG_RUNNING_LIMIT_MESSAGE
518
+ if status not in _FAILURE_STATUS_STRINGS:
519
+ return None
509
520
  if isinstance(reason, str) and reason.strip():
510
521
  return _safe_cli_error_text(reason.strip())
511
522
  return None
@@ -540,6 +551,35 @@ def _terminal_reason_line(record: dict[str, Any]) -> Optional[str]:
540
551
  return line
541
552
 
542
553
 
554
+ def _live_view_warning_message(record: dict[str, Any]) -> Optional[str]:
555
+ """The known non-terminal live-view warning, or ``None``.
556
+
557
+ ``warning_code`` and ``warning_message`` are separate from terminal
558
+ failure fields. Training can continue while live view is unavailable.
559
+ """
560
+ if str(record.get("status", "")) not in {_RUNNING_STATUS, _COMPLETED_STATUS}:
561
+ return None
562
+ if record.get("warning_code") != LIVE_VIEW_UNAVAILABLE_WARNING_CODE:
563
+ return None
564
+ message = record.get("warning_message")
565
+ if not isinstance(message, str) or not message.strip():
566
+ return None
567
+ return _safe_cli_error_text(message.strip())
568
+
569
+
570
+ def _emit_live_view_warning(client: JobsApiClient, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> bool:
571
+ """Print the live-view warning to stderr and return whether it was present."""
572
+ try:
573
+ record = client.get_job(job_id, scope=scope)
574
+ except JobsApiError:
575
+ return False
576
+ message = _live_view_warning_message(record)
577
+ if message is not None:
578
+ print(f"warning: {message}", file=sys.stderr, flush=True)
579
+ return True
580
+ return False
581
+
582
+
543
583
  def _emit_terminal_reason(client: JobsApiClient, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> None:
544
584
  """Print a failed/cancelled job's terminal reason (:func:`_terminal_reason_line`)
545
585
  to stderr, or do nothing. Best-effort: a re-fetch that 404s (or any HTTP
@@ -6,6 +6,7 @@ import sys
6
6
  import time
7
7
  from typing import Callable, Optional, TypeVar
8
8
 
9
+ from simulo._client import credentials
9
10
  from simulo._client.jobs_api import JobsApiHTTPError, JobsApiUnavailable
10
11
  from simulo.interfaces.platform.runs import LOG_ARCHIVE_READ_FAILED_CODE
11
12
 
@@ -13,7 +14,8 @@ _T = TypeVar("_T")
13
14
 
14
15
  #: How long a printed "waiting..." line stays valid before ``_WaitAnnouncer``
15
16
  #: re-prints it — so a multi-minute wait doesn't read as frozen on a real
16
- #: tty (critic MINOR; no prior periodic-reannounce precedent in this file,
17
+ #: tty. Non-TTY output announces each wait only once.
18
+ #: (critic MINOR; no prior periodic-reannounce precedent in this file,
17
19
  #: so this is a new, small, self-contained helper rather than a bigger
18
20
  #: refactor of the existing single-print wait loops).
19
21
  _SPINNER_REANNOUNCE_INTERVAL_S = 15.0
@@ -23,8 +25,8 @@ class _WaitAnnouncer:
23
25
  """Prints *message* immediately, then re-prints it every
24
26
  ``_SPINNER_REANNOUNCE_INTERVAL_S`` while ``poll()`` keeps being called —
25
27
  so a multi-minute queued-wait or gap-retry doesn't look frozen on a real
26
- terminal. One small stateful helper shared by both wait loops below
27
- rather than duplicating the timer bookkeeping twice.
28
+ terminal. Non-TTY output prints it once. One small stateful helper shared
29
+ by both wait loops below rather than duplicating the timer bookkeeping twice.
28
30
  """
29
31
 
30
32
  def __init__(self, message: str) -> None:
@@ -33,7 +35,9 @@ class _WaitAnnouncer:
33
35
 
34
36
  def poll(self) -> None:
35
37
  now = time.monotonic()
36
- if self._last_announced_at is None or now - self._last_announced_at >= _SPINNER_REANNOUNCE_INTERVAL_S:
38
+ if self._last_announced_at is None or (
39
+ sys.stderr.isatty() and now - self._last_announced_at >= _SPINNER_REANNOUNCE_INTERVAL_S
40
+ ):
37
41
  print(self._message, file=sys.stderr, flush=True)
38
42
  self._last_announced_at = now
39
43
 
@@ -123,8 +127,10 @@ def _retry_transient(call: Callable[[], _T], *, give_up: Callable[[str], str]) -
123
127
  """Run ``call()``, retrying a transient failure a bounded number of times.
124
128
 
125
129
  Retried: a load-balancer 502/503/504 and ``JobsApiUnavailable`` (connection
126
- reset or refused, or the client's request timeout). A 429 and every other
127
- error go straight to the caller, which owns their policy, and a ``503``
130
+ reset or refused, or the client's request timeout), including those wrapped
131
+ in ``TokenRefreshError``. A refresh rejected with 401/403 is never retried.
132
+ A 429 and every other error go straight to the caller, which owns their
133
+ policy, and a ``503``
128
134
  carrying the log-read-failure code is left alone because the caller already
129
135
  explains it in its own words. After the last retry the failure becomes a
130
136
  ``GaveUpHTTPError`` (code ``server_unavailable``) or ``GaveUpUnavailable``
@@ -138,26 +144,32 @@ def _retry_transient(call: Callable[[], _T], *, give_up: Callable[[str], str]) -
138
144
  while True:
139
145
  try:
140
146
  return call()
141
- except JobsApiHTTPError as exc:
142
- if exc.status not in _TRANSIENT_5XX_STATUSES or exc.code == LOG_ARCHIVE_READ_FAILED_CODE:
147
+ except (JobsApiHTTPError, JobsApiUnavailable, credentials.TokenRefreshError) as exc:
148
+ # Refresh wraps HTTP failures in a credential error. Classify its
149
+ # cause so a server outage is retried without retrying a rejection.
150
+ failure = exc.__cause__ if isinstance(exc, credentials.TokenRefreshError) else exc
151
+ if isinstance(failure, JobsApiHTTPError):
152
+ if failure.status not in _TRANSIENT_5XX_STATUSES or failure.code == LOG_ARCHIVE_READ_FAILED_CODE:
153
+ raise
154
+ delay = next(delays, None)
155
+ if delay is None:
156
+ raise GaveUpHTTPError(
157
+ failure.status,
158
+ "server_unavailable",
159
+ give_up(f"the server kept returning HTTP {failure.status}"),
160
+ ) from exc
161
+ what = f"the server returned HTTP {failure.status}"
162
+ elif isinstance(failure, JobsApiUnavailable):
163
+ delay = next(delays, None)
164
+ if delay is None:
165
+ raise GaveUpUnavailable(
166
+ f"{give_up('the connection to the server kept failing')}; {failure}"
167
+ ) from exc
168
+ what = "the connection to the server failed"
169
+ if first_note:
170
+ what += f": {' '.join(str(failure).split()).rstrip('.')}"
171
+ else:
143
172
  raise
144
- delay = next(delays, None)
145
- if delay is None:
146
- raise GaveUpHTTPError(
147
- exc.status,
148
- "server_unavailable",
149
- give_up(f"the server kept returning HTTP {exc.status}"),
150
- ) from exc
151
- what = f"the server returned HTTP {exc.status}"
152
- except JobsApiUnavailable as exc:
153
- delay = next(delays, None)
154
- if delay is None:
155
- raise GaveUpUnavailable(f"{give_up('the connection to the server kept failing')}; {exc}") from exc
156
- what = "the connection to the server failed"
157
- if first_note:
158
- # Name the cause once, so a mistyped SIMULO_API_URL can be
159
- # spotted on the first line instead of after the last retry.
160
- what += f": {' '.join(str(exc).split()).rstrip('.')}"
161
173
  print(f"note: {what}; retrying in {delay:g}s...", file=sys.stderr, flush=True)
162
174
  first_note = False
163
175
  time.sleep(delay)
@@ -60,6 +60,7 @@ from simulo.interfaces.platform.asset_catalog import (
60
60
  ASSET_ARCHIVE_TOO_LARGE_CODE,
61
61
  ASSET_KINDS,
62
62
  ASSET_NOT_EXPORTABLE_CODE,
63
+ ASSET_NOT_FOUND_CODE,
63
64
  ASSET_SLUG_PATTERN,
64
65
  ASSET_STORAGE_QUOTA_EXCEEDED_CODE,
65
66
  ASSET_TOO_MANY_CONCURRENT_UPLOADS_CODE,
@@ -165,6 +166,7 @@ def _friendly_asset_error(exc: AssetApiHTTPError) -> str:
165
166
  )
166
167
  if exc.code in (
167
168
  ASSET_ARCHIVE_TOO_LARGE_CODE,
169
+ ASSET_NOT_FOUND_CODE,
168
170
  ASSET_TOO_MANY_FILES_CODE,
169
171
  ASSET_TOO_MANY_CONCURRENT_UPLOADS_CODE,
170
172
  ASSET_VERSION_NOT_UPLOADING_CODE,
@@ -513,7 +515,7 @@ def _echo_facts_block(echo: Callable[[str], None], facts: dict[str, Any]) -> Non
513
515
  # step_cost_indicator" — PR-11's facts.py emits EXACTLY these names).
514
516
  collision = facts.get("collision_shape_count")
515
517
  if collision is not None:
516
- extras.append(f"{collision} collision meshes")
518
+ extras.append(f"{collision} collision {'mesh' if collision == 1 else 'meshes'}")
517
519
  units = facts.get("units")
518
520
  if units:
519
521
  extras.append(f"units {units}")
@@ -1730,9 +1732,10 @@ def _resolve_target_version(
1730
1732
  base_url=base_url,
1731
1733
  token=token,
1732
1734
  )
1735
+ ref = _asset_display_ref(publisher=parsed.publisher, kind=kind, name=name, version=None)
1733
1736
  return None, (
1734
- f"{kind}/{name} has no version v{parsed.version} in the catalog. "
1735
- f"See its versions with `simulo asset inspect {kind}/{name}`."
1737
+ f"{ref} has no version v{parsed.version} in the catalog. "
1738
+ f"See its versions with `simulo asset inspect {ref}`."
1736
1739
  )
1737
1740
  if not _has_known_asset_version_status(matches[0]):
1738
1741
  return None, _ASSET_UNRECOGNIZED_VERSION_STATUS_MESSAGE