envloop-cli 0.1.1__tar.gz → 0.2.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.
- envloop_cli-0.2.0/.gitignore +8 -0
- envloop_cli-0.2.0/PKG-INFO +101 -0
- envloop_cli-0.2.0/README.pypi.md +89 -0
- envloop_cli-0.2.0/envloop/__init__.py +5 -0
- envloop_cli-0.2.0/envloop/agent_runs.py +208 -0
- envloop_cli-0.2.0/envloop/auth_cli.py +285 -0
- envloop_cli-0.2.0/envloop/auth_login.py +127 -0
- envloop_cli-0.2.0/envloop/auth_workspace.py +290 -0
- envloop_cli-0.2.0/envloop/cli.py +452 -0
- envloop_cli-0.2.0/envloop/cli_output.py +206 -0
- envloop_cli-0.2.0/envloop/client.py +548 -0
- envloop_cli-0.2.0/envloop/config.py +440 -0
- envloop_cli-0.2.0/envloop/http.py +274 -0
- envloop_cli-0.2.0/envloop/lifecycle_cli.py +53 -0
- envloop_cli-0.2.0/envloop/navigation_resources.py +260 -0
- envloop_cli-0.2.0/envloop/resource_cli.py +164 -0
- envloop_cli-0.2.0/envloop/resources.py +210 -0
- envloop_cli-0.2.0/envloop/train.py +51 -0
- envloop_cli-0.2.0/envloop/training/__init__.py +128 -0
- envloop_cli-0.2.0/envloop/training/credentials.py +56 -0
- envloop_cli-0.2.0/envloop/training/service_client.py +40 -0
- envloop_cli-0.2.0/pyproject.toml +60 -0
- envloop_cli-0.1.1/.gitignore +0 -5
- envloop_cli-0.1.1/PKG-INFO +0 -93
- envloop_cli-0.1.1/README.md +0 -85
- envloop_cli-0.1.1/pyproject.toml +0 -35
- envloop_cli-0.1.1/src/el_cli/__init__.py +0 -1
- envloop_cli-0.1.1/src/el_cli/cli.py +0 -257
- envloop_cli-0.1.1/src/el_cli/client.py +0 -335
- envloop_cli-0.1.1/src/el_cli/http.py +0 -126
- envloop_cli-0.1.1/src/el_cli/train.py +0 -53
- envloop_cli-0.1.1/tests/test_bearer.py +0 -117
- envloop_cli-0.1.1/tests/test_cli_help.py +0 -98
- envloop_cli-0.1.1/tests/test_http_proxy.py +0 -59
- envloop_cli-0.1.1/tests/test_job_submit_cli.py +0 -50
- envloop_cli-0.1.1/tests/test_jobs_http.py +0 -183
- envloop_cli-0.1.1/tests/test_train_cli.py +0 -42
- {envloop_cli-0.1.1/src/el_cli → envloop_cli-0.2.0/envloop}/utils/__init__.py +0 -0
- {envloop_cli-0.1.1/src/el_cli → envloop_cli-0.2.0/envloop}/utils/logging.py +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: envloop-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
|
|
5
|
+
Project-URL: Homepage, https://envloop.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.envloop.ai
|
|
7
|
+
Project-URL: Agent instructions, https://docs.envloop.ai/skill.md
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Provides-Extra: train
|
|
10
|
+
Requires-Dist: tinker==0.30.2; extra == 'train'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# EnvLoop CLI and Python SDK
|
|
14
|
+
|
|
15
|
+
Install the public client to run agent Jobs, inspect Trials, manage training and
|
|
16
|
+
checkpoints, and deploy or call inference endpoints. The `el` and `envloop`
|
|
17
|
+
commands are equivalent. The Python import package is `envloop`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Use a Python virtual environment:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python -m pip install envloop-cli
|
|
25
|
+
el --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For Python training workflows, install the optional training dependency:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m pip install 'envloop-cli[train]'
|
|
32
|
+
python -c 'from envloop import Client, training; print("EnvLoop SDK ready")'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The base client uses Python's standard library. The training extra installs the
|
|
36
|
+
training protocol client. You do not need a source checkout, GitHub account,
|
|
37
|
+
local execution backend, Docker, or a GPU to install the client.
|
|
38
|
+
|
|
39
|
+
## Connect
|
|
40
|
+
|
|
41
|
+
Use an authorized EnvLoop account and project. Browser authorization:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
el auth login
|
|
45
|
+
el auth status --check
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For an existing project API key, use the masked configuration wizard:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
el auth config
|
|
52
|
+
el auth status --check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
CI can read credentials from `ENVLOOP_API_KEY` and connection settings from
|
|
56
|
+
`ENVLOOP_BASE_URL`, `ENVLOOP_WORKSPACE_ID` and `ENVLOOP_PROJECT_ID`. Keep keys in
|
|
57
|
+
your secret manager. Public API access is scoped to the authorized project.
|
|
58
|
+
|
|
59
|
+
## Explore your project
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
el job list --limit 5
|
|
63
|
+
el train list --limit 5
|
|
64
|
+
el checkpoint list --limit 5
|
|
65
|
+
el inference endpoint list
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Query a returned Job ID with `el job get JOB_ID` and `el job result JOB_ID`.
|
|
69
|
+
Trial commands use the spelling `trail`, for example
|
|
70
|
+
`el trail artifact list TRIAL_ID`. Use each command's `--help` for options.
|
|
71
|
+
|
|
72
|
+
For the Python SDK:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from envloop import Client
|
|
76
|
+
|
|
77
|
+
with Client.remote() as client:
|
|
78
|
+
print(client.jobs.list(limit=5))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Submitting work, training a model or starting inference can allocate billable
|
|
82
|
+
resources. Follow the task-specific guide and retain the outputs you need before
|
|
83
|
+
cleaning up temporary resources.
|
|
84
|
+
|
|
85
|
+
## Existing installations
|
|
86
|
+
|
|
87
|
+
This distribution provides both the CLI and the `envloop` import package. Use a
|
|
88
|
+
fresh environment if you previously installed the separate source distribution
|
|
89
|
+
named `envloop`, so two distributions do not own the same Python module files.
|
|
90
|
+
Existing client configuration is reused; installing a package does not change
|
|
91
|
+
your account permissions or move remote resources. Check `el --help` when
|
|
92
|
+
upgrading because command groups may differ from your earlier installation.
|
|
93
|
+
|
|
94
|
+
## Documentation
|
|
95
|
+
|
|
96
|
+
- [English documentation](https://docs.envloop.ai/)
|
|
97
|
+
- [中文文档](https://docs.envloop.ai/zh-cn/)
|
|
98
|
+
- [Installation](https://docs.envloop.ai/cli-install)
|
|
99
|
+
- [CLI reference](https://docs.envloop.ai/reference/cli)
|
|
100
|
+
- [Training and deployment](https://docs.envloop.ai/training/train-deploy)
|
|
101
|
+
- [Agent entry: skill.md](https://docs.envloop.ai/skill.md)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# EnvLoop CLI and Python SDK
|
|
2
|
+
|
|
3
|
+
Install the public client to run agent Jobs, inspect Trials, manage training and
|
|
4
|
+
checkpoints, and deploy or call inference endpoints. The `el` and `envloop`
|
|
5
|
+
commands are equivalent. The Python import package is `envloop`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Use a Python virtual environment:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m pip install envloop-cli
|
|
13
|
+
el --help
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For Python training workflows, install the optional training dependency:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install 'envloop-cli[train]'
|
|
20
|
+
python -c 'from envloop import Client, training; print("EnvLoop SDK ready")'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The base client uses Python's standard library. The training extra installs the
|
|
24
|
+
training protocol client. You do not need a source checkout, GitHub account,
|
|
25
|
+
local execution backend, Docker, or a GPU to install the client.
|
|
26
|
+
|
|
27
|
+
## Connect
|
|
28
|
+
|
|
29
|
+
Use an authorized EnvLoop account and project. Browser authorization:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
el auth login
|
|
33
|
+
el auth status --check
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For an existing project API key, use the masked configuration wizard:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
el auth config
|
|
40
|
+
el auth status --check
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
CI can read credentials from `ENVLOOP_API_KEY` and connection settings from
|
|
44
|
+
`ENVLOOP_BASE_URL`, `ENVLOOP_WORKSPACE_ID` and `ENVLOOP_PROJECT_ID`. Keep keys in
|
|
45
|
+
your secret manager. Public API access is scoped to the authorized project.
|
|
46
|
+
|
|
47
|
+
## Explore your project
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
el job list --limit 5
|
|
51
|
+
el train list --limit 5
|
|
52
|
+
el checkpoint list --limit 5
|
|
53
|
+
el inference endpoint list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Query a returned Job ID with `el job get JOB_ID` and `el job result JOB_ID`.
|
|
57
|
+
Trial commands use the spelling `trail`, for example
|
|
58
|
+
`el trail artifact list TRIAL_ID`. Use each command's `--help` for options.
|
|
59
|
+
|
|
60
|
+
For the Python SDK:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from envloop import Client
|
|
64
|
+
|
|
65
|
+
with Client.remote() as client:
|
|
66
|
+
print(client.jobs.list(limit=5))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Submitting work, training a model or starting inference can allocate billable
|
|
70
|
+
resources. Follow the task-specific guide and retain the outputs you need before
|
|
71
|
+
cleaning up temporary resources.
|
|
72
|
+
|
|
73
|
+
## Existing installations
|
|
74
|
+
|
|
75
|
+
This distribution provides both the CLI and the `envloop` import package. Use a
|
|
76
|
+
fresh environment if you previously installed the separate source distribution
|
|
77
|
+
named `envloop`, so two distributions do not own the same Python module files.
|
|
78
|
+
Existing client configuration is reused; installing a package does not change
|
|
79
|
+
your account permissions or move remote resources. Check `el --help` when
|
|
80
|
+
upgrading because command groups may differ from your earlier installation.
|
|
81
|
+
|
|
82
|
+
## Documentation
|
|
83
|
+
|
|
84
|
+
- [English documentation](https://docs.envloop.ai/)
|
|
85
|
+
- [中文文档](https://docs.envloop.ai/zh-cn/)
|
|
86
|
+
- [Installation](https://docs.envloop.ai/cli-install)
|
|
87
|
+
- [CLI reference](https://docs.envloop.ai/reference/cli)
|
|
88
|
+
- [Training and deployment](https://docs.envloop.ai/training/train-deploy)
|
|
89
|
+
- [Agent entry: skill.md](https://docs.envloop.ai/skill.md)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Agent runs: a read-only view of Train Jobs grouped by their agent's run label.
|
|
2
|
+
|
|
3
|
+
The platform stores no run object. Agents tag every Train Job they create with
|
|
4
|
+
``user_metadata`` (the RSI loop: ``rsi_run``, ``rsi_round``, ``rsi_checkpoint``...);
|
|
5
|
+
this module pages through ``GET /v1/train/jobs`` and derives runs from those tags,
|
|
6
|
+
using the same rules as the Console's agent runs page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from .train import TrainJobs
|
|
12
|
+
|
|
13
|
+
# The generic ``agent_*`` key wins over the RSI loop's ``rsi_*`` one (the Console's rule).
|
|
14
|
+
KEYS = {
|
|
15
|
+
"run": ("agent_run", "rsi_run"),
|
|
16
|
+
"round": ("agent_round", "rsi_round"),
|
|
17
|
+
"purpose": ("agent_purpose", "rsi_purpose"),
|
|
18
|
+
"method": ("agent_method", "rsi_method"),
|
|
19
|
+
"checkpoint": ("agent_checkpoint", "rsi_checkpoint"),
|
|
20
|
+
}
|
|
21
|
+
RUN_KEYS = KEYS["run"] # first non-empty string wins
|
|
22
|
+
ACTIVE_STATUSES = frozenset({"LAUNCHING", "STARTING", "RUNNING", "RECOVERING"})
|
|
23
|
+
TERMINAL_STATUSES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
|
|
24
|
+
PAGE_LIMIT = 100 # the platform's maximum page size
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentRunError(RuntimeError):
|
|
28
|
+
"""A run lookup or listing failed; ``code`` is safe to print."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str) -> None:
|
|
31
|
+
self.code = code
|
|
32
|
+
super().__init__(code)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _metadata(job) -> dict:
|
|
36
|
+
metadata = job.get("user_metadata")
|
|
37
|
+
return metadata if isinstance(metadata, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _field(job, name: str):
|
|
41
|
+
metadata = _metadata(job)
|
|
42
|
+
for key in KEYS[name]:
|
|
43
|
+
value = metadata.get(key)
|
|
44
|
+
if value is not None and value != "":
|
|
45
|
+
return value
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_key(job) -> str | None:
|
|
50
|
+
"""The run a Train Job belongs to, or None when it carries no run label."""
|
|
51
|
+
metadata = _metadata(job)
|
|
52
|
+
for key in RUN_KEYS:
|
|
53
|
+
value = metadata.get(key)
|
|
54
|
+
if isinstance(value, str) and value.strip():
|
|
55
|
+
return value
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _round(job) -> int | None:
|
|
60
|
+
value = _field(job, "round")
|
|
61
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
try:
|
|
65
|
+
return int(value.strip())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _order(job):
|
|
72
|
+
# Creation order, as the platform pages jobs (newest first, reversed here).
|
|
73
|
+
return (str(job.get("created_at") or ""), str(job.get("train_job_id") or ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _duration_sec(job) -> int | None:
|
|
77
|
+
if job.get("status") not in TERMINAL_STATUSES:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
seconds = (datetime.fromisoformat(job["updated_at"])
|
|
81
|
+
- datetime.fromisoformat(job["created_at"])).total_seconds()
|
|
82
|
+
except (KeyError, TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
return round(seconds) if seconds >= 0 else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _checkpoint(job) -> dict | None:
|
|
88
|
+
"""The checkpoint a job produced: its agent's version name and platform path."""
|
|
89
|
+
stored = job.get("checkpoint")
|
|
90
|
+
path = stored.get("path") if isinstance(stored, dict) else None
|
|
91
|
+
name = _field(job, "checkpoint")
|
|
92
|
+
if not isinstance(path, str) or not path:
|
|
93
|
+
return None
|
|
94
|
+
return {"name": name, "path": path}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _job_view(job) -> dict:
|
|
98
|
+
metadata = _metadata(job)
|
|
99
|
+
return {
|
|
100
|
+
"train_job_id": job.get("train_job_id"),
|
|
101
|
+
"status": job.get("status"),
|
|
102
|
+
"round": _round(job),
|
|
103
|
+
"purpose": _field(job, "purpose"),
|
|
104
|
+
"method": _field(job, "method"),
|
|
105
|
+
"transitions": list(job.get("transitions") or []),
|
|
106
|
+
"created_at": job.get("created_at"),
|
|
107
|
+
"updated_at": job.get("updated_at"),
|
|
108
|
+
"duration_sec": _duration_sec(job),
|
|
109
|
+
"base_model": job.get("base_model"),
|
|
110
|
+
"lora_rank": job.get("lora_rank"),
|
|
111
|
+
"model_id": job.get("model_id"),
|
|
112
|
+
"checkpoint": _checkpoint(job),
|
|
113
|
+
"user_metadata": metadata,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summary(run: str, jobs: list) -> dict:
|
|
118
|
+
"""Summarize one run's jobs, given in creation order."""
|
|
119
|
+
latest = jobs[-1]
|
|
120
|
+
if any(job.get("status") in ACTIVE_STATUSES for job in jobs):
|
|
121
|
+
status = "running"
|
|
122
|
+
else:
|
|
123
|
+
status = str(latest.get("status") or "unknown").lower()
|
|
124
|
+
succeeded = [job for job in jobs if job.get("status") == "SUCCEEDED"]
|
|
125
|
+
latest_saved = next((job for job in reversed(jobs) if _checkpoint(job) is not None), None)
|
|
126
|
+
latest_checkpoint = _checkpoint(latest_saved) if latest_saved is not None else None
|
|
127
|
+
if latest_checkpoint is not None:
|
|
128
|
+
latest_checkpoint["train_job_id"] = latest_saved.get("train_job_id")
|
|
129
|
+
rounds = [number for number in map(_round, jobs) if number is not None]
|
|
130
|
+
activity = [stamp for job in jobs if isinstance(stamp := job.get("updated_at") or job.get("created_at"), str)]
|
|
131
|
+
return {
|
|
132
|
+
"run": run,
|
|
133
|
+
"agent": _metadata(latest).get("source"),
|
|
134
|
+
"status": status,
|
|
135
|
+
"latest_round": max(rounds, default=None),
|
|
136
|
+
"trainings": {"succeeded": len(succeeded), "total": len(jobs)},
|
|
137
|
+
"latest_checkpoint": latest_checkpoint,
|
|
138
|
+
"base_model": latest.get("base_model"),
|
|
139
|
+
"created_at": jobs[0].get("created_at"),
|
|
140
|
+
"updated_at": max(activity, default=None),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detail(run: str, jobs: list) -> dict:
|
|
145
|
+
views = [_job_view(job) for job in jobs]
|
|
146
|
+
rounds: dict = {}
|
|
147
|
+
for view in views:
|
|
148
|
+
rounds.setdefault(view["round"], []).append(view)
|
|
149
|
+
ordered = sorted(rounds, key=lambda number: (number is None, number or 0))
|
|
150
|
+
checkpoints = [
|
|
151
|
+
{**view["checkpoint"], "round": view["round"], "train_job_id": view["train_job_id"],
|
|
152
|
+
"created_at": view["created_at"]}
|
|
153
|
+
for view in views if view["checkpoint"] is not None
|
|
154
|
+
]
|
|
155
|
+
return {
|
|
156
|
+
**_summary(run, jobs),
|
|
157
|
+
"rounds": [{"round": number, "jobs": rounds[number]} for number in ordered],
|
|
158
|
+
"checkpoints": checkpoints,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AgentRuns:
|
|
163
|
+
"""Read-only agent runs derived from the visible Train Jobs listing."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, train_jobs: TrainJobs) -> None:
|
|
166
|
+
self._train_jobs = train_jobs
|
|
167
|
+
|
|
168
|
+
def _runs(self):
|
|
169
|
+
jobs, ids, seen, cursor, observed_at = [], set(), set(), None, None
|
|
170
|
+
while True:
|
|
171
|
+
page = self._train_jobs.list(cursor=cursor, limit=PAGE_LIMIT)
|
|
172
|
+
if observed_at is None:
|
|
173
|
+
observed_at = page.get("observed_at")
|
|
174
|
+
for job in page.get("items") or []:
|
|
175
|
+
job_id = job.get("train_job_id") if isinstance(job, dict) else None
|
|
176
|
+
if not isinstance(job, dict) or job_id in ids:
|
|
177
|
+
continue
|
|
178
|
+
if job_id is not None:
|
|
179
|
+
ids.add(job_id)
|
|
180
|
+
jobs.append(job)
|
|
181
|
+
cursor = page.get("next_cursor")
|
|
182
|
+
if cursor is None:
|
|
183
|
+
break
|
|
184
|
+
if cursor in seen:
|
|
185
|
+
raise AgentRunError("TRAIN_JOB_CURSOR_REPEATED")
|
|
186
|
+
seen.add(cursor)
|
|
187
|
+
runs: dict = {}
|
|
188
|
+
for job in jobs:
|
|
189
|
+
key = run_key(job)
|
|
190
|
+
if key is not None:
|
|
191
|
+
runs.setdefault(key, []).append(job)
|
|
192
|
+
for group in runs.values():
|
|
193
|
+
group.sort(key=_order)
|
|
194
|
+
return runs, observed_at
|
|
195
|
+
|
|
196
|
+
def list(self) -> dict:
|
|
197
|
+
"""Every run, most recently updated first, as ``{"items": [...], "observed_at"}``."""
|
|
198
|
+
runs, observed_at = self._runs()
|
|
199
|
+
items = sorted((_summary(run, jobs) for run, jobs in runs.items()),
|
|
200
|
+
key=lambda item: (item["updated_at"] or "", item["run"]), reverse=True)
|
|
201
|
+
return {"items": items, "observed_at": observed_at}
|
|
202
|
+
|
|
203
|
+
def get(self, run: str) -> dict:
|
|
204
|
+
"""One run's summary, its rounds in order with their Train Jobs, and its checkpoints."""
|
|
205
|
+
runs, observed_at = self._runs()
|
|
206
|
+
if run not in runs:
|
|
207
|
+
raise AgentRunError("AGENT_RUN_NOT_FOUND")
|
|
208
|
+
return {**_detail(run, runs[run]), "observed_at": observed_at}
|