simulo 0.27.0__tar.gz → 0.28.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.27.0/src/simulo.egg-info → simulo-0.28.0}/PKG-INFO +2 -2
- {simulo-0.27.0 → simulo-0.28.0}/pyproject.toml +2 -2
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_runner.py +15 -3
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/app.py +37 -1
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_api.py +1 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/bundle.py +2 -1
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/cli.py +192 -51
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/credentials.py +134 -53
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/http.py +115 -2
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/packaging.py +10 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/preflight_api.py +5 -1
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/preflight_render.py +36 -11
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/submit_api.py +45 -28
- {simulo-0.27.0 → simulo-0.28.0/src/simulo.egg-info}/PKG-INFO +2 -2
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/requires.txt +1 -1
- {simulo-0.27.0 → simulo-0.28.0}/MANIFEST.in +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/PYPI.md +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/setup.cfg +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/__init__.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_entrypoint.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_secure_downloads.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_package.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_pins.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/builtin_aliases.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/cancel_api.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/export_api.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/export_bundle.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/facades.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/identity_api.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/install_samples.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/jobs_api.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/learning.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/outputs.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/runtime_display.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/seed_ref.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/__init__.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/inference/app.py.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/scenario/app.py.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/training/app.py.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/view_fragment.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/view_session_api.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo/py.typed +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/SOURCES.txt +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {simulo-0.27.0 → simulo-0.28.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.28.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,7 @@ 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.21,>=0.20.0
|
|
19
19
|
Requires-Dist: mcap<2,>=1.3
|
|
20
20
|
Requires-Dist: defusedxml>=0.7.1
|
|
21
21
|
Requires-Dist: usd-core<27,>=26.5
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simulo"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.28.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"
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
# mcap: used by `simulo recordings` to read back a downloaded recording and
|
|
27
27
|
# verify it (message count) — MIT-licensed, deps only lz4+zstandard.
|
|
28
28
|
dependencies = [
|
|
29
|
-
"simulo-interfaces>=0.
|
|
29
|
+
"simulo-interfaces>=0.20.0,<0.21",
|
|
30
30
|
"mcap>=1.3,<2",
|
|
31
31
|
# Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
|
|
32
32
|
# loop 2, security NIT): stdlib xml.etree.ElementTree relies on
|
|
@@ -27,14 +27,26 @@ from typing import Any, NoReturn, Optional
|
|
|
27
27
|
|
|
28
28
|
from simulo.interfaces.ids import JobId, JobPublicId
|
|
29
29
|
from simulo.interfaces.platform.enums import JobStatus
|
|
30
|
-
from simulo.interfaces.platform.runs import JOB_SCOPE_MINE
|
|
30
|
+
from simulo.interfaces.platform.runs import JOB_SCOPE_MINE
|
|
31
31
|
|
|
32
32
|
#: Poll cadence for cloud ``.get()``. Module-level so tests can shrink it.
|
|
33
33
|
_POLL_INTERVAL_S = 1.0
|
|
34
34
|
|
|
35
|
-
_TERMINAL_STATUS_STRINGS = frozenset(str(status) for status in TERMINAL_JOB_STATUSES)
|
|
36
35
|
_COMPLETED_STATUS = str(JobStatus.COMPLETED)
|
|
37
36
|
|
|
37
|
+
#: The tolerant-client check (`M55-org-runtime-policy`, issue #1537):
|
|
38
|
+
#: EVERY status this client can observe outside these two live values is
|
|
39
|
+
#: terminal, whether or not it is one this client's own ``JobStatus`` happens
|
|
40
|
+
#: to know by name. Membership is checked as "not one of the known LIVE
|
|
41
|
+
#: values", never "is one of the known TERMINAL values" — a closed terminal
|
|
42
|
+
#: set silently treats a FUTURE value it has never heard of as still-live
|
|
43
|
+
#: and polls forever (:class:`JobTimeoutError` only fires when the caller
|
|
44
|
+
#: passed a ``timeout``; with none, :meth:`JobHandle._get_cloud` hangs).
|
|
45
|
+
#: Mirrors the existing precedent for an unrecognized ``kind``
|
|
46
|
+
#: (``simulo.interfaces.platform.submit``): a value outside the known set is
|
|
47
|
+
#: treated as data to render, never as grounds to keep waiting on it.
|
|
48
|
+
_LIVE_STATUS_STRINGS = frozenset({str(JobStatus.QUEUED), str(JobStatus.RUNNING)})
|
|
49
|
+
|
|
38
50
|
|
|
39
51
|
class JobFailedError(RuntimeError):
|
|
40
52
|
"""A cloud job reached a terminal ``failed``/``cancelled`` status.
|
|
@@ -154,7 +166,7 @@ class JobHandle:
|
|
|
154
166
|
while True:
|
|
155
167
|
record = client.get_job(str(self._job_id), scope=JOB_SCOPE_MINE)
|
|
156
168
|
status = str(record.get("status"))
|
|
157
|
-
if status in
|
|
169
|
+
if status not in _LIVE_STATUS_STRINGS:
|
|
158
170
|
if status == _COMPLETED_STATUS:
|
|
159
171
|
return client.get_result(str(self._job_id), scope=JOB_SCOPE_MINE)
|
|
160
172
|
exit_code = record.get("exit_code")
|
|
@@ -514,6 +514,26 @@ def _spawn_cloud(
|
|
|
514
514
|
# already lives there, keyed by this same `job_name`.
|
|
515
515
|
job_entry = manifest.get("jobs", {}).get(job_name) or {}
|
|
516
516
|
system = (job_entry.get("resources") or {}).get("system")
|
|
517
|
+
# The job's own manifest-declared `timeout` (`M55-org-runtime-policy`,
|
|
518
|
+
# issue #1537) — already validated at `@app.job(timeout=...)` decoration
|
|
519
|
+
# time and recorded on THIS job's own manifest entry, exactly like
|
|
520
|
+
# `system` just above. Forwarded explicitly to `create_job` because the
|
|
521
|
+
# control plane never parses the uploaded manifest archive itself (see
|
|
522
|
+
# `JobSubmitRequest.manifest_timeout_s`'s own docstring).
|
|
523
|
+
manifest_timeout_s = job_entry.get("timeout")
|
|
524
|
+
if manifest_timeout_s is not None and (
|
|
525
|
+
isinstance(manifest_timeout_s, bool) or not isinstance(manifest_timeout_s, int)
|
|
526
|
+
):
|
|
527
|
+
# This manifest was just written by THIS process (see the comment on
|
|
528
|
+
# `Manifest.from_json` re-parsing above) — a non-integer `timeout`
|
|
529
|
+
# here would mean `@app.job`'s own decoration-time validation
|
|
530
|
+
# somehow let one through, not an untrusted value to degrade
|
|
531
|
+
# silently on. Fail loudly rather than send a malformed wire field.
|
|
532
|
+
raise RuntimeError(
|
|
533
|
+
f"Package at {package_path} has a non-integer jobs[{job_name!r}].timeout "
|
|
534
|
+
f"({manifest_timeout_s!r}) in its manifest — this should be impossible; "
|
|
535
|
+
"please report this as a bug."
|
|
536
|
+
)
|
|
517
537
|
|
|
518
538
|
tar_path = ensure_tar(package_path)
|
|
519
539
|
archive_bytes = tar_path.read_bytes()
|
|
@@ -568,6 +588,7 @@ def _spawn_cloud(
|
|
|
568
588
|
assets=asset_pins,
|
|
569
589
|
app_name=app_name,
|
|
570
590
|
system=system,
|
|
591
|
+
timeout_s=manifest_timeout_s,
|
|
571
592
|
)
|
|
572
593
|
|
|
573
594
|
raw_public_id = record.get("public_id")
|
|
@@ -590,9 +611,24 @@ def _spawn_cloud(
|
|
|
590
611
|
# the ref resolved to the intended source job. Seed provenance still
|
|
591
612
|
# carries machine UUIDs only, so keep this confirmation neutral rather
|
|
592
613
|
# than leaking a UUID while that response contract catches up.
|
|
614
|
+
#
|
|
615
|
+
# `seed["name"]`/`seed["kind"]` are server-supplied text: a
|
|
616
|
+
# compromised control plane — or a user pointed at an attacker-run
|
|
617
|
+
# `SIMULO_API_URL` — could embed ANSI/OSC sequences here, so both
|
|
618
|
+
# route through the same module `cli.py`'s own sanitizers are built
|
|
619
|
+
# on (`preflight_render`) rather than a second one.
|
|
620
|
+
# `sanitize_server_text` alone is NOT enough here (cross-vendor
|
|
621
|
+
# review): it deliberately preserves TAB/LF for its usual
|
|
622
|
+
# multi-line preflight-finding callers, but this confirmation is a
|
|
623
|
+
# single line — an unstripped LF would forge an extra line this
|
|
624
|
+
# code never printed. `_single_line` collapses it instead.
|
|
625
|
+
from simulo._client.preflight_render import sanitize_server_text_single_line
|
|
626
|
+
|
|
593
627
|
seed = record.get("seed") or {}
|
|
628
|
+
seed_name = sanitize_server_text_single_line(str(seed.get("name")))
|
|
629
|
+
seed_kind = sanitize_server_text_single_line(str(seed.get("kind")))
|
|
594
630
|
print(
|
|
595
|
-
f"Continuing from selected job ({
|
|
631
|
+
f"Continuing from selected job ({seed_name}, {seed_kind} checkpoint)",
|
|
596
632
|
file=sys.stderr,
|
|
597
633
|
)
|
|
598
634
|
|
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
from typing import TYPE_CHECKING, Any, Optional, Sequence
|
|
21
21
|
|
|
22
22
|
from simulo._client.asset import Asset
|
|
23
|
-
from simulo._client.packaging import canonical_args, package, read_bundle_package_id
|
|
23
|
+
from simulo._client.packaging import assert_credentials_outside_project, canonical_args, package, read_bundle_package_id
|
|
24
24
|
from simulo._client.volume import Volume
|
|
25
25
|
from simulo.interfaces.platform import is_platform_pinned
|
|
26
26
|
from simulo.interfaces.platform.manifest import Manifest
|
|
@@ -222,6 +222,7 @@ def ensure_bundle(
|
|
|
222
222
|
(deterministically restoring the correct content for this exact
|
|
223
223
|
submission), never served as a silently wrong package.
|
|
224
224
|
"""
|
|
225
|
+
assert_credentials_outside_project(project_root)
|
|
225
226
|
extra_asset_uris = tuple(sorted({asset.uri for asset in extra_assets}))
|
|
226
227
|
cache_key = (id(app), job_name, canonical_args(args), extra_asset_uris)
|
|
227
228
|
cached = _BUNDLE_CACHE.get(cache_key)
|
|
@@ -361,6 +361,20 @@ _MAX_LIST_LIMIT = 100
|
|
|
361
361
|
#: (``--all`` on the output commands) or a machine-readable dump
|
|
362
362
|
#: (``--json``), not a table — so those are what the ceiling footer names.
|
|
363
363
|
_MAX_LIST_VIEW_ROWS = 250
|
|
364
|
+
#: A bounded list view may retry a rate-limited page, but no more than this
|
|
365
|
+
#: many times. Unlike follow loops, a list is a finite read rather than an
|
|
366
|
+
#: operator-controlled wait, so retrying forever would leave the command hung.
|
|
367
|
+
_LIST_RATE_LIMIT_MAX_RETRIES = 3
|
|
368
|
+
#: Total time a bounded list view may spend waiting to retry one or more 429s.
|
|
369
|
+
#: This covers the control plane's current one-minute jobs-list rate-limit
|
|
370
|
+
#: window plus request overhead. A Retry-After that would exceed the remaining
|
|
371
|
+
#: operation budget remains terminal rather than making a listing wait without
|
|
372
|
+
#: a bound.
|
|
373
|
+
_LIST_RATE_LIMIT_RETRY_BUDGET_S = 90.0
|
|
374
|
+
#: Retry-After is required on the platform's 429 responses. Keep a small
|
|
375
|
+
#: fallback for an older or faulty server that omits it so the bounded retry
|
|
376
|
+
#: does not immediately repeat the request that was just rate-limited.
|
|
377
|
+
_LIST_RATE_LIMIT_RETRY_FALLBACK_S = 1.0
|
|
364
378
|
|
|
365
379
|
|
|
366
380
|
def _limit_int(value: str, ceiling: int) -> int:
|
|
@@ -416,15 +430,33 @@ def _fetch_list_view(
|
|
|
416
430
|
``limit <= _MAX_LIST_LIMIT`` still issues exactly ONE request of exactly
|
|
417
431
|
that size — byte-identical to the single-request behavior this replaced.
|
|
418
432
|
|
|
419
|
-
|
|
433
|
+
A 429 retries the same page after its Retry-After delay. Retries and
|
|
434
|
+
their total wait have explicit bounds; when either is exhausted the
|
|
435
|
+
original error remains terminal, so callers never render accumulated rows
|
|
436
|
+
as a complete listing.
|
|
437
|
+
|
|
438
|
+
Apart from bounded retries, it terminates in at most
|
|
439
|
+
``ceil(limit / page_size)`` successful requests: every successful
|
|
420
440
|
iteration either breaks or appends a full page.
|
|
421
441
|
"""
|
|
422
442
|
page_size = min(limit, _MAX_LIST_LIMIT)
|
|
423
443
|
rows: list[dict[str, Any]] = []
|
|
424
444
|
total = 0
|
|
425
445
|
page = 1
|
|
446
|
+
retries = 0
|
|
447
|
+
retry_deadline = time.monotonic() + _LIST_RATE_LIMIT_RETRY_BUDGET_S
|
|
426
448
|
while True:
|
|
427
|
-
|
|
449
|
+
try:
|
|
450
|
+
page_rows, total = fetch_page(page, page_size)
|
|
451
|
+
except http.HttpHTTPError as exc:
|
|
452
|
+
if exc.status != 429 or retries >= _LIST_RATE_LIMIT_MAX_RETRIES:
|
|
453
|
+
raise
|
|
454
|
+
retry_delay = max(exc.retry_after or 0, _LIST_RATE_LIMIT_RETRY_FALLBACK_S)
|
|
455
|
+
if time.monotonic() + retry_delay > retry_deadline:
|
|
456
|
+
raise
|
|
457
|
+
retries += 1
|
|
458
|
+
_sleep_for_rate_limit(exc.retry_after, _LIST_RATE_LIMIT_RETRY_FALLBACK_S, deadline=retry_deadline)
|
|
459
|
+
continue
|
|
428
460
|
rows.extend(page_rows)
|
|
429
461
|
# Enough for the view, a short page (nothing more exists), or we hold
|
|
430
462
|
# everything the server says there is.
|
|
@@ -543,9 +575,32 @@ _COMPLETED_STATUS = str(JobStatus.COMPLETED)
|
|
|
543
575
|
_RUNNING_STATUS = str(JobStatus.RUNNING)
|
|
544
576
|
_CANCELLED_STATUS = str(JobStatus.CANCELLED)
|
|
545
577
|
#: The terminal statuses that carry a self-diagnosis reason (JobRecord's
|
|
546
|
-
#: ``status_reason``/``status_detail``): a failed
|
|
547
|
-
#: ``completed`` job has none.
|
|
548
|
-
|
|
578
|
+
#: ``status_reason``/``status_detail``): a failed, cancelled, or timed-out
|
|
579
|
+
#: job. A cleanly ``completed`` job has none.
|
|
580
|
+
#:
|
|
581
|
+
#: ``timed_out`` (`M55-org-runtime-policy`) is added here even though the
|
|
582
|
+
#: control plane still projects a stored ``timed_out`` job as ``failed`` on
|
|
583
|
+
#: every read route today (see that milestone's PR description) — this set
|
|
584
|
+
#: must already be correct for the moment that projection lifts, not
|
|
585
|
+
#: rewritten again at that later date to add the one value its own author
|
|
586
|
+
#: had every reason to add now, while this exact line is open.
|
|
587
|
+
_FAILURE_STATUS_STRINGS = frozenset({str(JobStatus.FAILED), str(JobStatus.CANCELLED), str(JobStatus.TIMED_OUT)})
|
|
588
|
+
|
|
589
|
+
#: The tolerant-client check for ``simulo view`` specifically
|
|
590
|
+
#: (`M55-org-runtime-policy`, issue #1537) — used ONLY by
|
|
591
|
+
#: :func:`_mint_view_session` and :func:`_cmd_view`'s own post-wait check,
|
|
592
|
+
#: never by the OTHER ``_TERMINAL_STATUS_STRINGS`` call sites on this
|
|
593
|
+
#: surface (``logs --follow``/``cancel``'s own wait loops), which are
|
|
594
|
+
#: unaffected by this milestone. Checked as "not one of the known LIVE
|
|
595
|
+
#: values" rather than "is one of the known TERMINAL values": a job whose
|
|
596
|
+
#: status is anything OTHER than ``queued``/``running`` — including a
|
|
597
|
+
#: FUTURE value this client has never heard of — has ended, and this
|
|
598
|
+
#: command must refuse to mint a view session against it rather than
|
|
599
|
+
#: proceed as though the job were still live (the exact bug an unrecognized
|
|
600
|
+
#: ``timed_out`` produced before this set existed: ``status in
|
|
601
|
+
#: _TERMINAL_STATUS_STRINGS`` reads false for an unknown value, and the
|
|
602
|
+
#: command fell through to minting a session anyway).
|
|
603
|
+
_LIVE_STATUS_STRINGS = frozenset({str(JobStatus.QUEUED), _RUNNING_STATUS})
|
|
549
604
|
|
|
550
605
|
|
|
551
606
|
def _log_archive_unavailable(exc: JobsApiHTTPError) -> bool:
|
|
@@ -593,7 +648,7 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
|
|
|
593
648
|
# longer available": the logs are almost certainly still there and the
|
|
594
649
|
# single most useful thing to tell the user is to try again.
|
|
595
650
|
print(
|
|
596
|
-
f"Logs for job {job_id} could not be read right now — this is temporary, "
|
|
651
|
+
f"Logs for job {_safe_table_cell(job_id)} could not be read right now — this is temporary, "
|
|
597
652
|
"not a lost log. Try again in a moment.",
|
|
598
653
|
file=sys.stderr,
|
|
599
654
|
flush=True,
|
|
@@ -620,7 +675,7 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
|
|
|
620
675
|
"Simulo could not save this job's log output, so it could not be kept. "
|
|
621
676
|
"The job's status and result are unaffected."
|
|
622
677
|
)
|
|
623
|
-
print(f"Logs for job {job_id} are no longer available. {detail}", file=sys.stderr, flush=True)
|
|
678
|
+
print(f"Logs for job {_safe_table_cell(job_id)} are no longer available. {detail}", file=sys.stderr, flush=True)
|
|
624
679
|
return 1
|
|
625
680
|
|
|
626
681
|
|
|
@@ -800,6 +855,25 @@ def _display_resource_id(record: dict[str, Any], machine_field: str, *, unavaila
|
|
|
800
855
|
output must not regain a UUID leak merely because one response is stale.
|
|
801
856
|
Explicit JSON/info output bypasses this helper and preserves the raw wire
|
|
802
857
|
record unchanged.
|
|
858
|
+
|
|
859
|
+
The ``public_id`` branch is a strictly-validated public ID (``parse_*_
|
|
860
|
+
public_id``) and needs nothing further. The fallback branch is server
|
|
861
|
+
text with no such validation: bounded in width here so every caller —
|
|
862
|
+
table cells, prompts, and narrative prose alike — gets a safe value
|
|
863
|
+
without individually remembering to wrap it, matching the choke-point
|
|
864
|
+
reasoning :func:`_safe_table_cell` documents.
|
|
865
|
+
|
|
866
|
+
The NUL sentinel below is load-bearing, NOT vestigial (cross-vendor
|
|
867
|
+
review caught a version of this fix that assumed otherwise and
|
|
868
|
+
dropped it, breaking ``_resource_display_name``'s empty-name path,
|
|
869
|
+
which returns THIS function's value verbatim as a filename basis —
|
|
870
|
+
``_safe_download_filename`` refuses a NUL-tainted name outright rather
|
|
871
|
+
than silently reconstructing a different one). Every DISPLAY-only
|
|
872
|
+
caller (a table cell, a prompt, narrative prose — never a filename)
|
|
873
|
+
strips it by also routing through :func:`_safe_table_cell` /
|
|
874
|
+
:func:`_safe_single_line_text`, which remove every Unicode ``C*``
|
|
875
|
+
category including NUL; this function does not strip it itself only
|
|
876
|
+
because a filename-path caller needs it to survive this boundary.
|
|
803
877
|
"""
|
|
804
878
|
public_id = _record_public_id(record, machine_field)
|
|
805
879
|
if public_id is not None:
|
|
@@ -816,6 +890,8 @@ def _display_resource_id(record: dict[str, Any], machine_field: str, *, unavaila
|
|
|
816
890
|
nul_sentinel = "\x00" if "\x00" in raw_machine_text else ""
|
|
817
891
|
if _is_uuid_text(machine_text) or _MACHINE_UUID_RE.search(machine_text):
|
|
818
892
|
return unavailable + nul_sentinel
|
|
893
|
+
if len(machine_text) > _MAX_OUTPUTS_LINE_CHARS:
|
|
894
|
+
machine_text = machine_text[: _MAX_OUTPUTS_LINE_CHARS - 1].rstrip() + "…"
|
|
819
895
|
return machine_text + nul_sentinel
|
|
820
896
|
|
|
821
897
|
|
|
@@ -871,7 +947,17 @@ def _handle_display_id(handle: JobHandle) -> str:
|
|
|
871
947
|
|
|
872
948
|
|
|
873
949
|
def _resolved_job_ref(record: dict[str, Any], fallback: str) -> str:
|
|
874
|
-
"""The complete friendly ref for subsequent calls, failing closed on UUIDs.
|
|
950
|
+
"""The complete friendly ref for subsequent calls, failing closed on UUIDs.
|
|
951
|
+
|
|
952
|
+
Deliberately returns ``fallback`` UNSANITIZED in the synthetic-ID
|
|
953
|
+
compatibility case: this value feeds follow-up API requests, and
|
|
954
|
+
mutating it here would send a different identifier than the one the
|
|
955
|
+
server actually issued (`test_announce_latest_sanitizes_every_rendered_
|
|
956
|
+
field` in test_cli_outputs.py pins exactly this). Every CALLER that also
|
|
957
|
+
prints this value to the terminal is responsible for its own
|
|
958
|
+
:func:`_safe_table_cell`/:func:`_short_job_id` wrap at the print site —
|
|
959
|
+
do not sanitize here.
|
|
960
|
+
"""
|
|
875
961
|
public_id = _record_public_id(record, "job_id")
|
|
876
962
|
if public_id is not None:
|
|
877
963
|
return public_id
|
|
@@ -1852,8 +1938,19 @@ def _seed_summary(
|
|
|
1852
1938
|
public_id_cache[machine_id] = resolved
|
|
1853
1939
|
except JobsApiError:
|
|
1854
1940
|
pass
|
|
1855
|
-
|
|
1856
|
-
|
|
1941
|
+
# `_safe_table_cell`, not a bare assignment: `job_id` can come from
|
|
1942
|
+
# `_display_resource_id`'s non-public-id fallback, which can carry a
|
|
1943
|
+
# NUL sentinel through this DISPLAY path on purpose (cross-vendor
|
|
1944
|
+
# review) — only safe to leave unstripped for
|
|
1945
|
+
# `_resource_display_name`'s filename-basis use, never this summary.
|
|
1946
|
+
short = _safe_table_cell(job_id)
|
|
1947
|
+
# `seed["name"]`/`seed["kind"]` are server-supplied text (#623) — route
|
|
1948
|
+
# through the same choke point every other rendered field in this file
|
|
1949
|
+
# uses (`_safe_table_cell`) so BOTH of this function's callers (the
|
|
1950
|
+
# `simulo jobs` "CONTINUED FROM" column and `simulo result`'s stderr
|
|
1951
|
+
# line) are covered by one fix, per this file's own row-builder-SHAPE
|
|
1952
|
+
# design principle (see `_safe_table_cell`'s docstring).
|
|
1953
|
+
name = _safe_table_cell(seed.get("name") or seed.get("kind") or "checkpoint")
|
|
1857
1954
|
return f"{short} ({name})"
|
|
1858
1955
|
|
|
1859
1956
|
|
|
@@ -1942,10 +2039,12 @@ def _safe_output_detail_text(text: str) -> str:
|
|
|
1942
2039
|
terminal encoding can print — left alone, ``print()`` raises
|
|
1943
2040
|
``UnicodeEncodeError: surrogates not allowed`` and crashes an
|
|
1944
2041
|
otherwise-successful command (NFR: users never see a stack trace).
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2042
|
+
``sanitize_server_text`` now strips these too (Unicode category
|
|
2043
|
+
``Cs``, cross-vendor review), but this UTF-8 round-trip with
|
|
2044
|
+
``errors="replace"`` stays as the belt-and-braces layer — a
|
|
2045
|
+
category check is not the ONLY way an unencodable codepoint can
|
|
2046
|
+
reach this function, and turning it into a literal ``?`` instead of
|
|
2047
|
+
raising is what actually prevents the crash either way.
|
|
1949
2048
|
"""
|
|
1950
2049
|
text = sanitize_server_text(text)
|
|
1951
2050
|
return text.encode("utf-8", "replace").decode("utf-8")
|
|
@@ -2235,15 +2334,27 @@ def _cmd_jobs(args: argparse.Namespace) -> int:
|
|
|
2235
2334
|
)
|
|
2236
2335
|
rows = [
|
|
2237
2336
|
(
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2337
|
+
# `_safe_table_cell` (not a bare call): `_job_display_id`'s
|
|
2338
|
+
# non-public-id fallback can carry a NUL sentinel through this
|
|
2339
|
+
# DISPLAY path on purpose (cross-vendor review) — it is only
|
|
2340
|
+
# safe to leave unstripped for `_resource_display_name`'s
|
|
2341
|
+
# filename-basis use, never for a table cell.
|
|
2342
|
+
_safe_table_cell(_job_display_id(job)),
|
|
2343
|
+
# `job_name`/`status` are server-supplied text (#623) — the same
|
|
2344
|
+
# class of defect `_announce_latest` already guards against for
|
|
2345
|
+
# these exact two fields on a sibling code path; this table
|
|
2346
|
+
# never had the matching fix.
|
|
2347
|
+
_safe_table_cell(job.get("job_name", "-")),
|
|
2348
|
+
_safe_table_cell(job.get("status", "-")),
|
|
2241
2349
|
)
|
|
2242
2350
|
+ ((reason or "-",) if show_reason_column else ())
|
|
2243
2351
|
+ ((outputs_summary or "-",) if show_outputs_column else ())
|
|
2244
2352
|
+ (
|
|
2245
|
-
|
|
2246
|
-
|
|
2353
|
+
# `started_at`/`exit_code` are server-supplied too (#623
|
|
2354
|
+
# cross-vendor review) — the same table row builder already
|
|
2355
|
+
# wraps job_name/status, but left these two raw.
|
|
2356
|
+
_safe_table_cell(job.get("started_at") or "-"),
|
|
2357
|
+
"-" if job.get("exit_code") is None else _safe_table_cell(job.get("exit_code")),
|
|
2247
2358
|
)
|
|
2248
2359
|
+ ((seed_summary or "-",) if show_seed_column else ())
|
|
2249
2360
|
for job, seed_summary, reason, outputs_summary in zip(jobs, seed_summaries, reason_codes, outputs_summaries)
|
|
@@ -2899,9 +3010,9 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
|
|
|
2899
3010
|
# the chosen convention (issue #682).
|
|
2900
3011
|
targeted = args.all or args.name is not None
|
|
2901
3012
|
if targeted:
|
|
2902
|
-
print(f"No recordings for job {job_id
|
|
3013
|
+
print(f"No recordings for job {_safe_repr(job_id)}.", file=sys.stderr)
|
|
2903
3014
|
return 1
|
|
2904
|
-
print(f"No recordings for job {job_id
|
|
3015
|
+
print(f"No recordings for job {_safe_repr(job_id)}.")
|
|
2905
3016
|
return 0
|
|
2906
3017
|
|
|
2907
3018
|
if args.all:
|
|
@@ -2953,7 +3064,7 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
|
|
|
2953
3064
|
else:
|
|
2954
3065
|
available = _join_bounded(sorted(_safe_table_cell(_recording_name(record)) for record in records))
|
|
2955
3066
|
print(
|
|
2956
|
-
f"Job {job_id
|
|
3067
|
+
f"Job {_safe_repr(job_id)} has {total} recordings; pass one of ({available}) or --all.",
|
|
2957
3068
|
file=sys.stderr,
|
|
2958
3069
|
)
|
|
2959
3070
|
_print_list_truncation_footer(len(records), total, mention_all=False, cap=_RECORDINGS_VIEW_CAP, file=sys.stderr)
|
|
@@ -2987,10 +3098,17 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
|
|
|
2987
3098
|
# (see _safe_download_filename).
|
|
2988
3099
|
destination_observation, dest = _implicit_server_download_destination(_recording_name(record))
|
|
2989
3100
|
if destination_observation is None or dest is None:
|
|
3101
|
+
# BOTH interpolations are server text and BOTH are bounded
|
|
3102
|
+
# (`_safe_repr`, #623): `repr()` already neutralizes the control
|
|
3103
|
+
# bytes `_safe_download_filename` lets through, but a name that
|
|
3104
|
+
# is long AND unsafe passes the length check, fails the shape
|
|
3105
|
+
# check, and would otherwise land here at full length — the same
|
|
3106
|
+
# wall-of-text hazard `simulo outputs`'/`simulo models`' own
|
|
3107
|
+
# refusal messages already guard against (this one had not).
|
|
2990
3108
|
print(
|
|
2991
3109
|
f"Refusing to download recording "
|
|
2992
|
-
f"{_child_display_id(record, 'recording_id', label='recording')
|
|
2993
|
-
f"name {_recording_name(record)
|
|
3110
|
+
f"{_safe_repr(_child_display_id(record, 'recording_id', label='recording'))}: server-reported "
|
|
3111
|
+
f"name {_safe_repr(_recording_name(record))} is not a safe local filename. Pass -o PATH to "
|
|
2994
3112
|
"choose the destination yourself.",
|
|
2995
3113
|
file=sys.stderr,
|
|
2996
3114
|
)
|
|
@@ -3440,9 +3558,15 @@ def _download_model_and_report(
|
|
|
3440
3558
|
label = f" ({_safe_table_cell(kind)})" if kind else ""
|
|
3441
3559
|
verification_note = "sha256 verified" if expected_sha256 else "sha256 not provided by server"
|
|
3442
3560
|
# Report the LOCAL filename actually written (dest.name), not the raw
|
|
3443
|
-
# server-supplied record["name"]
|
|
3444
|
-
#
|
|
3445
|
-
#
|
|
3561
|
+
# server-supplied record["name"]. `_safe_download_filename` DOES strip
|
|
3562
|
+
# control characters (it calls `_safe_single_line_text`, not only the
|
|
3563
|
+
# traversal/reserved-name shape checks) — an earlier version of this
|
|
3564
|
+
# comment claimed otherwise, which cross-vendor review caught as
|
|
3565
|
+
# reversing the actual security ownership. Both halves of this line
|
|
3566
|
+
# are still wrapped in `_safe_table_cell` below as defense-in-depth
|
|
3567
|
+
# (this print does not itself re-derive whether `resolved.name` came
|
|
3568
|
+
# from that exact vetting path), never as the ONLY thing standing
|
|
3569
|
+
# between server text and the terminal.
|
|
3446
3570
|
print(
|
|
3447
3571
|
f"Saved {_safe_table_cell(resolved.name)}{label} -> "
|
|
3448
3572
|
f"{_safe_table_cell(resolved.resolve())} ({verification_note})"
|
|
@@ -3634,7 +3758,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
|
|
|
3634
3758
|
_warn_json_bounded(len(records), total)
|
|
3635
3759
|
return 0
|
|
3636
3760
|
if not records:
|
|
3637
|
-
print(f"No models for job {job_id
|
|
3761
|
+
print(f"No models for job {_safe_repr(job_id)} (only training jobs produce models).")
|
|
3638
3762
|
return 0
|
|
3639
3763
|
_print_model_table(records)
|
|
3640
3764
|
if paginated:
|
|
@@ -3643,12 +3767,12 @@ def _cmd_models(args: argparse.Namespace) -> int:
|
|
|
3643
3767
|
# is exactly the user who wants the next step, and they should not have
|
|
3644
3768
|
# to read documentation to find it. Human output only — `--json`
|
|
3645
3769
|
# returned above, so machine output is unaffected.
|
|
3646
|
-
print(f"Take a policy off the platform (ONNX + local verification): simulo export {job_id}")
|
|
3770
|
+
print(f"Take a policy off the platform (ONNX + local verification): simulo export {_safe_table_cell(job_id)}")
|
|
3647
3771
|
return 0
|
|
3648
3772
|
|
|
3649
3773
|
records = client.list_models(job_id, scope=scope) # NAME/--all path — needs the complete set
|
|
3650
3774
|
if not records:
|
|
3651
|
-
print(f"No models for job {job_id
|
|
3775
|
+
print(f"No models for job {_safe_repr(job_id)} (only training jobs produce models).", file=sys.stderr)
|
|
3652
3776
|
return 1
|
|
3653
3777
|
|
|
3654
3778
|
if args.all:
|
|
@@ -4417,11 +4541,16 @@ def _no_checkpoint_message(
|
|
|
4417
4541
|
selected_kind = kind or MODEL_KINDS[0]
|
|
4418
4542
|
suggestion = _first_job_with_models(jobs_client, submit_client, exclude_job_id=job_id, scope=scope)
|
|
4419
4543
|
lines = [
|
|
4420
|
-
f"Job {job_id} has no {selected_kind} checkpoint to export — only a training run produces "
|
|
4544
|
+
f"Job {_safe_table_cell(job_id)} has no {selected_kind} checkpoint to export — only a training run produces "
|
|
4421
4545
|
"checkpoints (a rollout, a cancelled run, or a run that failed before its first save has none).",
|
|
4422
4546
|
]
|
|
4423
4547
|
if suggestion is not None:
|
|
4424
|
-
|
|
4548
|
+
# `suggestion` comes from `_resolved_job_ref` (via
|
|
4549
|
+
# `_first_job_with_models`), which deliberately returns its
|
|
4550
|
+
# non-public-id fallback RAW for follow-up API calls (cross-vendor
|
|
4551
|
+
# review) — this use is display-only, so it needs its own wrap
|
|
4552
|
+
# here rather than at the source.
|
|
4553
|
+
lines += ["", "Export a run that has one:", f" simulo export {_safe_table_cell(suggestion)}"]
|
|
4425
4554
|
else:
|
|
4426
4555
|
lines += [
|
|
4427
4556
|
"",
|
|
@@ -4458,11 +4587,11 @@ def _export_create_error_message(
|
|
|
4458
4587
|
model_ref = "the selected checkpoint"
|
|
4459
4588
|
return (
|
|
4460
4589
|
f"No checkpoint {model_ref} under job {_human_job_ref(job_id)} — a --model ID must name one of THIS job's "
|
|
4461
|
-
f"checkpoints. List them with `simulo models {job_id}`."
|
|
4590
|
+
f"checkpoints. List them with `simulo models {_safe_table_cell(job_id)}`."
|
|
4462
4591
|
)
|
|
4463
4592
|
return (
|
|
4464
|
-
f"Simulo could not resolve a checkpoint to export for job {job_id}. "
|
|
4465
|
-
f"List its checkpoints with `simulo models {job_id}`."
|
|
4593
|
+
f"Simulo could not resolve a checkpoint to export for job {_safe_table_cell(job_id)}. "
|
|
4594
|
+
f"List its checkpoints with `simulo models {_safe_table_cell(job_id)}`."
|
|
4466
4595
|
)
|
|
4467
4596
|
if exc.code == "seed_model_unavailable":
|
|
4468
4597
|
return _no_checkpoint_message(
|
|
@@ -4475,9 +4604,9 @@ def _export_create_error_message(
|
|
|
4475
4604
|
)
|
|
4476
4605
|
if exc.code == "seed_job_not_terminal":
|
|
4477
4606
|
return (
|
|
4478
|
-
f"Job {job_id} has not finished yet — an export converts a FINISHED run's checkpoint.\n"
|
|
4607
|
+
f"Job {_safe_table_cell(job_id)} has not finished yet — an export converts a FINISHED run's checkpoint.\n"
|
|
4479
4608
|
"Wait for it, then export:\n"
|
|
4480
|
-
f" simulo logs {job_id} --follow"
|
|
4609
|
+
f" simulo logs {_safe_table_cell(job_id)} --follow"
|
|
4481
4610
|
)
|
|
4482
4611
|
if exc.code == "export_selector_conflict":
|
|
4483
4612
|
return "Pass either --model MODEL_ID or --kind best|latest, not both — they answer the same question."
|
|
@@ -4609,7 +4738,7 @@ def _export_failure_copy(reason: Optional[str], *, job_id: str, kind: Optional[s
|
|
|
4609
4738
|
"through it disagreed with the source PyTorch policy beyond the tolerance. No bundle was "
|
|
4610
4739
|
"published — a converted-but-inequivalent policy is worse than no policy.",
|
|
4611
4740
|
"Try the other checkpoint of this run:",
|
|
4612
|
-
f" simulo export {job_id} --kind {other_kind}",
|
|
4741
|
+
f" simulo export {_safe_table_cell(job_id)} --kind {other_kind}",
|
|
4613
4742
|
)
|
|
4614
4743
|
if reason == PolicyExportFailureReason.LOCAL_VALIDATION_FAILED.value:
|
|
4615
4744
|
return (
|
|
@@ -4617,19 +4746,19 @@ def _export_failure_copy(reason: Optional[str], *, job_id: str, kind: Optional[s
|
|
|
4617
4746
|
"verify.py on your machine, not to the platform-side conversion check, so this export cannot "
|
|
4618
4747
|
"be trusted either way.",
|
|
4619
4748
|
"Try again:",
|
|
4620
|
-
f" simulo export {job_id}",
|
|
4749
|
+
f" simulo export {_safe_table_cell(job_id)}",
|
|
4621
4750
|
)
|
|
4622
4751
|
if reason == PolicyExportFailureReason.CONVERSION_FAILED.value:
|
|
4623
4752
|
return (
|
|
4624
4753
|
"The converter failed before it produced a loadable model. Nothing about your policy is known "
|
|
4625
4754
|
"to be unexportable — this is the retryable one of the four export failures.",
|
|
4626
4755
|
"Try again:",
|
|
4627
|
-
f" simulo export {job_id}",
|
|
4756
|
+
f" simulo export {_safe_table_cell(job_id)}",
|
|
4628
4757
|
)
|
|
4629
4758
|
return (
|
|
4630
4759
|
"The export job did not finish. This is an ordinary job failure, not a verdict about your policy.",
|
|
4631
4760
|
"Try again:",
|
|
4632
|
-
f" simulo export {job_id}",
|
|
4761
|
+
f" simulo export {_safe_table_cell(job_id)}",
|
|
4633
4762
|
)
|
|
4634
4763
|
|
|
4635
4764
|
|
|
@@ -4800,7 +4929,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
|
|
|
4800
4929
|
print(
|
|
4801
4930
|
"\nStopped watching. The export is still running on Simulo.\n"
|
|
4802
4931
|
"Reattach to it — this returns the export already on its way rather than starting a second one:\n"
|
|
4803
|
-
f" simulo export {job_id}",
|
|
4932
|
+
f" simulo export {_safe_table_cell(job_id)}",
|
|
4804
4933
|
file=sys.stderr,
|
|
4805
4934
|
)
|
|
4806
4935
|
return 130
|
|
@@ -4825,7 +4954,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
|
|
|
4825
4954
|
)
|
|
4826
4955
|
echo("")
|
|
4827
4956
|
echo("Try again:")
|
|
4828
|
-
echo(f" simulo export {job_id}")
|
|
4957
|
+
echo(f" simulo export {_safe_table_cell(job_id)}")
|
|
4829
4958
|
else:
|
|
4830
4959
|
_report_export_failure(echo, record, job_id=job_id, kind=args.kind)
|
|
4831
4960
|
if json_mode:
|
|
@@ -4840,7 +4969,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
|
|
|
4840
4969
|
echo("")
|
|
4841
4970
|
echo("Bundle left on the platform (--no-download). Run the export again without it to download and")
|
|
4842
4971
|
echo("unpack the bundle:")
|
|
4843
|
-
echo(f" simulo export {job_id}")
|
|
4972
|
+
echo(f" simulo export {_safe_table_cell(job_id)}")
|
|
4844
4973
|
if json_mode:
|
|
4845
4974
|
print(json.dumps({"export": record, "bundle_dir": None}, indent=2))
|
|
4846
4975
|
return 0
|
|
@@ -5093,7 +5222,7 @@ def _mint_view_session(
|
|
|
5093
5222
|
if exc.code != _STREAM_ENDPOINT_NOT_READY:
|
|
5094
5223
|
raise
|
|
5095
5224
|
job = jobs_client.get_job(job_id, scope=scope)
|
|
5096
|
-
if str(job.get("status")) in
|
|
5225
|
+
if str(job.get("status")) not in _LIVE_STATUS_STRINGS:
|
|
5097
5226
|
return None, _job_ended_message(job_id)
|
|
5098
5227
|
if time.monotonic() >= deadline:
|
|
5099
5228
|
display_ref = _human_job_ref(job_id)
|
|
@@ -5256,7 +5385,7 @@ def _cmd_view(args: argparse.Namespace) -> int:
|
|
|
5256
5385
|
jobs_client = JobsApiClient(base_url, token=token)
|
|
5257
5386
|
view_client = ViewSessionApiClient(base_url, token=token)
|
|
5258
5387
|
|
|
5259
|
-
if str(job.get("status")) in
|
|
5388
|
+
if str(job.get("status")) not in _LIVE_STATUS_STRINGS:
|
|
5260
5389
|
print(_job_ended_message(job_id), file=sys.stderr)
|
|
5261
5390
|
return 1
|
|
5262
5391
|
|
|
@@ -5282,7 +5411,7 @@ def _cmd_view(args: argparse.Namespace) -> int:
|
|
|
5282
5411
|
# The actual bootstrap URL deliberately retains the internal job UUID in
|
|
5283
5412
|
# its fragment. It is a browser-only transport detail: ordinary CLI
|
|
5284
5413
|
# output names the public job and must not echo that UUID-bearing URL.
|
|
5285
|
-
print(f"Opening live view for job {job_id} in your browser...")
|
|
5414
|
+
print(f"Opening live view for job {_safe_table_cell(job_id)} in your browser...")
|
|
5286
5415
|
webbrowser.open(url)
|
|
5287
5416
|
return 0
|
|
5288
5417
|
|
|
@@ -5375,9 +5504,18 @@ def _confirm_latest_job_cancel(job: dict[str, Any], *, assume_yes: bool) -> Opti
|
|
|
5375
5504
|
if not _stdin_is_tty():
|
|
5376
5505
|
print(_CANCEL_NON_INTERACTIVE_REFUSAL, file=sys.stderr)
|
|
5377
5506
|
return 2
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5507
|
+
# `_safe_table_cell`, not a bare call: `_job_display_id`'s non-public-id
|
|
5508
|
+
# fallback can carry a NUL sentinel through this DISPLAY path on
|
|
5509
|
+
# purpose (cross-vendor review) — only safe to leave unstripped for
|
|
5510
|
+
# `_resource_display_name`'s filename-basis use, never a prompt.
|
|
5511
|
+
job_id = _safe_table_cell(_job_display_id(job))
|
|
5512
|
+
# `job_name`/`status` are server-supplied text (#623) — the same two
|
|
5513
|
+
# fields `simulo jobs`' table and `_announce_latest` already sanitize.
|
|
5514
|
+
# `_safe_table_cell` (not `_safe_single_line_text`) is deliberate here
|
|
5515
|
+
# too: an unbounded hostile name could otherwise push the y/N question
|
|
5516
|
+
# off-screen, the identical width hazard the table guards against.
|
|
5517
|
+
name = _safe_table_cell(job.get("job_name", "-"))
|
|
5518
|
+
status = _safe_table_cell(job.get("status", "-"))
|
|
5381
5519
|
answer = input(f"Cancel job {job_id} ({name}, {status})? [y/N] ")
|
|
5382
5520
|
if answer.strip().lower() not in ("y", "yes"):
|
|
5383
5521
|
print("Nothing cancelled.", file=sys.stderr)
|
|
@@ -8663,7 +8801,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
8663
8801
|
)
|
|
8664
8802
|
asset_delete.set_defaults(func=_observe_handler(asset_delete, _cmd_asset_delete))
|
|
8665
8803
|
|
|
8666
|
-
login = sub.add_parser(
|
|
8804
|
+
login = sub.add_parser(
|
|
8805
|
+
"login",
|
|
8806
|
+
help="Authenticate via browser; set SIMULO_CREDENTIALS_FILE to save a separate actor session.",
|
|
8807
|
+
)
|
|
8667
8808
|
login.set_defaults(func=_observe_handler(login, _cmd_login))
|
|
8668
8809
|
|
|
8669
8810
|
logout = sub.add_parser("logout", help="Clear the local session and revoke the refresh token server-side.")
|