jambu-gpu 0.1.0__py3-none-any.whl

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 (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
jambu_gpu/cli/run.py ADDED
@@ -0,0 +1,37 @@
1
+ """`gpu run` - execute a workload against a guaranteed runtime (spec section 16)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ import typer
8
+
9
+ from ..core.errors import JambuError, WorkloadFailed
10
+ from ._common import get_context, handle, print_json
11
+
12
+
13
+ def run_command(
14
+ ctx: typer.Context,
15
+ command: List[str] = typer.Argument(
16
+ ...,
17
+ help=(
18
+ "Command to execute, e.g. `python experiments/run.py`. Flags are passed "
19
+ "through; put `--` first if one collides with a gpu option."
20
+ ),
21
+ ),
22
+ label: str = typer.Option("", "--label", help="Human label recorded with the workload."),
23
+ ) -> None:
24
+ """Ensure the runtime is READY, then run the command with heartbeat tracking."""
25
+ context = get_context(ctx)
26
+ try:
27
+ engine = context.engine()
28
+ engine.run(list(command), label=label)
29
+ except WorkloadFailed as exc:
30
+ if context.json:
31
+ print_json({"ok": False, "returncode": exc.returncode})
32
+ raise typer.Exit(exc.returncode) from exc
33
+ except JambuError as exc:
34
+ handle(exc)
35
+ return
36
+ if context.json:
37
+ print_json({"ok": True, "returncode": 0})
jambu_gpu/cli/setup.py ADDED
@@ -0,0 +1,165 @@
1
+ """`gpu setup` / `init` / `offers` - provisioning entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from ..core.config import (
12
+ DEFAULT_CONFIG_TEMPLATE,
13
+ DEFAULT_CONFIG_TEMPLATE_FLAT,
14
+ CONFIG_FILENAMES,
15
+ )
16
+ from ..core.errors import JambuError
17
+ from ._common import console, fmt_money, get_context, handle, print_json
18
+
19
+
20
+ def init_command(
21
+ ctx: typer.Context,
22
+ model: str = typer.Option("empero-ai/Qwythos-9B-v2", "--model", help="HuggingFace model id."),
23
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
24
+ flat: bool = typer.Option(
25
+ False,
26
+ "--flat",
27
+ help=(
28
+ "Write a legacy, gpu-runtime-only config.yml (no gpu_runtime: "
29
+ "wrapper) instead of the shared jambu.yaml."
30
+ ),
31
+ ),
32
+ ) -> None:
33
+ """Write a starter jambu.yaml (and .env.example) in the current directory."""
34
+ if flat:
35
+ target = Path.cwd() / "config.yml"
36
+ content = DEFAULT_CONFIG_TEMPLATE_FLAT.format(model_id=model)
37
+ else:
38
+ target = Path.cwd() / CONFIG_FILENAMES[0] # jambu.yaml
39
+ content = DEFAULT_CONFIG_TEMPLATE.format(model_id=model)
40
+
41
+ if target.exists() and not force:
42
+ console.print(Text(f"! {target} already exists (use --force)", style="yellow"))
43
+ raise typer.Exit(1)
44
+ target.write_text(content)
45
+ console.print(Text(f"+ wrote {target}", style="green"))
46
+ if not flat:
47
+ console.print(
48
+ Text(
49
+ " gpu-runtime config lives under the gpu_runtime: key - other "
50
+ "Jambu Lab tools can share this file",
51
+ style="dim",
52
+ )
53
+ )
54
+
55
+ env_example = Path.cwd() / ".env.example"
56
+ if not env_example.exists():
57
+ env_example.write_text(
58
+ "# Credentials never belong in jambu.yaml / config.yml.\n"
59
+ "VAST_API_KEY=\n"
60
+ "# Optional, for gated HuggingFace models:\n"
61
+ "HF_TOKEN=\n"
62
+ )
63
+ console.print(Text(f"+ wrote {env_example}", style="green"))
64
+ console.print("next: export VAST_API_KEY=... && gpu validate")
65
+
66
+
67
+ def setup_command(
68
+ ctx: typer.Context,
69
+ recreate: bool = typer.Option(
70
+ False, "--recreate", help="Replace an existing instance that no longer matches jambu.yaml."
71
+ ),
72
+ allow_cost_override: bool = typer.Option(
73
+ False, "--allow-cost-override", help="Provision even above budget.max_hourly_cost_usd."
74
+ ),
75
+ no_wait: bool = typer.Option(
76
+ False, "--no-wait", help="Return as soon as the instance is created."
77
+ ),
78
+ ) -> None:
79
+ """Provision the instance, install the watchdog and start the model server."""
80
+ context = get_context(ctx)
81
+ try:
82
+ engine = context.engine()
83
+ state = engine.setup(
84
+ recreate=recreate, allow_cost_override=allow_cost_override, wait=not no_wait
85
+ )
86
+ except JambuError as exc:
87
+ handle(exc)
88
+ return
89
+
90
+ payload = {
91
+ "instance_id": state.instance_id,
92
+ "state": state.state,
93
+ "endpoint": state.endpoint,
94
+ "model": state.model_id,
95
+ "hourly_cost_usd": state.hourly_cost_usd,
96
+ "watchdog": state.watchdog.to_dict(),
97
+ }
98
+ if context.json:
99
+ print_json(payload)
100
+ return
101
+ if state.endpoint:
102
+ console.print(
103
+ f"\n endpoint [bold]{state.endpoint}[/bold]\n"
104
+ f" openai [bold]{state.endpoint.rstrip('/')}/v1[/bold]\n"
105
+ f" model [bold]{state.model_id}[/bold]"
106
+ )
107
+ console.print("\nnext: gpu run python your_experiment.py")
108
+
109
+
110
+ def offers_command(
111
+ ctx: typer.Context,
112
+ limit: int = typer.Option(10, "--limit", "-n", help="How many offers to show."),
113
+ ) -> None:
114
+ """Show matching capacity and its price before committing to it."""
115
+ context = get_context(ctx)
116
+ try:
117
+ engine = context.engine()
118
+ engine.provider.require_capability("pricing_query")
119
+ spec = engine.build_spec()
120
+ offers = engine.provider.find_offers(spec, limit=limit)
121
+ except JambuError as exc:
122
+ handle(exc)
123
+ return
124
+
125
+ rows = [
126
+ {
127
+ "id": o.id,
128
+ "gpu": o.gpu_name,
129
+ "count": o.gpu_count,
130
+ "vram_gb": o.gpu_vram_gb,
131
+ "disk_gb": o.disk_gb,
132
+ "hourly_cost_usd": o.hourly_cost_usd,
133
+ "region": o.region,
134
+ }
135
+ for o in offers
136
+ ]
137
+ if context.json:
138
+ print_json({"offers": rows})
139
+ return
140
+
141
+ if not rows:
142
+ console.print(Text("no matching offers", style="yellow"))
143
+ return
144
+
145
+ ceiling = engine.config.budget.max_hourly_cost_usd
146
+ table = Table(title=f"{engine.provider.display_name} offers", title_justify="left")
147
+ for column in ("OFFER", "GPU", "N", "VRAM", "DISK", "$/H", "REGION"):
148
+ table.add_column(column)
149
+ for row in rows:
150
+ over = ceiling is not None and row["hourly_cost_usd"] > float(ceiling)
151
+ table.add_row(
152
+ row["id"],
153
+ row["gpu"],
154
+ str(row["count"]),
155
+ f"{row['vram_gb']:.0f} GB",
156
+ f"{row['disk_gb']:.0f} GB",
157
+ Text(
158
+ fmt_money(row["hourly_cost_usd"]) or "-",
159
+ style="red" if over else "green",
160
+ ),
161
+ row["region"] or "-",
162
+ )
163
+ console.print(table)
164
+ if ceiling:
165
+ console.print(Text(f" budget ceiling: ${float(ceiling):.3f}/h", style="dim"))
@@ -0,0 +1,214 @@
1
+ """`gpu status` / `logs` / `events` - reconciled observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from ..core.durations import format_duration
12
+ from ..core.errors import JambuError
13
+ from ._common import (
14
+ console,
15
+ fmt_money,
16
+ fmt_time,
17
+ get_context,
18
+ handle,
19
+ kv_table,
20
+ print_json,
21
+ state_text,
22
+ )
23
+
24
+
25
+ def status_command(
26
+ ctx: typer.Context,
27
+ watch: float = typer.Option(
28
+ 0, "--watch", "-w", help="Refresh every N seconds (0 = once)."
29
+ ),
30
+ ) -> None:
31
+ """Reconcile local and provider state, then report the normalized result."""
32
+ context = get_context(ctx)
33
+ while True:
34
+ try:
35
+ payload = context.engine().status()
36
+ except JambuError as exc:
37
+ handle(exc)
38
+ return
39
+
40
+ if context.json:
41
+ print_json(payload)
42
+ return
43
+
44
+ _render(payload)
45
+ if not watch:
46
+ return
47
+ time.sleep(max(2.0, watch))
48
+ console.rule(style="dim")
49
+
50
+
51
+ def _render(payload: dict) -> None:
52
+ decision = payload.get("decision") or {}
53
+ watchdog = payload.get("watchdog") or {}
54
+ rows = [
55
+ ("state", state_text(payload.get("state", "unknown"))),
56
+ ("provider", payload.get("provider")),
57
+ ("instance", payload.get("instance_id") or "-"),
58
+ ("model", payload.get("model")),
59
+ ("endpoint", payload.get("endpoint") or "-"),
60
+ ("created", fmt_time(payload.get("created_at"))),
61
+ (
62
+ "age",
63
+ format_duration(payload.get("age_seconds"))
64
+ if payload.get("age_seconds") is not None
65
+ else None,
66
+ ),
67
+ ("idle", format_duration(payload.get("idle_seconds"))),
68
+ ("cost", _cost(payload)),
69
+ ("locked", _lock(payload)),
70
+ (
71
+ "watchdog",
72
+ _watchdog(watchdog),
73
+ ),
74
+ ("guard", _guard(decision, payload)),
75
+ ]
76
+ console.print(kv_table("Runtime", rows))
77
+
78
+ workloads = [w for w in payload.get("workloads", []) if w["state"] in ("running", "queued")]
79
+ recent = sorted(
80
+ payload.get("workloads", []), key=lambda w: w.get("started_at") or 0, reverse=True
81
+ )[:5]
82
+ if recent:
83
+ table = Table(title="Workloads", title_justify="left", box=None)
84
+ for column in ("ID", "STATE", "LABEL", "STARTED", "RC"):
85
+ table.add_column(column)
86
+ for record in recent:
87
+ table.add_row(
88
+ record["id"],
89
+ Text(
90
+ record["state"],
91
+ style="cyan" if record["state"] == "running" else "dim",
92
+ ),
93
+ (record.get("label") or "")[:48],
94
+ fmt_time(record.get("started_at")) or "-",
95
+ "-" if record.get("returncode") is None else str(record["returncode"]),
96
+ )
97
+ console.print(table)
98
+ if workloads:
99
+ console.print(
100
+ Text(f" {len(workloads)} active workload(s) hold the instance open", style="cyan")
101
+ )
102
+
103
+
104
+ def _cost(payload: dict) -> str:
105
+ hourly = payload.get("hourly_cost_usd")
106
+ session = payload.get("session_cost_usd")
107
+ if hourly is None and session is None:
108
+ return "-"
109
+ parts = []
110
+ if hourly is not None:
111
+ parts.append(f"{fmt_money(hourly)}/h")
112
+ if session:
113
+ parts.append(f"{fmt_money(session)} this session")
114
+ return " ".join(parts) or "-"
115
+
116
+
117
+ def _lock(payload: dict) -> Text:
118
+ if not payload.get("locked"):
119
+ return Text("no", style="dim")
120
+ until = fmt_time(payload.get("locked_until"))
121
+ return Text(f"yes (until {until})" if until else "yes (indefinite)", style="yellow")
122
+
123
+
124
+ def _watchdog(watchdog: dict) -> Text:
125
+ if not watchdog.get("enabled"):
126
+ return Text("disabled - shutdown depends on this CLI staying alive", style="yellow")
127
+ if watchdog.get("reachable"):
128
+ return Text(f"live at {watchdog.get('url')}", style="green")
129
+ if watchdog.get("installed"):
130
+ return Text(
131
+ f"unreachable ({watchdog.get('last_error') or 'no answer'})", style="yellow"
132
+ )
133
+ return Text("not installed", style="yellow")
134
+
135
+
136
+ def _guard(decision: dict, payload: dict) -> Text:
137
+ action = decision.get("action", "none")
138
+ reason = decision.get("reason") or "-"
139
+ if action == "none":
140
+ policy = payload.get("policy") or {}
141
+ idle_timeout = policy.get("idle_timeout_s")
142
+ idle = payload.get("idle_seconds") or 0
143
+ if idle_timeout and reason == "within_policy":
144
+ remaining = max(0.0, float(idle_timeout) - float(idle))
145
+ return Text(
146
+ f"ok ({reason}); auto-stop in {format_duration(remaining)} if idle",
147
+ style="green",
148
+ )
149
+ return Text(f"ok ({reason})", style="green")
150
+ return Text(f"{action} pending: {reason}", style="bold yellow")
151
+
152
+
153
+ def logs_command(
154
+ ctx: typer.Context,
155
+ lines: int = typer.Option(200, "--lines", "-n", help="How many lines to show."),
156
+ ) -> None:
157
+ """Show provider-side container logs for the instance."""
158
+ context = get_context(ctx)
159
+ try:
160
+ output = context.engine().logs(limit=lines)
161
+ except JambuError as exc:
162
+ handle(exc)
163
+ return
164
+ if context.json:
165
+ print_json({"lines": output})
166
+ return
167
+ for line in output:
168
+ console.print(line, markup=False, highlight=False)
169
+
170
+
171
+ def events_command(
172
+ ctx: typer.Context,
173
+ limit: int = typer.Option(40, "--limit", "-n", help="How many events to show."),
174
+ event: str = typer.Option("", "--event", help="Filter by event name."),
175
+ ) -> None:
176
+ """Show the structured lifecycle event log (spec section 24)."""
177
+ context = get_context(ctx)
178
+ try:
179
+ engine = context.engine()
180
+ except JambuError as exc:
181
+ handle(exc)
182
+ return
183
+ records = engine.events.tail(limit=limit, event=event or None)
184
+ if context.json:
185
+ print_json({"events": records})
186
+ return
187
+ if not records:
188
+ console.print(Text("no events recorded yet", style="dim"))
189
+ return
190
+ table = Table(title="Lifecycle events", title_justify="left", box=None)
191
+ for column in ("TS", "EVENT", "DETAIL"):
192
+ table.add_column(column)
193
+ for record in records:
194
+ detail = {
195
+ k: v
196
+ for k, v in record.items()
197
+ if k not in ("ts", "event", "pid", "provider", "source")
198
+ }
199
+ table.add_row(
200
+ str(record.get("ts", "")),
201
+ Text(str(record.get("event", "")), style=_event_style(record.get("event", ""))),
202
+ ", ".join(f"{k}={v}" for k, v in detail.items())[:110],
203
+ )
204
+ console.print(table)
205
+
206
+
207
+ def _event_style(event: str) -> str:
208
+ if "error" in event or "failed" in event:
209
+ return "red"
210
+ if "guard" in event or "stopped" in event or "destroyed" in event:
211
+ return "yellow"
212
+ if "ready" in event or "created" in event:
213
+ return "green"
214
+ return "white"
@@ -0,0 +1,57 @@
1
+ """`gpu validate` - fail before provisioning (spec sections 5, 15)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.text import Text
7
+
8
+ from ..core.errors import JambuError
9
+ from ._common import console, get_context, handle, print_json
10
+
11
+
12
+ def validate_command(
13
+ ctx: typer.Context,
14
+ offline: bool = typer.Option(
15
+ False, "--offline", help="Skip provider API checks (schema and policy only)."
16
+ ),
17
+ ) -> None:
18
+ """Validate jambu.yaml, credentials, capabilities and available capacity."""
19
+ context = get_context(ctx)
20
+ try:
21
+ engine = context.engine()
22
+ result = engine.validate(check_provider=not offline)
23
+ except JambuError as exc:
24
+ if context.json:
25
+ print_json({"ok": False, "errors": [{"message": str(exc)}]})
26
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
27
+ handle(exc)
28
+ return
29
+
30
+ payload = {
31
+ "ok": result.ok,
32
+ "provider": engine.provider.name,
33
+ "model": engine.config.model.id,
34
+ "runtime": engine.config.runtime.engine,
35
+ "fingerprint": engine.config.fingerprint(),
36
+ "errors": [{"field": i.field, "message": i.message} for i in result.errors],
37
+ "warnings": [{"field": i.field, "message": i.message} for i in result.warnings],
38
+ }
39
+ if context.json:
40
+ print_json(payload)
41
+ raise typer.Exit(0 if result.ok else 2)
42
+
43
+ console.print(
44
+ f"config: [bold]{engine.config.source_path}[/bold]\n"
45
+ f"provider: [bold]{engine.provider.display_name}[/bold] "
46
+ f"runtime: [bold]{engine.runtime.describe()}[/bold]"
47
+ )
48
+ for issue in result.warnings:
49
+ console.print(Text(f"! {issue.field or 'config'}: {issue.message}", style="yellow"))
50
+ for issue in result.errors:
51
+ console.print(Text(f"x {issue.field or 'config'}: {issue.message}", style="bold red"))
52
+
53
+ if result.ok:
54
+ console.print(Text("+ configuration is valid", style="bold green"))
55
+ else:
56
+ console.print(Text(f"{len(result.errors)} error(s) must be fixed", style="bold red"))
57
+ raise typer.Exit(2)
File without changes
@@ -0,0 +1,124 @@
1
+ """Client for the remote lifecycle watchdog.
2
+
3
+ The watchdog is the authority on activity: it holds the heartbeat state and
4
+ performs the shutdown. The CLI only reports activity to it and reads it back.
5
+ Every call is best-effort - losing the watchdog must never break a workload,
6
+ and an unreachable watchdog is surfaced as a warning, not a crash.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ import httpx
14
+
15
+ from .errors import JambuError
16
+
17
+
18
+ class WatchdogUnavailable(JambuError):
19
+ """The remote watchdog could not be reached."""
20
+
21
+
22
+ class WatchdogClient:
23
+ def __init__(self, base_url: str, token: Optional[str], timeout: float = 10.0) -> None:
24
+ self.base_url = base_url.rstrip("/")
25
+ self.token = token
26
+ self.timeout = timeout
27
+
28
+ @property
29
+ def headers(self) -> dict[str, str]:
30
+ return {"X-Jambu-Token": self.token} if self.token else {}
31
+
32
+ # -- transport ----------------------------------------------------------
33
+
34
+ def _request(self, method: str, path: str, payload: Optional[dict] = None) -> dict[str, Any]:
35
+ url = f"{self.base_url}{path}"
36
+ try:
37
+ response = httpx.request(
38
+ method,
39
+ url,
40
+ json=payload,
41
+ headers=self.headers,
42
+ timeout=self.timeout,
43
+ )
44
+ except httpx.HTTPError as exc:
45
+ raise WatchdogUnavailable(f"watchdog unreachable at {url}: {exc}") from exc
46
+ if response.status_code == 401:
47
+ raise WatchdogUnavailable("watchdog rejected the control token")
48
+ if response.status_code >= 400:
49
+ raise WatchdogUnavailable(
50
+ f"watchdog returned {response.status_code}: {response.text[:200]}"
51
+ )
52
+ try:
53
+ return response.json()
54
+ except ValueError:
55
+ return {}
56
+
57
+ def try_request(self, method: str, path: str, payload: Optional[dict] = None) -> Optional[dict]:
58
+ try:
59
+ return self._request(method, path, payload)
60
+ except WatchdogUnavailable:
61
+ return None
62
+
63
+ # -- routes -------------------------------------------------------------
64
+
65
+ def ping(self) -> bool:
66
+ try:
67
+ self._request("GET", "/health")
68
+ return True
69
+ except WatchdogUnavailable:
70
+ return False
71
+
72
+ def state(self) -> dict[str, Any]:
73
+ return self._request("GET", "/state")
74
+
75
+ def try_state(self) -> Optional[dict[str, Any]]:
76
+ return self.try_request("GET", "/state")
77
+
78
+ def events(self, limit: int = 100) -> list[dict[str, Any]]:
79
+ data = self.try_request("GET", f"/events?limit={limit}") or {}
80
+ return data.get("events", [])
81
+
82
+ def identify(
83
+ self, instance_id: str, hourly_cost_usd: Optional[float] = None
84
+ ) -> Optional[dict]:
85
+ return self.try_request(
86
+ "POST",
87
+ "/identify",
88
+ {"instance_id": instance_id, "hourly_cost_usd": hourly_cost_usd},
89
+ )
90
+
91
+ def activity(self) -> Optional[dict]:
92
+ return self.try_request("POST", "/activity")
93
+
94
+ def mark_starting(self) -> Optional[dict]:
95
+ return self.try_request("POST", "/starting")
96
+
97
+ def mark_ready(self) -> Optional[dict]:
98
+ return self.try_request("POST", "/ready")
99
+
100
+ def workload_start(self, workload_id: str, label: str = "") -> Optional[dict]:
101
+ return self.try_request(
102
+ "POST", "/workload/start", {"id": workload_id, "label": label}
103
+ )
104
+
105
+ def workload_heartbeat(self, workload_id: str) -> Optional[dict]:
106
+ return self.try_request("POST", "/workload/heartbeat", {"id": workload_id})
107
+
108
+ def workload_end(
109
+ self, workload_id: str, returncode: Optional[int], state: Optional[str] = None
110
+ ) -> Optional[dict]:
111
+ return self.try_request(
112
+ "POST",
113
+ "/workload/end",
114
+ {"id": workload_id, "returncode": returncode, "state": state},
115
+ )
116
+
117
+ def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict:
118
+ return self._request("POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason})
119
+
120
+ def unlock(self, reason: str = "manual") -> dict:
121
+ return self._request("POST", "/unlock", {"reason": reason})
122
+
123
+ def shutdown(self, action: str = "stop", reason: str = "manual") -> Optional[dict]:
124
+ return self.try_request("POST", "/shutdown", {"action": action, "reason": reason})