manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The `manifold run` command group — submit and track policy x benchmark runs."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, assert_never
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
from manifold_cli.commands import run_log
|
|
11
|
+
from manifold_cli.commands.organization import (
|
|
12
|
+
ORGANIZATION_CALL_ERRORS,
|
|
13
|
+
exit_for_call_error,
|
|
14
|
+
resolve_organization,
|
|
15
|
+
)
|
|
16
|
+
from manifold_cli.dependencies import Dependencies
|
|
17
|
+
from manifold_cli.models.catalog import (
|
|
18
|
+
InvalidReferenceError,
|
|
19
|
+
join_versioned_ref,
|
|
20
|
+
split_versioned_ref,
|
|
21
|
+
)
|
|
22
|
+
from manifold_cli.models.organization import OrganizationNotFoundError
|
|
23
|
+
from manifold_cli.models.run import (
|
|
24
|
+
Run,
|
|
25
|
+
RunCancelRejectedError,
|
|
26
|
+
RunDeleteRejectedError,
|
|
27
|
+
RunNotFoundError,
|
|
28
|
+
RunStatus,
|
|
29
|
+
RunSubmitError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ORG_OPTION = typer.Option(
|
|
33
|
+
"--org", help="Organization identifier; required if you belong to more than one."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# How many runs `run list` shows when the caller does not say; the same default
|
|
37
|
+
# the API applies to one page.
|
|
38
|
+
DEFAULT_LIST_LIMIT = 50
|
|
39
|
+
MIN_RUN_NAME_LEN = 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _status(run: Run) -> str:
|
|
43
|
+
# NOTE: the assert_never arm is what makes a new RunStatus a typecheck
|
|
44
|
+
# failure here rather than a KeyError at print time.
|
|
45
|
+
if run.status is RunStatus.QUEUED:
|
|
46
|
+
color = "yellow"
|
|
47
|
+
elif (
|
|
48
|
+
run.status is RunStatus.DISPATCHED
|
|
49
|
+
or run.status is RunStatus.PREPARING
|
|
50
|
+
or run.status is RunStatus.STARTING
|
|
51
|
+
or run.status is RunStatus.RUNNING
|
|
52
|
+
):
|
|
53
|
+
color = "cyan"
|
|
54
|
+
elif run.status is RunStatus.COMPLETED:
|
|
55
|
+
color = "green"
|
|
56
|
+
elif run.status is RunStatus.FAILED or run.status is RunStatus.CANCELED:
|
|
57
|
+
color = "red"
|
|
58
|
+
else:
|
|
59
|
+
assert_never(run.status)
|
|
60
|
+
return f"[{color}]{run.status.value}[/]"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _progress(run: Run) -> str:
|
|
64
|
+
"""Episodes recorded against the count the benchmark version declares.
|
|
65
|
+
|
|
66
|
+
Includes the success rate once the first episode has landed.
|
|
67
|
+
"""
|
|
68
|
+
progress = f"{run.completed_episodes}/{run.episodes} episodes"
|
|
69
|
+
if run.mean_success is None:
|
|
70
|
+
return progress
|
|
71
|
+
return f"{progress}, {run.mean_success:.0%} success"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _duration(run: Run) -> str:
|
|
75
|
+
"""How long the run has been executing, or nothing while it has not started.
|
|
76
|
+
|
|
77
|
+
A queued run shows no duration rather than a zero: the server measures from
|
|
78
|
+
the first episode, so queue time is outside it.
|
|
79
|
+
"""
|
|
80
|
+
if run.duration_sec is None:
|
|
81
|
+
return ""
|
|
82
|
+
return f" [dim]duration[/dim] {run.duration_sec:.1f}s"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _pairing(run: Run) -> str:
|
|
86
|
+
return (
|
|
87
|
+
f"{escape(run.policy_slug)}:{escape(run.policy_version)} x "
|
|
88
|
+
f"{escape(run.benchmark_slug)}:{escape(run.benchmark_version)}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _print_run(console: Console, run: Run) -> None:
|
|
93
|
+
label = f" [dim]{escape(run.name)}[/dim]" if run.name else ""
|
|
94
|
+
console.print(
|
|
95
|
+
f"[bold]{run.id}[/bold] "
|
|
96
|
+
f"{_pairing(run)} "
|
|
97
|
+
f"{_progress(run)} "
|
|
98
|
+
f"{_status(run)}{label}",
|
|
99
|
+
highlight=False,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_webapp_url(webapp_url: str, organization: str, run_id: UUID) -> str:
|
|
104
|
+
return f"{webapp_url.rstrip('/')}/{organization}/runs/{run_id}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_run_name(value: str) -> str:
|
|
108
|
+
name = value.strip()
|
|
109
|
+
if len(name) < MIN_RUN_NAME_LEN:
|
|
110
|
+
raise typer.BadParameter(
|
|
111
|
+
f"must be at least {MIN_RUN_NAME_LEN} characters", param_hint="'--name'"
|
|
112
|
+
)
|
|
113
|
+
return name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
117
|
+
console = Console()
|
|
118
|
+
run_app = typer.Typer(
|
|
119
|
+
name="run",
|
|
120
|
+
help="Submit and track policy x benchmark runs.",
|
|
121
|
+
no_args_is_help=True,
|
|
122
|
+
)
|
|
123
|
+
app.add_typer(run_app)
|
|
124
|
+
run_log.register(run_app, deps)
|
|
125
|
+
|
|
126
|
+
@run_app.command()
|
|
127
|
+
def submit(
|
|
128
|
+
policy: Annotated[
|
|
129
|
+
str,
|
|
130
|
+
typer.Argument(
|
|
131
|
+
help="Policy to run, as [<org>/]<identifier>[:<version>], for "
|
|
132
|
+
"example my-policy:0.0.1. Without a version, the newest "
|
|
133
|
+
"version runs."
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
benchmark: Annotated[
|
|
137
|
+
str,
|
|
138
|
+
typer.Argument(
|
|
139
|
+
help="Benchmark to run against, as "
|
|
140
|
+
"[<org>/]<identifier>[:<version>]. Without a version, the "
|
|
141
|
+
"newest version runs."
|
|
142
|
+
),
|
|
143
|
+
],
|
|
144
|
+
name: Annotated[
|
|
145
|
+
str,
|
|
146
|
+
typer.Option(
|
|
147
|
+
"--name",
|
|
148
|
+
callback=_validate_run_name,
|
|
149
|
+
help="Label for this run in the runs list.",
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
# NOTE: the default is None rather than 1. A default of 1 would make
|
|
153
|
+
# every submit that leaves the flag off override the benchmark version's
|
|
154
|
+
# count with a serial run.
|
|
155
|
+
shards: Annotated[
|
|
156
|
+
int | None,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--shards",
|
|
159
|
+
min=1,
|
|
160
|
+
help="Parallel shards to fan out over, overriding the benchmark "
|
|
161
|
+
"version's own count. Left off, the version's count stands. A "
|
|
162
|
+
"count above the run's episode count is lowered to it, and "
|
|
163
|
+
"nothing reports that.",
|
|
164
|
+
),
|
|
165
|
+
] = None,
|
|
166
|
+
shards_per_gpu: Annotated[
|
|
167
|
+
int | None,
|
|
168
|
+
typer.Option(
|
|
169
|
+
"--shards-per-gpu",
|
|
170
|
+
min=1,
|
|
171
|
+
help="Parallel shards per GPU of each benchmark runner, "
|
|
172
|
+
"overriding the benchmark version's own counts and "
|
|
173
|
+
"--shards.",
|
|
174
|
+
),
|
|
175
|
+
] = None,
|
|
176
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Queue a run of a policy against a benchmark.
|
|
179
|
+
|
|
180
|
+
The run belongs to the organization --org names, and a private policy
|
|
181
|
+
or benchmark has to be owned by that organization; a public one may
|
|
182
|
+
come from anywhere. So the private entries of two organizations cannot
|
|
183
|
+
be paired, and a private pair cannot be run under a third
|
|
184
|
+
organization, even by a member of all of them.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
# Settled before the session is touched, so a mistyped reference
|
|
188
|
+
# costs no round trip.
|
|
189
|
+
policy_owner, policy_slug, policy_version = split_versioned_ref(policy)
|
|
190
|
+
benchmark_owner, benchmark_slug, benchmark_version = split_versioned_ref(
|
|
191
|
+
benchmark
|
|
192
|
+
)
|
|
193
|
+
org = resolve_organization(console, deps.organization_service, organization)
|
|
194
|
+
run = deps.run_service.submit(
|
|
195
|
+
org,
|
|
196
|
+
join_versioned_ref(policy_owner, policy_slug, None),
|
|
197
|
+
join_versioned_ref(benchmark_owner, benchmark_slug, None),
|
|
198
|
+
name=name,
|
|
199
|
+
shards=shards,
|
|
200
|
+
shards_per_gpu=shards_per_gpu,
|
|
201
|
+
policy_version=policy_version,
|
|
202
|
+
benchmark_version=benchmark_version,
|
|
203
|
+
)
|
|
204
|
+
except InvalidReferenceError as exc:
|
|
205
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
206
|
+
raise typer.Exit(1) from exc
|
|
207
|
+
except RunSubmitError as exc:
|
|
208
|
+
console.print(f"[red]Run rejected:[/red] {escape(str(exc))}")
|
|
209
|
+
raise typer.Exit(1) from exc
|
|
210
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
211
|
+
raise exit_for_call_error(console, exc) from exc
|
|
212
|
+
console.print(f"[green]✓ Run queued[/green] [bold]{run.id}[/bold]")
|
|
213
|
+
console.print(f" {_pairing(run)}, {run.episodes} episodes", highlight=False)
|
|
214
|
+
console.print(f"Follow it with: manifold run watch {run.id}")
|
|
215
|
+
run_url = _run_webapp_url(deps.manifold_webapp_url, org, run.id)
|
|
216
|
+
console.print(f"View it at: {escape(run_url)}", soft_wrap=True)
|
|
217
|
+
|
|
218
|
+
@run_app.command("list")
|
|
219
|
+
def list_runs(
|
|
220
|
+
limit: Annotated[
|
|
221
|
+
int, typer.Option("--limit", min=1, help="Runs to show (newest first).")
|
|
222
|
+
] = DEFAULT_LIST_LIMIT,
|
|
223
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""List your organization's runs, newest first."""
|
|
226
|
+
try:
|
|
227
|
+
org = resolve_organization(console, deps.organization_service, organization)
|
|
228
|
+
runs, total = deps.run_service.list(org, limit=limit)
|
|
229
|
+
except OrganizationNotFoundError as exc:
|
|
230
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
231
|
+
raise typer.Exit(1) from exc
|
|
232
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
233
|
+
raise exit_for_call_error(console, exc) from exc
|
|
234
|
+
if len(runs) == 0:
|
|
235
|
+
console.print("No runs yet. Submit one with: manifold run submit")
|
|
236
|
+
return
|
|
237
|
+
for run in runs:
|
|
238
|
+
_print_run(console, run)
|
|
239
|
+
if len(runs) < total:
|
|
240
|
+
console.print(
|
|
241
|
+
f"[dim]Showing {len(runs)} of {total}; raise --limit for more.[/dim]"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@run_app.command()
|
|
245
|
+
def get(
|
|
246
|
+
run_id: Annotated[UUID, typer.Argument(help="Run id.")],
|
|
247
|
+
episodes: Annotated[
|
|
248
|
+
bool,
|
|
249
|
+
typer.Option("--episodes", help="Also list the episodes recorded so far."),
|
|
250
|
+
] = False,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Show one run's progress and outcome."""
|
|
253
|
+
try:
|
|
254
|
+
run = deps.run_service.get(run_id)
|
|
255
|
+
recorded = deps.run_service.episodes(run_id) if episodes else []
|
|
256
|
+
except RunNotFoundError as exc:
|
|
257
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
258
|
+
raise typer.Exit(1) from exc
|
|
259
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
260
|
+
raise exit_for_call_error(console, exc) from exc
|
|
261
|
+
_print_run(console, run)
|
|
262
|
+
console.print(
|
|
263
|
+
f" [dim]visibility[/dim] {run.visibility.value} "
|
|
264
|
+
f"[dim]submitted[/dim] {run.created_at:%Y-%m-%d %H:%M:%S}"
|
|
265
|
+
f"{_duration(run)}"
|
|
266
|
+
)
|
|
267
|
+
if not episodes:
|
|
268
|
+
return
|
|
269
|
+
if len(recorded) == 0:
|
|
270
|
+
console.print(" No episodes recorded yet.")
|
|
271
|
+
return
|
|
272
|
+
for episode in recorded:
|
|
273
|
+
outcome = "[green]✓[/green]" if episode.success else "[red]✗[/red]"
|
|
274
|
+
console.print(
|
|
275
|
+
f" {outcome} [bold]{episode.episode_idx}[/bold] "
|
|
276
|
+
f"{escape(episode.task_name)} "
|
|
277
|
+
f"[dim]{episode.steps} steps, {episode.elapsed_sec:.1f}s[/dim]"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@run_app.command()
|
|
281
|
+
def cancel(
|
|
282
|
+
run_id: Annotated[UUID, typer.Argument(help="Run id.")],
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Stop a run, keeping the episodes it has already recorded."""
|
|
285
|
+
try:
|
|
286
|
+
run = deps.run_service.cancel(run_id)
|
|
287
|
+
except RunNotFoundError as exc:
|
|
288
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
289
|
+
raise typer.Exit(1) from exc
|
|
290
|
+
except RunCancelRejectedError as exc:
|
|
291
|
+
console.print(f"[red]Run not canceled:[/red] {escape(str(exc))}")
|
|
292
|
+
raise typer.Exit(1) from exc
|
|
293
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
294
|
+
raise exit_for_call_error(console, exc) from exc
|
|
295
|
+
console.print(f"[green]✓ Run canceled[/green] [bold]{run.id}[/bold]")
|
|
296
|
+
console.print(f" {_pairing(run)}, {_progress(run)}", highlight=False)
|
|
297
|
+
|
|
298
|
+
@run_app.command()
|
|
299
|
+
def delete(
|
|
300
|
+
run_id: Annotated[UUID, typer.Argument(help="Run id.")],
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Delete a run that has reached a terminal status.
|
|
303
|
+
|
|
304
|
+
A deleted run is left out of `run list`, and `run get` reports it as not
|
|
305
|
+
found. Stop a run that is still in progress with `run cancel` first.
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
deps.run_service.delete(run_id)
|
|
309
|
+
except RunNotFoundError as exc:
|
|
310
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
311
|
+
raise typer.Exit(1) from exc
|
|
312
|
+
except RunDeleteRejectedError as exc:
|
|
313
|
+
console.print(f"[red]Run not deleted:[/red] {escape(str(exc))}")
|
|
314
|
+
raise typer.Exit(1) from exc
|
|
315
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
316
|
+
raise exit_for_call_error(console, exc) from exc
|
|
317
|
+
console.print(f"[green]✓ Run deleted[/green] [bold]{run_id}[/bold]")
|
|
318
|
+
|
|
319
|
+
@run_app.command()
|
|
320
|
+
def watch(
|
|
321
|
+
run_id: Annotated[UUID, typer.Argument(help="Run id.")],
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Follow a run until it reaches a terminal status."""
|
|
324
|
+
try:
|
|
325
|
+
final = deps.run_service.watch(
|
|
326
|
+
run_id, on_update=lambda run: _print_run(console, run)
|
|
327
|
+
)
|
|
328
|
+
except KeyboardInterrupt:
|
|
329
|
+
console.print("\n[yellow]Stopped watching (the run continues).[/yellow]")
|
|
330
|
+
raise typer.Exit(130) from None
|
|
331
|
+
except RunNotFoundError as exc:
|
|
332
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
333
|
+
raise typer.Exit(1) from exc
|
|
334
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
335
|
+
raise exit_for_call_error(console, exc) from exc
|
|
336
|
+
if final.status is not RunStatus.COMPLETED:
|
|
337
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The `manifold run logs` command group — list, search and read a run's output.
|
|
2
|
+
|
|
3
|
+
A stream is identified by its assignment id, producer, worker index and source.
|
|
4
|
+
`streams` prints those four values in the order `run logs read` takes them, and
|
|
5
|
+
`search` prints the `run logs read` command for each match, so an agent can run
|
|
6
|
+
the next step without rebuilding its arguments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from manifold_cli.commands.organization import (
|
|
19
|
+
ORGANIZATION_CALL_ERRORS,
|
|
20
|
+
exit_for_call_error,
|
|
21
|
+
)
|
|
22
|
+
from manifold_cli.commands.table import print_table
|
|
23
|
+
from manifold_cli.dependencies import Dependencies
|
|
24
|
+
from manifold_cli.models.log import LogLine, LogMatch, LogProducer, LogSource
|
|
25
|
+
from manifold_cli.models.pagination import SortOrder
|
|
26
|
+
from manifold_cli.models.run import RunNotFoundError
|
|
27
|
+
|
|
28
|
+
RUN_ID_ARGUMENT = typer.Argument(help="Run id.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register(run_app: typer.Typer, deps: Dependencies) -> None:
|
|
32
|
+
console = Console()
|
|
33
|
+
logs_app = typer.Typer(
|
|
34
|
+
name="logs",
|
|
35
|
+
help="List, search and read the output of a run's runners and workers.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
run_app.add_typer(logs_app)
|
|
39
|
+
|
|
40
|
+
@logs_app.command()
|
|
41
|
+
def streams(run_id: Annotated[UUID, RUN_ID_ARGUMENT]) -> None:
|
|
42
|
+
"""List every output stream of every assignment of a run.
|
|
43
|
+
|
|
44
|
+
Start here when investigating a run's output. Prints each stream's line
|
|
45
|
+
count and its process's exit code. The first four columns are the
|
|
46
|
+
arguments `run logs read` takes.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
run_streams = deps.run_service.log_streams(run_id)
|
|
50
|
+
except RunNotFoundError as exc:
|
|
51
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
52
|
+
raise typer.Exit(1) from exc
|
|
53
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
54
|
+
raise exit_for_call_error(console, exc) from exc
|
|
55
|
+
if len(run_streams) == 0:
|
|
56
|
+
console.print("This run has not recorded any output yet.")
|
|
57
|
+
return
|
|
58
|
+
table = Table(box=None, pad_edge=False)
|
|
59
|
+
for heading in (
|
|
60
|
+
"Assignment",
|
|
61
|
+
"Producer",
|
|
62
|
+
"Worker",
|
|
63
|
+
"Source",
|
|
64
|
+
"Side",
|
|
65
|
+
"Status",
|
|
66
|
+
"Lines",
|
|
67
|
+
"Exit",
|
|
68
|
+
):
|
|
69
|
+
table.add_column(heading, no_wrap=True)
|
|
70
|
+
for stream in run_streams:
|
|
71
|
+
status = stream.status.value
|
|
72
|
+
if stream.reason is not None:
|
|
73
|
+
status = f"{status} ({stream.reason.value})"
|
|
74
|
+
table.add_row(
|
|
75
|
+
str(stream.assignment_id),
|
|
76
|
+
stream.producer.value,
|
|
77
|
+
str(stream.worker_index),
|
|
78
|
+
stream.source.value,
|
|
79
|
+
stream.side.value,
|
|
80
|
+
status,
|
|
81
|
+
str(stream.line_count),
|
|
82
|
+
"-" if stream.exit_code is None else str(stream.exit_code),
|
|
83
|
+
)
|
|
84
|
+
print_table(console, table)
|
|
85
|
+
console.print(
|
|
86
|
+
f"Search them with: manifold run logs search {run_id} PATTERN\n"
|
|
87
|
+
f"Read one with: manifold run logs read {run_id} "
|
|
88
|
+
"ASSIGNMENT_ID PRODUCER WORKER SOURCE",
|
|
89
|
+
soft_wrap=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@logs_app.command()
|
|
93
|
+
def search(
|
|
94
|
+
run_id: Annotated[UUID, RUN_ID_ARGUMENT],
|
|
95
|
+
pattern: Annotated[
|
|
96
|
+
str,
|
|
97
|
+
typer.Argument(
|
|
98
|
+
help="Case-insensitive POSIX regular expression, such as "
|
|
99
|
+
"'traceback|error'."
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
assignment_id: Annotated[
|
|
103
|
+
UUID | None,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--assignment-id", help="Search only this assignment's streams."
|
|
106
|
+
),
|
|
107
|
+
] = None,
|
|
108
|
+
context_lines: Annotated[
|
|
109
|
+
int | None,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--context",
|
|
112
|
+
"-C",
|
|
113
|
+
min=0,
|
|
114
|
+
help="Lines to show before and after each match. [default: 3]",
|
|
115
|
+
),
|
|
116
|
+
] = None,
|
|
117
|
+
since: Annotated[
|
|
118
|
+
datetime | None,
|
|
119
|
+
typer.Option(
|
|
120
|
+
parser=_parse_aware_datetime,
|
|
121
|
+
metavar="TIMESTAMP",
|
|
122
|
+
help="Search only lines written at or after this ISO 8601 "
|
|
123
|
+
"timestamp with a time zone.",
|
|
124
|
+
),
|
|
125
|
+
] = None,
|
|
126
|
+
until: Annotated[
|
|
127
|
+
datetime | None,
|
|
128
|
+
typer.Option(
|
|
129
|
+
parser=_parse_aware_datetime,
|
|
130
|
+
metavar="TIMESTAMP",
|
|
131
|
+
help="Search only lines written before this ISO 8601 timestamp "
|
|
132
|
+
"with a time zone.",
|
|
133
|
+
),
|
|
134
|
+
] = None,
|
|
135
|
+
order: Annotated[
|
|
136
|
+
SortOrder,
|
|
137
|
+
typer.Option(
|
|
138
|
+
help="Print the oldest match first with asc, or the newest first "
|
|
139
|
+
"with desc."
|
|
140
|
+
),
|
|
141
|
+
] = SortOrder.ASC,
|
|
142
|
+
cursor: Annotated[
|
|
143
|
+
str | None,
|
|
144
|
+
typer.Option(
|
|
145
|
+
help="Print the page that follows this cursor from an earlier search."
|
|
146
|
+
),
|
|
147
|
+
] = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Search a run's output for lines matching a regular expression.
|
|
150
|
+
|
|
151
|
+
Prints up to 20 matches per page. Each match's header is the `run logs
|
|
152
|
+
read` command that centres on it, followed by the time the line was
|
|
153
|
+
written, and `>` marks each matching line.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
page = deps.run_service.search_log(
|
|
157
|
+
run_id,
|
|
158
|
+
pattern=pattern,
|
|
159
|
+
assignment_id=assignment_id,
|
|
160
|
+
context_lines=context_lines,
|
|
161
|
+
since=since,
|
|
162
|
+
until=until,
|
|
163
|
+
order=order,
|
|
164
|
+
cursor=cursor,
|
|
165
|
+
)
|
|
166
|
+
except RunNotFoundError as exc:
|
|
167
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
168
|
+
raise typer.Exit(1) from exc
|
|
169
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
170
|
+
raise exit_for_call_error(console, exc) from exc
|
|
171
|
+
if page.total == 0:
|
|
172
|
+
console.print("0 lines match.")
|
|
173
|
+
return
|
|
174
|
+
for match in page.items:
|
|
175
|
+
_print_match(console, run_id, match)
|
|
176
|
+
console.print(
|
|
177
|
+
f"Showing {len(page.items)} of {page.total} matches.", soft_wrap=True
|
|
178
|
+
)
|
|
179
|
+
if page.next_cursor is not None:
|
|
180
|
+
console.print(
|
|
181
|
+
"Next page: repeat the command with --cursor "
|
|
182
|
+
f"{escape(page.next_cursor)}, or narrow the search with "
|
|
183
|
+
"--assignment-id, --since or --until.",
|
|
184
|
+
soft_wrap=True,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@logs_app.command()
|
|
188
|
+
def read(
|
|
189
|
+
run_id: Annotated[UUID, RUN_ID_ARGUMENT],
|
|
190
|
+
assignment_id: Annotated[
|
|
191
|
+
UUID,
|
|
192
|
+
typer.Argument(
|
|
193
|
+
metavar="ASSIGNMENT_ID", help="Assignment id from `run logs streams`."
|
|
194
|
+
),
|
|
195
|
+
],
|
|
196
|
+
producer: Annotated[LogProducer, typer.Argument(help="runner or worker.")],
|
|
197
|
+
worker_index: Annotated[
|
|
198
|
+
int,
|
|
199
|
+
typer.Argument(
|
|
200
|
+
metavar="WORKER", min=0, help="Worker index; 0 for a runner stream."
|
|
201
|
+
),
|
|
202
|
+
],
|
|
203
|
+
source: Annotated[LogSource, typer.Argument(help="stdout or stderr.")],
|
|
204
|
+
around_seq: Annotated[
|
|
205
|
+
int | None,
|
|
206
|
+
typer.Option(
|
|
207
|
+
"--around",
|
|
208
|
+
min=0,
|
|
209
|
+
help="Centre the window on this seq. Without it, print the stream's "
|
|
210
|
+
"tail.",
|
|
211
|
+
),
|
|
212
|
+
] = None,
|
|
213
|
+
lines: Annotated[
|
|
214
|
+
int | None,
|
|
215
|
+
typer.Option(
|
|
216
|
+
"--lines",
|
|
217
|
+
"-n",
|
|
218
|
+
min=1,
|
|
219
|
+
help="Lines to show. [default: 50]",
|
|
220
|
+
),
|
|
221
|
+
] = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Read a window of one stream from `run logs streams`.
|
|
224
|
+
|
|
225
|
+
Each line starts with its seq, and `>` marks the `--around` line. Move
|
|
226
|
+
the window by passing a seq near either end of the lines shown.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
stream_lines = deps.run_service.read_log(
|
|
230
|
+
run_id,
|
|
231
|
+
assignment_id=assignment_id,
|
|
232
|
+
producer=producer,
|
|
233
|
+
worker_index=worker_index,
|
|
234
|
+
source=source,
|
|
235
|
+
around_seq=around_seq,
|
|
236
|
+
lines=lines,
|
|
237
|
+
)
|
|
238
|
+
except RunNotFoundError as exc:
|
|
239
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
240
|
+
raise typer.Exit(1) from exc
|
|
241
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
242
|
+
raise exit_for_call_error(console, exc) from exc
|
|
243
|
+
if len(stream_lines) == 0:
|
|
244
|
+
console.print("This stream is empty or does not exist.")
|
|
245
|
+
return
|
|
246
|
+
_print_lines(stream_lines, marked_seq=around_seq)
|
|
247
|
+
if around_seq is not None and all(
|
|
248
|
+
line.seq != around_seq for line in stream_lines
|
|
249
|
+
):
|
|
250
|
+
console.print(f"[yellow]seq {around_seq} is not in this stream.[/yellow]")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_aware_datetime(value: str) -> datetime:
|
|
254
|
+
"""An ISO 8601 timestamp with a time zone, which the search API requires."""
|
|
255
|
+
try:
|
|
256
|
+
instant = datetime.fromisoformat(value)
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
raise typer.BadParameter(f"'{value}' is not an ISO 8601 timestamp") from exc
|
|
259
|
+
if instant.tzinfo is None:
|
|
260
|
+
raise typer.BadParameter(
|
|
261
|
+
f"'{value}' does not include a time zone; add one, such as {value}Z "
|
|
262
|
+
f"or {value}+00:00"
|
|
263
|
+
)
|
|
264
|
+
return instant
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _print_match(console: Console, run_id: UUID, match: LogMatch) -> None:
|
|
268
|
+
"""The `run logs read` command centred on a match and the time the matched
|
|
269
|
+
line was written, then the match's lines with context."""
|
|
270
|
+
matched = next(line for line in match.lines if line.seq == match.seq)
|
|
271
|
+
console.print(
|
|
272
|
+
f"manifold run logs read {run_id} {match.assignment_id} "
|
|
273
|
+
f"{match.producer.value} {match.worker_index} {match.source.value} "
|
|
274
|
+
f"--around {match.seq} # {matched.ts.isoformat()}",
|
|
275
|
+
soft_wrap=True,
|
|
276
|
+
highlight=False,
|
|
277
|
+
)
|
|
278
|
+
_print_lines(match.lines, marked_seq=match.seq)
|
|
279
|
+
console.print()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _print_lines(stream_lines: list[LogLine], *, marked_seq: int | None) -> None:
|
|
283
|
+
"""One line per log line: a `>` marker, the seq, and the text as written.
|
|
284
|
+
|
|
285
|
+
Log text bypasses Rich, which would substitute emoji codes such as `:100:`,
|
|
286
|
+
drop carriage returns and expand tabs.
|
|
287
|
+
"""
|
|
288
|
+
seq_width = len(str(stream_lines[-1].seq))
|
|
289
|
+
for line in stream_lines:
|
|
290
|
+
marker = ">" if line.seq == marked_seq else " "
|
|
291
|
+
typer.echo(f"{marker} {line.seq:>{seq_width}} {line.text}")
|