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,539 @@
1
+ """The `manifold benchmark` command group."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from manifold_cli.bridge import run_bridge
15
+ from manifold_cli.clients.image import ImageInspectError
16
+ from manifold_cli.commands.organization import (
17
+ ORGANIZATION_CALL_ERRORS,
18
+ exit_for_call_error,
19
+ resolve_owner,
20
+ )
21
+ from manifold_cli.commands.table import catalog_table, print_table, terminal_width
22
+ from manifold_cli.dependencies import Dependencies
23
+ from manifold_cli.models.benchmark import (
24
+ BenchmarkNotFoundError,
25
+ BenchmarkVersion,
26
+ InvalidImageRubricError,
27
+ InvalidImageSpecError,
28
+ )
29
+ from manifold_cli.models.catalog import (
30
+ CatalogRegisterError,
31
+ InvalidImageError,
32
+ InvalidReferenceError,
33
+ Visibility,
34
+ join_versioned_ref,
35
+ split_ref,
36
+ split_versioned_ref,
37
+ )
38
+ from manifold_cli.models.run import SchedulingType
39
+ from manifold_cli.transport import DEFAULT_TRANSPORTS, TcpTransport, TransportError
40
+ from manifold_cli.transport.diagnostics import PingResult, ping
41
+
42
+ DEFAULT_BRIDGE_LISTEN = "127.0.0.1:9001"
43
+
44
+ DEFAULT_PING_BYTES = 4 * 1024
45
+ DEFAULT_PING_TIMEOUT_SECONDS = 30.0
46
+
47
+
48
+ def _publish_rubric(
49
+ console: Console,
50
+ deps: Dependencies,
51
+ slug: str,
52
+ version: str,
53
+ owner: str,
54
+ *,
55
+ image: str,
56
+ ) -> None:
57
+ """Publish the rubric the just-registered image carries, and say so.
58
+
59
+ Silent for an image carrying none. An image this box does not have is
60
+ reported as unread rather than as carrying nothing.
61
+ """
62
+ try:
63
+ tasks = deps.benchmark_service.publish_image_rubric(
64
+ slug, version, owner, image=image
65
+ )
66
+ except ImageInspectError as exc:
67
+ console.print(f"[dim]Rubric not read: {escape(str(exc))}.[/dim]")
68
+ return
69
+ except InvalidImageRubricError as exc:
70
+ console.print(f"[red]{escape(str(exc))}[/red]")
71
+ raise typer.Exit(1) from exc
72
+ except (CatalogRegisterError, BenchmarkNotFoundError) as exc:
73
+ console.print(f"[red]{escape(str(exc))}[/red]")
74
+ raise typer.Exit(1) from exc
75
+ except ORGANIZATION_CALL_ERRORS as exc:
76
+ raise exit_for_call_error(console, exc) from exc
77
+ if tasks is None:
78
+ return
79
+ console.print(
80
+ f"[green]✓ Published rubric[/green] "
81
+ f"[dim]{tasks} task{'' if tasks == 1 else 's'}[/dim]"
82
+ )
83
+
84
+
85
+ def _read_spec(console: Console, deps: Dependencies, image: str) -> dict | None:
86
+ """The spec read from `image`, or None when the image has no spec file or
87
+ cannot be read.
88
+
89
+ An unreadable image registers without a spec, and the output says so. A
90
+ spec that is not a JSON object stops the command before it registers
91
+ anything.
92
+ """
93
+ try:
94
+ return deps.benchmark_service.read_image_spec(image)
95
+ except ImageInspectError as exc:
96
+ console.print(f"[dim]Spec not read: {escape(str(exc))}.[/dim]")
97
+ return None
98
+ except InvalidImageSpecError as exc:
99
+ console.print(f"[red]{escape(str(exc))}[/red]")
100
+ raise typer.Exit(1) from exc
101
+
102
+
103
+ def _describe_shards(version: BenchmarkVersion) -> str:
104
+ if version.shards_per_gpu is not None:
105
+ return f"{version.shards_per_gpu} shards per GPU"
106
+ return f"{version.shards} shard{'' if version.shards == 1 else 's'}"
107
+
108
+
109
+ def register(app: typer.Typer, deps: Dependencies) -> None:
110
+ console = Console()
111
+ benchmark_app = typer.Typer(
112
+ help="Run benchmarks against a policy.", no_args_is_help=True
113
+ )
114
+
115
+ @benchmark_app.command("list")
116
+ def list_benchmarks() -> None:
117
+ """List the benchmarks you can see."""
118
+ try:
119
+ benchmarks = deps.benchmark_service.list_benchmarks()
120
+ except ORGANIZATION_CALL_ERRORS as exc:
121
+ raise exit_for_call_error(console, exc) from exc
122
+ if len(benchmarks) == 0:
123
+ console.print("There are no benchmarks yet.")
124
+ return
125
+ print_table(console, catalog_table(benchmarks, terminal_width(console)))
126
+
127
+ @benchmark_app.command()
128
+ def versions(
129
+ benchmark: Annotated[
130
+ str,
131
+ typer.Argument(
132
+ help="Benchmark identifier, or <org>/<identifier> to specify its owner."
133
+ ),
134
+ ],
135
+ ) -> None:
136
+ """List the versions of a benchmark, newest first."""
137
+ try:
138
+ owner, slug = split_ref(benchmark)
139
+ except InvalidReferenceError as exc:
140
+ console.print(f"[red]{escape(str(exc))}[/red]")
141
+ raise typer.Exit(1) from exc
142
+ # A qualified reference names its owner outright. A bare slug resolves
143
+ # the way `run submit` resolves one: the caller's own benchmark first,
144
+ # then a public benchmark of that slug.
145
+ list_versions = (
146
+ deps.benchmark_service.list_versions
147
+ if owner is not None
148
+ else deps.benchmark_service.list_own_or_public_versions
149
+ )
150
+ if owner is None:
151
+ try:
152
+ owner = resolve_owner(console, deps.organization_service)
153
+ except ORGANIZATION_CALL_ERRORS as exc:
154
+ raise exit_for_call_error(console, exc) from exc
155
+ try:
156
+ found = list_versions(slug, owner)
157
+ except BenchmarkNotFoundError as exc:
158
+ console.print(f"[red]{escape(str(exc))}[/red]")
159
+ raise typer.Exit(1) from exc
160
+ except ORGANIZATION_CALL_ERRORS as exc:
161
+ raise exit_for_call_error(console, exc) from exc
162
+ if len(found) == 0:
163
+ console.print(f"'{escape(benchmark)}' has no versions yet.")
164
+ return
165
+ table = Table(box=None, pad_edge=False)
166
+ table.add_column("Version")
167
+ table.add_column("Adapter", style="dim")
168
+ table.add_column("Episodes", style="dim", justify="right")
169
+ table.add_column("Shards", style="dim", justify="right")
170
+ table.add_column("Shards/GPU", style="dim", justify="right")
171
+ for version in found:
172
+ table.add_row(
173
+ Text(version.version, style="bold"),
174
+ version.adapter.value,
175
+ Text(str(version.episodes)),
176
+ Text(str(version.shards)),
177
+ Text(
178
+ str(version.shards_per_gpu)
179
+ if version.shards_per_gpu is not None
180
+ else "-"
181
+ ),
182
+ )
183
+ print_table(console, table)
184
+
185
+ @benchmark_app.command()
186
+ def init(
187
+ benchmark: Annotated[
188
+ str,
189
+ typer.Argument(
190
+ help="Benchmark identifier to register the image under, "
191
+ "or <org>/<identifier> to specify the organization that owns it."
192
+ ),
193
+ ],
194
+ image: Annotated[
195
+ str,
196
+ typer.Option(
197
+ "--image",
198
+ help="Container image, e.g. ghcr.io/bifrost/libero-spatial:0.1.0. "
199
+ "Its tag is the version this registers.",
200
+ ),
201
+ ],
202
+ episodes: Annotated[
203
+ int,
204
+ typer.Option(
205
+ "--episodes",
206
+ min=1,
207
+ help="Episodes this image runs. Every run of the version "
208
+ "executes that many.",
209
+ ),
210
+ ],
211
+ minimum_gpu_memory_gb: Annotated[
212
+ int,
213
+ typer.Option(
214
+ "--minimum-gpu-memory-gb",
215
+ min=0,
216
+ help="Minimum memory required on one GPU. Set to 0 to allow "
217
+ "placement on runners without a GPU.",
218
+ ),
219
+ ],
220
+ minimum_driver_major: Annotated[
221
+ int | None,
222
+ typer.Option(
223
+ "--minimum-driver-major",
224
+ min=1,
225
+ help="Minimum NVIDIA driver major version required on a "
226
+ "runner. Omit to leave the lower bound open.",
227
+ ),
228
+ ] = None,
229
+ maximum_driver_major: Annotated[
230
+ int | None,
231
+ typer.Option(
232
+ "--maximum-driver-major",
233
+ min=1,
234
+ help="Maximum NVIDIA driver major version allowed on a "
235
+ "runner. Omit to leave the upper bound open.",
236
+ ),
237
+ ] = None,
238
+ minimum_gpu_count: Annotated[
239
+ int | None,
240
+ typer.Option(
241
+ "--minimum-gpu-count",
242
+ min=0,
243
+ help="Minimum number of GPUs required on one runner. If omitted, "
244
+ "the default is 1, or 0 when --minimum-gpu-memory-gb is 0.",
245
+ ),
246
+ ] = None,
247
+ shards: Annotated[
248
+ int,
249
+ typer.Option(
250
+ "--shards",
251
+ min=1,
252
+ help="Parallel shards the image partitions its episodes across. "
253
+ "A run fans out to this many unless `run submit --shards` names "
254
+ "another count; 1 does not partition. A count above `--episodes` "
255
+ "is clamped to it at registration.",
256
+ ),
257
+ ] = 1,
258
+ shards_per_gpu: Annotated[
259
+ int | None,
260
+ typer.Option(
261
+ "--shards-per-gpu",
262
+ min=1,
263
+ help="Parallel shards per GPU of each benchmark runner, in place "
264
+ "of --shards. A run fans out to this many per GPU, up to one shard "
265
+ "per episode, unless `run submit` sets another count.",
266
+ ),
267
+ ] = None,
268
+ scheduling_type: Annotated[
269
+ SchedulingType,
270
+ typer.Option(
271
+ "--scheduling-type",
272
+ help="How the benchmark side divides a run's episodes among its "
273
+ "shards: with `preassigned`, each shard's worker runs the episodes "
274
+ "of its own shard, and with `task_queue`, each worker runs tasks "
275
+ "claimed from the run's task queue.",
276
+ ),
277
+ ] = SchedulingType.PREASSIGNED,
278
+ allow_preemptible: Annotated[
279
+ bool,
280
+ typer.Option(
281
+ "--allow-preemptible",
282
+ help="Let a run of this version use runners whose VM the "
283
+ "provider may stop at any time, for both its policy and its "
284
+ "benchmark side.",
285
+ ),
286
+ ] = False,
287
+ display_name: Annotated[
288
+ str | None,
289
+ typer.Option(
290
+ "--display-name",
291
+ help="Name shown in `benchmark list`. Left off, an existing "
292
+ "benchmark keeps the name it has and a new one uses its identifier.",
293
+ ),
294
+ ] = None,
295
+ visibility: Annotated[
296
+ Visibility | None,
297
+ typer.Option(
298
+ "--visibility",
299
+ help="Who can read the benchmark. Left off, an existing "
300
+ "benchmark keeps the visibility it has and a new one is visible "
301
+ "to its organization.",
302
+ ),
303
+ ] = None,
304
+ ) -> None:
305
+ """Register a container image as a version of a benchmark."""
306
+ try:
307
+ owner, slug = split_ref(benchmark)
308
+ except InvalidReferenceError as exc:
309
+ console.print(f"[red]{escape(str(exc))}[/red]")
310
+ raise typer.Exit(1) from exc
311
+ spec = _read_spec(console, deps, image)
312
+ try:
313
+ if owner is None:
314
+ owner = resolve_owner(console, deps.organization_service)
315
+ version = deps.benchmark_service.register_container_version(
316
+ slug,
317
+ owner,
318
+ image=image,
319
+ episodes=episodes,
320
+ minimum_gpu_memory_gb=minimum_gpu_memory_gb,
321
+ minimum_driver_major=minimum_driver_major,
322
+ maximum_driver_major=maximum_driver_major,
323
+ minimum_gpu_count=minimum_gpu_count,
324
+ scheduling_type=scheduling_type,
325
+ allow_preemptible=allow_preemptible,
326
+ shards=shards,
327
+ shards_per_gpu=shards_per_gpu,
328
+ display_name=display_name,
329
+ visibility=visibility,
330
+ spec=spec,
331
+ )
332
+ except (
333
+ InvalidImageError,
334
+ CatalogRegisterError,
335
+ BenchmarkNotFoundError,
336
+ ) as exc:
337
+ console.print(f"[red]{escape(str(exc))}[/red]")
338
+ raise typer.Exit(1) from exc
339
+ except ORGANIZATION_CALL_ERRORS as exc:
340
+ raise exit_for_call_error(console, exc) from exc
341
+ console.print(
342
+ f"[green]✓ Registered[/green] [bold]{escape(benchmark)}[/bold] "
343
+ f"[bold]{escape(version.version)}[/bold] "
344
+ f"[dim]{version.adapter.value}, {version.episodes} episodes, "
345
+ f"{_describe_shards(version)}[/dim]"
346
+ )
347
+ console.print(f" {escape(image)}")
348
+ if spec is not None:
349
+ console.print("[green]✓ Recorded spec[/green]")
350
+ _publish_rubric(console, deps, slug, version.version, owner, image=image)
351
+ console.print(
352
+ f"Run it with: manifold run submit <policy> {escape(benchmark)} --name NAME"
353
+ )
354
+
355
+ @benchmark_app.command()
356
+ def spec(
357
+ benchmark: Annotated[
358
+ str,
359
+ typer.Argument(
360
+ help="Benchmark version, as [<org>/]<identifier>:<version>."
361
+ ),
362
+ ],
363
+ ) -> None:
364
+ """Print a benchmark version's spec as JSON.
365
+
366
+ The spec lists the observations, actions and embodiment the benchmark
367
+ sends to a policy. It was read from the image when the version was
368
+ registered. Save the output to pair a policy with the benchmark.
369
+ """
370
+ try:
371
+ owner, slug, version = split_versioned_ref(benchmark)
372
+ except InvalidReferenceError as exc:
373
+ console.print(f"[red]{escape(str(exc))}[/red]")
374
+ raise typer.Exit(1) from exc
375
+ if version is None:
376
+ console.print(
377
+ f"[red]'{escape(benchmark)}' names no version; "
378
+ "write <identifier>:<version>.[/red]"
379
+ )
380
+ raise typer.Exit(1)
381
+ or_public = owner is None
382
+ try:
383
+ if owner is None:
384
+ owner = resolve_owner(console, deps.organization_service)
385
+ found = deps.benchmark_service.version_spec(
386
+ slug, owner, version, or_public=or_public
387
+ )
388
+ except BenchmarkNotFoundError as exc:
389
+ console.print(f"[red]{escape(str(exc))}[/red]")
390
+ raise typer.Exit(1) from exc
391
+ except ORGANIZATION_CALL_ERRORS as exc:
392
+ raise exit_for_call_error(console, exc) from exc
393
+ if found is None:
394
+ console.print(
395
+ f"[red]'{escape(benchmark)}' was registered without a spec.[/red]"
396
+ )
397
+ raise typer.Exit(1)
398
+ typer.echo(json.dumps(found, indent=2))
399
+
400
+ @benchmark_app.command()
401
+ def delete(
402
+ benchmark: Annotated[
403
+ str,
404
+ typer.Argument(
405
+ help="Benchmark to delete, as [<org>/]<identifier>[:<version>], for "
406
+ "example libero-spatial:0.0.1. With a version, only that "
407
+ "version is deleted."
408
+ ),
409
+ ],
410
+ ) -> None:
411
+ """Delete a benchmark, or one version of it.
412
+
413
+ A deleted benchmark or version is left out of listings and cannot be used
414
+ in new runs. `run get` still shows it on the runs that used it.
415
+ Registering it again restores it. A restored benchmark takes the display
416
+ name and visibility of that registration, not its earlier ones.
417
+ """
418
+ try:
419
+ owner, slug, version = split_versioned_ref(benchmark)
420
+ except InvalidReferenceError as exc:
421
+ console.print(f"[red]{escape(str(exc))}[/red]")
422
+ raise typer.Exit(1) from exc
423
+ try:
424
+ if owner is None:
425
+ owner = resolve_owner(console, deps.organization_service)
426
+ deps.benchmark_service.delete(slug, owner, version)
427
+ except BenchmarkNotFoundError as exc:
428
+ console.print(f"[red]{escape(str(exc))}[/red]")
429
+ raise typer.Exit(1) from exc
430
+ except ORGANIZATION_CALL_ERRORS as exc:
431
+ raise exit_for_call_error(console, exc) from exc
432
+ deleted = (
433
+ "Benchmark deleted" if version is None else "Benchmark version deleted"
434
+ )
435
+ console.print(
436
+ f"[green]✓ {deleted}[/green] "
437
+ f"[bold]{escape(join_versioned_ref(owner, slug, version))}[/bold]"
438
+ )
439
+
440
+ @benchmark_app.command(hidden=True)
441
+ def run(
442
+ target: Annotated[
443
+ str,
444
+ typer.Argument(help="Policy target, e.g. tcp:127.0.0.1:9000 or iroh:ID."),
445
+ ],
446
+ use_ping: Annotated[
447
+ bool,
448
+ typer.Option(
449
+ "--ping", help="Run a built-in ping benchmark (round-trips a payload)."
450
+ ),
451
+ ] = False,
452
+ size: Annotated[
453
+ int,
454
+ typer.Option("--size", help="Ping payload size in bytes."),
455
+ ] = DEFAULT_PING_BYTES,
456
+ timeout: Annotated[
457
+ float,
458
+ typer.Option(
459
+ "--timeout",
460
+ help="Abort the ping if it has not completed after this many seconds.",
461
+ ),
462
+ ] = DEFAULT_PING_TIMEOUT_SECONDS,
463
+ ) -> None:
464
+ """Run a benchmark against the policy at the given transport target."""
465
+ if not use_ping:
466
+ console.print(
467
+ "[red]Real benchmarks are not available yet.[/red] "
468
+ "Pass --ping to run the built-in ping benchmark."
469
+ )
470
+ raise typer.Exit(1)
471
+ payload = os.urandom(size)
472
+
473
+ async def run_ping() -> PingResult:
474
+ async with asyncio.timeout(timeout):
475
+ return await ping(connector, payload)
476
+
477
+ try:
478
+ transport, rest = DEFAULT_TRANSPORTS.resolve(target)
479
+ connector = transport.build_connector(rest)
480
+ result = asyncio.run(run_ping())
481
+ except TimeoutError as exc:
482
+ console.print(f"[red]Ping timed out[/red] after {timeout:g} s.")
483
+ raise typer.Exit(1) from exc
484
+ except (TransportError, OSError) as exc:
485
+ console.print(f"[red]Ping failed:[/red] {escape(str(exc))}")
486
+ raise typer.Exit(1) from exc
487
+
488
+ if not result.matched:
489
+ console.print(
490
+ f"[red]Mismatch:[/red] sent {result.size} bytes, echo differed."
491
+ )
492
+ raise typer.Exit(1)
493
+
494
+ console.print(
495
+ f"[green]OK[/green] round-tripped {result.size} bytes via "
496
+ f"[bold]{escape(target)}[/bold] in [bold]{result.elapsed_ms:.2f} ms[/bold]."
497
+ )
498
+
499
+ @benchmark_app.command(hidden=True)
500
+ def bridge(
501
+ target: Annotated[
502
+ str,
503
+ typer.Argument(
504
+ help="Policy-bridge target for our transport, e.g. "
505
+ "tcp:100.101.132.68:9500 or iroh:ENDPOINT_ID."
506
+ ),
507
+ ],
508
+ listen: Annotated[
509
+ str,
510
+ typer.Option(
511
+ "--listen", help="Local address to accept the SDK benchmark, HOST:PORT."
512
+ ),
513
+ ] = DEFAULT_BRIDGE_LISTEN,
514
+ ) -> None:
515
+ """Expose a local TCP socket forwarding to a policy bridge over transport."""
516
+
517
+ async def run() -> None:
518
+ transport, rest = DEFAULT_TRANSPORTS.resolve(target)
519
+ connector = transport.build_connector(rest)
520
+ listener = await TcpTransport().open_listener(listen)
521
+ console.print(
522
+ "[green]Benchmark bridge up.[/green] Point the SDK benchmark's "
523
+ f"--server at: [bold]{escape(listener.target)}[/bold]"
524
+ )
525
+ console.print(
526
+ f"Forwarding to policy bridge at [bold]{escape(target)}[/bold]."
527
+ )
528
+ console.print("Bridging... (press Ctrl-C to stop)")
529
+ await run_bridge(listener, connector)
530
+
531
+ try:
532
+ asyncio.run(run())
533
+ except KeyboardInterrupt:
534
+ console.print("\n[yellow]Benchmark bridge stopped.[/yellow]")
535
+ except (TransportError, OSError) as exc:
536
+ console.print(f"[red]Bridge failed:[/red] {escape(str(exc))}")
537
+ raise typer.Exit(1) from exc
538
+
539
+ app.add_typer(benchmark_app, name="benchmark")
@@ -0,0 +1,154 @@
1
+ from http import HTTPStatus
2
+ from json import JSONDecodeError
3
+
4
+ import httpx
5
+ import typer
6
+ from pydantic import ValidationError
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from manifold_cli.commands.table import print_table
13
+ from manifold_cli.dependencies import Dependencies
14
+ from manifold_cli.models.errors import (
15
+ ManifoldApiError,
16
+ NotLoggedInError,
17
+ SessionExpiredError,
18
+ )
19
+ from manifold_cli.services.organization import OrganizationService
20
+
21
+ NO_ORGANIZATION_MESSAGE = (
22
+ "You do not have an organization yet. Reach out to Bifrost to get set up."
23
+ )
24
+
25
+ # Failures an organization API call can raise: no/expired credentials, a
26
+ # transport or HTTP-status error, or a 2xx body that isn't the JSON shape we
27
+ # expect. The login epilogue in commands.auth reuses this to stay in lockstep.
28
+ ORGANIZATION_CALL_ERRORS = (
29
+ NotLoggedInError,
30
+ SessionExpiredError,
31
+ ManifoldApiError,
32
+ httpx.HTTPError,
33
+ JSONDecodeError, # a 2xx body that isn't JSON
34
+ ValidationError, # a 2xx body that doesn't match our schema
35
+ )
36
+
37
+
38
+ def resolve_organization(
39
+ console: Console,
40
+ service: OrganizationService,
41
+ organization: str | None,
42
+ ) -> str:
43
+ """Settle which organization a --org command acts as.
44
+
45
+ Every policy, benchmark and run call is made as one organization. Anyone
46
+ who belongs to multiple organizations must specify which to use.
47
+ """
48
+ if organization is not None:
49
+ return organization.strip().lower()
50
+ organizations = service.list()
51
+ if len(organizations) == 1:
52
+ return organizations[0].slug
53
+ if len(organizations) == 0:
54
+ console.print(NO_ORGANIZATION_MESSAGE)
55
+ raise typer.Exit(1)
56
+ slugs = ", ".join(o.slug for o in organizations)
57
+ console.print(
58
+ f"You belong to several organizations ({slugs}); pick one with --org."
59
+ )
60
+ raise typer.Exit(1)
61
+
62
+
63
+ def resolve_owner(console: Console, service: OrganizationService) -> str:
64
+ """Settle who owns the entity a bare `<slug>` reference names.
65
+
66
+ A qualified `<org>/<slug>` names its owner already, so this is the bare
67
+ slug's fallback: the caller's own organization, or the qualified form when
68
+ they belong to several.
69
+ """
70
+ organizations = service.list()
71
+ if len(organizations) == 1:
72
+ return organizations[0].slug
73
+ if len(organizations) == 0:
74
+ console.print(NO_ORGANIZATION_MESSAGE)
75
+ raise typer.Exit(1)
76
+ slugs = ", ".join(o.slug for o in organizations)
77
+ console.print(
78
+ f"You belong to several organizations ({slugs}); "
79
+ "specify the owner as <org>/<identifier>."
80
+ )
81
+ raise typer.Exit(1)
82
+
83
+
84
+ def exit_for_call_error(console: Console, exc: Exception) -> typer.Exit:
85
+ """Report a failed API call, and return the Exit the command raises.
86
+
87
+ Every command group shares this so each failure names its own cause: a
88
+ missing or lapsed session names the command that fixes it, an HTTP error
89
+ status is reported with what the server said, and only a transport failure
90
+ is reported as Manifold being unreachable.
91
+ """
92
+ match exc:
93
+ case NotLoggedInError():
94
+ console.print("Not logged in. Run `manifold auth login` to authenticate.")
95
+ case SessionExpiredError():
96
+ console.print(
97
+ "[yellow]Session expired.[/yellow] "
98
+ "Run `manifold auth login` to re-authenticate."
99
+ )
100
+ case ManifoldApiError():
101
+ console.print(_api_error_message(exc))
102
+ case JSONDecodeError() | ValidationError():
103
+ console.print(
104
+ f"[red]Manifold returned an unexpected response:[/red] "
105
+ f"{escape(str(exc))}"
106
+ )
107
+ case _:
108
+ console.print(f"[red]Could not reach Manifold:[/red] {escape(str(exc))}")
109
+ return typer.Exit(1)
110
+
111
+
112
+ def _api_error_message(exc: ManifoldApiError) -> str:
113
+ """The line for an HTTP error status no domain exception claimed.
114
+
115
+ The server's `detail` is the message where it sent one, and an excerpt of
116
+ the raw body is the fallback, clipped to the 200 characters `str(exc)`
117
+ shows.
118
+ """
119
+ said = escape(exc.detail or exc.body[:200]) or "no detail given"
120
+ if exc.status_code == HTTPStatus.FORBIDDEN:
121
+ return f"[red]You do not have permission for this:[/red] {said}"
122
+ if exc.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
123
+ return f"[red]Manifold failed (HTTP {exc.status_code}):[/red] {said}"
124
+ return f"[red]Manifold rejected the request (HTTP {exc.status_code}):[/red] {said}"
125
+
126
+
127
+ def register(app: typer.Typer, deps: Dependencies) -> None:
128
+ console = Console()
129
+ org_app = typer.Typer(
130
+ name="org",
131
+ help="Manage organizations.",
132
+ no_args_is_help=True,
133
+ )
134
+ app.add_typer(org_app)
135
+
136
+ @org_app.command("list")
137
+ def list_organizations() -> None:
138
+ """List the organizations you belong to."""
139
+ try:
140
+ organizations = deps.organization_service.list()
141
+ except ORGANIZATION_CALL_ERRORS as exc:
142
+ raise exit_for_call_error(console, exc) from exc
143
+ if not organizations:
144
+ console.print(NO_ORGANIZATION_MESSAGE)
145
+ return
146
+ table = Table(box=None, pad_edge=False)
147
+ table.add_column("Identifier")
148
+ table.add_column("Name")
149
+ for organization in organizations:
150
+ table.add_row(
151
+ Text(organization.slug, style="bold"),
152
+ Text(organization.display_name),
153
+ )
154
+ print_table(console, table)