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,852 @@
1
+ """Register and manage the runners on this box."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from shlex import join
6
+ from typing import Annotated, assert_never
7
+ from uuid import UUID
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+ from rich.table import Table
13
+
14
+ from manifold_cli.commands.organization import (
15
+ ORGANIZATION_CALL_ERRORS,
16
+ exit_for_call_error,
17
+ )
18
+ from manifold_cli.commands.table import relative_time
19
+ from manifold_cli.dependencies import Dependencies, make_runner_registration_client
20
+ from manifold_cli.models.credentials import RunnerCredential
21
+ from manifold_cli.models.errors import (
22
+ AccessTokenRejectedError,
23
+ AmbiguousRunnerHomeError,
24
+ InvalidAccessTokenError,
25
+ NoRunnerHomeError,
26
+ NotLoggedInError,
27
+ RunnerDeploymentMismatchError,
28
+ )
29
+ from manifold_cli.models.organization import OrganizationNotFoundError
30
+ from manifold_cli.models.runner import (
31
+ GpuModel,
32
+ InstanceOverrideType,
33
+ ListedRunner,
34
+ Runner,
35
+ RunnerNameTakenError,
36
+ RunnerNotFoundError,
37
+ RunnerProvider,
38
+ RunnerStatus,
39
+ )
40
+ from manifold_cli.models.runner_home import BoxFacts, RunnerHome
41
+ from manifold_cli.runner.gpu import gpu_facts, gpu_memory_gb
42
+ from manifold_cli.runner.ram import host_ram_gb
43
+ from manifold_cli.services.runner import RunnerService
44
+ from manifold_cli.services.runner_daemon import RunnerDaemonServiceError
45
+
46
+ ORG_OPTION = typer.Option(
47
+ "--org", help="Organization identifier; defaults to the operator organization."
48
+ )
49
+ _UNTIL_HELP = (
50
+ "End time as an ISO 8601 timestamp, such as 2026-11-04T15:00 or "
51
+ "2026-11-04T15:00+08:00. A timestamp without a time zone is local time."
52
+ )
53
+
54
+
55
+ def register(app: typer.Typer, deps: Dependencies) -> None:
56
+ console = Console()
57
+ runner_app = typer.Typer(
58
+ name="runner",
59
+ help=(
60
+ "Register runners on this box, list an organization's runners, and "
61
+ "set their instance overrides and placement pauses."
62
+ ),
63
+ no_args_is_help=True,
64
+ # Only operators run these, so they stay out of a customer's --help.
65
+ hidden=True,
66
+ )
67
+ app.add_typer(runner_app)
68
+
69
+ @runner_app.command("register")
70
+ def register_runner(
71
+ name: Annotated[str, typer.Argument(help="Name for the runner.")],
72
+ gpu_model: Annotated[
73
+ GpuModel,
74
+ typer.Option(
75
+ "--gpu-model",
76
+ help="GPU model of this box. Manifold charges for billed time per "
77
+ "GPU of this model.",
78
+ ),
79
+ ],
80
+ force: Annotated[
81
+ bool,
82
+ typer.Option(
83
+ "--force",
84
+ help="Replace an existing runner on this box.",
85
+ ),
86
+ ] = False,
87
+ cache_dir: Annotated[
88
+ Path | None,
89
+ typer.Option("--cache-dir", help="Directory this box's workers cache in."),
90
+ ] = None,
91
+ advertise_host: Annotated[
92
+ str | None,
93
+ typer.Option(
94
+ "--advertise-host",
95
+ help="Address this box's policy publishes for its peer to "
96
+ "dial. Defaults to the address the route towards Manifold uses.",
97
+ ),
98
+ ] = None,
99
+ provider: Annotated[
100
+ RunnerProvider | None,
101
+ typer.Option(
102
+ "--provider",
103
+ help="Cloud provider of the VM this box runs on.",
104
+ ),
105
+ ] = None,
106
+ is_preemptible: Annotated[
107
+ bool,
108
+ typer.Option(
109
+ "--preemptible",
110
+ help="The provider may stop this box's VM at any time.",
111
+ ),
112
+ ] = False,
113
+ organization: Annotated[str | None, ORG_OPTION] = None,
114
+ ) -> None:
115
+ """Register a runner and start its persistent system service.
116
+
117
+ The command stores the runner token in its home. MANIFOLD_TOKEN takes
118
+ precedence over the operator's stored WorkOS login. The persistent
119
+ service runs as the user who registers it. The command returns after
120
+ systemd enables and starts the service.
121
+ """
122
+ access_token = deps.manifold_token
123
+ runner_service = deps.runner_service
124
+ registration_client = None
125
+ try:
126
+ if access_token is not None:
127
+ registration_client = make_runner_registration_client(
128
+ access_token, deps.manifold_api_url
129
+ )
130
+ runner_service = RunnerService(registration_client)
131
+ _register_runner(
132
+ name=name,
133
+ force=force,
134
+ cache_dir=cache_dir,
135
+ advertise_host=advertise_host,
136
+ gpu_model=gpu_model,
137
+ provider=provider,
138
+ is_preemptible=is_preemptible,
139
+ organization=organization,
140
+ access_token=access_token,
141
+ runner_service=runner_service,
142
+ deps=deps,
143
+ console=console,
144
+ )
145
+ except InvalidAccessTokenError as exc:
146
+ console.print(f"[red]{escape(str(exc))}[/red]")
147
+ raise typer.Exit(1) from exc
148
+ finally:
149
+ if registration_client is not None:
150
+ registration_client.close()
151
+
152
+ @runner_app.command()
153
+ def unregister(
154
+ runner: Annotated[
155
+ str | None,
156
+ typer.Option(
157
+ "--runner",
158
+ help="Which home, by name or by id, on a box holding several.",
159
+ ),
160
+ ] = None,
161
+ organization: Annotated[str | None, ORG_OPTION] = None,
162
+ ) -> None:
163
+ """Revoke and remove a runner registered on this box."""
164
+ try:
165
+ home = deps.runner_homes.resolve(runner)
166
+ except (NoRunnerHomeError, AmbiguousRunnerHomeError) as exc:
167
+ console.print(f"[red]{escape(str(exc))}[/red]")
168
+ raise typer.Exit(1) from exc
169
+ stored = home.load()
170
+ if stored is None:
171
+ console.print(
172
+ f"[red]Could not read runner home at {escape(str(home.path))}.[/red]",
173
+ soft_wrap=True,
174
+ )
175
+ raise typer.Exit(1)
176
+ if stored.credential.api_url.rstrip("/") != deps.manifold_api_url.rstrip("/"):
177
+ mismatch = RunnerDeploymentMismatchError(
178
+ stored.credential.api_url, deps.manifold_api_url
179
+ )
180
+ console.print(f"[red]{escape(str(mismatch))}[/red]")
181
+ command = join(
182
+ (
183
+ "env",
184
+ "-u",
185
+ "MANIFOLD_TOKEN",
186
+ f"MANIFOLD_API_URL={stored.credential.api_url}",
187
+ "manifold",
188
+ "runner",
189
+ "unregister",
190
+ "--runner",
191
+ stored.name,
192
+ )
193
+ )
194
+ console.print(f"Run `{escape(command)}`.", soft_wrap=True)
195
+ raise typer.Exit(1)
196
+ try:
197
+ deps.runner_daemon_service.uninstall(home)
198
+ except RunnerDaemonServiceError as exc:
199
+ console.print(f"[red]{escape(str(exc))}[/red]")
200
+ raise typer.Exit(1) from exc
201
+ try:
202
+ revoked = deps.runner_service.revoke(
203
+ stored.runner_id, organization=organization
204
+ )
205
+ except RunnerNotFoundError as exc:
206
+ console.print("[red]Runner service removed, but revoke failed.[/red]")
207
+ console.print(f"[red]{escape(str(exc))}[/red]")
208
+ _print_unregister_retry(
209
+ console, name=stored.name, organization=organization
210
+ )
211
+ raise typer.Exit(1) from exc
212
+ except ORGANIZATION_CALL_ERRORS as exc:
213
+ console.print("[red]Runner service removed, but revoke failed.[/red]")
214
+ _print_unregister_retry(
215
+ console, name=stored.name, organization=organization
216
+ )
217
+ raise exit_for_call_error(console, exc) from exc
218
+ home.clear()
219
+ console.print(
220
+ f"[green]✓ Runner unregistered[/green] "
221
+ f"[bold]{revoked.id}[/bold] {escape(revoked.name)}"
222
+ )
223
+
224
+ @runner_app.command("list")
225
+ def list_runners(
226
+ organization: Annotated[str | None, ORG_OPTION] = None,
227
+ ) -> None:
228
+ """List the active runners, alphabetically."""
229
+ try:
230
+ runners = deps.runner_service.list(organization=organization)
231
+ except OrganizationNotFoundError as exc:
232
+ console.print(f"[red]{escape(str(exc))}[/red]")
233
+ raise typer.Exit(1) from exc
234
+ except ORGANIZATION_CALL_ERRORS as exc:
235
+ raise exit_for_call_error(console, exc) from exc
236
+ if len(runners) == 0:
237
+ register_command = _command(
238
+ "runner",
239
+ "register",
240
+ "NAME",
241
+ "--gpu-model",
242
+ "GPU_MODEL",
243
+ *_org_arguments(organization),
244
+ )
245
+ console.print(
246
+ f"There are no runners yet. Register one with: "
247
+ f"`{escape(register_command)}`"
248
+ )
249
+ return
250
+ active_runners = sorted(
251
+ (runner for runner in runners if runner.revoked_at is None),
252
+ key=lambda runner: runner.name.casefold(),
253
+ )
254
+ if len(active_runners) == 0:
255
+ console.print("There are no active runners.")
256
+ return
257
+ console.print(_runner_summary(active_runners, console.width))
258
+
259
+ @runner_app.command()
260
+ def show(
261
+ name: Annotated[str, typer.Argument(help="Runner name.")],
262
+ organization: Annotated[str | None, ORG_OPTION] = None,
263
+ ) -> None:
264
+ """Show one active runner's details."""
265
+ runner = _resolve_runner(console, deps.runner_service, name, organization)
266
+ console.print(_runner_details(runner))
267
+
268
+ @runner_app.command("keep-running")
269
+ def keep_running(
270
+ name: Annotated[str, typer.Argument(help="Runner name.")],
271
+ until: Annotated[
272
+ datetime,
273
+ typer.Option(help=_UNTIL_HELP, parser=_parse_until, metavar="TIME"),
274
+ ],
275
+ organization: Annotated[str | None, ORG_OPTION] = None,
276
+ ) -> None:
277
+ """Have the runner instance service start a managed runner's instance and
278
+ keep it running until the given time, for maintenance or to add capacity
279
+ ahead of runs.
280
+
281
+ The runner instance service starts the instance if it is stopped and does
282
+ not stop it before --until, even while the runner is idle or its daemon
283
+ is not responding. Placement still assigns runs to the runner; add
284
+ `pause-placement` to keep runs off it during maintenance.
285
+ """
286
+ runner = _resolve_runner(console, deps.runner_service, name, organization)
287
+ try:
288
+ updated = deps.runner_service.keep_running(
289
+ runner.id, until=until, organization=organization
290
+ )
291
+ except ORGANIZATION_CALL_ERRORS as exc:
292
+ raise exit_for_call_error(console, exc) from exc
293
+ console.print(_runner_overrides(updated, datetime.now(UTC)))
294
+
295
+ @runner_app.command("keep-stopped")
296
+ def keep_stopped(
297
+ name: Annotated[str, typer.Argument(help="Runner name.")],
298
+ until: Annotated[
299
+ datetime | None,
300
+ typer.Option(
301
+ help=f"{_UNTIL_HELP} Omit it to keep the override in effect until "
302
+ "`clear-overrides`.",
303
+ parser=_parse_until,
304
+ metavar="TIME",
305
+ ),
306
+ ] = None,
307
+ organization: Annotated[str | None, ORG_OPTION] = None,
308
+ ) -> None:
309
+ """Stop the runner instance service from starting a managed runner's
310
+ instance, for example to take a broken VM out of the fleet.
311
+
312
+ Setting the override does not stop an instance that is already running,
313
+ but the service still stops it once it is idle or unresponsive. Add
314
+ `pause-placement` to keep runs off the runner as well.
315
+ """
316
+ runner = _resolve_runner(console, deps.runner_service, name, organization)
317
+ try:
318
+ updated = deps.runner_service.keep_stopped(
319
+ runner.id, until=until, organization=organization
320
+ )
321
+ except ORGANIZATION_CALL_ERRORS as exc:
322
+ raise exit_for_call_error(console, exc) from exc
323
+ console.print(_runner_overrides(updated, datetime.now(UTC)))
324
+
325
+ @runner_app.command("pause-placement")
326
+ def pause_placement(
327
+ name: Annotated[str, typer.Argument(help="Runner name.")],
328
+ until: Annotated[
329
+ datetime,
330
+ typer.Option(help=_UNTIL_HELP, parser=_parse_until, metavar="TIME"),
331
+ ],
332
+ organization: Annotated[str | None, ORG_OPTION] = None,
333
+ ) -> None:
334
+ """Stop placement from assigning runs to a runner until the given time.
335
+
336
+ Works on any runner, including one that Bifrost manages manually. A run
337
+ already on the runner keeps running until it finishes.
338
+ """
339
+ runner = _resolve_runner(console, deps.runner_service, name, organization)
340
+ try:
341
+ updated = deps.runner_service.pause_placement(
342
+ runner.id, until=until, organization=organization
343
+ )
344
+ except ORGANIZATION_CALL_ERRORS as exc:
345
+ raise exit_for_call_error(console, exc) from exc
346
+ console.print(_runner_overrides(updated, datetime.now(UTC)))
347
+
348
+ @runner_app.command("clear-overrides")
349
+ def clear_overrides(
350
+ name: Annotated[str, typer.Argument(help="Runner name.")],
351
+ organization: Annotated[str | None, ORG_OPTION] = None,
352
+ ) -> None:
353
+ """Clear a runner's instance override and resume placement on it.
354
+
355
+ After a `keep-running` override is cleared, the runner instance service
356
+ stops the instance once it has been idle for the idle timeout, counted
357
+ from the clear, or once its daemon has not been seen within the liveness
358
+ window.
359
+ """
360
+ runner = _resolve_runner(console, deps.runner_service, name, organization)
361
+ try:
362
+ updated = deps.runner_service.clear_overrides(
363
+ runner.id, organization=organization
364
+ )
365
+ except ORGANIZATION_CALL_ERRORS as exc:
366
+ raise exit_for_call_error(console, exc) from exc
367
+ console.print(_runner_overrides(updated, datetime.now(UTC)))
368
+
369
+ @runner_app.command()
370
+ def revoke(
371
+ runner_id: Annotated[UUID, typer.Argument(help="Runner id.")],
372
+ organization: Annotated[str | None, ORG_OPTION] = None,
373
+ ) -> None:
374
+ """Retire a runner's credential."""
375
+ try:
376
+ runner = deps.runner_service.revoke(runner_id, organization=organization)
377
+ except RunnerNotFoundError as exc:
378
+ console.print(f"[red]{escape(str(exc))}[/red]")
379
+ raise typer.Exit(1) from exc
380
+ except ORGANIZATION_CALL_ERRORS as exc:
381
+ raise exit_for_call_error(console, exc) from exc
382
+ console.print(
383
+ f"[green]✓ Runner revoked[/green] [bold]{runner.id}[/bold] "
384
+ f"{escape(runner.name)}"
385
+ )
386
+
387
+
388
+ def _register_runner(
389
+ *,
390
+ name: str,
391
+ force: bool,
392
+ cache_dir: Path | None,
393
+ advertise_host: str | None,
394
+ gpu_model: GpuModel,
395
+ provider: RunnerProvider | None,
396
+ is_preemptible: bool,
397
+ organization: str | None,
398
+ access_token: str | None,
399
+ runner_service: RunnerService,
400
+ deps: Dependencies,
401
+ console: Console,
402
+ ) -> None:
403
+ registered = deps.runner_homes.named(name)
404
+ if registered is not None and not force:
405
+ # NOTE: a home path is read to be typed at, and Rich would wrap it
406
+ # on a narrow terminal and put a newline through it. soft_wrap
407
+ # leaves the line whole and lets the terminal reflow it.
408
+ console.print(
409
+ f"[red]A runner is already registered at "
410
+ f"{escape(str(registered.home.path))}.[/red]",
411
+ soft_wrap=True,
412
+ )
413
+ if access_token is not None:
414
+ console.print("The existing registration remains.")
415
+ _print_operator_recovery(
416
+ console,
417
+ name=registered.stored.name,
418
+ gpu_model=gpu_model,
419
+ organization=organization,
420
+ )
421
+ else:
422
+ console.print(
423
+ "Register over it with --force, which revokes the runner it holds."
424
+ )
425
+ raise typer.Exit(1)
426
+ if registered is not None:
427
+ stored = registered.stored.credential.api_url
428
+ if stored != deps.manifold_api_url:
429
+ mismatch = RunnerDeploymentMismatchError(stored, deps.manifold_api_url)
430
+ console.print(f"[red]{escape(str(mismatch))}[/red]")
431
+ unregister = join(
432
+ (
433
+ "env",
434
+ "-u",
435
+ "MANIFOLD_TOKEN",
436
+ f"MANIFOLD_API_URL={stored}",
437
+ "manifold",
438
+ "runner",
439
+ "unregister",
440
+ "--runner",
441
+ registered.stored.name,
442
+ )
443
+ )
444
+ retry = _command(
445
+ "runner",
446
+ "register",
447
+ name,
448
+ "--gpu-model",
449
+ gpu_model,
450
+ *_org_arguments(organization),
451
+ )
452
+ console.print(
453
+ "Revoking from here would retire a runner on the other deployment. "
454
+ f"Run `{escape(unregister)}`, then retry "
455
+ f"`{escape(retry)}`.",
456
+ soft_wrap=True,
457
+ )
458
+ raise typer.Exit(1)
459
+ if access_token is not None:
460
+ console.print(
461
+ "[red]The manifold runner register command uses MANIFOLD_TOKEN "
462
+ "only for registration and does not route revoke through that "
463
+ "client.[/red]"
464
+ )
465
+ console.print("The existing registration remains.")
466
+ _print_operator_recovery(
467
+ console,
468
+ name=registered.stored.name,
469
+ gpu_model=gpu_model,
470
+ organization=organization,
471
+ )
472
+ raise typer.Exit(1)
473
+ try:
474
+ # Revoke before minting: the server's name uniqueness covers
475
+ # unrevoked runners only, so the name stays taken until the one
476
+ # holding it is retired. The home goes with it, since the runner
477
+ # minted next keys a directory of its own.
478
+ if registered is not None:
479
+ deps.runner_daemon_service.uninstall(registered.home)
480
+ runner_service.revoke(
481
+ registered.stored.runner_id, organization=organization
482
+ )
483
+ registered.home.clear()
484
+ facts = gpu_facts()
485
+ minted = runner_service.mint(
486
+ name,
487
+ ram_gb=host_ram_gb(),
488
+ gpu_memory_gb=gpu_memory_gb(facts.devices),
489
+ gpu_devices=facts.devices,
490
+ gpu_model=gpu_model,
491
+ provider=provider,
492
+ is_preemptible=is_preemptible,
493
+ organization=organization,
494
+ )
495
+ except RunnerNameTakenError as exc:
496
+ console.print(f"[red]{escape(str(exc))}[/red]")
497
+ org_arguments = _org_arguments(organization)
498
+ list_command = _command("runner", "list", *org_arguments)
499
+ revoke_command = _command("runner", "revoke", "RUNNER_ID", *org_arguments)
500
+ retry_command = _command(
501
+ "runner", "register", name, "--gpu-model", gpu_model, *org_arguments
502
+ )
503
+ if access_token is not None:
504
+ console.print(
505
+ "The manifold runner register command uses MANIFOLD_TOKEN only "
506
+ "for registration and does not route revoke through that client. "
507
+ f"Ask an operator to run `{escape(list_command)}`, substitute the "
508
+ f"real ID for RUNNER_ID in `{escape(revoke_command)}`, then run "
509
+ f"`{escape(retry_command)}`.",
510
+ soft_wrap=True,
511
+ )
512
+ else:
513
+ console.print(
514
+ "Another box in this or another organization holds that name, or "
515
+ "its home was removed without revoking it. Run "
516
+ f"`{escape(list_command)}`, substitute the real ID "
517
+ f"for RUNNER_ID in `{escape(revoke_command)}`, then run "
518
+ f"`{escape(retry_command)}`.",
519
+ soft_wrap=True,
520
+ )
521
+ raise typer.Exit(1) from exc
522
+ except (RunnerNotFoundError, OrganizationNotFoundError) as exc:
523
+ console.print(f"[red]{escape(str(exc))}[/red]")
524
+ raise typer.Exit(1) from exc
525
+ except NotLoggedInError as exc:
526
+ console.print("No registration credential is available.")
527
+ console.print(
528
+ "Export MANIFOLD_TOKEN or run `manifold auth login` to store an "
529
+ "operator credential.",
530
+ soft_wrap=True,
531
+ )
532
+ raise typer.Exit(1) from exc
533
+ except ORGANIZATION_CALL_ERRORS as exc:
534
+ raise exit_for_call_error(console, exc) from exc
535
+ except AccessTokenRejectedError as exc:
536
+ console.print(f"[red]{escape(str(exc))}[/red]")
537
+ console.print(
538
+ "Check that MANIFOLD_TOKEN was created by the deployment configured "
539
+ "in MANIFOLD_API_URL.",
540
+ soft_wrap=True,
541
+ )
542
+ raise typer.Exit(1) from exc
543
+ except RunnerDaemonServiceError as exc:
544
+ console.print(f"[red]{escape(str(exc))}[/red]")
545
+ raise typer.Exit(1) from exc
546
+ home = deps.runner_homes.for_runner(minted.runner.id)
547
+ home.save(
548
+ RunnerHome(
549
+ name=name,
550
+ runner_id=minted.runner.id,
551
+ credential=RunnerCredential(
552
+ api_url=deps.manifold_api_url, token=minted.token
553
+ ),
554
+ box=BoxFacts(
555
+ gpu_devices=[device.index for device in facts.devices],
556
+ cache_dir=cache_dir,
557
+ advertise_host=advertise_host,
558
+ ),
559
+ )
560
+ )
561
+ try:
562
+ unit_name = deps.runner_daemon_service.install(home)
563
+ except RunnerDaemonServiceError as exc:
564
+ if access_token is not None:
565
+ console.print(f"[red]{escape(str(exc))}[/red]")
566
+ console.print("The incomplete registration remains.")
567
+ _print_operator_recovery(
568
+ console, name=name, gpu_model=gpu_model, organization=organization
569
+ )
570
+ raise typer.Exit(1) from exc
571
+ try:
572
+ runner_service.revoke(minted.runner.id, organization=organization)
573
+ except (RunnerNotFoundError, *ORGANIZATION_CALL_ERRORS):
574
+ retry_command = _command(
575
+ "runner",
576
+ "register",
577
+ name,
578
+ "--gpu-model",
579
+ gpu_model,
580
+ "--force",
581
+ *_org_arguments(organization),
582
+ )
583
+ console.print(
584
+ "[red]The runner was registered, but its service could not "
585
+ "start and the registration could not be revoked.[/red]"
586
+ )
587
+ console.print(f"Fix the service error, then run `{escape(retry_command)}`.")
588
+ raise typer.Exit(1) from exc
589
+ home.clear()
590
+ console.print(f"[red]{escape(str(exc))}[/red]")
591
+ console.print("The incomplete registration was revoked.")
592
+ raise typer.Exit(1) from exc
593
+ console.print(
594
+ f"[green]✓ Runner registered[/green] [bold]{minted.runner.id}[/bold] "
595
+ f"{escape(minted.runner.name)}"
596
+ )
597
+ console.print(f"[dim]home[/dim] {escape(str(home.path))}", soft_wrap=True)
598
+ console.print(f"[dim]service[/dim] {unit_name}")
599
+
600
+
601
+ def _print_operator_recovery(
602
+ console: Console, *, name: str, gpu_model: GpuModel, organization: str | None
603
+ ) -> None:
604
+ org_arguments = _org_arguments(organization)
605
+ unregister = _command("runner", "unregister", "--runner", name, *org_arguments)
606
+ register = _command(
607
+ "runner", "register", name, "--gpu-model", gpu_model, *org_arguments
608
+ )
609
+ console.print("An operator must unregister this runner and register again:")
610
+ console.print(f"`{escape(unregister)}`")
611
+ console.print(f"`{escape(register)}`")
612
+
613
+
614
+ def _print_unregister_retry(
615
+ console: Console, *, name: str, organization: str | None
616
+ ) -> None:
617
+ retry = _command(
618
+ "runner", "unregister", "--runner", name, *_org_arguments(organization)
619
+ )
620
+ console.print(f"The runner home remains. Retry with `{escape(retry)}`.")
621
+
622
+
623
+ def _command(*arguments: str) -> str:
624
+ return join(("manifold", *arguments))
625
+
626
+
627
+ def _org_arguments(organization: str | None) -> tuple[str, ...]:
628
+ if organization is None:
629
+ return ()
630
+ return ("--org", organization)
631
+
632
+
633
+ def _resolve_runner(
634
+ console: Console,
635
+ runner_service: RunnerService,
636
+ name: str,
637
+ organization: str | None,
638
+ ) -> ListedRunner:
639
+ """The active runner with the given name, or exit after reporting why it
640
+ could not be found."""
641
+ try:
642
+ runners = runner_service.list(organization=organization)
643
+ except OrganizationNotFoundError as exc:
644
+ console.print(f"[red]{escape(str(exc))}[/red]")
645
+ raise typer.Exit(1) from exc
646
+ except ORGANIZATION_CALL_ERRORS as exc:
647
+ raise exit_for_call_error(console, exc) from exc
648
+ runner = _find_active_runner(runners, name)
649
+ if runner is None:
650
+ console.print(f"[red]Runner '{escape(name)}' not found.[/red]")
651
+ raise typer.Exit(1)
652
+ return runner
653
+
654
+
655
+ def _parse_until(value: str) -> datetime:
656
+ try:
657
+ until = datetime.fromisoformat(value)
658
+ except ValueError as exc:
659
+ raise typer.BadParameter(f"'{value}' is not an ISO 8601 timestamp") from exc
660
+ # `astimezone` converts a naive timestamp from local time.
661
+ return until.astimezone(UTC)
662
+
663
+
664
+ def _runner_overrides(runner: Runner, now: datetime) -> Table:
665
+ table = Table(box=None, pad_edge=False, show_header=False)
666
+ table.add_column(style="dim", no_wrap=True)
667
+ table.add_column(overflow="fold")
668
+ table.add_row("name", escape(runner.name))
669
+ table.add_row("instance", _instance_override(runner, now))
670
+ table.add_row("placement", _placement(runner, now))
671
+ return table
672
+
673
+
674
+ def _runner_summary(runners: list[ListedRunner], width: int) -> Table:
675
+ if width < 80:
676
+ return _narrow_runner_summary(runners)
677
+
678
+ table = Table(box=None, pad_edge=False)
679
+ table.add_column("Name", overflow="fold")
680
+ table.add_column("Status", overflow="fold")
681
+ table.add_column("RAM", overflow="fold")
682
+ table.add_column("GPU", overflow="fold")
683
+ table.add_column("Overrides", overflow="fold")
684
+ table.add_column("Preemptible", overflow="fold")
685
+ now = datetime.now(UTC)
686
+ for runner in runners:
687
+ table.add_row(
688
+ f"[bold]{escape(runner.name)}[/bold]",
689
+ _status(runner),
690
+ _ram(runner),
691
+ _gpu_summary(runner),
692
+ _overrides_summary(runner, now),
693
+ _preemptible(runner),
694
+ )
695
+ return table
696
+
697
+
698
+ def _narrow_runner_summary(runners: list[ListedRunner]) -> Table:
699
+ now = datetime.now(UTC)
700
+ table = Table(box=None, pad_edge=False, show_header=False)
701
+ table.add_column(style="dim", no_wrap=True)
702
+ table.add_column(overflow="fold")
703
+ for runner in runners:
704
+ table.add_row("name", f"[bold]{escape(runner.name)}[/bold]")
705
+ table.add_row("status", _status(runner))
706
+ table.add_row("ram", _ram(runner))
707
+ table.add_row("gpu", _gpu_summary(runner))
708
+ table.add_row("overrides", _overrides_summary(runner, now))
709
+ table.add_row("preemptible", _preemptible(runner), end_section=True)
710
+ return table
711
+
712
+
713
+ def _find_active_runner(runners: list[ListedRunner], name: str) -> ListedRunner | None:
714
+ return next(
715
+ (
716
+ runner
717
+ for runner in runners
718
+ if runner.name == name and runner.revoked_at is None
719
+ ),
720
+ None,
721
+ )
722
+
723
+
724
+ def _runner_details(runner: ListedRunner) -> Table:
725
+ table = Table(box=None, pad_edge=False, show_header=False)
726
+ table.add_column(style="dim", no_wrap=True)
727
+ table.add_column(overflow="fold")
728
+ table.add_row("name", escape(runner.name))
729
+ table.add_row("runner id", str(runner.id))
730
+ table.add_row("created", f"{runner.created_at:%Y-%m-%d %H:%M}")
731
+ table.add_row("status", _status(runner))
732
+ now = datetime.now(UTC)
733
+ table.add_row("last seen", _last_seen_details(runner, now))
734
+ table.add_row("ram", _ram(runner))
735
+ table.add_row("gpus", _gpus(runner))
736
+ table.add_row("preemptible", _preemptible(runner))
737
+ table.add_row("instance", _instance_override(runner, now))
738
+ table.add_row("placement", _placement(runner, now))
739
+ return table
740
+
741
+
742
+ def _status(runner: ListedRunner) -> str:
743
+ if not isinstance(runner.status, RunnerStatus):
744
+ return escape(runner.status)
745
+ if runner.status is RunnerStatus.IDLE:
746
+ return "[green]idle[/]"
747
+ elif runner.status is RunnerStatus.BUSY:
748
+ return "[cyan]busy[/]"
749
+ elif runner.status is RunnerStatus.STARTING:
750
+ return "[cyan]starting[/]"
751
+ elif runner.status is RunnerStatus.NEVER_CONTACTED:
752
+ return "[yellow]never contacted[/]"
753
+ elif runner.status is RunnerStatus.OFFLINE:
754
+ return "[red]offline[/]"
755
+ elif runner.status is RunnerStatus.REVOKED:
756
+ return "[red]revoked[/]"
757
+ else:
758
+ assert_never(runner.status)
759
+
760
+
761
+ def _overrides_summary(runner: Runner, now: datetime) -> str:
762
+ """The overrides in effect, without their end times. `show` prints the end
763
+ times."""
764
+ overrides = []
765
+ instance_override = runner.instance_override
766
+ if instance_override is not None and instance_override.is_in_effect(now):
767
+ overrides.append(_override_type(instance_override.override_type))
768
+ if runner.is_placement_paused(now):
769
+ overrides.append("placement paused")
770
+ if len(overrides) == 0:
771
+ return "none"
772
+ return ", ".join(overrides)
773
+
774
+
775
+ def _instance_override(runner: Runner, now: datetime) -> str:
776
+ instance_override = runner.instance_override
777
+ if instance_override is None or not instance_override.is_in_effect(now):
778
+ return "none"
779
+ override_type = _override_type(instance_override.override_type)
780
+ if instance_override.until is None:
781
+ return f"{override_type} until cleared"
782
+ return f"{override_type} until {_local_time(instance_override.until)}"
783
+
784
+
785
+ def _placement(runner: Runner, now: datetime) -> str:
786
+ if runner.placement_paused_until is None or not runner.is_placement_paused(now):
787
+ return "open"
788
+ return f"paused until {_local_time(runner.placement_paused_until)}"
789
+
790
+
791
+ def _override_type(override_type: InstanceOverrideType | str) -> str:
792
+ if not isinstance(override_type, InstanceOverrideType):
793
+ return escape(override_type)
794
+ if override_type is InstanceOverrideType.KEEP_RUNNING:
795
+ return "keep running"
796
+ elif override_type is InstanceOverrideType.KEEP_STOPPED:
797
+ return "keep stopped"
798
+ else:
799
+ assert_never(override_type)
800
+
801
+
802
+ def _local_time(instant: datetime) -> str:
803
+ return f"{instant.astimezone():%Y-%m-%d %H:%M %Z}"
804
+
805
+
806
+ def _last_seen_relative(runner: Runner, now: datetime) -> str:
807
+ if runner.last_seen_at is None:
808
+ return "never"
809
+ # A report stamped ahead of this clock is skew, not a sighting yet to come.
810
+ return relative_time(min(runner.last_seen_at, now), now)
811
+
812
+
813
+ def _last_seen_details(runner: Runner, now: datetime) -> str:
814
+ if runner.last_seen_at is None:
815
+ return "never"
816
+ relative = _last_seen_relative(runner, now)
817
+ exact = runner.last_seen_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
818
+ return f"{relative} ({exact})"
819
+
820
+
821
+ def _ram(runner: Runner) -> str:
822
+ if runner.ram_gb is None:
823
+ return "unknown"
824
+ return f"{runner.ram_gb} GB"
825
+
826
+
827
+ def _gpus(runner: Runner) -> str:
828
+ if runner.gpu_device_count == 0:
829
+ return "none"
830
+ return ", ".join(
831
+ f"{device.index}: {escape(device.name)} ({device.memory_gb} GB)"
832
+ for device in runner.gpu_devices
833
+ )
834
+
835
+
836
+ def _gpu_summary(runner: Runner) -> str:
837
+ if runner.gpu_device_count == 0:
838
+ return "none"
839
+ if len(runner.gpu_devices) == 0:
840
+ return str(runner.gpu_device_count)
841
+ first = runner.gpu_devices[0]
842
+ if any(
843
+ device.name != first.name or device.memory_gb != first.memory_gb
844
+ for device in runner.gpu_devices[1:]
845
+ ):
846
+ return f"{runner.gpu_device_count} GPUs"
847
+ count = "" if runner.gpu_device_count == 1 else f"{runner.gpu_device_count} x "
848
+ return f"{count}{escape(first.name)} ({first.memory_gb} GB)"
849
+
850
+
851
+ def _preemptible(runner: Runner) -> str:
852
+ return "yes" if runner.is_preemptible else "no"