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.
Files changed (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. 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}")