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.
Files changed (39) hide show
  1. {simulo-0.9.0/src/simulo.egg-info → simulo-0.10.0}/PKG-INFO +2 -2
  2. {simulo-0.9.0 → simulo-0.10.0}/pyproject.toml +2 -2
  3. simulo-0.10.0/src/simulo/_client/cancel_api.py +105 -0
  4. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/cli.py +494 -18
  5. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/jobs_api.py +21 -4
  6. {simulo-0.9.0 → simulo-0.10.0/src/simulo.egg-info}/PKG-INFO +2 -2
  7. {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/SOURCES.txt +1 -0
  8. {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/requires.txt +1 -1
  9. {simulo-0.9.0 → simulo-0.10.0}/MANIFEST.in +0 -0
  10. {simulo-0.9.0 → simulo-0.10.0}/PYPI.md +0 -0
  11. {simulo-0.9.0 → simulo-0.10.0}/setup.cfg +0 -0
  12. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/__init__.py +0 -0
  13. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/__init__.py +0 -0
  14. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_entrypoint.py +0 -0
  15. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_mounts.py +0 -0
  16. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/_runner.py +0 -0
  17. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/app.py +0 -0
  18. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/asset.py +0 -0
  19. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/bundle.py +0 -0
  20. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/config.py +0 -0
  21. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/credentials.py +0 -0
  22. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/discovery.py +0 -0
  23. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/http.py +0 -0
  24. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/learning.py +0 -0
  25. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/login.py +0 -0
  26. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/manifest.py +0 -0
  27. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/mode.py +0 -0
  28. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/packaging.py +0 -0
  29. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/registry.py +0 -0
  30. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/runtime.py +0 -0
  31. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/stub.py +0 -0
  32. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/submit_api.py +0 -0
  33. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/view_session_api.py +0 -0
  34. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/_client/volume.py +0 -0
  35. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/callbacks.py +0 -0
  36. {simulo-0.9.0 → simulo-0.10.0}/src/simulo/py.typed +0 -0
  37. {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  38. {simulo-0.9.0 → simulo-0.10.0}/src/simulo.egg-info/entry_points.txt +0 -0
  39. {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.9.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.3,>=0.2
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.9.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.2,<0.3", "mcap>=1.3,<2"]
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 ONLY — the thin client
29
- never reads the executor's run store from disk, because submit and execute behave as if on
30
- two separate machines. They target the jobs API at the base URL resolved by
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 view`` take an OPTIONAL job id: omitted, they resolve the most
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 _drain_logs(client: JobsApiClient, job_id: str, *, follow: bool) -> int:
388
- """Print log chunks from byte offset 0; ``follow`` polls to a terminal status.
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
- Shared by ``simulo logs`` and the post-``simulo run`` default follow — one
391
- follow loop, one exit-code-mirrors-outcome rule.
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 from byte offset 0.
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``: poll (~0.7s) while the job runs, then drain and
414
- mirror the job's outcome in the exit code (0 for ``completed``, 1 otherwise).
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="Poll for new output until the job finishes; exit code mirrors job success/failure.",
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(self, job_id: str, offset: int) -> tuple[str, int, str]:
125
- """One log chunk from ``offset``; returns ``(chunk, next_offset, status)``.
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
- payload = self._get(JOB_LOGS_ROUTE_TEMPLATE.format(job_id=_quote(job_id)), {"offset": str(offset)})
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
- return chunk, next_offset, status
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.9.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.3,>=0.2
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"
@@ -17,6 +17,7 @@ src/simulo/_client/_runner.py
17
17
  src/simulo/_client/app.py
18
18
  src/simulo/_client/asset.py
19
19
  src/simulo/_client/bundle.py
20
+ src/simulo/_client/cancel_api.py
20
21
  src/simulo/_client/cli.py
21
22
  src/simulo/_client/config.py
22
23
  src/simulo/_client/credentials.py
@@ -1,4 +1,4 @@
1
- simulo-interfaces<0.3,>=0.2
1
+ simulo-interfaces<0.4,>=0.3
2
2
  mcap<2,>=1.3
3
3
 
4
4
  [dev]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes