simrun 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.
simrun/client.py ADDED
@@ -0,0 +1,407 @@
1
+ """The client of the service: snapshots working trees, then calls the API.
2
+
3
+ The CLI (`simrun.cli`) and the MCP server (`simrun.mcp`) both go through `Client`.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from pydantic import ValidationError
14
+
15
+ from simrun.plugins import TaskType, load_task_types, resolve
16
+ from simrun.service.app import (
17
+ CleanupResult,
18
+ OutputFile,
19
+ RunSummary,
20
+ TaskEstimate,
21
+ TaskProgress,
22
+ Usage,
23
+ ValidationResult,
24
+ )
25
+ from simrun.service.compare import CompareTable
26
+ from simrun.service.controller import TERMINAL_RUN_STATES
27
+ from simrun.service.edits import (
28
+ EditRequest,
29
+ EditResult,
30
+ EditSession,
31
+ EditState,
32
+ OpenEdit,
33
+ )
34
+ from simrun.service.logs import DEFAULT_TAIL_LINES
35
+ from simrun.snapshot import snapshot
36
+ from simrun.store import RunRecord, RunState
37
+ from simrun.workflow import SplitMode, Workflow, load_workflow
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ URL_ENV = "SIMRUN_URL"
42
+ TOKEN_ENV = "SIMRUN_TOKEN"
43
+ STORE_ENV = "SIMRUN_STORE"
44
+ # one long poll of the service, below its maximum of 300 s
45
+ POLL_S = 30.0
46
+ REQUEST_TIMEOUT_S = 60.0
47
+ BACKOFF_START_S = 1.0
48
+ BACKOFF_MAX_S = 10.0
49
+ # answers of a proxy or a restarting service, retried by `wait` and `simrun edit`
50
+ RETRY_STATUS = (502, 503, 504)
51
+
52
+
53
+ def plan_tasks(workflow: Workflow, types: dict[str, TaskType]) -> Workflow:
54
+ """The workflow with the settings of its tasks planned by their types.
55
+
56
+ A task whose type is in `types` and has a `plan` gets the settings the plan
57
+ returns for its validated settings, the fields which were given or which the
58
+ plan set, dumped as JSON: the defaults stay with the plugin of the snapshot,
59
+ which validates the settings in the pods. Every other task is kept as written,
60
+ e.g. a task of a type which is not installed locally.
61
+
62
+ Args:
63
+ workflow: The workflow, which is not changed.
64
+ types: The task types installed locally, by name.
65
+
66
+ Returns:
67
+ A copy of the workflow with the planned settings.
68
+
69
+ Raises:
70
+ ValueError: The settings of a task are invalid for its type.
71
+ """
72
+ tasks = []
73
+ for task in workflow.tasks:
74
+ task_type = types.get(task.type)
75
+ if task_type is None or task_type.plan is None:
76
+ tasks.append(task)
77
+ continue
78
+ try:
79
+ settings = task_type.settings.model_validate(task.settings)
80
+ except ValidationError as error:
81
+ raise ValueError(
82
+ f"task {task.id!r} of type {task.type!r}: {error}"
83
+ ) from error
84
+ planned = resolve(task_type.plan)(settings)
85
+ logger.debug("task %s planned by %s", task.id, task_type.plan)
86
+ tasks.append(
87
+ task.model_copy(
88
+ update={"settings": planned.model_dump(mode="json", exclude_unset=True)}
89
+ )
90
+ )
91
+ return workflow.model_copy(update={"tasks": tasks})
92
+
93
+
94
+ class ClientError(Exception):
95
+ """The service refused a request.
96
+
97
+ Attributes:
98
+ status: The HTTP status.
99
+ detail: The message of the service.
100
+ """
101
+
102
+ def __init__(self, status: int, detail: str) -> None:
103
+ """Create the error."""
104
+ super().__init__(f"{status}: {detail}")
105
+ self.status = status
106
+ self.detail = detail
107
+
108
+
109
+ def _detail(response: httpx.Response) -> str:
110
+ try:
111
+ detail = response.json()["detail"]
112
+ except ValueError, KeyError, TypeError:
113
+ return response.text or response.reason_phrase
114
+ if isinstance(detail, list):
115
+ return "; ".join(
116
+ f"{'.'.join(str(p) for p in d.get('loc', []))}: {d.get('msg', d)}"
117
+ if isinstance(d, dict)
118
+ else str(d)
119
+ for d in detail
120
+ )
121
+ return str(detail)
122
+
123
+
124
+ class Client:
125
+ """A client of the service of one user or agent.
126
+
127
+ Args:
128
+ url: Base URL of the service.
129
+ token: Bearer token.
130
+ store: The run store as mounted locally, where snapshots are written.
131
+ transport: An HTTP transport, e.g. for tests.
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ url: str,
137
+ token: str,
138
+ store: Path,
139
+ transport: httpx.BaseTransport | None = None,
140
+ ) -> None:
141
+ """Create the client."""
142
+ self.url = url.rstrip("/")
143
+ self.store = store
144
+ self._http = httpx.Client(
145
+ base_url=url,
146
+ headers={"Authorization": f"Bearer {token}"},
147
+ transport=transport,
148
+ timeout=REQUEST_TIMEOUT_S,
149
+ )
150
+
151
+ @classmethod
152
+ def from_env(cls) -> Client:
153
+ """The client of `SIMRUN_URL`, `SIMRUN_TOKEN` and `SIMRUN_STORE`.
154
+
155
+ Raises:
156
+ RuntimeError: When one of them is not set.
157
+ """
158
+ values: dict[str, str] = {}
159
+ for name in (URL_ENV, TOKEN_ENV, STORE_ENV):
160
+ value = os.environ.get(name)
161
+ if not value:
162
+ raise RuntimeError(f"{name} is not set")
163
+ values[name] = value
164
+ return cls(values[URL_ENV], values[TOKEN_ENV], Path(values[STORE_ENV]))
165
+
166
+ def close(self) -> None:
167
+ """Close the connections."""
168
+ self._http.close()
169
+
170
+ def _call(self, method: str, path: str, **kw: Any) -> httpx.Response:
171
+ response = self._http.request(method, path, **kw)
172
+ if response.is_error:
173
+ raise ClientError(response.status_code, _detail(response))
174
+ return response
175
+
176
+ def _request(
177
+ self,
178
+ workflow: Path | Workflow,
179
+ sources: dict[str, Path],
180
+ group: str | None,
181
+ split: SplitMode | None,
182
+ reserve: str | None,
183
+ ) -> dict[str, Any]:
184
+ if isinstance(workflow, Path):
185
+ workflow = load_workflow(workflow)
186
+ workflow = plan_tasks(workflow, load_task_types())
187
+ provenance = [
188
+ snapshot(path, self.store, name) for name, path in sources.items()
189
+ ]
190
+ return {
191
+ "workflow": workflow.model_dump(mode="json"),
192
+ "sources": [p.model_dump(mode="json") for p in provenance],
193
+ "group": group,
194
+ "split": split.value if split else None,
195
+ "reserve": reserve,
196
+ }
197
+
198
+ def submit(
199
+ self,
200
+ workflow: Path | Workflow,
201
+ sources: dict[str, Path],
202
+ group: str | None = None,
203
+ split: SplitMode | None = None,
204
+ reserve: str | None = None,
205
+ ) -> RunSummary:
206
+ """Plan the tasks, snapshot the sources and submit a workflow.
207
+
208
+ The settings of a task whose type is installed locally and has a `plan`
209
+ are replaced by the planned ones, see `plan_tasks`.
210
+
211
+ Args:
212
+ workflow: The workflow or its YAML file.
213
+ sources: Working trees by source name, the model first.
214
+ group: Group of the run.
215
+ split: Split mode of every task.
216
+ reserve: A node to reserve.
217
+
218
+ Returns:
219
+ The summary of the run, `submitting` while the service resolves its
220
+ environment, builds its image and submits its workflow.
221
+
222
+ Raises:
223
+ ClientError: When the service refuses the submission.
224
+ """
225
+ body = self._request(workflow, sources, group, split, reserve)
226
+ return RunSummary.model_validate(self._call("POST", "/runs", json=body).json())
227
+
228
+ def estimate(
229
+ self,
230
+ workflow: Path | Workflow,
231
+ sources: dict[str, Path],
232
+ group: str | None = None,
233
+ split: SplitMode | None = None,
234
+ reserve: str | None = None,
235
+ ) -> dict[str, TaskEstimate]:
236
+ """The resources, plans and durations of the tasks of a submission."""
237
+ body = self._request(workflow, sources, group, split, reserve)
238
+ data = self._call("POST", "/estimate", json=body).json()
239
+ return {k: TaskEstimate.model_validate(v) for k, v in data.items()}
240
+
241
+ def validate(self, workflow: Path | Workflow) -> ValidationResult:
242
+ """Validate a workflow with its tasks planned, as `submit` submits it.
243
+
244
+ Raises:
245
+ ValueError: The settings of a task are invalid for its local type.
246
+ """
247
+ if isinstance(workflow, Path):
248
+ workflow = load_workflow(workflow)
249
+ body = plan_tasks(workflow, load_task_types()).model_dump(mode="json")
250
+ return ValidationResult.model_validate(
251
+ self._call("POST", "/validate", json=body).json()
252
+ )
253
+
254
+ def open_edit(self, body: OpenEdit) -> EditSession:
255
+ """Open an edit session for the workflow file of this machine."""
256
+ data = self._call("POST", "/edits", json=body.model_dump(mode="json")).json()
257
+ return EditSession.model_validate(data)
258
+
259
+ def edit_state(self, session_id: str, state: EditState) -> EditSession:
260
+ """Send what was read from disk to an edit session."""
261
+ response = self._call(
262
+ "PUT",
263
+ f"/edits/{session_id}/state",
264
+ json=state.model_dump(mode="json", exclude_none=True),
265
+ )
266
+ return EditSession.model_validate(response.json())
267
+
268
+ def edit_requests(self, session_id: str, timeout: float) -> list[EditRequest]:
269
+ """Long poll the open requests of the browser, for up to `timeout` s."""
270
+ response = self._call(
271
+ "GET", f"/edits/{session_id}/requests", params={"timeout": timeout}
272
+ )
273
+ return [EditRequest.model_validate(item) for item in response.json()]
274
+
275
+ def edit_result(self, session_id: str, request_id: str, result: EditResult) -> None:
276
+ """Answer a request of the browser."""
277
+ self._call(
278
+ "POST",
279
+ f"/edits/{session_id}/requests/{request_id}/result",
280
+ json=result.model_dump(mode="json"),
281
+ )
282
+
283
+ def close_edit(self, session_id: str, timeout: float = REQUEST_TIMEOUT_S) -> None:
284
+ """Close an edit session, the command stopped, waiting up to `timeout` s."""
285
+ self._call("POST", f"/edits/{session_id}/close", timeout=timeout)
286
+
287
+ def wait(self, run_id: str, timeout_s: float | None = None) -> RunSummary:
288
+ """Wait for a run to end, or for `timeout_s` seconds (None: forever).
289
+
290
+ A dropped connection or a restarting service is retried with a backoff
291
+ of 1 s doubling to 10 s, until the timeout.
292
+
293
+ Returns:
294
+ The summary of the run, not terminal when the timeout passed; when the
295
+ timeout passes during an outage, the last summary obtained.
296
+
297
+ Raises:
298
+ ClientError: For an unknown run or another refusal.
299
+ httpx.TransportError: When the service was never reached before the
300
+ timeout.
301
+ """
302
+ last: RunSummary | None = None
303
+ deadline = None if timeout_s is None else time.monotonic() + timeout_s
304
+ backoff = BACKOFF_START_S
305
+ while True:
306
+ remaining = None if deadline is None else deadline - time.monotonic()
307
+ poll = POLL_S if remaining is None else min(POLL_S, max(remaining, 0.0))
308
+ try:
309
+ response = self._http.get(
310
+ f"/runs/{run_id}/wait", params={"timeout": poll}
311
+ )
312
+ if response.status_code in RETRY_STATUS:
313
+ raise httpx.TransportError(f"status {response.status_code}")
314
+ except httpx.TransportError as error:
315
+ remaining = None if deadline is None else deadline - time.monotonic()
316
+ if remaining is not None and remaining <= 0:
317
+ if last is None:
318
+ raise
319
+ return last
320
+ logger.warning(
321
+ "wait for %s: %s, retrying in %.0f s", run_id, error, backoff
322
+ )
323
+ time.sleep(backoff if remaining is None else min(backoff, remaining))
324
+ backoff = min(backoff * 2, BACKOFF_MAX_S)
325
+ continue
326
+ backoff = BACKOFF_START_S
327
+ if response.is_error:
328
+ raise ClientError(response.status_code, _detail(response))
329
+ summary = last = RunSummary.model_validate(response.json())
330
+ if summary.state in TERMINAL_RUN_STATES:
331
+ return summary
332
+ if deadline is not None and time.monotonic() >= deadline:
333
+ return summary
334
+
335
+ def status(self, run_id: str) -> RunRecord:
336
+ """The record of a run."""
337
+ return RunRecord.model_validate(self._call("GET", f"/runs/{run_id}").json())
338
+
339
+ def progress(self, run_id: str) -> dict[str, TaskProgress]:
340
+ """The progress of the tasks of a run."""
341
+ data = self._call("GET", f"/runs/{run_id}/progress").json()
342
+ return {k: TaskProgress.model_validate(v) for k, v in data.items()}
343
+
344
+ def list_runs(
345
+ self,
346
+ group: str | None = None,
347
+ agent: str | None = None,
348
+ state: RunState | None = None,
349
+ owner: str | None = None,
350
+ ) -> list[RunSummary]:
351
+ """The runs, filtered by group, agent, state and owner."""
352
+ filters = {"group": group, "agent": agent, "state": state, "owner": owner}
353
+ params = {k: v for k, v in filters.items() if v is not None}
354
+ data = self._call("GET", "/runs", params=params).json()
355
+ return [RunSummary.model_validate(r) for r in data]
356
+
357
+ def compare(self, run_ids: list[str]) -> CompareTable:
358
+ """Provenance and summaries of runs side by side."""
359
+ data = self._call("POST", "/compare", json={"runs": run_ids}).json()
360
+ return CompareTable.model_validate(data)
361
+
362
+ def cancel(self, run_id: str) -> RunSummary:
363
+ """Cancel a run."""
364
+ return RunSummary.model_validate(
365
+ self._call("POST", f"/runs/{run_id}/cancel").json()
366
+ )
367
+
368
+ def logs(
369
+ self, run_id: str, task_id: str, tail_lines: int = DEFAULT_TAIL_LINES
370
+ ) -> str:
371
+ """The logs of the pods of a task, the last `tail_lines` of each (0: all)."""
372
+ return self._call(
373
+ "GET", f"/runs/{run_id}/logs/{task_id}", params={"tail_lines": tail_lines}
374
+ ).text
375
+
376
+ def outputs(self, run_id: str, task_id: str) -> list[OutputFile]:
377
+ """The output files of a task, paths relative to the store."""
378
+ data = self._call("GET", f"/runs/{run_id}/outputs/{task_id}").json()
379
+ return [OutputFile.model_validate(f) for f in data]
380
+
381
+ def delete(self, run_id: str) -> CleanupResult:
382
+ """Delete an ended run."""
383
+ return CleanupResult.model_validate(
384
+ self._call("DELETE", f"/runs/{run_id}").json()
385
+ )
386
+
387
+ def cleanup(
388
+ self,
389
+ older_than_days: float,
390
+ group: str | None = None,
391
+ failed_only: bool = False,
392
+ dry_run: bool = False,
393
+ ) -> CleanupResult:
394
+ """Remove ended runs and the snapshots nothing refers to any more."""
395
+ body = {
396
+ "older_than_days": older_than_days,
397
+ "group": group,
398
+ "failed_only": failed_only,
399
+ "dry_run": dry_run,
400
+ }
401
+ return CleanupResult.model_validate(
402
+ self._call("POST", "/cleanup", json=body).json()
403
+ )
404
+
405
+ def usage(self) -> Usage:
406
+ """Bytes of the store per run and group."""
407
+ return Usage.model_validate(self._call("GET", "/usage").json())
simrun/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """The configuration of the cluster the runs are submitted to."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ # the base image of the images of the environments: uv with the python of the
6
+ # service, pinned by the digest of its index, which the registry mirrors (the tag
7
+ # `0.13.7-python3.14-trixie-slim` of `ghcr.io/astral-sh/uv`)
8
+ DEFAULT_BASE_IMAGE = (
9
+ "astral-sh/uv:0.13.7-python3.14-trixie-slim"
10
+ "@sha256:e80c05a310400cfc90af00b5b7271ef9ad24816e3265e1162af8a678e1aad099"
11
+ )
12
+
13
+
14
+ class ClusterConfig(BaseModel):
15
+ """Where and how the pods of a run are placed in the cluster.
16
+
17
+ Attributes:
18
+ namespace: Namespace of the workflows and their pods.
19
+ local_queue: Kueue LocalQueue of the pods.
20
+ store_claim: PersistentVolumeClaim of the NFS run store.
21
+ store_mount: Path the run store is mounted at in a pod.
22
+ scratch_path: Local scratch, a hostPath on the local disk of a node, mounted
23
+ at the same path in a pod.
24
+ registry: Host of the image registry inside the network, as the nodes and
25
+ BuildKit name it in image references.
26
+ registry_url: URL of the API of the registry as the service reaches it,
27
+ `http://<registry>` by default.
28
+ service_account: Service account of the pods of the workflows, allowed to
29
+ write the results of the Argo executor.
30
+ buildkit: Address of the BuildKit daemon the builds of images run on.
31
+ buildctl_image: Image of the job which sends a build to BuildKit.
32
+ base_image: The base image of the images of the environments, an image with
33
+ uv and the python of the service (`ServiceSettings.python`); a
34
+ repository of `registry` (the mirror of an image of another registry)
35
+ unless it names a registry host itself.
36
+ grace_s: Seconds a worker has to stop after SIGTERM.
37
+ ttl_after_finished_s: Seconds a finished workflow is kept.
38
+ """
39
+
40
+ namespace: str = "simrun"
41
+ local_queue: str = "simrun"
42
+ store_claim: str = "simrun-store"
43
+ store_mount: str = "/store"
44
+ scratch_path: str = "/scratch"
45
+ registry: str
46
+ registry_url: str | None = None
47
+ service_account: str = "simrun-workflow"
48
+ buildkit: str = "tcp://buildkitd.simrun:1234"
49
+ buildctl_image: str = "moby/buildkit:v0.34.2-rootless"
50
+ base_image: str = DEFAULT_BASE_IMAGE
51
+ grace_s: int = 60
52
+ ttl_after_finished_s: int = 86400
53
+
54
+ def base_image_ref(self) -> str:
55
+ """The base image of the environments as the builds name it.
56
+
57
+ `base_image` below `registry`, unless its first component is a registry
58
+ host (it has a `.` or a `:`, or is `localhost`).
59
+ """
60
+ first, slash, _ = self.base_image.partition("/")
61
+ if slash and ("." in first or ":" in first or first == "localhost"):
62
+ return self.base_image
63
+ return f"{self.registry}/{self.base_image}"