simulo 0.9.0__tar.gz → 0.10.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.9.0/src/simulo.egg-info → simulo-0.10.0}/PKG-INFO +2 -2
- {simulo-0.9.0 → simulo-0.10.0}/pyproject.toml +2 -2
- simulo-0.10.0/src/simulo/_client/cancel_api.py +105 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/cli.py +494 -18
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/jobs_api.py +21 -4
- {simulo-0.9.0 → simulo-0.10.0/src/simulo.egg-info}/PKG-INFO +2 -2
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/SOURCES.txt +1 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/requires.txt +1 -1
- {simulo-0.9.0 → simulo-0.10.0}/MANIFEST.in +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/PYPI.md +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/setup.cfg +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/__init__.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_entrypoint.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_runner.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/app.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/bundle.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/credentials.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/http.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/learning.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/manifest.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/packaging.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/submit_api.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/view_session_api.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo/py.typed +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {simulo-0.9.0 → simulo-0.10.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.10.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.4,>=0.3
|
|
19
19
|
Requires-Dist: mcap<2,>=1.3
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: black>=24.0; 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.10.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"
|
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
# guard, resolved only at job-execution time — never in this distribution.
|
|
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
|
-
dependencies = ["simulo-interfaces>=0.
|
|
28
|
+
dependencies = ["simulo-interfaces>=0.3,<0.4", "mcap>=1.3,<2"]
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
31
|
dev = ["black>=24.0", "isort>=5.13", "ruff>=0.5", "mypy>=1.10", "pytest>=8.2", "build>=1.2", "twine>=5.1"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""HTTP client for the job cancel endpoint (stdlib only, torch-free).
|
|
2
|
+
|
|
3
|
+
Wire contract (job-cancel-and-demo-apps plan — see
|
|
4
|
+
``context/implementation-plans/job-cancel-and-demo-apps-plan.md``; the control
|
|
5
|
+
plane's half of this route is built by a parallel PR, so this client codes
|
|
6
|
+
directly against the plan's PINNED contract rather than waiting on it):
|
|
7
|
+
``POST /v1/jobs/{job_id}/cancel`` (Cognito bearer, org-scoped).
|
|
8
|
+
|
|
9
|
+
* 200 ``{"job_id": ..., "status": "cancelled"}`` — a QUEUED job is cancelled
|
|
10
|
+
immediately (it can never be claimed by a worker after this).
|
|
11
|
+
* 202 ``{"job_id": ..., "status": "running", "cancel_requested": true}`` — a
|
|
12
|
+
RUNNING job has cancellation requested; the worker confirms termination
|
|
13
|
+
within one heartbeat interval. Idempotent — a repeat cancel of an
|
|
14
|
+
already-cancel-requested running job returns the same 202 shape again.
|
|
15
|
+
* 409 ``job_not_running`` — the job is already in a terminal state
|
|
16
|
+
(completed/failed/cancelled); the response message carries the actual
|
|
17
|
+
status, but the caller (``simulo._client.cli``) does not surface it
|
|
18
|
+
verbatim — it reuses the same friendly "has ended" copy ``simulo view``
|
|
19
|
+
uses for the identical situation.
|
|
20
|
+
* 404 ``job_not_found`` — unknown or cross-org job id (anti-enumeration;
|
|
21
|
+
never 403). This is the cancel route's ONLY pinned 404 code — mirrors
|
|
22
|
+
``view_session_api.py``'s contract, and the caller relies on that the same
|
|
23
|
+
way: any OTHER 404 code means the ``/cancel`` route itself isn't deployed
|
|
24
|
+
in this environment (e.g. prod before this feature ships there), not that
|
|
25
|
+
the job is missing — see ``cli._CANCEL_UNAVAILABLE_MESSAGE``.
|
|
26
|
+
|
|
27
|
+
Error responses use the standard ``{"error": {"code", "message", "request_id"}}``
|
|
28
|
+
envelope, parsed by ``http.py`` exactly like every other client in this
|
|
29
|
+
package.
|
|
30
|
+
|
|
31
|
+
The route path is a LOCAL constant here rather than imported from
|
|
32
|
+
``simulo.interfaces.platform.runs`` — deliberately, the same reconciliation
|
|
33
|
+
call ``view_session_api.py`` made: the control-plane cancel route is being
|
|
34
|
+
built by a parallel PR and may land independently of this one, so this client
|
|
35
|
+
codes directly against the plan's pinned path/response shape rather than
|
|
36
|
+
sharing a file with that PR (avoiding a merge conflict). Reconcile into a
|
|
37
|
+
shared ``simulo.interfaces.platform`` constant in a follow-up once that PR has
|
|
38
|
+
merged, if a shared module materializes for it.
|
|
39
|
+
|
|
40
|
+
Shares request plumbing (structured-error parsing, the https-when-token
|
|
41
|
+
guard, the foreign-host bearer rule) with ``jobs_api.py``/``view_session_api.py``
|
|
42
|
+
via ``http.py`` — one request path, not reimplemented a fourth time.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any, Optional
|
|
48
|
+
|
|
49
|
+
from simulo._client import http
|
|
50
|
+
|
|
51
|
+
#: ``POST`` — request cancellation of a queued or running job. ``.format(job_id=...)``.
|
|
52
|
+
CANCEL_ROUTE_TEMPLATE = "/v1/jobs/{job_id}/cancel"
|
|
53
|
+
|
|
54
|
+
_REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
|
|
55
|
+
_UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
|
|
56
|
+
|
|
57
|
+
#: Required fields of a well-formed cancel response (see module docstring).
|
|
58
|
+
_REQUIRED_FIELDS = ("job_id", "status")
|
|
59
|
+
|
|
60
|
+
# Backward-compatible-style aliases — same convention as jobs_api.py /
|
|
61
|
+
# view_session_api.py: these are literally ``http.HttpError``/``http.HttpHTTPError``
|
|
62
|
+
# (not new subclasses), so callers that already catch ``JobsApiError`` (also
|
|
63
|
+
# ``http.HttpError``) catch these too without any extra wiring.
|
|
64
|
+
CancelApiError = http.HttpError
|
|
65
|
+
CancelApiHTTPError = http.HttpHTTPError
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CancelApiClient:
|
|
69
|
+
"""Client for ``POST /v1/jobs/{job_id}/cancel``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
|
|
72
|
+
if not base_url.startswith(("http://", "https://")):
|
|
73
|
+
raise CancelApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
|
|
74
|
+
self._base_url = base_url.rstrip("/")
|
|
75
|
+
self._token = token
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def base_url(self) -> str:
|
|
79
|
+
return self._base_url
|
|
80
|
+
|
|
81
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
82
|
+
"""Request cancellation of *job_id*; returns the parsed 200/202 body.
|
|
83
|
+
|
|
84
|
+
Raises :class:`CancelApiHTTPError` on any non-2xx response — the
|
|
85
|
+
caller (``simulo._client.cli``) classifies by ``exc.code``, never by
|
|
86
|
+
``exc.status`` alone (both ``job_not_running`` and the route-missing
|
|
87
|
+
degradation share statuses with other codes, exactly like
|
|
88
|
+
``ViewSessionApiClient.create_view_session``'s callers already do).
|
|
89
|
+
"""
|
|
90
|
+
path = CANCEL_ROUTE_TEMPLATE.format(job_id=http.quote_path_segment(job_id))
|
|
91
|
+
payload = http.request_json(
|
|
92
|
+
"POST",
|
|
93
|
+
self._base_url + path,
|
|
94
|
+
token=self._token,
|
|
95
|
+
api_base_url=self._base_url,
|
|
96
|
+
json_body={},
|
|
97
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
98
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
99
|
+
)
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
raise CancelApiError(f"Malformed cancel response for job {job_id!r}: expected a JSON object.")
|
|
102
|
+
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
|
|
103
|
+
if missing:
|
|
104
|
+
raise CancelApiError(f"Malformed cancel response for job {job_id!r}: missing field(s) {missing}.")
|
|
105
|
+
return payload
|
|
@@ -25,14 +25,14 @@ 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`` / ``simulo view``) talk HTTP
|
|
29
|
-
never reads the executor's run store from disk, because submit and
|
|
30
|
-
two separate machines. They target the jobs API at the base URL
|
|
31
|
-
``config.resolve_base_url`` (env override -> ``SIMULO_ENV`` preset ->
|
|
28
|
+
``simulo recordings`` / ``simulo models`` / ``simulo view`` / ``simulo cancel``) talk HTTP
|
|
29
|
+
ONLY — the thin client never reads the executor's run store from disk, because submit and
|
|
30
|
+
execute behave as if on two separate machines. They target the jobs API at the base URL
|
|
31
|
+
resolved by ``config.resolve_base_url`` (env override -> ``SIMULO_ENV`` preset ->
|
|
32
32
|
credentials -> the local ``simulo-backend serve`` stand-in), authenticated
|
|
33
33
|
with the saved credentials' bearer token when logged in. ``simulo logs``,
|
|
34
|
-
``simulo result``, and ``simulo
|
|
35
|
-
recent job over the API (and announce what they resolved to on stderr).
|
|
34
|
+
``simulo result``, ``simulo view``, and ``simulo cancel`` take an OPTIONAL job id: omitted,
|
|
35
|
+
they resolve the most recent job over the API (and announce what they resolved to on stderr).
|
|
36
36
|
|
|
37
37
|
``simulo view [JOB_ID]`` opens the default browser onto a running ``--viewstream`` job's live
|
|
38
38
|
3D scene: it mints a short-lived session (``POST /v1/jobs/{id}/view-session``) and opens
|
|
@@ -40,6 +40,18 @@ recent job over the API (and announce what they resolved to on stderr).
|
|
|
40
40
|
FRAGMENT, never the query string, so it never reaches server access logs. A queued job is
|
|
41
41
|
waited on with a spinner; a finished job (completed/failed/cancelled) is refused with a clear
|
|
42
42
|
message; a job not started with ``--viewstream`` surfaces the server's own error.
|
|
43
|
+
|
|
44
|
+
``simulo cancel [JOB_ID] [--yes/-y]`` requests cancellation of a queued or running job
|
|
45
|
+
(``POST /v1/jobs/{id}/cancel`` — job-cancel-and-demo-apps plan). JOB_ID omitted resolves the
|
|
46
|
+
most recent job ORG-WIDE, so it is confirmed before acting — a ``y``/``N`` prompt on a
|
|
47
|
+
terminal, or refused (exit 2) without ``--yes`` when stdin isn't a terminal; an EXPLICIT
|
|
48
|
+
JOB_ID is never prompted (naming the target is the confirmation). Past that gate: a queued
|
|
49
|
+
job is cancelled immediately (prints a one-line confirmation, exits 0). A running job has
|
|
50
|
+
cancellation requested and this command waits (~2s poll cadence, Ctrl-C to detach without
|
|
51
|
+
un-cancelling) until the worker confirms termination, then prints the final status. A job
|
|
52
|
+
already in a terminal state is refused with cancel-specific copy naming the job's actual
|
|
53
|
+
status. A control plane without the cancel route (e.g. prod before this feature ships
|
|
54
|
+
there) degrades gracefully, exactly like ``simulo view`` does for a missing view-session route.
|
|
43
55
|
"""
|
|
44
56
|
|
|
45
57
|
from __future__ import annotations
|
|
@@ -58,6 +70,7 @@ from simulo._client import config, credentials, http
|
|
|
58
70
|
from simulo._client._entrypoint import run_entrypoint
|
|
59
71
|
from simulo._client._runner import JobHandle
|
|
60
72
|
from simulo._client.app import App, spawn_recorder, viewstream_requested
|
|
73
|
+
from simulo._client.cancel_api import CancelApiClient, CancelApiHTTPError
|
|
61
74
|
from simulo._client.discovery import import_app_module
|
|
62
75
|
from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError, JobsApiUnavailable
|
|
63
76
|
from simulo._client.login import LoginError, perform_login
|
|
@@ -66,11 +79,27 @@ from simulo._client.registry import find_app
|
|
|
66
79
|
from simulo._client.submit_api import SubmitApiClient, SubmitApiError
|
|
67
80
|
from simulo._client.view_session_api import ViewSessionApiClient, ViewSessionApiError, ViewSessionApiHTTPError
|
|
68
81
|
from simulo.interfaces.platform.enums import JobStatus
|
|
69
|
-
from simulo.interfaces.platform.runs import TERMINAL_JOB_STATUSES
|
|
82
|
+
from simulo.interfaces.platform.runs import LOG_TAIL_MAX_BYTES, TERMINAL_JOB_STATUSES
|
|
70
83
|
|
|
71
84
|
#: ``--follow`` poll cadence. Module-level so tests can shrink it.
|
|
72
85
|
_FOLLOW_POLL_INTERVAL_S = 0.7
|
|
73
86
|
|
|
87
|
+
#: ``simulo logs --follow`` starts from the last this-many LINES of existing
|
|
88
|
+
#: output by default (``tail -f`` style) — override with ``--tail N`` /
|
|
89
|
+
#: ``--from-start``. Plain ``simulo logs`` (no ``--follow``) still dumps the
|
|
90
|
+
#: full log; ``simulo run``'s auto-follow starts at offset 0 (a brand-new
|
|
91
|
+
#: job's history IS its live output).
|
|
92
|
+
_DEFAULT_TAIL_LINES = 50
|
|
93
|
+
|
|
94
|
+
#: Generous bytes-per-line heuristic for sizing the ``tail_bytes`` request
|
|
95
|
+
#: window from a line count (server tail semantics are byte-based). Long
|
|
96
|
+
#: training-log lines rarely exceed ~200 bytes; over-fetching just means a
|
|
97
|
+
#: few extra lines are trimmed client-side.
|
|
98
|
+
_TAIL_BYTES_PER_LINE = 200
|
|
99
|
+
#: Floor on the requested tail window — a tiny ``--tail N`` still fetches
|
|
100
|
+
#: enough bytes to survive a run of unusually long lines.
|
|
101
|
+
_TAIL_MIN_BYTES = 4 * 1024
|
|
102
|
+
|
|
74
103
|
#: ``simulo view`` poll cadence while a resolved job is still QUEUED.
|
|
75
104
|
#: Module-level (like ``_FOLLOW_POLL_INTERVAL_S`` above) so tests can shrink it.
|
|
76
105
|
_VIEW_POLL_INTERVAL_S = 0.7
|
|
@@ -84,9 +113,18 @@ _MINT_GAP_WINDOW_S = 90.0
|
|
|
84
113
|
#: ``_VIEW_POLL_INTERVAL_S``) so tests can shrink the two loops independently.
|
|
85
114
|
_MINT_GAP_POLL_INTERVAL_S = 1.0
|
|
86
115
|
|
|
116
|
+
#: ``simulo cancel`` poll cadence while waiting for a RUNNING job's cancel to
|
|
117
|
+
#: be confirmed. Module-level (like the poll constants above) so tests can
|
|
118
|
+
#: shrink it. Heartbeat cadence bounds real-world cancel latency to ~15s
|
|
119
|
+
#: worst case (job-cancel-and-demo-apps plan) — ~2s here is fast enough to
|
|
120
|
+
#: feel responsive without hammering the server while waiting.
|
|
121
|
+
_CANCEL_POLL_INTERVAL_S = 2.0
|
|
122
|
+
|
|
87
123
|
# String-typed mirrors of the contract's status sets (the API sends strings).
|
|
88
124
|
_TERMINAL_STATUS_STRINGS = frozenset(str(status) for status in TERMINAL_JOB_STATUSES)
|
|
89
125
|
_COMPLETED_STATUS = str(JobStatus.COMPLETED)
|
|
126
|
+
_RUNNING_STATUS = str(JobStatus.RUNNING)
|
|
127
|
+
_CANCELLED_STATUS = str(JobStatus.CANCELLED)
|
|
90
128
|
|
|
91
129
|
|
|
92
130
|
def _resolve_client_context() -> tuple[str, Optional[str]]:
|
|
@@ -384,15 +422,129 @@ def _announce_latest(record: dict[str, Any], verb: str) -> str:
|
|
|
384
422
|
return job_id
|
|
385
423
|
|
|
386
424
|
|
|
387
|
-
def
|
|
388
|
-
"""
|
|
425
|
+
def _tail_window_bytes(tail_lines: int) -> int:
|
|
426
|
+
"""Size the ``tail_bytes`` request window for a line count (0 = end-of-log)."""
|
|
427
|
+
if tail_lines == 0:
|
|
428
|
+
return 0
|
|
429
|
+
return min(max(tail_lines * _TAIL_BYTES_PER_LINE, _TAIL_MIN_BYTES), LOG_TAIL_MAX_BYTES)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _split_lines_lf_only(text: str) -> list[str]:
|
|
433
|
+
"""Split ``text`` into lines on ``\\n`` ONLY, each piece keeping its
|
|
434
|
+
trailing ``\\n`` (POSIX ``tail -n`` semantics — mirrors the SHAPE of
|
|
435
|
+
``str.splitlines(keepends=True)`` but not its universal-newline scanning).
|
|
436
|
+
|
|
437
|
+
Deliberately NOT ``str.splitlines()``: that treats a bare ``\\r`` as a
|
|
438
|
+
line boundary too, so a tqdm-style progress update (``33%\\r34%\\r...\\r
|
|
439
|
+
99%\\n``) counts as ~100 "lines" instead of the one real line it is until
|
|
440
|
+
its terminating ``\\n`` — a lone mid-line ``\\r`` is a cursor move, not a
|
|
441
|
+
newline. ``\\r\\n`` (CRLF) still ends on the ``\\n`` and is still one
|
|
442
|
+
line, terminator intact (the trailing ``\\r`` rides along with its piece).
|
|
443
|
+
"""
|
|
444
|
+
if not text:
|
|
445
|
+
return []
|
|
446
|
+
pieces = text.split("\n")
|
|
447
|
+
result = [piece + "\n" for piece in pieces[:-1]]
|
|
448
|
+
if pieces[-1]: # trailing partial line with no terminator yet
|
|
449
|
+
result.append(pieces[-1])
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _trim_to_last_lines(text: str, lines: int, *, drop_first_partial: bool) -> str:
|
|
454
|
+
"""Client-side half of tail: keep only the last ``lines`` lines of ``text``.
|
|
389
455
|
|
|
390
|
-
|
|
391
|
-
|
|
456
|
+
``drop_first_partial`` — the server's tail start is a BYTE position
|
|
457
|
+
(``max(0, total - tail_bytes)``) and may land mid-line (or mid-UTF-8
|
|
458
|
+
sequence, decoded as U+FFFD); when the window started mid-stream the first
|
|
459
|
+
"line" is a fragment, so it is dropped before counting.
|
|
460
|
+
"""
|
|
461
|
+
if drop_first_partial:
|
|
462
|
+
cut = text.find("\n")
|
|
463
|
+
text = text[cut + 1 :] if cut >= 0 else ""
|
|
464
|
+
if lines <= 0:
|
|
465
|
+
return ""
|
|
466
|
+
return "".join(_split_lines_lf_only(text)[-lines:])
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _start_tail(client: JobsApiClient, job_id: str, tail_lines: int) -> tuple[int, str, bool]:
|
|
470
|
+
"""Open a follow at the log's tail; print it; return ``(resume_offset, status, honored)``.
|
|
471
|
+
|
|
472
|
+
Requests the tail window (one ``tail_bytes`` call, then plain offset pages
|
|
473
|
+
for a window larger than one response chunk — never the full history),
|
|
474
|
+
trims to the last ``tail_lines`` lines, prints them, and reports where
|
|
475
|
+
live paging resumes. ``honored=False`` means the server is too old to know
|
|
476
|
+
``tail_bytes``, detected either way a legacy server can react to the
|
|
477
|
+
unknown/out-of-range param: (a) it silently IGNORES it (missing
|
|
478
|
+
``start_offset`` on an otherwise-200 response), or (b) a strict
|
|
479
|
+
query-validation layer REJECTS it outright (422) — e.g. a server whose
|
|
480
|
+
``le=`` bound on ``tail_bytes`` predates a since-raised
|
|
481
|
+
:data:`~simulo.interfaces.platform.runs.LOG_TAIL_MAX_BYTES` (see that
|
|
482
|
+
constant's rollout-order caveat). Either way the caller gets the SAME
|
|
483
|
+
full-history fallback: the (a) response is printed VERBATIM after a
|
|
484
|
+
one-line stderr note, or (b) triggers one bare retry without
|
|
485
|
+
``tail_bytes`` before the same note+full-history path — the caller then
|
|
486
|
+
continues normal paging from ``resume_offset`` (never a silent hang or a
|
|
487
|
+
mis-trim).
|
|
488
|
+
"""
|
|
489
|
+
window = _tail_window_bytes(tail_lines)
|
|
490
|
+
try:
|
|
491
|
+
chunk, offset, status, start_offset = client.get_log_chunk(job_id, 0, tail_bytes=window)
|
|
492
|
+
except JobsApiHTTPError as exc:
|
|
493
|
+
if exc.status != 422:
|
|
494
|
+
raise
|
|
495
|
+
# A server that rejects (rather than ignores) the unknown/out-of-
|
|
496
|
+
# range tail_bytes param — retry once, plain, then degrade exactly
|
|
497
|
+
# like the "ignored" case below.
|
|
498
|
+
chunk, offset, status, start_offset = client.get_log_chunk(job_id, 0)
|
|
499
|
+
start_offset = None
|
|
500
|
+
if start_offset is None:
|
|
501
|
+
print(
|
|
502
|
+
"note: this control plane doesn't support tail; showing the log from the start.",
|
|
503
|
+
file=sys.stderr,
|
|
504
|
+
flush=True,
|
|
505
|
+
)
|
|
506
|
+
text = chunk
|
|
507
|
+
else:
|
|
508
|
+
buffer = chunk
|
|
509
|
+
while chunk and offset < start_offset + window:
|
|
510
|
+
# The tail window exceeded one response chunk — drain the rest of
|
|
511
|
+
# the window with normal offset pages before trimming.
|
|
512
|
+
next_chunk, next_offset, status, _ = client.get_log_chunk(job_id, offset)
|
|
513
|
+
if next_offset <= offset:
|
|
514
|
+
# A misbehaving server returned a non-empty chunk without
|
|
515
|
+
# advancing the offset — stop instead of looping/growing the
|
|
516
|
+
# buffer unboundedly on a response that can never converge.
|
|
517
|
+
break
|
|
518
|
+
chunk, offset = next_chunk, next_offset
|
|
519
|
+
buffer += chunk
|
|
520
|
+
text = _trim_to_last_lines(buffer, tail_lines, drop_first_partial=start_offset > 0)
|
|
521
|
+
if text:
|
|
522
|
+
sys.stdout.write(text)
|
|
523
|
+
sys.stdout.flush()
|
|
524
|
+
return offset, status, start_offset is not None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _drain_logs(client: JobsApiClient, job_id: str, *, follow: bool, tail_lines: Optional[int] = None) -> int:
|
|
528
|
+
"""Print log chunks; ``follow`` polls to a terminal status.
|
|
529
|
+
|
|
530
|
+
``tail_lines=None`` prints from byte offset 0 (full history — plain
|
|
531
|
+
``simulo logs``, ``--from-start``, and ``simulo run``'s auto-follow, where
|
|
532
|
+
offset 0 is naturally correct for a brand-new job). An int starts ``tail
|
|
533
|
+
-f`` style instead: only the last ``tail_lines`` lines of existing output,
|
|
534
|
+
then live — so re-attaching to a long-running job never re-dumps (or
|
|
535
|
+
re-downloads) its whole history. Shared by ``simulo logs`` and the
|
|
536
|
+
post-``simulo run`` default follow — one follow loop, one
|
|
537
|
+
exit-code-mirrors-outcome rule.
|
|
392
538
|
"""
|
|
393
539
|
offset = 0
|
|
540
|
+
if tail_lines is not None:
|
|
541
|
+
offset, _status, honored = _start_tail(client, job_id, tail_lines)
|
|
542
|
+
if honored and not follow:
|
|
543
|
+
return 0 # printed the last N lines that exist — done
|
|
544
|
+
# Not honored (old server): the loop below simply continues the full
|
|
545
|
+
# history from where the server's offset-0 answer left off.
|
|
394
546
|
while True:
|
|
395
|
-
chunk, offset, status = client.get_log_chunk(job_id, offset)
|
|
547
|
+
chunk, offset, status, _ = client.get_log_chunk(job_id, offset)
|
|
396
548
|
if chunk:
|
|
397
549
|
sys.stdout.write(chunk)
|
|
398
550
|
sys.stdout.flush()
|
|
@@ -404,14 +556,32 @@ def _drain_logs(client: JobsApiClient, job_id: str, *, follow: bool) -> int:
|
|
|
404
556
|
time.sleep(_FOLLOW_POLL_INTERVAL_S)
|
|
405
557
|
|
|
406
558
|
|
|
559
|
+
def _resolve_tail_lines(args: argparse.Namespace) -> Optional[int]:
|
|
560
|
+
"""Map the ``logs`` flags onto ``_drain_logs``'s ``tail_lines``.
|
|
561
|
+
|
|
562
|
+
``--from-start`` (or plain ``simulo logs`` with no tail flag) → ``None``
|
|
563
|
+
(full history). ``--tail N`` → N, with or without ``--follow`` (``tail
|
|
564
|
+
-n`` / ``tail -nf`` parity). ``--follow`` alone → the default tail
|
|
565
|
+
(:data:`_DEFAULT_TAIL_LINES`) — following a long-running job should show
|
|
566
|
+
where it IS, not replay everything it ever printed.
|
|
567
|
+
"""
|
|
568
|
+
if args.from_start:
|
|
569
|
+
return None
|
|
570
|
+
if args.tail is not None:
|
|
571
|
+
return args.tail
|
|
572
|
+
return _DEFAULT_TAIL_LINES if args.follow else None
|
|
573
|
+
|
|
574
|
+
|
|
407
575
|
def _cmd_logs(args: argparse.Namespace) -> int:
|
|
408
|
-
"""``simulo logs [JOB_ID] [--follow]`` — print log chunks
|
|
576
|
+
"""``simulo logs [JOB_ID] [--follow] [--tail N | --from-start]`` — print log chunks.
|
|
409
577
|
|
|
410
578
|
JOB_ID omitted: resolve the most recent job over the API and announce it on
|
|
411
579
|
stderr — with ``--follow`` an empty store waits for the first job to appear;
|
|
412
580
|
plain on an empty store is a clear error (exit 1). Plain: print what exists
|
|
413
|
-
and exit 0. ``--follow``:
|
|
414
|
-
|
|
581
|
+
and exit 0. ``--follow``: start from the last ``_DEFAULT_TAIL_LINES`` lines
|
|
582
|
+
(true-tail — never re-dumps history; ``--tail N`` / ``--from-start``
|
|
583
|
+
override), then poll (~0.7s) while the job runs, drain, and mirror the
|
|
584
|
+
job's outcome in the exit code (0 for ``completed``, 1 otherwise).
|
|
415
585
|
"""
|
|
416
586
|
base_url, token = _resolve_client_context()
|
|
417
587
|
client = JobsApiClient(base_url, token=token)
|
|
@@ -441,7 +611,7 @@ def _cmd_logs(args: argparse.Namespace) -> int:
|
|
|
441
611
|
return 1
|
|
442
612
|
raise
|
|
443
613
|
|
|
444
|
-
return _drain_logs(client, job_id, follow=args.follow)
|
|
614
|
+
return _drain_logs(client, job_id, follow=args.follow, tail_lines=_resolve_tail_lines(args))
|
|
445
615
|
|
|
446
616
|
|
|
447
617
|
def _cmd_result(args: argparse.Namespace) -> int:
|
|
@@ -1207,6 +1377,246 @@ def _cmd_view(args: argparse.Namespace) -> int:
|
|
|
1207
1377
|
return 0
|
|
1208
1378
|
|
|
1209
1379
|
|
|
1380
|
+
# ---------------------------------------------------------------------------
|
|
1381
|
+
# simulo cancel
|
|
1382
|
+
# ---------------------------------------------------------------------------
|
|
1383
|
+
|
|
1384
|
+
#: The cancel route's ONE pinned 404 code (cancel_api.py module docstring).
|
|
1385
|
+
#: Mirrors ``_JOB_NOT_FOUND`` above (view-session's identical convention): any
|
|
1386
|
+
#: OTHER 404 code means the ``/cancel`` route itself isn't deployed in this
|
|
1387
|
+
#: environment, not that this particular job is missing.
|
|
1388
|
+
_CANCEL_JOB_NOT_FOUND = "job_not_found"
|
|
1389
|
+
#: The cancel route's pinned "already terminal" code.
|
|
1390
|
+
_CANCEL_JOB_NOT_RUNNING = "job_not_running"
|
|
1391
|
+
#: Printed (and the command exits 1) when the cancel call 404s with any code
|
|
1392
|
+
#: other than ``job_not_found`` — i.e. this control plane doesn't have the
|
|
1393
|
+
#: ``/cancel`` route at all (e.g. prod before this feature ships there),
|
|
1394
|
+
#: rather than not knowing this particular job. Mirrors
|
|
1395
|
+
#: ``_VIEW_SESSIONS_UNAVAILABLE_MESSAGE``'s degrade-gracefully contract
|
|
1396
|
+
#: exactly, including covering both the Starlette-default-404 envelope shape
|
|
1397
|
+
#: (``code == "Not Found"``) and a non-envelope 404 from a proxy in front of
|
|
1398
|
+
#: the control plane (``code == "http_error"``, ``http.py``'s fallback).
|
|
1399
|
+
_CANCEL_UNAVAILABLE_MESSAGE = (
|
|
1400
|
+
"Job cancel isn't available in this environment yet (the control plane here doesn't "
|
|
1401
|
+
"support job cancellation). The job itself is unaffected — check its status with "
|
|
1402
|
+
"`simulo jobs`."
|
|
1403
|
+
)
|
|
1404
|
+
#: Printed (exit 2) when the ID-LESS form is run non-interactively without ``--yes``.
|
|
1405
|
+
_CANCEL_NON_INTERACTIVE_REFUSAL = "refusing to cancel the latest job non-interactively without --yes"
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _cancel_already_ended_message(job_id: str, server_message: str) -> str:
|
|
1409
|
+
"""The cancel-specific "already terminal" refusal (MAJOR fix, critic
|
|
1410
|
+
review PR #345): the original implementation reused ``_job_ended_message``
|
|
1411
|
+
verbatim, which is ``simulo view``'s copy ("live view is only available
|
|
1412
|
+
while a job is running") — the wrong command's voice for a ``simulo
|
|
1413
|
+
cancel`` caller. *server_message* is the cancel route's own 409 message,
|
|
1414
|
+
which per the plan's wire contract carries the job's actual terminal
|
|
1415
|
+
status (e.g. "job is 'completed', not running") — included parenthetically
|
|
1416
|
+
rather than dropped, the same way ``_cmd_result`` surfaces
|
|
1417
|
+
``exc.message`` directly for its analogous "no result yet" 404.
|
|
1418
|
+
|
|
1419
|
+
NIT (critic review, deliberately left as-is): an already-cancelled job
|
|
1420
|
+
also 409s here — script-unfriendly (a script that races a real cancel
|
|
1421
|
+
against its own retry sees exit 1), but this IS the plan-pinned
|
|
1422
|
+
contract, not a bug in this client.
|
|
1423
|
+
"""
|
|
1424
|
+
return f"Job {job_id!r} has already ended — only a queued or running job can be cancelled. ({server_message})"
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _stdin_is_tty() -> bool:
|
|
1428
|
+
"""``sys.stdin.isatty()`` behind a thin indirection.
|
|
1429
|
+
|
|
1430
|
+
Tests monkeypatch THIS function rather than swapping the real
|
|
1431
|
+
interpreter-wide ``sys.stdin`` object: replacing ``sys.stdin`` itself
|
|
1432
|
+
(even for one test, even restored via ``monkeypatch.setattr``) was tried
|
|
1433
|
+
first and reliably hung a LATER, unrelated test file in the full suite —
|
|
1434
|
+
pytest's own capture machinery holds long-lived references to the
|
|
1435
|
+
original ``sys.stdin`` and gets confused by a mid-session swap. A plain
|
|
1436
|
+
function monkeypatch has no such blast radius.
|
|
1437
|
+
"""
|
|
1438
|
+
return sys.stdin.isatty()
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _confirm_latest_job_cancel(job: dict[str, Any], *, assume_yes: bool) -> Optional[int]:
|
|
1442
|
+
"""Safety gate for the ID-LESS ``simulo cancel`` form ONLY (security MEDIUM,
|
|
1443
|
+
PR #345 review).
|
|
1444
|
+
|
|
1445
|
+
The id-less form resolves the most recent job ORG-WIDE — no
|
|
1446
|
+
``created_by`` filter — so it can just as easily be a teammate's job as
|
|
1447
|
+
the caller's own, and (before this gate) the resolution announce fired
|
|
1448
|
+
in the same breath as the cancel POST, leaving no abort window. An
|
|
1449
|
+
EXPLICIT ``JOB_ID`` is never gated here: naming the target IS the
|
|
1450
|
+
confirmation, the same norm ``docker stop``/``gh run cancel`` use.
|
|
1451
|
+
|
|
1452
|
+
TTY: prompts ``Cancel job <id> (<name>, <status>)? [y/N] `` and proceeds
|
|
1453
|
+
only on ``y``/``Y``/``yes`` (case-insensitive) — anything else (including
|
|
1454
|
+
a bare Enter) aborts. Non-TTY (e.g. a script, a CI job, a pipe): refuses
|
|
1455
|
+
outright unless ``--yes``/``-y`` was passed — there is no one to answer a
|
|
1456
|
+
prompt, so silently proceeding would be exactly the footgun this gate
|
|
1457
|
+
exists to close. ``--yes`` skips the prompt in both cases.
|
|
1458
|
+
|
|
1459
|
+
Returns ``None`` to proceed with the cancel (the cancel API is NOT called
|
|
1460
|
+
yet at this point), or an exit code for the caller to return immediately
|
|
1461
|
+
without ever calling it.
|
|
1462
|
+
"""
|
|
1463
|
+
if assume_yes:
|
|
1464
|
+
return None
|
|
1465
|
+
if not _stdin_is_tty():
|
|
1466
|
+
print(_CANCEL_NON_INTERACTIVE_REFUSAL, file=sys.stderr)
|
|
1467
|
+
return 2
|
|
1468
|
+
job_id = str(job.get("job_id"))
|
|
1469
|
+
name = job.get("job_name", "-")
|
|
1470
|
+
status = job.get("status", "-")
|
|
1471
|
+
answer = input(f"Cancel job {job_id} ({name}, {status})? [y/N] ")
|
|
1472
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
1473
|
+
print("Nothing cancelled.", file=sys.stderr)
|
|
1474
|
+
return 1
|
|
1475
|
+
return None
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _wait_for_cancel_confirmation(client: JobsApiClient, job_id: str, initial_status: str) -> dict[str, Any]:
|
|
1479
|
+
"""Poll a RUNNING job whose cancellation was just requested, until it goes terminal.
|
|
1480
|
+
|
|
1481
|
+
The ``simulo cancel`` twin of ``_wait_for_running_or_terminal``: prints
|
|
1482
|
+
the wait message immediately via ``_WaitAnnouncer`` (then re-prints every
|
|
1483
|
+
``_SPINNER_REANNOUNCE_INTERVAL_S``, shared with the view-session waits —
|
|
1484
|
+
a multi-minute cancel wait is not a frozen terminal either), while
|
|
1485
|
+
actually re-fetching the job at the SEPARATE, slower
|
|
1486
|
+
``_CANCEL_POLL_INTERVAL_S`` (~2s) cadence — heartbeat-bounded cancel
|
|
1487
|
+
latency (~15s worst case per the plan) does not need `simulo view`'s
|
|
1488
|
+
faster queued-wait cadence. ``KeyboardInterrupt`` is NOT caught here — it
|
|
1489
|
+
propagates to the caller, which prints the "cancel stays requested" note
|
|
1490
|
+
and exits 130 (the server-side ``cancel_requested`` flag is untouched by
|
|
1491
|
+
detaching from the wait).
|
|
1492
|
+
"""
|
|
1493
|
+
announcer = _WaitAnnouncer(
|
|
1494
|
+
"Cancel requested — waiting for the worker to stop the job... "
|
|
1495
|
+
"(Ctrl-C to stop waiting; the cancel stays requested)"
|
|
1496
|
+
)
|
|
1497
|
+
job: dict[str, Any] = {"status": initial_status}
|
|
1498
|
+
while str(job.get("status")) not in _TERMINAL_STATUS_STRINGS:
|
|
1499
|
+
announcer.poll()
|
|
1500
|
+
time.sleep(_CANCEL_POLL_INTERVAL_S)
|
|
1501
|
+
job = client.get_job(job_id)
|
|
1502
|
+
return job
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _cmd_cancel(args: argparse.Namespace) -> int:
|
|
1506
|
+
"""``simulo cancel [JOB_ID] [--yes/-y]`` — request cancellation of a queued or running job.
|
|
1507
|
+
|
|
1508
|
+
JOB_ID omitted (the destructive-footgun case, security MEDIUM PR #345
|
|
1509
|
+
review): resolve the most recent job over the API — ORG-WIDE, not just
|
|
1510
|
+
the caller's own — announce it on stderr (the same latest-job default
|
|
1511
|
+
the other observe commands use), then gate on confirmation
|
|
1512
|
+
(``_confirm_latest_job_cancel``) BEFORE ever calling the cancel API: a
|
|
1513
|
+
TTY prompts ``y``/``N``; a non-TTY (script/CI/pipe) refuses outright
|
|
1514
|
+
(exit 2) unless ``--yes``/``-y`` was passed. JOB_ID given explicitly is
|
|
1515
|
+
NEVER gated — naming the target is itself the confirmation (the same
|
|
1516
|
+
norm ``docker stop``/``gh run cancel`` use); ``--yes`` is accepted and
|
|
1517
|
+
silently ignored there, harmless for scripts that pass it unconditionally.
|
|
1518
|
+
|
|
1519
|
+
Past the gate, no pre-check of the job's current status (unlike
|
|
1520
|
+
``simulo view``, which needs it to decide whether to wait for a queued
|
|
1521
|
+
job to start streaming): the cancel route's own response —
|
|
1522
|
+
200/202/409/404 — already tells this command everything it needs.
|
|
1523
|
+
|
|
1524
|
+
* A QUEUED job is cancelled immediately (200): prints a one-line
|
|
1525
|
+
confirmation and exits 0.
|
|
1526
|
+
* A RUNNING job has cancellation requested (202, idempotent on repeat):
|
|
1527
|
+
prints a wait message and polls (``_wait_for_cancel_confirmation``)
|
|
1528
|
+
until the worker reports it terminal, then prints the final status —
|
|
1529
|
+
exit 0 only if it reached ``cancelled`` (mirrors the follow loop's
|
|
1530
|
+
"exit code mirrors outcome" rule; any other terminal status, e.g. the
|
|
1531
|
+
job finished on its own in the race, is not what the user asked for).
|
|
1532
|
+
Ctrl-C during the wait detaches WITHOUT un-cancelling: the server-side
|
|
1533
|
+
request stays in effect, and this command exits 130 with a note to
|
|
1534
|
+
check `simulo jobs`. A transient error mid-wait (e.g. a 5xx re-fetching
|
|
1535
|
+
the job) gets the SAME "cancel remains requested" note, exit 1 —
|
|
1536
|
+
never read as "the cancel failed".
|
|
1537
|
+
* A job already in a terminal state (409 ``job_not_running``) is refused
|
|
1538
|
+
with cancel-specific copy (``_cancel_already_ended_message``), exit 1
|
|
1539
|
+
— including when the job is ALREADY ``cancelled`` (the plan-pinned
|
|
1540
|
+
contract makes this a 409 too; script-unfriendly, but not a bug here).
|
|
1541
|
+
* An unknown/cross-org job id (404 ``job_not_found``) is reported the
|
|
1542
|
+
same way ``simulo logs``/``simulo view`` report one.
|
|
1543
|
+
* A control plane without the cancel route at all (404 with any OTHER
|
|
1544
|
+
code) degrades gracefully, mirroring ``simulo view``'s handling of a
|
|
1545
|
+
missing view-session route — a clean one-liner, exit 1, no retry.
|
|
1546
|
+
"""
|
|
1547
|
+
base_url, token = _resolve_client_context()
|
|
1548
|
+
jobs_client = JobsApiClient(base_url, token=token)
|
|
1549
|
+
cancel_client = CancelApiClient(base_url, token=token)
|
|
1550
|
+
|
|
1551
|
+
job_id: Optional[str] = args.job_id
|
|
1552
|
+
if job_id is None:
|
|
1553
|
+
latest = jobs_client.get_latest_job()
|
|
1554
|
+
if latest is None:
|
|
1555
|
+
print(
|
|
1556
|
+
"No jobs found — nothing to cancel. Execute a package with "
|
|
1557
|
+
"`simulo-backend run-package` to create one.",
|
|
1558
|
+
file=sys.stderr,
|
|
1559
|
+
)
|
|
1560
|
+
return 1
|
|
1561
|
+
job_id = _announce_latest(latest, "cancelling")
|
|
1562
|
+
abort_code = _confirm_latest_job_cancel(latest, assume_yes=bool(args.yes))
|
|
1563
|
+
if abort_code is not None:
|
|
1564
|
+
return abort_code
|
|
1565
|
+
|
|
1566
|
+
try:
|
|
1567
|
+
payload = cancel_client.cancel_job(job_id)
|
|
1568
|
+
except CancelApiHTTPError as exc:
|
|
1569
|
+
if exc.status == 404 and exc.code != _CANCEL_JOB_NOT_FOUND:
|
|
1570
|
+
print(_CANCEL_UNAVAILABLE_MESSAGE, file=sys.stderr)
|
|
1571
|
+
return 1
|
|
1572
|
+
if exc.status == 404:
|
|
1573
|
+
print(f"Job {job_id!r} not found. List runs with `simulo jobs`.", file=sys.stderr)
|
|
1574
|
+
return 1
|
|
1575
|
+
if exc.code == _CANCEL_JOB_NOT_RUNNING:
|
|
1576
|
+
print(_cancel_already_ended_message(job_id, exc.message), file=sys.stderr)
|
|
1577
|
+
return 1
|
|
1578
|
+
raise
|
|
1579
|
+
|
|
1580
|
+
# Tolerant success classification (BLOCKER fix, critic review PR #345):
|
|
1581
|
+
# the ONLY status that means "done, no further action" is "cancelled".
|
|
1582
|
+
# ANY other successful body — the pinned "running" shape, a
|
|
1583
|
+
# presentational variant like "cancelling", or anything else the server
|
|
1584
|
+
# might legitimately report — means the job has not been confirmed
|
|
1585
|
+
# stopped yet, so it feeds the wait loop below rather than hard-failing
|
|
1586
|
+
# on an unrecognized-but-plausible in-progress status string. If the
|
|
1587
|
+
# server ever reported a genuinely wrong terminal status here (e.g.
|
|
1588
|
+
# "completed"), `_wait_for_cancel_confirmation` still degrades
|
|
1589
|
+
# sensibly: it recognizes that status as terminal immediately (no
|
|
1590
|
+
# polling) and the final report below reflects it honestly.
|
|
1591
|
+
status = str(payload.get("status"))
|
|
1592
|
+
if status == _CANCELLED_STATUS:
|
|
1593
|
+
print(f"Job {job_id} cancelled.")
|
|
1594
|
+
return 0
|
|
1595
|
+
|
|
1596
|
+
try:
|
|
1597
|
+
job = _wait_for_cancel_confirmation(jobs_client, job_id, status)
|
|
1598
|
+
except KeyboardInterrupt:
|
|
1599
|
+
print(
|
|
1600
|
+
f"\nStopped waiting — job {job_id}'s cancel remains requested. Check `simulo jobs` for its status.",
|
|
1601
|
+
file=sys.stderr,
|
|
1602
|
+
)
|
|
1603
|
+
return 130
|
|
1604
|
+
except JobsApiError as exc:
|
|
1605
|
+
# MINOR (critic review PR #345): a transient error mid-wait (e.g. a
|
|
1606
|
+
# 5xx blip re-fetching the job) must not read as "the cancel
|
|
1607
|
+
# failed" — the server-side cancel_requested flag is untouched,
|
|
1608
|
+
# exactly like the Ctrl-C path above, so the note matches it.
|
|
1609
|
+
print(
|
|
1610
|
+
f"error: {exc}\nThe cancel remains requested — check with `simulo jobs`.",
|
|
1611
|
+
file=sys.stderr,
|
|
1612
|
+
)
|
|
1613
|
+
return 1
|
|
1614
|
+
|
|
1615
|
+
final_status = str(job.get("status"))
|
|
1616
|
+
print(f"Job {job_id} is now {final_status}.")
|
|
1617
|
+
return 0 if final_status == _CANCELLED_STATUS else 1
|
|
1618
|
+
|
|
1619
|
+
|
|
1210
1620
|
def _observe_handler(
|
|
1211
1621
|
subparser: argparse.ArgumentParser, command: Callable[[argparse.Namespace], int]
|
|
1212
1622
|
) -> Callable[[argparse.Namespace, Sequence[str]], int]:
|
|
@@ -1247,12 +1657,23 @@ def _observe_handler(
|
|
|
1247
1657
|
return _handle
|
|
1248
1658
|
|
|
1249
1659
|
|
|
1660
|
+
def _nonnegative_int(value: str) -> int:
|
|
1661
|
+
"""argparse type for ``--tail N`` — a clear message beats ValueError's."""
|
|
1662
|
+
try:
|
|
1663
|
+
parsed = int(value)
|
|
1664
|
+
except ValueError:
|
|
1665
|
+
raise argparse.ArgumentTypeError(f"expected an integer number of lines, got {value!r}") from None
|
|
1666
|
+
if parsed < 0:
|
|
1667
|
+
raise argparse.ArgumentTypeError(f"expected a non-negative number of lines, got {parsed}")
|
|
1668
|
+
return parsed
|
|
1669
|
+
|
|
1670
|
+
|
|
1250
1671
|
def _build_parser() -> argparse.ArgumentParser:
|
|
1251
1672
|
parser = argparse.ArgumentParser(
|
|
1252
1673
|
prog="simulo",
|
|
1253
1674
|
description=(
|
|
1254
1675
|
"Simulo thin client — submit an app (local disk, or the cloud once logged in) and "
|
|
1255
|
-
"observe its runs (simulo jobs / logs / result / recordings / models / view)."
|
|
1676
|
+
"observe its runs (simulo jobs / logs / result / recordings / models / view / cancel)."
|
|
1256
1677
|
),
|
|
1257
1678
|
)
|
|
1258
1679
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -1346,7 +1767,29 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1346
1767
|
logs.add_argument(
|
|
1347
1768
|
"--follow",
|
|
1348
1769
|
action="store_true",
|
|
1349
|
-
help=
|
|
1770
|
+
help=(
|
|
1771
|
+
"Poll for new output until the job finishes, starting from the last "
|
|
1772
|
+
f"{_DEFAULT_TAIL_LINES} lines of existing output (tail -f style; use --tail N or "
|
|
1773
|
+
"--from-start to change that). Exit code mirrors job success/failure."
|
|
1774
|
+
),
|
|
1775
|
+
)
|
|
1776
|
+
tail_group = logs.add_mutually_exclusive_group()
|
|
1777
|
+
tail_group.add_argument(
|
|
1778
|
+
"--tail",
|
|
1779
|
+
type=_nonnegative_int,
|
|
1780
|
+
metavar="N",
|
|
1781
|
+
default=None,
|
|
1782
|
+
help=(
|
|
1783
|
+
"Show only the last N lines of existing output (0 = none — with --follow, stream "
|
|
1784
|
+
f"new output only). Default with --follow: {_DEFAULT_TAIL_LINES}. The request window is "
|
|
1785
|
+
"sized by an assumed ~200 bytes/line, so an unusually large N of unusually long lines may "
|
|
1786
|
+
"show fewer than N lines; use --from-start if you need an exact count."
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
tail_group.add_argument(
|
|
1790
|
+
"--from-start",
|
|
1791
|
+
action="store_true",
|
|
1792
|
+
help="Print the full log from the beginning (with --follow: full history, then live).",
|
|
1350
1793
|
)
|
|
1351
1794
|
logs.set_defaults(func=_observe_handler(logs, _cmd_logs))
|
|
1352
1795
|
|
|
@@ -1428,6 +1871,39 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1428
1871
|
),
|
|
1429
1872
|
)
|
|
1430
1873
|
view.set_defaults(func=_observe_handler(view, _cmd_view))
|
|
1874
|
+
|
|
1875
|
+
cancel = sub.add_parser(
|
|
1876
|
+
"cancel",
|
|
1877
|
+
help=(
|
|
1878
|
+
"Request cancellation of a queued or running job "
|
|
1879
|
+
"(POST /v1/jobs/{id}/cancel; default: the most recent job — confirmed before acting, "
|
|
1880
|
+
"see --yes)."
|
|
1881
|
+
),
|
|
1882
|
+
)
|
|
1883
|
+
cancel.add_argument(
|
|
1884
|
+
"job_id",
|
|
1885
|
+
nargs="?",
|
|
1886
|
+
default=None,
|
|
1887
|
+
help=(
|
|
1888
|
+
"Job id to cancel. Naming it explicitly skips confirmation (you named the target). "
|
|
1889
|
+
"Omitted: cancels the most recent job ORG-WIDE — a destructive default, so it is "
|
|
1890
|
+
"confirmed first: a `y`/`N` prompt on a terminal, or refused (exit 2) without --yes "
|
|
1891
|
+
"when not running interactively. A queued job is cancelled immediately; a running "
|
|
1892
|
+
"job has cancellation requested and this command waits for the worker to confirm it "
|
|
1893
|
+
"(Ctrl-C to detach without un-cancelling)."
|
|
1894
|
+
),
|
|
1895
|
+
)
|
|
1896
|
+
cancel.add_argument(
|
|
1897
|
+
"--yes",
|
|
1898
|
+
"-y",
|
|
1899
|
+
action="store_true",
|
|
1900
|
+
help=(
|
|
1901
|
+
"Skip the confirmation prompt for the no-JOB_ID form (required there when stdin "
|
|
1902
|
+
"isn't a terminal, e.g. scripts/CI). Accepted and silently ignored when JOB_ID is "
|
|
1903
|
+
"given explicitly, since that invocation is never prompted anyway."
|
|
1904
|
+
),
|
|
1905
|
+
)
|
|
1906
|
+
cancel.set_defaults(func=_observe_handler(cancel, _cmd_cancel))
|
|
1431
1907
|
return parser
|
|
1432
1908
|
|
|
1433
1909
|
|
|
@@ -121,20 +121,37 @@ class JobsApiClient:
|
|
|
121
121
|
raise JobsApiError(f"Malformed job record response for {job_id!r}.")
|
|
122
122
|
return payload
|
|
123
123
|
|
|
124
|
-
def get_log_chunk(
|
|
125
|
-
|
|
124
|
+
def get_log_chunk(
|
|
125
|
+
self, job_id: str, offset: int, *, tail_bytes: Optional[int] = None
|
|
126
|
+
) -> tuple[str, int, str, Optional[int]]:
|
|
127
|
+
"""One log chunk from ``offset``; returns ``(chunk, next_offset, status, start_offset)``.
|
|
126
128
|
|
|
127
129
|
Byte-offset semantics per the contract: resume from the returned
|
|
128
130
|
``next_offset``; an empty chunk with an unchanged offset means "caught
|
|
129
131
|
up" — poll again while ``status`` is non-terminal.
|
|
132
|
+
|
|
133
|
+
``tail_bytes`` (contract tail semantics; requires ``offset == 0``) asks
|
|
134
|
+
the server to start the read that many bytes before the END of the
|
|
135
|
+
stream. ``start_offset`` in the returned tuple is the server's computed
|
|
136
|
+
start position when tail was honored, and ``None`` otherwise —
|
|
137
|
+
including when ``tail_bytes`` was requested but the server is too old
|
|
138
|
+
to know the parameter (it then answers from offset 0 exactly like a
|
|
139
|
+
plain request; callers use ``start_offset is None`` to detect that and
|
|
140
|
+
degrade to full history rather than mis-trim).
|
|
130
141
|
"""
|
|
131
|
-
|
|
142
|
+
query = {"offset": str(offset)}
|
|
143
|
+
if tail_bytes is not None:
|
|
144
|
+
query["tail_bytes"] = str(tail_bytes)
|
|
145
|
+
payload = self._get(JOB_LOGS_ROUTE_TEMPLATE.format(job_id=_quote(job_id)), query)
|
|
132
146
|
if not isinstance(payload, dict):
|
|
133
147
|
raise JobsApiError(f"Malformed log chunk response for {job_id!r}.")
|
|
134
148
|
chunk, next_offset, status = payload.get("chunk"), payload.get("next_offset"), payload.get("status")
|
|
135
149
|
if not isinstance(chunk, str) or not isinstance(next_offset, int) or not isinstance(status, str):
|
|
136
150
|
raise JobsApiError(f"Malformed log chunk response for {job_id!r}: {payload!r}.")
|
|
137
|
-
|
|
151
|
+
start_offset = payload.get("start_offset") if tail_bytes is not None else None
|
|
152
|
+
if start_offset is not None and not isinstance(start_offset, int):
|
|
153
|
+
raise JobsApiError(f"Malformed log chunk response for {job_id!r}: {payload!r}.")
|
|
154
|
+
return chunk, next_offset, status, start_offset
|
|
138
155
|
|
|
139
156
|
def get_result(self, job_id: str) -> Any:
|
|
140
157
|
"""The result JSON. 404 :class:`JobsApiHTTPError` until the job succeeded."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simulo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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.4,>=0.3
|
|
19
19
|
Requires-Dist: mcap<2,>=1.3
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: black>=24.0; 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|